Site / With Remote and SSR
On the server: a page with its data in it
The plan: what crosses
const plan = SSR.plan(
{
owner: App.owner,
Message,
// What the browser starts from before the slice is set on it.
initial: placements.initial({ route: AppRoute.Home(), remote: Remote.initial }).model,
},
{
id: 'projects',
state: Projection.pick(App.model.route),
surfaces: Object.values(sources),
parts: [Remote.resume(Data)],
meta: model => Site.metaOf(AppSite, model.route, model),
},
)Each line names an owner:
stateis the application's slice: only the route. Every other field starts frominitialin the browser.partsare other packages' state, each crossing on its own terms.Remote.resume(Data)captures exactly what the active Surfaces read, with the cursors a "load more" needs. Fields no Surface selects stay on the server, and so do loading marks, failures and pending work.surfacesare the Site's sources again.SSR.renderrefuses a page where an active Surface reads something neitherstatenor a part sent, naming the Surface and the field, so a page that would fetch again in the browser never ships.metais the Site's head for the route, read from the Model the server reached.
Preparing and rendering one address
const prepare = (path: string, client: Layer.Layer<RemoteClient>) =>
Data.satisfy(initial(urlAt(path)).model, sources).pipe(
Effect.map(prepared =>
configOf({
initial: () => placements.initial({ route: prepared.route, remote: prepared.remote }),
resources: client,
container: null,
}),
),
Effect.provide(client),
)
const renderPage = (path: string, client: Layer.Layer<RemoteClient>) =>
Effect.gen(function* () {
const config = yield* prepare(path, client)
const rendered = yield* SSR.render(config, plan, { buildId: 'b', url: `${ORIGIN}${path}` })
return SSR.page(TEMPLATE, rendered)
})Data.satisfy is the browser's loop run to completion before drawing: it
fetches what the active sources lack, pass after pass, until a pass plans
nothing. On /owners/ada that is one query; on /, none. Sources for other
routes are inactive, so passing all of them fetches no more.
SSR.render draws the view from the prepared Model, then draws it again from
the Model the browser will start from (the baseline with the slice and
Remote's capture set on it), and refuses the page if the two differ. That
check is what makes the handover safe. The page carries the route and the
projects as JSON on its root element, with the description from meta in its
head.
configOf is the runtime config both sides share: placements.runtime with
the same update, view and routing callbacks, given the container (none on
the server) and the client. One config means the server cannot draw a page the
browser would not.
SSR.inspect(plan, model) returns the same coverage as data. The test asserts
that OwnerPage is active, will be the same in the browser, and has nothing
left unresumed.
In the browser: take over, fetch nothing
const takeOver = (container: HTMLElement, buildId: string) =>
SSR.hydrate(
configOf({
initial,
resources: Remote.clientLayer(Remote.http('/remote')),
container,
}),
plan,
{ buildId },
)SSR.hydrate builds the Model from the baseline, the route and Remote's
capture, and adopts the server's HTML; init does not run. Remote's
subscription finds the store already holds what OwnerPage reads, and asks
for none of it. From the first click on, it is the browser loop above.
Import SSR from foldkit-ssr/client in the modules the browser loads; the
server's renderer stays out of the page's script.