foldkit-ssr
v0.7.1
Published
Server rendering for Foldkit Plus: render once on the server, hand the Model over, and hydrate without rerunning init.
Maintainers
Readme
foldkit-ssr
Server rendering for a Foldkit application that hands the browser the Model
the server reached, not the inputs to rebuild it. init runs once, on the
server. The browser adopts the server's HTML and starts from the slice of the
Model it owns.
Early. The API below is built and tested; as a 0.x package it may still change between minor versions.
pnpm add foldkit-ssr foldkit-surface foldkit effect// Which part of the Model the browser owns.
const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route, App.model.draft),
})
// Server: render, and write that slice into the page.
const html = SSR.page(template, await Effect.runPromise(SSR.render(config, Post, { buildId, url })))
// Browser (from 'foldkit-ssr/client'): take the page over from the slice, without running init.
SSR.hydrate(config, Post, { buildId })Why you would use it
Foldkit already renders on the server. What its page carries is the input:
the Flags that produced the first Model, which the browser decodes and feeds to
init again. That is simple and right when init is cheap and its Flags are
small.
It stops being right when init does real work: it loads a post, reads a
session, computes something expensive. Then the browser either repeats that
work or has to receive everything init read, as Flags, including data the
page never shows. foldkit-ssr sends the result instead: the fields the
browser owns, and nothing else.
| | Foldkit's own (renderToString + Runtime.hydrate) | foldkit-ssr (SSR.render + SSR.hydrate) |
| ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
| The page carries | The Flags | The plan's slice of the Model; no Flags |
| init runs | On the server, then again in the browser | On the server only |
| Server-only data | Crosses if init needs it | Stays on the server unless the slice includes it |
| A view that reads unsent state | Rebuilt in the browser; only development warns | Refused by SSR.render, naming the field, before it is served |
| What you write | Nothing beyond the config | A plan |
Beyond the handover it offers three things you can adopt one at a time:
server-owned static regions that never render in the browser, resumable
pages that answer clicks and typing before the runtime has booted, and
forms that work with scripts off, answered by running update on the
server.
What it owns, and what it does not
It owns the handover: which slice crosses, how it is written into the page, and the checks that the browser can start from it.
- Rendering and adoption stay Foldkit's.
SSR.rendercalls Foldkit'srenderToStringandSSR.hydratecallsRuntime.hydrate. Foldkit still refuses a page from another build and still adopts the server's nodes. - The Model stays the application's. A plan names fields of it; it adds no
state of its own, and every change after boot goes through
update. - Data belongs to its package. Remote's store crosses through Remote's own
part (
Remote.resume), which decides what to capture.
The mental model
server init ──▶ Model ──┬── plan.state ──▶ slice ──▶ envelope (JSON on the stamped root)
└── view ──▶ HTML (checked, see below)
browser envelope ──▶ baseline with the slice set on it ──▶ Model
Model + the server's HTML ──▶ Runtime.hydrate adopts ──▶ plan.boot CommandsThe one rule everything else serves: the browser's first Model renders the
same page the server sent. The browser's Model is the plan's baseline, by
default the application's initial, with the slice set onto it. So
SSR.render renders the view twice, once from the server's Model and once from
the browser's, and refuses the plan if the two differ. A view that reads a
field the plan does not send is caught there, on the server, instead of being
silently rebuilt in the browser.
A first page
The examples in this README share one application, a post page, sketched at
the end. App is its Surface application
from foldkit-surface, and config is what you would pass to
makeApplication, the same object on both sides: any of its four shapes, with
Flags or routing or neither, and routing's callbacks typed from update's
Message as Foldkit types them.
The code is in three modules, because the server and the browser import different things:
// post.ts, read by both: the plan. Which part of the Model the browser owns;
// the rest starts from the baseline, by default the application's initial Model.
import { Projection } from 'foldkit-surface'
import { SSR } from 'foldkit-ssr/client'
export const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route, App.model.draft),
})// server.ts, per request.
import { Effect } from 'effect'
import { SSR } from 'foldkit-ssr'
const result = await Effect.runPromise(SSR.render(config, Post, { buildId, url: request.url }))
const html = SSR.page(template, result)// client.ts, the page's script, instead of Runtime.hydrate.
import { SSR } from 'foldkit-ssr/client'
SSR.hydrate(config, Post, { buildId })foldkit-ssr/client is the browser's part: SSR.plan, SSR.resume,
SSR.hydrate, SSR.static, SSR.serving, Resume, and the attribute names,
Foldkit's root stamp (FOLDKIT_APP_ATTRIBUTE) among them. It imports nothing
of foldkit/experimental/server, whose renderer and HTML parser foldkit-ssr
brings along: about 200 kB minified (60 kB gzipped) off a page's script in the
SSR example. foldkit-ssr has all of it and the
server's calls, the same functions, so a plan made from either works with
both. Import foldkit-ssr/client in every module the page loads, the plan and
the view included, and foldkit-ssr only where the server alone runs.
What each call does:
SSR.planis a declaration. It does no I/O and holds no state. It throws only for a plan that could never work, such as two parts with one id.SSR.renderis an Effect. It runsinitonce, renders the view twice, checks the plan, and returns the rendered application and the envelope it carries on the stamped root. It fails withResumeUnsafewhen the browser could not start where the server did; the refusals say why and how to fix each.SSR.pageis pure: the rendered application in the template, through Foldkit'sinjectIntoTemplate. The envelope rides on the rendered root, so the template needs no place for it. An optionalheadadds markup before</head>, given what was rendered.SSR.hydratereads the envelope, starts Foldkit's runtime from the resumed Model, and runs the plan'sbootCommands.initdoes not run. A page with no server render at all starts on the client as usual.
An application with Flags passes flags to SSR.render. They shape the
server's init and never reach the page.
Commands init would have run: boot
The browser does not run init, so the Commands it returns would run nowhere.
SSR.render refuses a plan that would drop them, naming them. Say what the
browser runs on load in boot:
const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route, App.model.draft),
boot: () => [LoadPreferences()],
})An application assembled with foldkit-bundle runs its assembly's startup
Commands, which is where a Mirror.kv restores what the user saved:
boot: model => assembly.init(model).commands ?? []Styles in the first paint
A generated page styled with foldkit-mixins can ship the CSS its markup uses
in the head, so it is styled before any script runs. head is given the rendered
application; Style.usedIn returns the CSS of every compiled class the markup
carries. In the browser, Styles bring their rules as they draw and skip what
this sheet already carries, so nothing is added twice.
import { Style } from 'foldkit-mixins'
const html = SSR.page(template, result, {
head: rendered => `<style>${Style.usedIn(rendered.html)}</style>`,
})A served page has no template head to write to, so SSR.render, SSR.entry
and SSR.handle take the same function as styles instead, carried as the
rendered root's last child. Keep it to <style>: hydration adopts the nodes
around it and drops it on its first patch, and the browser's Styles skip what
it already carries. A void root holds no children, so styles for one are
refused with VoidRootWithStyles, naming the tag. Anything a head owns
(meta, links) stays with SSR.generate.
SSR.generate takes the same head, and answers a head that throws 500,
as it does a render that fails. SSR.entry takes no head: the host owns
the template in dynamic serving, so a served page carries no per-page head
markup, and the script brings what the first paint needs.
The head is part of the view
title, lang, dir, canonical and ogUrl are checked like the body.
Since Foldkit 0.163 nothing gives canonical a default from the URL, so a page
that wants one derives it from the route in its Model, as it does its title,
and sends the route in state.
Foldkit fills canonical and og:url only into tags the template already has
and adds none, so a render that sets either into a template without
<link rel="canonical" href=""> or <meta property="og:url" content=""> is
refused, naming the tag.
What a page says of itself: meta
A search result and a link preview read more than Foldkit's Document: a
description, an image, Open Graph, a post's dates and structured data. The
plan's meta gives them from the Model:
const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route, App.model.post),
meta: model => ({
description: model.post.excerpt,
type: 'article',
article: { published: model.post.publishedAt },
jsonLd: [{ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: model.post.title }],
}),
})SSR.page and SSR.generate write the tags (description, og:*,
twitter:card, article:*, robots, link rel="alternate", and one JSON-LD
script per entry) before </head>, every value escaped, and SSR.render
checks them like the view: a meta that reads a field state does not send
is ViewDependsOnUnsentState. SSR.hydrate keeps them in step with the Model,
so a move to another post replaces the description too. Each carries
data-foldkit-meta (META_ATTRIBUTE), which is how the browser finds the
ones to replace; leave such tags out of the template.
Serving pages
Through Foldkit's fetch handler
Foldkit's server entry is one function, renderPage(request), which
handleRequest calls for every request that is not a static file, on Node and
on Workers alike. SSR.entry is that function for a plan. The host owns the
template: the entry answers Rendered, with the envelope on the rendered
root, and the host injects it into its own template, in development through
the foldkit Vite plugin and in production through the fetch handler a
vite build emits.
import { handleRequest } from 'foldkit/experimental/server'
// The server entry.
export const { renderPage } = SSR.entry(config, Post, { buildId })
// A Worker, or any host that hands you a Web Request.
export default {
fetch: (request: Request) => handleRequest(request, { renderPage, template }),
}// vite.config.ts, for the dev server and the build id both entries compile in.
import { foldkit } from '@foldkit/vite-plugin'
export default defineConfig({
plugins: [foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })],
})// entry.server.ts and the browser's entry.ts alike.
buildId: import.meta.env.FOLDKIT_BUILD_ID,FOLDKIT_BUILD_ID names the deployment, from the environment: a commit or a
release tag, public and unique per deployment. The plugin compiles it into
both bundles; a script outside Vite, such as a prerender, reads
process.env.FOLDKIT_BUILD_ID, the same value the build saw.
Set it even though @foldkit/vite-plugin can now generate an id on its own.
The generated one is compiled into Foldkit alone, where foldkit-ssr cannot
read it, and the client must compare builds before it reads a page's payload,
so a Model serialized by another deployment is never decoded. Asking Foldkit
to expose that id is proposal 4.
GET and HEAD render the page for the request's URL; an application with
Flags passes flags: request => .... POST is handled only for a plan with a
server fallback. OPTIONS is answered
204 and any other method 405, each with the methods the entry answers in
allow. A render that fails or throws, flags that reject, and a plan the
render refuses are answered 500 with the reason logged, never with a page
the browser could not resume.
headers: request => ... rides on the Rendered result, set over the host's
own: a cache policy for a page that is one visitor's, or the CORS answer to a
preflight, which reaches the entry and not the host.
SSR.entry(config, Post, {
buildId,
headers: () => ({ 'cache-control': 'private, no-store', vary: 'cookie' }),
})A headers that throws is answered 500, as a failed render is.
What the page says of itself (meta) and per-page head markup are written
into a template's head, which the host owns here: a plan with meta is
refused when the entry is made, and served with SSR.generate below, which
owns its template.
foldkit#1448 asks Foldkit
for a per-request head channel beside the envelope.
At build time
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
const pages = await Effect.runPromise(
SSR.generate(config, Post, {
buildId,
template,
origin: 'https://example.com',
paths: ['/', '/about', '/docs/intro'],
}),
)
for (const page of pages) {
await mkdir(dirname(join('dist', page.file)), { recursive: true })
await writeFile(join('dist', page.file), page.html)
}Each path becomes index.html in its own folder (/about is
about/index.html); a path ending in .html keeps its name. origin makes
each path the full URL a routing application parses, and flags: path => ...
gives each its own Flags. The pages come back in the order of paths, typed as
a tuple, so const [home, about] = pages needs no check.
A static host serves one file for every query, and for /about and /about/
alike, so a generated page records its path alone: /about?ref=mail resumes
the page generated for /about, and /other is refused. A server-rendered page
records its path and query, and resumes only there.
A generated site also wants a sitemap and a robots.txt that names it.
SSR.sitemap takes the pages, with a modified date where one is known, and
SSR.robots the origin:
await writeFile(
'dist/sitemap.xml',
SSR.sitemap(
pages.map(page => ({ path: page.path, modified: '2026-09-27' })),
{ origin: 'https://example.com' },
),
)
await writeFile('dist/robots.txt', SSR.robots({ origin: 'https://example.com' }))modified is any date Date reads, written as its UTC day. A path listed
twice, one not starting with /, or a date Date cannot read is refused
rather than written into a sitemap a search engine would reject. robots
takes disallow paths and a sitemap path other than /sitemap.xml.
As a Vite plugin: foldkit-ssr/vite
foldkit-ssr/vite runs the same static build as a closeBundle step on the
application's own vite build, so a site is generated without a second script.
The application describes its site in a module (src/site.ts) and the plugin
evaluates it through a server, so vite.config.ts names a file, never
application code:
// vite.config.ts
import { staticSite } from 'foldkit-ssr/vite'
export default defineConfig({
plugins: [foldkit(), staticSite({ site: { module: '/src/site.ts' } })],
})src/site.ts exports a SiteModule: what SSR.generate takes, plus where the
pages land. config may be one config or a function of the path (a site whose
Model is prepared per page returns its own); paths may be a thunk that seeds
and lists; head is the same head; and sitemap/robots write their files
from the generated pages. files is the host's layout — 'directory'
(/about as about/index.html, the default) or 'flat' (about.html, for a
host with clean addresses and no directory indexes):
// src/site.ts
import type { SiteModule } from 'foldkit-ssr/vite'
export const site = {
origin: 'https://example.com',
paths: ['/', '/about'],
config: { Model, init, update, view, container: null, routing },
plan,
head: rendered => `<style>${Style.usedIn(rendered.html)}</style>`,
files: 'directory',
sitemap: true,
robots: true,
} satisfies SiteModulegenerateStaticSite(site) is the same build without Vite: it renders every
path and returns { pages, sitemap?, robots? }. fileFor(path, layout) maps
one path to the file a host serves it from, so a caller writes the files
itself; generateStaticSite refuses two paths that would claim one file.
Checking what the browser will read
The view check sees what the first render shows. A Surface can read a field the first view never shows: a like button's state, a menu's contents. Name the Surfaces the browser may activate, and the fields that may start from the baseline on purpose:
const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route, App.model.post.id, App.model.post.liked),
local: [App.model.menuOpen], // starts from the baseline, on purpose
surfaces: [
Surface.when(PostActions, App.model.route, AppRoute.Post, route => ({ id: route.id })),
Surface.at(Menu, undefined),
],
})SSR.render then refuses the plan, naming the Surface and the field, when an
active Surface reads a field that is in neither state nor local, is
activated by one, reads data no part of the plan resumes, or would activate
differently from the Model the browser starts from. The check runs for the
server's Model, because a Surface's reads follow its params.
SSR.inspect(plan, model) returns the same findings as data, for a test or a
debugging session: for each Surface, whether it is active, and where each read
comes from, state, local or missing.
A Surface.each family is one entry of surfaces: the envelope carries its
parent's reads with its instances', and coverage inspects each instance by key
(Member[u1]), saying which parent reveals it, so a gap names the instance.
State the slice cannot carry
Parts: another package's state
Some state is not a field to pick. Remote's normalized store holds whatever the server read, most of which the page never shows. A part is a package's own contribution to the envelope: it captures what the plan's active Surfaces read, and restores it in the browser.
const Post = SSR.plan(App, {
id: 'post',
state: Projection.pick(App.model.route),
surfaces: [PostPageAt],
parts: [Remote.resume(Data)],
})Remote.resume sends each field the Surfaces select, through relations, each
connection with its boundaries, and the live cursors of what it sends, and
nothing else of the store. The browser requests none of it again, and the
coverage check counts those reads as sent. Every part the plan names must be in
the page and restore, or the page is refused whole. The server restores each
part from its own capture before serving, so a part that could not is refused
there (UnrestorablePart), not in a user's browser.
Static regions: parts of the page the server owns
An article body, a product description, highlighted code: a part of the page no
Message changes need not render in the browser at all, and what it reads need
not be sent. Mark it with SSR.static:
const view = (model: Model, h: HtmlBuilder<Message>) => ({
title: 'Post', // not the post's title: the browser does not have it
body: h.main(
[],
[
SSR.static('post-copy', ih => [
ih.h1([], [model.post.title]),
ih.p([], [model.post.body]),
]),
PostActionsView(model, h), // the part the browser owns
],
),
})The region renders once, on the server, with Foldkit's inert builder, so it
cannot attach a handler. The browser adopts the server's markup as it is and
never runs the render, so this plan sends neither post.title nor post.body
and the view check still passes. A region changes only with a new document;
content a Message should change belongs in a Surface. A region the browser
needs that is not in the page, say after a Message shows one, is logged and
rendered in the browser.
Markup the browser adopts: SSR.serving
SSR.serving() is true while the view call in progress is the server rendering a page (both
of render's passes), and false while the browser resumes and outside any render. It is for
a view whose element hydration leaves alone, such as a custom element's light DOM, and which
sends markup there that the browser's own code then takes over. The rich-text editor's host is
one: pass serverRendered: SSR.serving to foldkit-richtext-dom's editorAt, and the page
carries the document instead of an empty editor until the script runs.
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 firstIt 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:
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:
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:
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
messageslist 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.renderrefuses a deferred plan while one would be active, naming each, until the plan declares it: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
subscriptionsandmanagedResourcesin the config given toSSR.render, as you would to Foldkit.bootCommands run at boot, so a deferred page'sMirror.kvrestore 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:
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.
Forms that work without scripts
A form whose OnSubmit names a Message can be answered by the server, before
any script has run or with scripts blocked, because update is pure and the
server can run it. The plan opts in:
const Todos = SSR.plan(App, {
id: 'todos',
state: Projection.pick(App.model.draft, App.model.todos),
surfaces: [Surface.at(TodoList, undefined)],
fallback: 'server',
})rh.form(
[rh.OnSubmit(Message.Added({ title: model.draft }))],
[rh.input([rh.Name('title'), rh.Value(model.draft), rh.OnInput(Message.Typed)])],
)The Message carries the title because a post without scripts fills it: the
server starts from init, whose draft is empty. With scripts on, it is the
Message as drawn, so a submit within a frame of the typing sends the text
drawn before it (docs/state-model.md).
A form that need not work without scripts sends Added() and reads
model.draft in update instead.
With scripts on, nothing changes: the page answers the submit and the form
never posts. Without them, the server wrote the form to post the Message to the
page's own URL, and SSR.entry hands that post to SSR.handle:
POST ──▶ decode the Message ──▶ set the posted fields into it (the typed `title`)
──▶ init, then boot and its Commands ──▶ update, and every Command after it
──▶ the page, renderedThe Message must be one the page's active Surfaces list. Commands run under the
config's resources Layer, as Foldkit's runtime would provide them, and under
the post's own, which SSR.entry's resources: request => Layer builds once
for it: who is asking, a client bound to them. Where both provide a service,
the post's is the one its Commands get, so a client bound to the request
replaces one bound to nobody. A render runs no Command; it reads the request
through flags. A post the
server cannot use is answered 400 with the reason (FallbackRefused); a
Command that fails is answered 500. The browser's loop runs for the life of
the page, so a Command that reschedules itself, a poll or a tick, is ordinary
there; on the server a post that has not settled within 100 Messages and
Commands is answered 500, naming the last Command. SSR.handle(request,
config, plan, { buildId, flags?, resources? }) can also be called from an
entry of your own.
A form a cookie session authorizes is a cross-site request forgery target,
and a form cannot carry a CSRF header. SSR.entry's origins names the
origins a form may be posted from; a post whose Origin (every browser sends
one on a POST) is not among them is answered 403 before its body is read
or flags and resources run. A page served with Referrer-Policy:
no-referrer posts Origin: null even to its own site; such a post, or one
with no Origin at all, is taken when its Fetch Metadata Sec-Fetch-Site,
which no page or form can set, says same-origin, and refused without it. A plan with a server fallback requires origins, since a post
runs flags, the config's resources and its own as whoever the cookie says
posted: an entry made without them throws. An entry of your own that calls
SSR.handle checks Origin itself.
When a page is refused
A refusal never shows a user a half-working page. On the server it is an error before anything is served; in the browser the page is contained, as Foldkit contains a page from another build, and the reason is logged.
On the server, SSR.render fails with ResumeUnsafe, whose reason is one
of:
| reason | What it means | The fix |
| -------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| ViewDependsOnUnsentState | The view, or its head, renders differently from the browser's Model | Add the field the message names to state, or stop the view reading it |
| UndeclaredStartup | init returned Commands and the plan has no boot | Name them in boot |
| Uncovered | An active Surface reads or is activated by an unsent field, or a binding sends a Message no active Surface lists | Send the field, name it local, or list the Message on the Surface |
| UnrestorablePart | A part cannot restore its own capture | A bug in that part |
| DuplicateStaticRegion | Two SSR.static regions share an id | Give each its own id |
| BindingInStaticRegion | A handler inside an SSR.static region | Move the element out, into a Surface |
| UndeclaredSurfaces | The page has bindings and the plan no surfaces | Name the Surfaces the page may activate |
| UnencodableBinding | The plan has no Message Schema, or a binding's Message does not encode through it | Make the plan from the application; build the Message with its constructor |
| EagerStartRequired | A deferred plan would start a Subscription or Managed Resource late | Declare it in deferrable, or start 'now' |
| VoidRootWithStyles | styles were given for a view whose root is void and holds nothing | Put the styles in the template's head with SSR.generate, or return an element root |
| UngeneratablePath | SSR.generate was given a path no file can be served at | Leave out the query and fragment; keep paths to distinct files |
In the browser, SSR.hydrate checks in Foldkit's order: the build id first,
then the envelope. SSR.resume reads the envelope off the stamped root and
returns its refusal as ResumeRefused: a page with no stamped root or more
than one, a root with no envelope (Missing, also for a second root as
Duplicate), Unreadable JSON, another Protocol version or Plan, state,
parts or bindings that are Invalid, or a Route other than the one the page
was rendered for. A page is resumed whole or not at all.
A page the browser does not take over
A page can be declined before anything is adopted: when(document) says
whether to take it over (a page rendered for a visitor, opened by an author),
and fresh(plan.version) whether its data is still current. A declined or
stale page is contained, as a refused one is, unless otherwise: 'render'
runs the application afresh in its place.
Drawn afresh at once, the page would show the fresh application's first draw,
usually its loading state, until its reads answer: the page blinks to "Loading"
and back. until(model) keeps the served page in view meanwhile. The fresh
application draws in a hidden element beside it, and when a Model it has drawn
satisfies until, it takes the page's place in one step, with the served
page's id. It never waits longer than UNTIL_LIMIT_MS (3 s). The CMS site
(examples/cms/src/ssr/sitePlan.ts) declines a page opened by an author, and
waits for the route's read and its Blocks' reads, which its Site.answered
checks with RemoteData.answered and QueryBlock.active(...).answered:
SSR.hydrate(config, plan, { buildId, when, otherwise: 'render', until: Site.answered })The served page stays as it was served while it waits: its links are plain links, and nothing it shows becomes live.
Costs and limits
SSR.renderrenders the view twice. That is the view check. A static region's render runs once.SSR.hydraterelies on how Foldkit hydrates today. It gives Foldkit a config whoseinitreturns the resumed Model, and removes theFlagskey, which Foldkit would otherwise require a payload for. The tests pin both, and fail the day Foldkit changes. foldkit#1449 asks whether Foldkit would support this directly.- A resumable page leans on two Foldkit behaviours, each pinned by a test
here and by Foldkit's own: the first patch removes attributes the browser's
view does not assert, and a control's value is re-asserted to the Model's.
It does not depend on
Runtime.hydraterendering its first frame before it returns, which Foldkit does today without promising it: until that frame commits (Render.afterCommit), the page keeps answering from its markers and sends the live page what only it can answer. - Development is not production. Under Vite's dev server, Foldkit's model preservation restores the previous Model after a reload and skips adoption.
- A closure handler makes its event wait for boot.
SSR.render's result lists each underunnamed({ element: 'button#point', event: 'pointerdown' }), and on a page that waits to bootSSR.entryandSSR.generatewarn about each once per process, naming the fix.
Lower-level API
SSR.render and SSR.hydrate are built from pieces usable on their own:
SSR.envelope(plan, model, { route? })returns the envelope for a Model, as JSON, andSSR.resume(plan, document, { route? })reads it back as aResult.SSR.rendercarries it on the stamped root, beside Foldkit's own stamps; the JSON escapes<as Foldkit escapes its Flags, and U+2028 and U+2029, and the attribute escapes&and"besides, so no string in the Model can break out of the value. Hydration drops the attribute on its first patch, so the browser reads it before it boots.SSR.inspect(plan, model): the plan's coverage as data.Resume.bindings(plan, document, root, model)decodes and checks a page's bindings, andResume.listen(root, { bindings, onAnswer, events? })answers events from the markers, one{ event, messages, unnamed? }per event.eventsis the envelope's list of every event the markers name; without itlistenreads every element's attributes to find them.SSR.hydrateuses both, passing the list; they are there for a custom boot.- Attribute names, for tools and tests:
RESUME_ATTRIBUTE(the envelope on the stamped root),STATIC_ATTRIBUTE,BINDING_ATTRIBUTE(a prefix, followed by the event),SLOT_ATTRIBUTE(a placement's root while the server renders) andFALLBACK_FIELD(the posted Message).
The application in these examples
A post page. App is Surface.application({ Model, Message, initial, update
}). The Surfaces (PostActions, Menu, Like), Data (a Remote.make
domain over App.model.remote) and LoadPreferences (a Command.define) are
the application's own. PostActionsAt and PostPageAt are Surfaces placed
for a plan, as Surface.when(PostActions, App.model.route, AppRoute.Post, route
=> ({ id: route.id })) places one in
the coverage example.
const AppRoute = defineRouteUnion({ Home: {}, Post: { id: Schema.String } })
const Model = Schema.Struct({
route: AppRoute,
post: Schema.Struct({
id: Schema.String,
title: Schema.String,
body: Schema.String,
liked: Schema.Boolean,
}),
draft: Schema.String,
menuOpen: Schema.Boolean,
remote: Remote.Model,
})
const Modifiers = Schema.Struct({
shiftKey: Schema.Boolean,
ctrlKey: Schema.Boolean,
altKey: Schema.Boolean,
metaKey: Schema.Boolean,
})
const Message = defineMessageUnion({
...Remote.messages,
Liked: { id: Schema.String },
ChangedDraft: { value: Schema.String },
Renamed: { id: Schema.String, title: Schema.String },
Pressed: { key: Schema.String, modifiers: Modifiers },
})See also
- What the tests prove.
- What this package asks of Foldkit.
- Site, Remote and SSR together: a routed application rendered with its server data, taken over, and built statically.
