SSR / foldkit-ssr

Resumable pages: answering before the runtime boots

Everything above boots Foldkit's runtime as soon as the page loads. A resumable page waits: it answers clicks and typing from the server's markup, and boots only when it must, so the first interaction does not wait for the application's code to start.

server   each handler ──▶ its Message, encoded ──▶ a marker on the element + an entry in the envelope

browser  event ──▶ the markers name its Messages ──▶ queued ──▶ the runtime boots
                                                             ──▶ update runs the queue first

It rests on the fact that a Foldkit handler is already a Message value. Resume.builder(h) is h that writes those values into the server's markup:

ts
const view = (model: Model, h: HtmlBuilder<Message>) => {
  const rh = Resume.builder(h)
  return {
    title: 'Post',
    body: rh.main(
      [],
      [
        rh.button([rh.OnClick(Message.Liked({ id: model.post.id }))], ['Like']),
        // A Message with a hole: the event fills `value`.
        rh.input([rh.Value(model.draft), rh.OnInput(Message.ChangedDraft)]),
        // Fixed fields beside the hole: the event fills `title`.
        rh.input([rh.OnChange(Message.Renamed, { id: model.post.id })]),
        // A key event fills `key` and `modifiers`.
        rh.div([rh.OnKeyDown(Message.Pressed)], []),
      ],
    ),
  }
}

rh differs from h in one place: OnInput, OnChange, OnKeyDown and OnKeyUp also take a Message's own constructor, and the event fills the field it leaves open. The types check it where it is written: the member must be one of the view's Messages, and leave exactly one string field, or exactly key and modifiers. A closure still works. It is not data, so the page cannot answer that event itself and boots on it instead, letting the live page answer. SSR.render names each such element and event in its result's unnamed. So does a hole form whose field has checks the empty placeholder fails, such as Schema.isMinLength(1): it cannot be written into the page as data, so it is treated as a closure.

A Surface renderer gets the same builder through Resume.view, and its rh makes only that Surface's Messages:

ts
const LikeView = Surface.rootView(
  Like,
  undefined,
  Resume.view((like, rh) => rh.button([rh.OnClick(Message.Liked({ id: like.id }))], ['Like'])),
)

Then say when the runtime starts:

ts
const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.post),
  surfaces: [PostActionsAt],
  start: 'on-interaction', // or 'idle'; 'now', the default, boots on load
})

The event that boots the page is not lost and does not count twice. The runtime starts from the resumed Model, so its first render is the served markup and every node is adopted, the input typed into included. The Messages answered before boot then go through the same update, in order, ahead of anything the live page answers, even an event dispatched in the task that boots it, so the Model ends where an eager boot would have taken it.

What a resumable page must declare

  • Its Surfaces. A Surface's messages list is what the page may dispatch before boot. A binding whose Message no active Surface lists is refused on the server, naming the element and the Message, and the browser refuses a page that carries one.
  • What may start late. A Subscription or Managed Resource that starts at boot starts later on a deferred page, which can change what the application does. SSR.render refuses a deferred plan while one would be active, naming each, until the plan declares it:
    ts
    const Post = SSR.plan(App, {
      id: 'post',
      state: Projection.pick(App.model.post),
      parts: [Remote.resume(Data)], // Remote's entries may start late; its part says so
      start: 'idle',
      deferrable: ['tick'], // this application's own clock may start late
    })
    For the check to see them, pass the application's subscriptions and managedResources in the config given to SSR.render, as you would to Foldkit. boot Commands run at boot, so a deferred page's Mirror.kv restore waits for the first interaction.

Bundles whose code loads on demand

A Bundle.lazy from foldkit-bundle keeps a bundle's update and view out of the boot chunk. Name each in the config's lazy list:

ts
const config = {
  Model,
  init: () => placements.init({ title: 'Uploads', upload }),
  update: placements.update(),
  view: (model: Model, h: HtmlBuilder<Message>) => {
    const rh = Resume.builder(h)
    return { title: model.title, body: rh.main([], [PlacedUpload.view(model, rh)]) }
  },
  container: null,
  subscriptions: placements.subscriptions(),
  lazy: [Upload],
}

The server loads the code before it renders, so the page carries the real view. The browser loads it before it boots and answers from the markers meanwhile, whatever start says. A binding inside a placement dispatches the parent's Message, as Foldkit's own handlers do, so the plan's Surface lists the wrapper variant, Message.GotUploadMessage.