Site / With Remote and SSR

On the server: a page with its data in it

The plan: what crosses

ts
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:

  • state is the application's slice: only the route. Every other field starts from initial in the browser.
  • parts are 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.
  • surfaces are the Site's sources again. SSR.render refuses a page where an active Surface reads something neither state nor a part sent, naming the Surface and the field, so a page that would fetch again in the browser never ships.
  • meta is the Site's head for the route, read from the Model the server reached.

Preparing and rendering one address

ts
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

ts
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.