@finesoft/front 0.3.0 → 0.4.1

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:
@@ -123,21 +193,18 @@ The adapter serves these static files directly. No controller runs at request ti
123
193
 
124
194
  ### Incremental Static Regeneration (ISR)
125
195
 
126
- The bundled server (`createServer`) and the preview server (`vp preview`) support cached on-demand regeneration. Configure via `finesoftFrontViteConfig`:
196
+ The bundled server (`createServer`) and the preview server (`vp preview`) also cache `prerender` routes at runtime: a route is rendered on its **first** request and the HTML kept in an in-memory LRU (`ISR_CACHE_MAX = 1000` entries, evicted least-recently-used). Subsequent requests serve the cached HTML without re-running the controller.
197
+
198
+ Mark routes `prerender` per route (`renderMode: "prerender"`) or per glob via the Vite plugin (config-level wins over route-level):
127
199
 
128
200
  ```ts
129
201
  finesoftFrontViteConfig({
130
202
  ssr: { entry: "src/ssr.ts" },
131
- isr: {
132
- // routes that should regenerate on demand
133
- routes: ["/blog/*"],
134
- // cache TTL in seconds
135
- ttl: 300,
136
- },
203
+ renderModes: { "/blog/*": "prerender" },
137
204
  });
138
205
  ```
139
206
 
140
- The first request after expiry triggers a fresh render; concurrent requests get the stale version until the regeneration completes. See [server & deployment](./09-server-and-deployment.md#isr) for details.
207
+ The runtime cache has **no TTL and no background regeneration** entries live until LRU-evicted or the process restarts. Time-based stale-while-revalidate is delegated to the CDN by the platform adapters (Netlify emits a real `stale-while-revalidate` header; Cloudflare a plain `max-age`; node/Vercel none). See [server & deployment](./09-server-and-deployment.md#isr-incremental-static-regeneration) for the full picture.
141
208
 
142
209
  ## `PrefetchedIntents` — the SSR → CSR bridge
143
210
 
@@ -22,7 +22,7 @@ export default defineConfig({
22
22
  i18n: { messagesDir: "src/locales" },
23
23
  proxies: [{ prefix: "/api", target: "https://upstream.example" }],
24
24
  adapter: "auto",
25
- isr: { routes: ["/blog/*"], ttl: 300 },
25
+ renderModes: { "/blog/*": "prerender" },
26
26
  }),
27
27
  ],
28
28
  });
@@ -30,13 +30,13 @@ export default defineConfig({
30
30
 
31
31
  ### Options
32
32
 
33
- | Option | Type | Notes |
34
- | ------------------ | ------------------------- | ------------------------------------------------------- |
35
- | `ssr.entry` | `string` | Path to your SSR entry (default `src/ssr.ts`). |
36
- | `i18n.messagesDir` | `string` | Folder with `{locale}.json` files (default off). |
37
- | `proxies` | `ProxyRouteConfig[]` | Declarative API forwarding. See below. |
38
- | `adapter` | `"auto" \| "node" \| ...` | Target platform. `"auto"` detects from env vars. |
39
- | `isr` | `{ routes, ttl }` | Incremental Static Regeneration for prerendered routes. |
33
+ | Option | Type | Notes |
34
+ | ------------------ | ---------------------------- | ------------------------------------------------------------------------------ |
35
+ | `ssr.entry` | `string` | Path to your SSR entry (default `src/ssr.ts`). |
36
+ | `i18n.messagesDir` | `string` | Folder with `{locale}.json` files (default off). |
37
+ | `proxies` | `ProxyRouteConfig[]` | Declarative API forwarding. See below. |
38
+ | `adapter` | `"auto" \| "node" \| ...` | Target platform. `"auto"` detects from env vars. |
39
+ | `renderModes` | `Record<string, RenderMode>` | Per-route render-mode override (glob keys); `"prerender"` enables ISR caching. |
40
40
 
41
41
  ### What it does
42
42
 
@@ -55,21 +55,20 @@ In build:
55
55
 
56
56
  ## `createServer` — the standalone Hono server
57
57
 
58
- For Node deployments and tests, the framework exports a function that gives you a ready-to-run Hono app:
58
+ For Node deployments and tests, the framework exports an async factory. It loads `.env`, detects the runtime, builds the Hono app, registers proxies + your `setup` routes, mounts the SSR catch-all, and **starts listening** (port from config or `PORT`, default `3000`) — then returns `{ app, vite, runtime }`:
59
59
 
60
60
  ```ts
61
61
  import { createServer } from "@finesoft/front";
62
62
 
63
- const app = createServer({
64
- ssrEntry: "./dist/server/ssr.js",
63
+ const { app } = await createServer({
64
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" }, // or ssrEntryPath in dev
65
65
  proxies: [{ prefix: "/api", target: "https://upstream.example" }],
66
- staticDir: "./dist/client",
67
- isr: { routes: ["/blog/*"], ttl: 300 },
66
+ port: 3000,
68
67
  });
69
68
 
70
- // app is a Hono instance — mount it however your runtime expects
71
- import { serve } from "@hono/node-server";
72
- serve({ fetch: app.fetch, port: 3000 });
69
+ // `app` is the started Hono instance — export it for serverless runtimes whose
70
+ // adapter imports the fetch handler (Vercel / Cloudflare / Netlify).
71
+ export { app };
73
72
  ```
74
73
 
75
74
  ### What it includes
@@ -149,48 +148,58 @@ This works for most CI environments — Vercel / Cloudflare / Netlify all set th
149
148
 
150
149
  ## ISR (Incremental Static Regeneration)
151
150
 
151
+ Mark routes `prerender` — per route (`renderMode: "prerender"`) or per glob via the Vite plugin's `renderModes` (config-level wins over route-level):
152
+
152
153
  ```ts
153
- isr: {
154
- routes: ["/blog/*", "/products/*"],
155
- ttl: 300, // seconds
156
- }
154
+ finesoftFrontViteConfig({
155
+ ssr: { entry: "src/ssr.ts" },
156
+ renderModes: { "/blog/*": "prerender", "/products/*": "prerender" },
157
+ });
157
158
  ```
158
159
 
159
- How it works:
160
+ A `prerender` route is served two ways:
160
161
 
161
- 1. First request to `/blog/hello-world`: render fully, cache the HTML, set expiry to now + 300s
162
- 2. Subsequent requests within TTL: serve cached HTML directly
163
- 3. After expiry: next request triggers re-render; concurrent requests get stale HTML until re-render finishes
162
+ 1. **Build-time static** the static adapter renders each prerender route at build and writes `dist/<route>.html` (one per locale when i18n is on). Served as plain static files; no controller runs at request time.
163
+ 2. **Runtime cache** — the bundled server (`createServer`) and `vp preview` render a prerender route on its **first** request and store the HTML in an in-memory LRU (`ISR_CACHE_MAX = 1000` entries, evicted least-recently-used). Subsequent requests serve the cached HTML without re-running the controller.
164
164
 
165
- The cache is in-memory per server instance. For multi-instance deployments where consistency matters, put a CDN in front and use HTTP `Cache-Control` headers instead.
165
+ > **No TTL, no background regeneration.** The runtime cache has no time-based expiry and no stale-while-revalidate — an entry lives until it is LRU-evicted or the process restarts. The "regenerate after N seconds" semantics live at the **CDN**, not in the framework (below). There is no `isr` config option and no programmatic invalidation API.
166
166
 
167
- Routes not matched by `isr.routes` always render fresh.
167
+ ### Stale-while-revalidate is delegated to the CDN
168
168
 
169
- ### Cache invalidation
169
+ Platform adapters set cache headers on prerender responses so the edge does the real ISR:
170
170
 
171
- Programmatic invalidation is not exposed in the public API. To force a refresh:
171
+ | Adapter | Header on prerender responses |
172
+ | ------------------------- | ------------------------------------------------------------------------------------------ |
173
+ | Netlify | `Netlify-CDN-Cache-Control: max-age=3600, stale-while-revalidate=3600, durable` (true SWR) |
174
+ | Cloudflare | `Cache-Control: public, max-age=3600` |
175
+ | Node (self-host) / Vercel | none — relies on the in-memory LRU |
176
+
177
+ The `3600`s window is a hard-coded per-adapter constant, not user-configurable. For multi-instance / multi-region deployments the CDN headers are what give you consistent caching; the in-memory LRU is per-instance single-server serving.
178
+
179
+ ### Cache invalidation
172
180
 
173
- - Restart the server (loses entire cache)
174
- - Wait for TTL
175
- - Add a cache-busting query param the controller can ignore but that bypasses the cache key
181
+ There is no programmatic invalidation API. To force a refresh:
176
182
 
177
- For production, push invalidation up to CDN level — the framework's in-memory cache is for single-instance serving.
183
+ - Restart the server (clears the entire in-memory LRU)
184
+ - Redeploy (rebuilds build-time static and resets caches)
185
+ - On Netlify / Cloudflare, purge the CDN cache for the path
178
186
 
179
187
  ## Custom Hono middleware
180
188
 
181
- If you need server logic outside the proxy and SSR (e.g., a webhook endpoint, a health check), mount it on the same Hono app:
189
+ If you need server logic outside the proxy and SSR (e.g., a webhook endpoint, a health check), register it via the `setup` hook — it runs after proxies but **before** the SSR catch-all, so your routes win:
182
190
 
183
191
  ```ts
184
- const app = createServer({ ssrEntry: "./dist/server/ssr.js" });
185
-
186
- app.get("/health", (c) => c.json({ status: "ok" }));
187
- app.post("/webhook", async (c) => {
188
- const body = await c.req.json();
189
- await handleWebhook(body);
190
- return c.json({ ok: true });
192
+ await createServer({
193
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" },
194
+ setup: (app) => {
195
+ app.get("/health", (c) => c.json({ status: "ok" }));
196
+ app.post("/webhook", async (c) => {
197
+ const body = await c.req.json();
198
+ await handleWebhook(body);
199
+ return c.json({ ok: true });
200
+ });
201
+ },
191
202
  });
192
-
193
- // SSR catch-all is registered last by createServer — your routes win.
194
203
  ```
195
204
 
196
205
  ## Environment variables
@@ -215,25 +224,16 @@ framework.container.register("config", () => ({
215
224
  For Node deployments behind a load balancer:
216
225
 
217
226
  ```ts
218
- import { serve } from "@hono/node-server";
219
-
220
- const app = createServer({
221
- /* ... */
227
+ await createServer({
228
+ ssr: { ssrProductionModule: "./dist/server/ssr.js" },
229
+ setup: (app) => app.get("/health", (c) => c.json({ ok: true })),
222
230
  });
223
- app.get("/health", (c) => c.json({ ok: true }));
224
-
225
- const server = serve({ fetch: app.fetch, port: 3000 });
226
231
 
227
- process.on("SIGTERM", () => {
228
- server.close(() => {
229
- // dispose Framework if you held a reference
230
- framework.dispose();
231
- process.exit(0);
232
- });
233
- });
232
+ // createServer starts the listener itself — no manual serve() needed.
233
+ process.on("SIGTERM", () => process.exit(0));
234
234
  ```
235
235
 
236
- `framework.dispose()` recursively disposes the container, calls `destroy()` on registered recorders/loggers, and unregisters all routes.
236
+ `createServer` does not return the underlying `http.Server`, so there's no built-in `server.close()` connection-drain. If you need graceful draining — or a handle to call `framework.dispose()` (recursively disposes the container, calls `destroy()` on recorders/loggers, unregisters routes) on shutdown — compose the lower level instead: build the Hono app and own framework yourself and `serve()` it so you keep both handles.
237
237
 
238
238
  ## Next
239
239
 
@@ -97,31 +97,29 @@ export const navigation = defineNavigation({
97
97
 
98
98
  ### Wiring it into the browser
99
99
 
100
- `startBrowserApp` gains an optional `navigation` field and an `onNavigationReady` callback that hands you a `NavigationHandle`:
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
101
 
102
102
  ```ts
103
103
  // src/main.ts
104
- import { startBrowserApp, type NavigationHandle } from "@finesoft/front";
104
+ import { startBrowserApp } from "@finesoft/front";
105
105
  import { bootstrap, navigation } from "./bootstrap";
106
- import { mount } from "./lib/mount";
107
-
108
- let handle: NavigationHandle;
109
106
 
110
107
  startBrowserApp({
111
108
  bootstrap,
112
- mount,
113
109
  callbacks,
114
110
  navigation: navigation.toBrowserConfig(),
115
- onNavigationReady(h) {
116
- handle = h;
117
- // Re-render whenever the snapshot changes
118
- h.subscribe((snapshot) => mountNavigation(snapshot));
119
- mountNavigation(h.getSnapshot());
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;
120
118
  },
121
119
  });
122
120
  ```
123
121
 
124
- When `navigation` is present, the framework builds a `NavigationController` and a history bridge, resolves the first screen, and gives you the handle. When it's absent, `startBrowserApp` runs the original flat single-page path unchanged.
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.
125
123
 
126
124
  ## Driving navigation
127
125
 
@@ -299,6 +297,8 @@ function renderApp(page, framework, snapshot) {
299
297
  }
300
298
  ```
301
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
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
303
 
304
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.
@@ -352,4 +352,4 @@ A single destination's dispatch failure never throws out of an operation — it
352
352
  ## Next
353
353
 
354
354
  - [Middleware](./03-middleware.md) — the guard semantics navigation reuses
355
- - [Rendering & hydration](./04-rendering-and-hydration.md) — how prefetched results cross the SSR → CSR boundary
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
@@ -0,0 +1,219 @@
1
+ # 12. Session restoration
2
+
3
+ The framework already restores the **first screen**: SSR injects the prefetched intent results through `PrefetchedIntents`, and the browser reuses them on the first navigation. Structured navigation also carries the current tree across back/forward via `history.state`.
4
+
5
+ But one class of state survives **none** of that: what the user was actually _doing_ when they **hard-reloaded, crashed the tab, or closed and came back** — which screen (or stack depth, or tab, or split column) they were on, the half-typed draft in a form, how far a list was scrolled. The in-memory `history.state` map is wiped by a full reload; `PrefetchedIntents` only covers the one server-rendered screen.
6
+
7
+ **Session restoration** fills that gap: it serializes a versioned, JSON-safe **session snapshot** (navigation position + app-registered state slices + navigation-scoped per-screen state) to a pluggable `Storage`, and rehydrates it on a fresh load. The framework ships **no UI** — it restores **state**, and your app re-renders from it however you like.
8
+
9
+ It is entirely opt-in: an app that never passes `session` to `startBrowserApp` is **byte-for-byte unchanged**.
10
+
11
+ ## The two scopes
12
+
13
+ A snapshot captures two layers of state, serialized together and restored together across a reload:
14
+
15
+ | Scope | Lives in | Keyed by | Lifetime | SwiftUI analogue |
16
+ | --------------------------- | -------- | -------------- | ----------------------------------------------------------------------- | ---------------- |
17
+ | **Global slices** | `slices` | `provider.key` | The whole session (theme, a cross-screen wizard draft…) | `@SceneStorage` |
18
+ | **Navigation-scoped state** | `scoped` | `entryKey` | Bound to one navigation entry — dropped when that entry leaves the tree | `@State` |
19
+
20
+ - **Global slices** are app-wide. You register a `SessionStateProvider` per slice; the framework orchestrates _when_ it is captured and persisted. It never interprets the contents — it only moves them.
21
+ - **Navigation-scoped state** is bound to a _navigation entry_, mirroring the position-scoped lifecycle of a SwiftUI view's `@State` (covered below).
22
+
23
+ ## Global slices: `SessionStateProvider`
24
+
25
+ An app registers one provider per slice. `capture()` returns a JSON-safe synchronous value; `restore(data)` puts it back (your app calls `setState` / refills the form / scrolls):
26
+
27
+ ```ts
28
+ import type { SessionStateProvider } from "@finesoft/front";
29
+
30
+ const themeSlice: SessionStateProvider<string> = {
31
+ key: "theme",
32
+ capture: () => getCurrentTheme(),
33
+ restore: (theme) => applyTheme(theme),
34
+ };
35
+ ```
36
+
37
+ The framework moves the value verbatim and never inspects it — so **you** decide what to capture. Exclude sensitive fields right here in `capture()`; a slice you never register is never captured.
38
+
39
+ ## Navigation-scoped state: the SwiftUI `@State` lifecycle
40
+
41
+ Navigation-scoped state is the interesting half. It is keyed by **entry identity**, not by visibility, and it follows the same position-scoped lifecycle as a SwiftUI view's `@State`:
42
+
43
+ > `A` → push `B` → go back (pop `B`) to `A`: **`B`'s state is discarded, `A`'s state is still there.**
44
+
45
+ The mechanism: each entry's state bag is stored under `entryKey = intent + " " + stableStringify(params)` — the same identity the navigation controller uses for a destination, so it is **stable across a reload**. After every committed navigation, the framework **prunes** the scoped map down to the entries **actually present in the tree** — note _present_, not _visible_. Any key whose entry is no longer in the tree is dropped.
46
+
47
+ ```ts
48
+ import { sessionEntryKey } from "@finesoft/front";
49
+
50
+ // When you render a screen, read/write its scoped bag with the entry's key:
51
+ const key = sessionEntryKey("post", { id: 7 });
52
+ store.scope.set(key, { scroll: 240, draft: "half a comment" });
53
+ const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
54
+ ```
55
+
56
+ Walking through the lifecycle:
57
+
58
+ - **push `B`** → tree `[A, B]`, present `{A, B}` → `A`'s state is **kept** (`A` is still on the stack, just not visible) and `B` gets its own scope.
59
+ - **pop `B`** → tree `[A]`, present `{A}` → **`B`'s scope is pruned away**, `A`'s is kept intact; going back to `A` renders with its retained state.
60
+ - **switch a TabView tab** → the other branches are still in the tree → their state is kept alive (exactly like SwiftUI keeping inactive tabs mounted).
61
+ - **across a reload** → `scoped` is serialized into the snapshot; after reload, every entry still in the tree gets its scope back, and a later pop discards it as usual.
62
+
63
+ `store.scope` is the `NavigationScopedState` instance held by the store — `get` / `set` / `delete` / `keys`, plus the `prune(presentKeys)` the framework calls for you. With the high-level `startBrowserApp({ session })` path you don't hold the store directly: the `SessionHandle` handed to your `mount` callback (context) exposes the same instance as `handle.scope` (still live after a restore rebuilds it), so you `handle.scope.get(entryKey)` / `set(entryKey, data)` the same way.
64
+
65
+ ### Flat vs structured: retention _is_ a stack
66
+
67
+ That "keep `A` under `B`, drop `B` on pop, restore `A`" behavior is, by definition, **stack semantics** — so it only exists in **structured navigation**, where a stack/tree can hold entries that are _present but not visible_.
68
+
69
+ A **flat single page has no stack**: `A → B` is a full-page replacement, so `presentKeys()` is always a single entry (the current URL). The moment you leave a screen its scope is pruned, and a browser **Back** re-renders it fresh.
70
+
71
+ Both modes support "current-screen scope + restore-across-reload". If you want "go Back and keep the previous screen", build it as a structured stack — push instead of replace. That is precisely what `NavigationStack` is _for_; it is not a shortcoming of flat mode.
72
+
73
+ ## The snapshot
74
+
75
+ `createSessionStore(options)` returns the `SessionStore` orchestrator. `capture()` assembles a snapshot without persisting; the snapshot model is:
76
+
77
+ ```ts
78
+ interface SessionSnapshot {
79
+ readonly version: number;
80
+ readonly navigation?: SerializedNavigation | SessionUrlLocation; // structured tree | { url }
81
+ readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
82
+ readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> state bag
83
+ readonly capturedAt: number; // epoch ms, for maxAgeMs expiry
84
+ }
85
+ ```
86
+
87
+ `navigation` is discriminated with a light guard: a `SerializedNavigation` always carries a `kind` (leaf/stack/tabs/split); a flat `SessionUrlLocation` carries a `url`. `isUrlLocation(nav)` tells them apart.
88
+
89
+ The store exposes:
90
+
91
+ ```ts
92
+ interface SessionStore {
93
+ register(provider: SessionStateProvider): () => void; // returns a disposer
94
+ readonly scope: NavigationScopedState;
95
+ capture(): SessionSnapshot; // assemble (nav + slices + scoped), no I/O
96
+ persist(snapshot?: SessionSnapshot): void; // capture() if omitted, then write
97
+ load(): SessionSnapshot | undefined; // read + validate (version / maxAge / shape)
98
+ restore(snapshot?: SessionSnapshot): void | Promise<void>; // load() if omitted, then apply
99
+ clear(): void; // remove the persisted snapshot
100
+ save(): void; // capture + persist — the manual escape hatch
101
+ }
102
+ ```
103
+
104
+ `load()` discards a snapshot whose version mismatches, whose `capturedAt` is older than `maxAgeMs`, or whose shape is malformed — it returns `undefined` rather than ever throwing into your app. A provider that throws in `capture()` / `restore()` is isolated: its slice is skipped, the error goes to `onError`, and the rest of the snapshot survives.
105
+
106
+ ## Persistence: `sessionStorage` by default, swappable
107
+
108
+ The snapshot is encoded with a stable stringify and written as a single `storage.set(key, ...)`. `Storage` is the existing core dependency interface, so durability is **your** choice:
109
+
110
+ ```ts
111
+ import { createWebStorage } from "@finesoft/front";
112
+
113
+ createWebStorage("session"); // sessionStorage — tab-scoped, cleared when the tab closes (default)
114
+ createWebStorage("local"); // localStorage — survives across tabs and restarts
115
+ ```
116
+
117
+ `createWebStorage` maps `get`/`set`/`delete` onto `getItem`/`setItem`/`removeItem`, swallows quota errors on write (session restoration is best-effort — it never interrupts navigation), and degrades to a safe no-op when the chosen Web Storage is unavailable (e.g. private mode `SecurityError`).
118
+
119
+ Because it is just the `Storage` interface, you can supply **any** implementation — an in-memory store for tests, or a server-synced `Storage` for cross-device restoration. The framework v1 ships no built-in server endpoint, but the seam is open.
120
+
121
+ ## Wiring it into the browser
122
+
123
+ Pass an optional `session` to `startBrowserApp`. When present, the framework builds a `SessionStore`, registers your providers, wires a `SessionBridge` (auto-capture on navigation + `pagehide`/`visibilitychange`), runs the boot restore after the first navigation, and hands you a `SessionHandle`:
124
+
125
+ ```ts
126
+ // src/main.ts
127
+ import { startBrowserApp } from "@finesoft/front";
128
+ import { bootstrap } from "./bootstrap";
129
+ import { themeSlice, draftSlice } from "./lib/session";
130
+
131
+ startBrowserApp({
132
+ bootstrap,
133
+ callbacks,
134
+ session: {
135
+ providers: [themeSlice, draftSlice],
136
+ // storage defaults to createWebStorage("session")
137
+ maxAgeMs: 1000 * 60 * 60 * 24, // discard snapshots older than a day (optional)
138
+ },
139
+ mount(target, { session, app }) {
140
+ // session: SessionHandle (save/clear/scope/...); app: unified nav+session handle.
141
+ // Auto-capture/restore already run; use session.save() / session.clear() as escape hatches.
142
+ // ... mount your UI; pass `app` (or `session`) to components ...
143
+ return () => undefined;
144
+ },
145
+ });
146
+ ```
147
+
148
+ When `session` is **absent**, none of this runs and the original `startBrowserApp` path is byte-for-byte unchanged.
149
+
150
+ ### Flat vs structured wiring (automatic)
151
+
152
+ `startBrowserApp` picks the navigation adapter for you:
153
+
154
+ - **With** a `navigation` config → the structured `createNavigationSessionAdapter(controller)`: it serializes the whole tree, and on restore `hydrate`s it back. Auto-capture is driven by the navigation handle's `subscribe`.
155
+ - **Without** `navigation` (flat single page) → the `createUrlSessionAdapter` bound to `framework.perform(makeFlowAction(url))`: it captures `{ url }` and navigates on restore.
156
+
157
+ You only choose the adapter directly if you are assembling the store yourself (e.g. on the server, or in tests).
158
+
159
+ ## The handle: manual save / clear / dispose
160
+
161
+ The `SessionHandle` (delivered in the mount context) gives you the escape hatches — auto-capture already runs, but you can force a write, clear the snapshot, or tear everything down. The unified `app` handle merges navigation commands with session `save`/`clear`/`scope`, so components can hold a single object instead of assembling their own controller:
162
+
163
+ ```ts
164
+ interface SessionHandle {
165
+ restore(currentUrl: string): void | Promise<void>; // boot restore (already called for you)
166
+ save(): void; // force an immediate persist
167
+ clear(): void; // drop the persisted snapshot (e.g. on logout)
168
+ dispose(): void; // unsubscribe navigation + remove pagehide/visibilitychange + clear timers
169
+ }
170
+ ```
171
+
172
+ Call `handle.clear()` on logout so the next user doesn't inherit a stale session; call `handle.dispose()` if you tear down the app instance yourself.
173
+
174
+ ### When does it capture?
175
+
176
+ You rarely call `save()` — capture is automatic:
177
+
178
+ - **On navigation change**: the bridge first prunes the scoped map to `adapter.presentKeys()` (this is where "pop `B` drops `B`'s state" actually lands), then **debounces** a write (default `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms, coalescing rapid navigations). Tune with `session.debounceMs`.
179
+ - **On `pagehide` and `visibilitychange` (hidden)**: it persists **immediately** and cancels any pending debounce — more reliable than `beforeunload` on mobile (the last state is captured before the tab is backgrounded or reclaimed).
180
+
181
+ ## Deep-link policy: `shouldRestore`
182
+
183
+ On boot the bridge reads the snapshot and applies it **only if** `shouldRestore(snapshot, currentUrl)` passes — a single boolean gate for the whole `nav + slices` restore. The default, `defaultShouldRestore`, honors **explicit deep links over a stale session**:
184
+
185
+ | Snapshot `navigation` | Restores when… |
186
+ | --------------------------------------- | ---------------------------------------------------------------------------- |
187
+ | **Flat** (`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **or** the current path is root `/` |
188
+ | **Structured** (`SerializedNavigation`) | the current path is root `/` |
189
+ | **None** (slices only) | always (URL-independent) |
190
+
191
+ So reloading the same page (or entering fresh at `/`) restores; opening a different deep link `/x` does **not** get overwritten by an old session. "Root" is the path `=== "/"` (query/hash stripped). Apps served under a base path should override the gate:
192
+
193
+ ```ts
194
+ session: {
195
+ providers: [themeSlice],
196
+ shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
197
+ }
198
+ ```
199
+
200
+ Restoring to a different state than the SSR'd URL produces one client-side jump (SSR renders the URL's screen, then the client restores). That timing is exposed through the bridge so you can control it; a pure-CSR app can restore before first paint and avoid it entirely.
201
+
202
+ ## What is _not_ captured
203
+
204
+ - **DOM you didn't register.** The framework never scans the DOM. State slices are whatever your providers `capture()` — nothing more.
205
+ - **Anything when you register no providers.** With only navigation (or nothing) registered, capture is effectively zero — the privacy default.
206
+ - **Sensitive fields you exclude.** `capture()` is your filter; strip tokens, PII, and the like there.
207
+ - **A stale, expired, or malformed snapshot.** `load()` returns `undefined` instead of crashing the app to restore a bad state.
208
+
209
+ ## Backward compatibility
210
+
211
+ - An app that doesn't pass `session` to `startBrowserApp` runs the **original path** with zero behavior change — the entire feature is gated behind that one field.
212
+ - Session restoration adds no requirement on the server. A server-synced snapshot is possible by supplying your own `Storage`, but nothing is built in.
213
+ - The framework restores **state**, never UI. Your `Page` models and how you render them are untouched.
214
+
215
+ ## Next
216
+
217
+ - [Navigation](./11-navigation.md) — the structured tree whose entries scope per-screen state
218
+ - [Rendering & hydration](./04-rendering-and-hydration.md) — how the first screen is already restored via prefetched results
219
+ - [DI container](./07-di-container.md) — the `Storage` dependency that session restoration persists through