@finesoft/front 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # 4. Rendering & hydration
2
2
 
3
- How a page travels from controller output to bytes on the wire, then back into a live browser app. This chapter covers SSR, CSR, prerender, and the `PrefetchedIntents` machinery that ties them together.
3
+ How a page travels from controller output to bytes on the wire, then back into a live browser app. This chapter covers SSR, CSR, prerender, the second axis they compose with — **app architecture** (flat single page vs structured navigation + islands) — and the `PrefetchedIntents` machinery that ties them together.
4
4
 
5
5
  ## The three modes side by side
6
6
 
@@ -15,6 +15,25 @@ How a page travels from controller output to bytes on the wire, then back into a
15
15
 
16
16
  Mode is **per-route**. Mix freely.
17
17
 
18
+ ## Two axes: render mode × app architecture
19
+
20
+ Render mode is one axis. The **app architecture** is a second, orthogonal axis:
21
+
22
+ - **Flat single page** — `createSSRRender` on the server, a single client mount that re-renders on each navigation. One root, one visible page. (See [SSR pipeline](#ssr-pipeline) below.)
23
+ - **Structured navigation + islands** — `createSSRNavigationRender` on the server, per-destination _islands_ on the client: independent roots that stay alive across tab/stack switches. (See [Navigation](./11-navigation.md) and [Islands SSR](#islands-ssr-structured-architecture-approach-c) below.)
24
+
25
+ The two axes compose into a matrix — render mode decides _when/where_ HTML is produced; architecture decides _how_ the app is structured:
26
+
27
+ | | Flat single page | Structured nav + islands (approach C) |
28
+ | ------------- | ------------------------- | ------------------------------------- |
29
+ | **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`, `react-minimal` |
30
+ | **csr** | ◐ shell → one client root | ◐ shell → islands mount client-side |
31
+ | **prerender** | ◐ cached flat SSR | ◐ cached approach-C SSR |
32
+
33
+ ✅ demonstrated by a starter template · ◐ composes by design, no starter template yet.
34
+
35
+ **Islands are SSR'd or CSR'd as a consequence of the mode, not as a separate choice:** under `ssr`/`prerender` the framework server-renders each visible island and the client _adopts and hydrates_ it; under `csr` there is no server HTML, so every island mounts fresh on the client. The per-mode sub-dimensions still apply on top — CSR has two triggers ([below](#csr-client-side-render)), prerender has build-time-static and runtime-ISR forms ([below](#prerender-static--isr)). Session restoration + DOM restore are a further orthogonal layer (client-side, post-hydration) that stacks onto any cell — see [Session restoration](./12-session-restoration.md).
36
+
18
37
  ## SSR pipeline
19
38
 
20
39
  ```
@@ -83,6 +102,57 @@ The Vite plugin and adapters call `render(url, options)` for you. You return `{
83
102
  - Sets HTTP status from `deny()` / `redirect()` / `rewrite()` results
84
103
  - Adds `Content-Location` header when `afterLoad` signaled a rewrite
85
104
 
105
+ ## Islands SSR (structured architecture, "approach C")
106
+
107
+ The structured architecture renders the **chrome** (tab bar, headers — the persistent frame) and the **island content** (the active page) as **independent hydration roots**, placed as siblings under the mount node:
108
+
109
+ ```html
110
+ <div id="app">
111
+ <div data-fs-chrome><!-- chrome SSR'd here --></div>
112
+ <main data-fs-outlet><!-- each visible island SSR'd here --></main>
113
+ </div>
114
+ ```
115
+
116
+ **Server** — `renderApp` renders the chrome; `renderIslandsHtml(snapshot, renderEntry)` renders each visible destination into the outlet with shared markers (`data-fs-entry` / `data-fs-intent` / `data-fs-key`) so the client can match them:
117
+
118
+ ```ts
119
+ // src/ssr.ts — structured entry (createSSRNavigationRender)
120
+ async renderApp(page, _framework, snapshot) {
121
+ const chromeHtml = await renderToString(createSSRApp(App, { snapshot }));
122
+ const islandsHtml = await renderIslandsHtml(snapshot, (entry) =>
123
+ renderToString(createSSRApp(VIEWS[entry.intent], { page: entry.page })),
124
+ );
125
+ return {
126
+ html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
127
+ head: `<title>${page.title}</title>`,
128
+ css: "",
129
+ };
130
+ }
131
+ ```
132
+
133
+ **Client** — `resolveIslandsShell(target)` locates (or creates) the chrome/outlet siblings and reports whether the chrome was server-rendered (`hydrate`). The island orchestrator adopts each SSR'd container by `data-fs-key` and calls your `mountEntry(entry, container)` with `entry.hydrate = true`, so you hydrate the existing DOM rather than create new:
134
+
135
+ ```ts
136
+ // src/main.ts
137
+ const mountEntry = (entry, container) => {
138
+ const factory = entry.hydrate ? createSSRApp : createApp; // hydrate SSR'd vs mount fresh (client nav)
139
+ const app = factory(VIEWS[entry.intent], { page: entry.page, controller: ctx.app });
140
+ app.mount(container);
141
+ return { unmount: () => app.unmount() };
142
+ };
143
+
144
+ startBrowserApp({
145
+ bootstrap,
146
+ mount,
147
+ callbacks,
148
+ navigation: { ...navigation.toBrowserConfig(), mountEntry },
149
+ });
150
+ ```
151
+
152
+ > **Synchronous-mount contract.** After `mountEntry` returns, the island's DOM must already exist: the framework restores `data-restore-root` fields on the next animation frame (see [Session restoration](./12-session-restoration.md)). Vue/Svelte `.mount()` satisfies this synchronously. **React** commits asynchronously, so wrap the **client-mount** path in `flushSync(() => root.render(view))` — only client-mounted islands need it (SSR'd islands already have their DOM from the server). See `templates/react-minimal/src/main.tsx`.
153
+
154
+ Complete examples: `templates/vue-minimal` and `templates/react-minimal` (both `ssr` + structured navigation + islands + session restoration).
155
+
86
156
  ## CSR (client-side render)
87
157
 
88
158
  For routes marked `renderMode: "csr"`, the server returns a minimal shell:
@@ -0,0 +1,355 @@
1
+ # 11. Navigation
2
+
3
+ Chapters 2–4 cover the **flat single-page** lifecycle: one URL → one intent → one page. This chapter adds **structured navigation** — a recursive, UI-agnostic navigation tree analogous to SwiftUI's `NavigationStack`, `TabView`, and `NavigationSplitView`.
4
+
5
+ The framework owns navigation **state**, URL/history wiring, and per-destination intent dispatch. It ships **no UI**. Your `Page` models stay exactly as content-agnostic as before — you render tabs, stacks, and split views however you like with Svelte, React, or Vue.
6
+
7
+ A single-leaf tree is **byte-for-byte** the flat single-page behavior, so this is fully opt-in: apps that never call `defineNavigation` are unaffected.
8
+
9
+ ## The mental model
10
+
11
+ Navigation state is a tree of four node kinds:
12
+
13
+ ```
14
+ NavigationNode = LeafNode | StackNode | TabsNode | SplitNode
15
+ ```
16
+
17
+ | Node | Holds | Meaning | SwiftUI |
18
+ | ----------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------- |
19
+ | `LeafNode` | `intent` + `params` | One destination (one intent dispatch) | a destination view |
20
+ | `StackNode` | ordered `entries[]` | A path: `entries[0]` is the root, the last is the visible top | `NavigationStack` |
21
+ | `TabsNode` | `active` key + `branches` | Parallel branches; **only the active one is visible** | `TabView` |
22
+ | `SplitNode` | `columns[]` + optional `visibility` | Side-by-side columns; visible set is **all columns by default**, narrowable to `detailOnly` / `doubleColumn` | `NavigationSplitView` |
23
+
24
+ A leaf carries `intent` + `params`, **not** a `Page`. The tree is pure, serializable data describing _where_ to go; the controller produces _what's there_ (the `Page`) during resolution and hands it back in a snapshot. This is what keeps the tree URL- and history-friendly.
25
+
26
+ Interior nodes nest recursively — a `TabView` of `NavigationStack`s, a split whose detail column is a stack, and so on.
27
+
28
+ ## Declaring a tree
29
+
30
+ Constructors live alongside everything else in `@finesoft/front`:
31
+
32
+ ```ts
33
+ import { leaf, stack, tabs, split } from "@finesoft/front";
34
+
35
+ // A single destination — equivalent to today's flat page
36
+ leaf("home");
37
+ leaf("product", { id: 42 });
38
+
39
+ // A stack: root only, or root + already-pushed entries
40
+ stack(leaf("feed"));
41
+ stack([leaf("feed"), leaf("post", { id: 7 })]);
42
+
43
+ // Tabs: each branch is its own stack
44
+ tabs({
45
+ active: "home",
46
+ branches: {
47
+ home: stack(leaf("home")),
48
+ search: stack(leaf("search")),
49
+ me: stack(leaf("me")),
50
+ },
51
+ });
52
+
53
+ // Split: sidebar + detail, where detail is a stack
54
+ split([
55
+ { id: "sidebar", content: leaf("folders") },
56
+ { id: "detail", content: stack(leaf("folder", { id: "inbox" })) },
57
+ ]);
58
+ ```
59
+
60
+ `tabs()` derives a stable tab `order` from the `branches` insertion order unless you pass `order` explicitly. `stack()` accepts a single root node or an array of entries.
61
+
62
+ ## A TabView of NavigationStacks
63
+
64
+ The most common shape: a bottom tab bar where each tab keeps its own navigation depth.
65
+
66
+ ```ts
67
+ // src/bootstrap.ts
68
+ import { type Framework, defineRoutes, defineNavigation, leaf, stack, tabs } from "@finesoft/front";
69
+ import { HomeController } from "./lib/controllers/home";
70
+ import { SearchController } from "./lib/controllers/search";
71
+ import { ProfileController } from "./lib/controllers/profile";
72
+ import { PostController } from "./lib/controllers/post";
73
+
74
+ export function bootstrap(framework: Framework): void {
75
+ defineRoutes(framework, [
76
+ { path: "/", intentId: "home", controller: new HomeController() },
77
+ { path: "/search", intentId: "search", controller: new SearchController() },
78
+ { path: "/me", intentId: "me", controller: new ProfileController() },
79
+ { path: "/posts/:id", intentId: "post", controller: new PostController() },
80
+ ]);
81
+ }
82
+
83
+ // The navigation structure, declared once
84
+ export const navigation = defineNavigation({
85
+ initial: tabs({
86
+ active: "home",
87
+ branches: {
88
+ home: stack(leaf("home")),
89
+ search: stack(leaf("search")),
90
+ me: stack(leaf("me")),
91
+ },
92
+ }),
93
+ });
94
+ ```
95
+
96
+ `defineNavigation` returns a normalized definition with two adapters — `toBrowserConfig()` for CSR and `toSSRDefinition()` for SSR — so you declare the tree **once** and hand the right shape to each runner.
97
+
98
+ ### Wiring it into the browser
99
+
100
+ `startBrowserApp` gains an optional `navigation` field; when present, the `NavigationHandle` (and a unified `app` handle) is handed to your `mount` callback in its context, ready to use:
101
+
102
+ ```ts
103
+ // src/main.ts
104
+ import { startBrowserApp } from "@finesoft/front";
105
+ import { bootstrap, navigation } from "./bootstrap";
106
+
107
+ startBrowserApp({
108
+ bootstrap,
109
+ callbacks,
110
+ navigation: navigation.toBrowserConfig(),
111
+ mount(target, { navigation: nav, app }) {
112
+ // nav/app are ready at mount time (no callback needed).
113
+ // Re-render whenever the snapshot changes:
114
+ nav?.subscribe((snapshot) => mountNavigation(snapshot));
115
+ if (nav) mountNavigation(nav.getSnapshot());
116
+ // ... mount your UI into `target`, pass `app` to components ...
117
+ return () => undefined;
118
+ },
119
+ });
120
+ ```
121
+
122
+ When `navigation` is present, the framework builds a `NavigationController` and a history bridge, resolves the first screen, and gives you the handle in the mount context. When it's absent, `startBrowserApp` runs the original flat single-page path unchanged.
123
+
124
+ ## Driving navigation
125
+
126
+ The `NavigationHandle` exposes the operations. Each returns a `Promise<NavigationSnapshot>` (the committed tree plus every visible destination's resolved `Page`) and, in the browser, writes the new state to history/URL.
127
+
128
+ ```ts
129
+ // Push a destination onto the active stack
130
+ await handle.push("post", { id: 7 });
131
+
132
+ // Pop back
133
+ await handle.pop(); // one level
134
+ await handle.pop(2); // two levels — never past the stack root
135
+ await handle.popToRoot();
136
+
137
+ // Replace the current top (e.g. login → dashboard without a back step)
138
+ await handle.replaceTop("dashboard");
139
+
140
+ // Switch the active tab — the other tabs keep their stack depth
141
+ await handle.selectTab("search");
142
+ ```
143
+
144
+ `pop` never pops below a stack's root entry. With no explicit target, stack operations act on the **deepest active stack** (the one currently visible), and `selectTab` acts on the **outermost** tabs node — exactly what you want for a tab bar driving the focused stack.
145
+
146
+ ### Reading the result
147
+
148
+ A `NavigationSnapshot` is what you render:
149
+
150
+ ```ts
151
+ const snapshot = handle.getSnapshot();
152
+ snapshot.tree; // the current NavigationNode tree
153
+ snapshot.destinations; // ResolvedDestination[]: { intent, params, page, status? }
154
+ ```
155
+
156
+ `destinations` is ordered to match `collectVisibleDestinations(tree)`: a tabs node contributes **only** its active branch, a split contributes **every** non-empty column. That ordering is also exactly what gets prefetched on the server.
157
+
158
+ Your view layer walks `snapshot.tree` to lay out the chrome (which tabs exist, how deep each stack is) and reads `snapshot.destinations` for the page content. The framework never tells you _how_ to draw any of it.
159
+
160
+ ## A NavigationSplitView
161
+
162
+ A split view shows multiple columns at once — the classic sidebar + detail (+ sub-detail) layout. Selecting in one column drives the next.
163
+
164
+ ```ts
165
+ export const navigation = defineNavigation({
166
+ initial: split([
167
+ { id: "sidebar", content: leaf("mailboxes") },
168
+ { id: "list", content: undefined }, // chosen later
169
+ { id: "detail", content: undefined },
170
+ ]),
171
+ });
172
+ ```
173
+
174
+ Use `selectColumn(columnId, intent, params?)` to set a column's content:
175
+
176
+ ```ts
177
+ // Pick a mailbox → fills the "list" column
178
+ await handle.selectColumn("list", "messages", { mailbox: "inbox" });
179
+
180
+ // Pick a message → fills the "detail" column
181
+ await handle.selectColumn("detail", "message", { id: 1024 });
182
+
183
+ // Re-pick a mailbox → clears "list" AND "detail" (everything after it)
184
+ await handle.selectColumn("list", "messages", { mailbox: "archive" });
185
+
186
+ // Clear a column explicitly by passing undefined for the intent
187
+ await handle.selectColumn("detail", undefined);
188
+ ```
189
+
190
+ Setting a column **clears every column after it**. Re-choosing the sidebar correctly invalidates the open detail, so you never render a stale "old detail with a new sidebar" combination.
191
+
192
+ By default every column is visible, so the snapshot's `destinations` contains one entry **per non-empty column** — the framework dispatches (and on the server, prefetches) each of them.
193
+
194
+ ### Column visibility
195
+
196
+ Mirroring SwiftUI's `NavigationSplitViewVisibility`, a split carries an optional **visibility** — bindable, serializable navigation state (not styling) that decides which columns count as visible, and therefore what gets prefetched on the server:
197
+
198
+ | `visibility` | Visible columns |
199
+ | ----------------------------- | ------------------------------- |
200
+ | `automatic` (default) / `all` | every column |
201
+ | `doubleColumn` | first + last (hides the middle) |
202
+ | `detailOnly` | last (detail) only |
203
+
204
+ ```ts
205
+ import { SPLIT_VISIBILITIES, visibleSplitColumns } from "@finesoft/front";
206
+
207
+ // Declare it up front (e.g. deep-link straight to the detail)
208
+ split(
209
+ [
210
+ { id: "sidebar", content: leaf("mailboxes") },
211
+ { id: "detail", content: leaf("message", { id: 7 }) },
212
+ ],
213
+ SPLIT_VISIBILITIES.DETAIL_ONLY,
214
+ );
215
+
216
+ // Or change it at runtime — newly-visible columns are dispatched, hidden ones are dropped from the snapshot
217
+ await handle.setVisibility(SPLIT_VISIBILITIES.DETAIL_ONLY); // only the detail destination remains
218
+ await handle.setVisibility(SPLIT_VISIBILITIES.ALL); // re-prefetches sidebar + list
219
+
220
+ // Render only the visible columns without re-implementing the mapping
221
+ for (const col of visibleSplitColumns(splitNode)) renderColumn(col);
222
+ ```
223
+
224
+ `detailOnly` deep-links resolve and prefetch **only** the detail column on the server — the hidden columns cost nothing until shown. Compact-width collapse (SwiftUI's `preferredCompactColumn`) is viewport-reactive rendering, so it stays entirely in your hands; read `getPlatform()` / the viewport and collapse the split into a stack view however you like.
225
+
226
+ ## Targeting a nested container
227
+
228
+ When a tree has more than one stack/tabs/split, pass an explicit `target` path to operate on a deeper one. A path is a list of steps from the root:
229
+
230
+ ```ts
231
+ import type { NavigationPath } from "@finesoft/front";
232
+
233
+ // The stack inside the detail column of a split
234
+ const detailStack: NavigationPath = [
235
+ { kind: "column", id: "detail" },
236
+ { kind: "stack-entry", index: 0 },
237
+ ];
238
+
239
+ await handle.push("attachment", { id: 3 }, { target: detailStack });
240
+ await handle.selectTab("photos", someTabsPath);
241
+ ```
242
+
243
+ Without a `target`, operations default to the active path, which is the right choice the vast majority of the time.
244
+
245
+ ## Pure operations (no controller needed)
246
+
247
+ Everything above is backed by pure, immutable tree functions you can use directly — for tests, optimistic computation, or building your own controller:
248
+
249
+ ```ts
250
+ import {
251
+ push,
252
+ pop,
253
+ selectTab,
254
+ collectVisibleDestinations,
255
+ resolveActivePath,
256
+ } from "@finesoft/front";
257
+
258
+ const next = push(tree, leaf("post", { id: 7 })); // returns a new tree
259
+ const visible = collectVisibleDestinations(next); // readonly LeafNode[]
260
+ const activePath = resolveActivePath(next);
261
+ ```
262
+
263
+ These never mutate their input — only the nodes on the changed path are rebuilt; the rest of the tree is shared by reference. Invalid targets (e.g. `selectTab` on a non-tabs node, popping an empty stack target) throw a `NavigationError`.
264
+
265
+ ## Server-side rendering
266
+
267
+ SSR prefetches **all** visible destinations and serializes them — plus the tree itself — into the HTML, so the browser's first render reuses the server result without refetching. Multi-column split views naturally prefetch multiple intents.
268
+
269
+ Use `createSSRNavigationRender` with the SSR adapter:
270
+
271
+ ```ts
272
+ // src/ssr.ts
273
+ import { createSSRNavigationRender } from "@finesoft/front";
274
+ import { bootstrap, navigation } from "./bootstrap";
275
+ import { renderApp } from "./lib/render";
276
+
277
+ export const render = createSSRNavigationRender({
278
+ bootstrap,
279
+ getErrorPage: (status, message) => ({
280
+ id: `error-${status}`,
281
+ pageType: "error",
282
+ title: message,
283
+ }),
284
+ renderApp, // (page, framework, snapshot) => { html, head, css }
285
+ navigation: navigation.toSSRDefinition(),
286
+ });
287
+ ```
288
+
289
+ `renderApp` receives three arguments: the **primary** page (the active leaf's result — compatible with the flat SSR `renderApp` signature), the framework, and the full multi-region `snapshot` so you can render tabs/split layouts:
290
+
291
+ ```ts
292
+ function renderApp(page, framework, snapshot) {
293
+ // page → the focused destination (e.g. for <title>, status)
294
+ // snapshot.tree → which tabs/columns to draw
295
+ // snapshot.destinations → the Page for each visible region
296
+ return renderYourFramework(snapshot);
297
+ }
298
+ ```
299
+
300
+ For the concrete islands shell that `renderApp` builds — chrome + per-destination islands as independent hydration roots, plus the client-side `mountEntry` / `resolveIslandsShell` that adopt and hydrate them — see [Islands SSR](./04-rendering-and-hydration.md#islands-ssr-structured-architecture-approach-c).
301
+
302
+ How it works under the hood: each visible destination is serialized through the **existing** `PrefetchedIntents` channel as a normal `{ intent, data: page }` entry, plus one sentinel entry carrying the serialized tree. `@finesoft/server` needs **zero changes** — it transports the sentinel through the same `#serialized-server-data` script. On hydration the browser bridge reads the tree back from history state (or the sentinel) and reuses the prefetched pages.
303
+
304
+ If a request has no structural deep-link and your app provides no skeleton, SSR falls back to `Router.resolve(url)` → a single leaf — i.e. today's flat single page, including its `renderMode`. The 404 path is unchanged.
305
+
306
+ ## Deep-linking with `createFullStateCodec`
307
+
308
+ By default, the **active leaf** drives the URL (`/posts/7`) and the full tree travels via history state — clean, shareable URLs for the focused destination. To encode the **entire** tree into the URL for full deep-linking (sharing a link that restores tabs, stack depth, and split selections), opt into `createFullStateCodec`:
309
+
310
+ ```ts
311
+ import { createFullStateCodec } from "@finesoft/front";
312
+
313
+ export const navigation = defineNavigation({
314
+ initial: tabs({
315
+ active: "home",
316
+ branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
317
+ }),
318
+ codec: createFullStateCodec(), // whole tree → "?__nav=..." query param
319
+ });
320
+ ```
321
+
322
+ Now URLs look like `/me?__nav=<encoded-tree>`, and pasting one restores the complete navigation state on both SSR and the browser. The encoding is compact (base64url), stable (sorted keys, so the same tree always yields the same string), and lossless. Pass `createFullStateCodec({ param: "nav" })` to rename the reserved query parameter.
323
+
324
+ You can also implement a custom `NavigationCodec` if you need a bespoke URL scheme — both built-ins only depend on the router's `getRoutes()` (and optional `reverse()`), nothing more.
325
+
326
+ ## Guards still work
327
+
328
+ Navigation-level `beforeLoad` / `afterLoad` guards run for the **primary** destination (the active leaf) on every navigation, with the same `redirect` / `rewrite` / `deny` semantics as [chapter 3](./03-middleware.md):
329
+
330
+ ```ts
331
+ export const navigation = defineNavigation({
332
+ initial: tabs({
333
+ active: "home",
334
+ branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
335
+ }),
336
+ beforeLoad: [authGuard],
337
+ });
338
+ ```
339
+
340
+ - `redirect` → handled as an in-app navigation (the browser reuses the FlowAction pipeline); the target isn't dispatched.
341
+ - `rewrite` → the new URL is re-resolved into the destination's intent/params.
342
+ - `deny` → the destination is marked with the deny status and its intent is not dispatched.
343
+
344
+ A single destination's dispatch failure never throws out of an operation — it records a `status` and a fallback page on that destination (the same `fallback` safety net as controllers), so one failing split column can't blank the whole screen.
345
+
346
+ ## Backward compatibility
347
+
348
+ - Apps that don't pass `navigation` to `startBrowserApp` / `createSSRRender` run the **original flat path** with zero behavior change.
349
+ - A single-leaf tree is equivalent to the flat single page: one visible destination, one resolve/dispatch, one before/after pass. SSR only adds the tree sentinel to `serverData` (stripped before it reaches `PrefetchedIntents`).
350
+ - `Page` stays content-agnostic. Navigation adds structure _around_ your pages; it never dictates their shape or how you render them.
351
+
352
+ ## Next
353
+
354
+ - [Middleware](./03-middleware.md) — the guard semantics navigation reuses
355
+ - [Rendering & hydration](./04-rendering-and-hydration.md) — the render-mode × architecture matrix, the islands SSR shell, and how prefetched results cross the SSR → CSR boundary