@finesoft/front 0.3.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  # 4. 渲染与 Hydration
2
2
 
3
- 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,以及把两端绑在一起的 `PrefetchedIntents` 机制。
3
+ 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,它们正交组合的第二根轴 —— **应用架构**(扁平单页 vs 结构化导航 + islands)—— 以及把两端绑在一起的 `PrefetchedIntents` 机制。
4
4
 
5
5
  ## 三种模式并排比
6
6
 
@@ -15,6 +15,25 @@
15
15
 
16
16
  模式是**按路由**配置,自由混合。
17
17
 
18
+ ## 两根轴:渲染模式 × 应用架构
19
+
20
+ 渲染模式是一根轴。**应用架构**是正交的第二根轴:
21
+
22
+ - **扁平单页** —— 服务端 `createSSRRender`,客户端单次挂载、每次导航重渲。一个 root、一个可见页。(见下方 [SSR 管线](#ssr-管线)。)
23
+ - **结构化导航 + islands** —— 服务端 `createSSRNavigationRender`,客户端按目标挂为 _islands_:独立 root,切 tab / 栈时保活不销毁。(见 [导航](./11-navigation.md) 与下方 [Islands SSR](#islands-ssr结构化架构方案-c)。)
24
+
25
+ 两轴组合成矩阵 —— 渲染模式决定 HTML _何时/何地_ 产出,架构决定应用 _怎么组织_:
26
+
27
+ | | 扁平单页 | 结构化导航 + islands(方案 C) |
28
+ | ------------- | ----------------------- | --------------------------------- |
29
+ | **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`、`react-minimal` |
30
+ | **csr** | ◐ 空壳 → 单 client root | ◐ 空壳 → islands 客户端挂 |
31
+ | **prerender** | ◐ 缓存扁平 SSR | ◐ 缓存方案 C SSR |
32
+
33
+ ✅ 有 starter 模板实证 · ◐ 设计上成立,暂无 starter 模板。
34
+
35
+ **islands 是 SSR 还是 CSR,是模式的结果、不是独立选项**:`ssr`/`prerender` 下框架服务端渲出每个可见 island、客户端 _收养并水合_;`csr` 下无服务端 HTML,每个 island 都在客户端新挂。每种模式的子维度仍叠加其上 —— CSR 有两个触发点([见下](#csr客户端渲染)),prerender 有构建期静态与运行时 ISR 两种形态([见下](#prerender静态--isr))。会话恢复 + DOM 恢复是再一层正交(客户端、post-hydration),可叠加在任意格上 —— 见 [会话恢复](./12-session-restoration.md)。
36
+
18
37
  ## SSR 管线
19
38
 
20
39
  ```
@@ -83,6 +102,57 @@ Vite 插件和 adapter 替你调用 `render(url, options)`。你返回 `{ html,
83
102
  - 根据 `deny()` / `redirect()` / `rewrite()` 结果设置 HTTP status
84
103
  - `afterLoad` 发出 rewrite 信号时加 `Content-Location` 头
85
104
 
105
+ ## Islands SSR(结构化架构,"方案 C")
106
+
107
+ 结构化架构把 **chrome**(tab bar、header —— 持久外框)和 **island 内容**(当前活动页)渲为**独立的水合 root**,作为挂载节点下的 sibling:
108
+
109
+ ```html
110
+ <div id="app">
111
+ <div data-fs-chrome><!-- chrome 渲在这 --></div>
112
+ <main data-fs-outlet><!-- 每个可见 island 渲在这 --></main>
113
+ </div>
114
+ ```
115
+
116
+ **服务端** —— `renderApp` 渲 chrome;`renderIslandsHtml(snapshot, renderEntry)` 把每个可见目标渲进 outlet,带上共享标记(`data-fs-entry` / `data-fs-intent` / `data-fs-key`)供客户端匹配:
117
+
118
+ ```ts
119
+ // src/ssr.ts —— 结构化入口(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
+ **客户端** —— `resolveIslandsShell(target)` 找到(或创建)chrome/outlet sibling,并报告 chrome 是否服务端渲过(`hydrate`)。island 编排器按 `data-fs-key` 收养每个 SSR'd 容器,调你的 `mountEntry(entry, container)` 时置 `entry.hydrate = true`,让你水合已有 DOM 而非新建:
134
+
135
+ ```ts
136
+ // src/main.ts
137
+ const mountEntry = (entry, container) => {
138
+ const factory = entry.hydrate ? createSSRApp : createApp; // 水合 SSR'd vs 新建(客户端导航)
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
+ > **同步挂载契约。** `mountEntry` 返回后,island 的 DOM 必须已就绪:框架在下一帧回填 `data-restore-root` 字段(见 [会话恢复](./12-session-restoration.md))。Vue/Svelte 的 `.mount()` 同步满足。**React** 异步提交 DOM,故须把**客户端新挂**路径用 `flushSync(() => root.render(view))` 包住 —— 仅客户端新挂的 island 需要(SSR'd island 的 DOM 已来自服务端)。见 `templates/react-minimal/src/main.tsx`。
153
+
154
+ 完整示例:`templates/vue-minimal` 与 `templates/react-minimal`(均为 `ssr` + 结构化导航 + islands + 会话恢复)。
155
+
86
156
  ## CSR(客户端渲染)
87
157
 
88
158
  `renderMode: "csr"` 的路由,服务端返回最小空壳:
@@ -97,31 +97,29 @@ export const navigation = defineNavigation({
97
97
 
98
98
  ### 接入浏览器
99
99
 
100
- `startBrowserApp` 新增一个可选 `navigation` 字段和一个 `onNavigationReady` 回调,把 `NavigationHandle` 交给你:
100
+ `startBrowserApp` 新增一个可选 `navigation` 字段;存在时,`NavigationHandle`(以及统一的 `app` 句柄)会在 `mount` 回调的 context 里交给你,挂载时即可直接使用:
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
- // 快照变更时重渲染
118
- h.subscribe((snapshot) => mountNavigation(snapshot));
119
- mountNavigation(h.getSnapshot());
111
+ mount(target, { navigation: nav, app }) {
112
+ // nav/app 在 mount 时已就绪,无需等待回调。
113
+ // 快照变更时重渲染:
114
+ nav?.subscribe((snapshot) => mountNavigation(snapshot));
115
+ if (nav) mountNavigation(nav.getSnapshot());
116
+ // ... 把 UI 挂载到 target,将 app 传给组件 ...
117
+ return () => undefined;
120
118
  },
121
119
  });
122
120
  ```
123
121
 
124
- 提供 `navigation` 时,框架会构建 `NavigationController` 和 history 桥、解析首屏、把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
122
+ 提供 `navigation` 时,框架会构建 `NavigationController` 和 history 桥、解析首屏,并在 mount context 中把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
125
123
 
126
124
  ## 驱动导航
127
125
 
@@ -299,6 +297,8 @@ function renderApp(page, framework, snapshot) {
299
297
  }
300
298
  ```
301
299
 
300
+ `renderApp` 具体要搭的 islands 外壳 —— chrome + 按目标的 islands 作为独立水合 root,以及客户端 `mountEntry` / `resolveIslandsShell` 如何收养并水合它们 —— 见 [Islands SSR](./04-rendering-and-hydration.md#islands-ssr结构化架构方案-c)。
301
+
302
302
  底层原理:每个可见目标经**既有的** `PrefetchedIntents` 通道序列化为一条普通的 `{ intent, data: page }`,再额外挂一条承载序列化树的哨兵条目。`@finesoft/server` **零改动** —— 它经同一个 `#serialized-server-data` 脚本透传哨兵。hydration 时浏览器桥从 history state(或哨兵)读回树,并复用预取的页面。
303
303
 
304
304
  若某请求没有结构化深链、应用也没提供骨架,SSR 回退到 `Router.resolve(url)` → 单个叶子 —— 即今天的扁平单页(含其 `renderMode`)。404 路径不变。
@@ -352,4 +352,4 @@ export const navigation = defineNavigation({
352
352
  ## 下一步
353
353
 
354
354
  - [中间件](./03-middleware.md) —— 导航复用的守卫语义
355
- - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 预取结果如何跨越 SSR → CSR 边界
355
+ - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 渲染模式 × 架构矩阵、islands SSR 外壳,以及预取结果如何跨越 SSR → CSR 边界
@@ -0,0 +1,219 @@
1
+ # 12. 会话恢复
2
+
3
+ 框架已经能恢复**首屏**:SSR 把 prefetch 出来的 intent 结果经 `PrefetchedIntents` 注入 HTML,浏览器在首次导航时复用它们;结构化导航的当前树也会随 `history.state` 走前进 / 后退。
4
+
5
+ 但有一类状态以上手段**全都救不回**:用户**硬重载 / 标签崩溃 / 关掉再回来**时「当时在干什么」—— 他在哪一屏(或哪个栈深、哪个 tab、split 哪一列)、表单里打了一半的草稿、列表滚到哪里。内存里的 `history.state` map 整页重载即清空,`PrefetchedIntents` 只覆盖服务端渲染的那一屏。
6
+
7
+ **会话恢复**填这个缺口:把一份带版本、JSON 安全的**会话快照**(导航位置 + 应用注册的状态切片 + 导航作用域的逐屏状态)序列化到可插拔 `Storage`,并在全新加载时重水化。框架**不含任何 UI** —— 它只恢复**状态**,应用据此自行重渲染。
8
+
9
+ 它完全可选:从不向 `startBrowserApp` 传 `session` 的应用**逐位等价**于原行为,毫无变化。
10
+
11
+ ## 两种作用域
12
+
13
+ 一份快照捕获两层状态,一起序列化、跨重载一起恢复:
14
+
15
+ | 作用域 | 存放于 | 键 | 生命周期 | SwiftUI 对标 |
16
+ | ------------------ | -------- | -------------- | -------------------------------------- | --------------- |
17
+ | **全局切片** | `slices` | `provider.key` | 整个会话(主题、跨屏向导草稿…) | `@SceneStorage` |
18
+ | **导航作用域状态** | `scoped` | `entryKey` | 绑定到某个导航条目 —— 条目离树即被丢弃 | `@State` |
19
+
20
+ - **全局切片**是 app-wide 的。每个切片注册一个 `SessionStateProvider`;框架编排*何时*捕获与落盘,但从不解释内容 —— 它只搬运。
21
+ - **导航作用域状态**绑定到某个*导航条目*,对标 SwiftUI 视图 `@State` 的位置作用域生命周期(见下)。
22
+
23
+ ## 全局切片:`SessionStateProvider`
24
+
25
+ 应用为每个切片注册一个 provider。`capture()` 返回 JSON 安全的同步值;`restore(data)` 把它放回(应用自行 `setState` / 回填表单 / 滚动):
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
+ 框架原样搬运值、从不窥探 —— 所以捕获什么由**你**决定。敏感字段就在 `capture()` 里自行排除;不注册的切片永不被捕获。
38
+
39
+ ## 导航作用域状态:SwiftUI `@State` 生命周期
40
+
41
+ 导航作用域状态是更有意思的一半。它按**条目身份**而非可见性建键,遵循与 SwiftUI 视图 `@State` 相同的位置作用域生命周期:
42
+
43
+ > `A` → push `B` → 返回(pop `B`)到 `A`:**`B` 的状态被丢弃,`A` 的状态仍在。**
44
+
45
+ 机制:每个条目的状态袋按 `entryKey = intent + " " + stableStringify(params)` 存放 —— 与导航 controller 给目标用的身份同源,故**跨重载稳定**。每次导航提交后,框架把 scoped map **prune** 到树中**实际存在**的条目 —— 注意是*存在*,不是*可见*。条目已不在树中的键即被丢弃。
46
+
47
+ ```ts
48
+ import { sessionEntryKey } from "@finesoft/front";
49
+
50
+ // 渲染某屏时,用该条目的键读写它的作用域袋:
51
+ const key = sessionEntryKey("post", { id: 7 });
52
+ store.scope.set(key, { scroll: 240, draft: "评论打了一半" });
53
+ const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
54
+ ```
55
+
56
+ 逐步走一遍生命周期:
57
+
58
+ - **push `B`** → 树 `[A, B]`,present `{A, B}` → `A` 的状态**保留**(`A` 仍在栈中、只是不可见),`B` 拿到自己的作用域。
59
+ - **pop `B`** → 树 `[A]`,present `{A}` → **`B` 的作用域被 prune 丢弃**,`A` 的原样保留;返回 `A` 按保留态渲染。
60
+ - **切 TabView 的 tab** → 其它分支仍在树中 → 其状态保活(与 SwiftUI 让未激活 tab 保持挂载一致)。
61
+ - **跨重载** → `scoped` 随快照序列化;重载后每个仍在树的条目恢复各自作用域,之后 pop 照常丢弃。
62
+
63
+ `store.scope` 是 store 持有的 `NavigationScopedState` 实例 —— `get` / `set` / `delete` / `keys`,外加框架替你调用的 `prune(presentKeys)`。用高层 `startBrowserApp({ session })` 时无需直接持有 store:`mount` 回调(context)交给你的 `SessionHandle` 上的 `handle.scope` 就是同一个实例(restore 重建后仍指向最新),照样 `handle.scope.get(entryKey)` / `set(entryKey, data)`。
64
+
65
+ ### 扁平 vs 结构化:保留语义**本质就是栈**
66
+
67
+ 「把 `A` 留在 `B` 底下、pop 时丢 `B`、恢复 `A`」这套行为,按定义就是**栈语义** —— 所以它只在**结构化导航**里成立,那里栈 / 树能持有*存在但不可见*的条目。
68
+
69
+ **扁平单页没有栈**:`A → B` 是整页替换,故 `presentKeys()` 恒为单条目(当前 URL)。一离开某屏,其作用域即被 prune,浏览器**返回**是 fresh 重渲染。
70
+
71
+ 两种模式都支持「当前屏作用域 + 跨重载恢复」。要「返回时保留上一屏」,就把它建成结构化栈 —— 用 push 而非 replace。这正是 `NavigationStack` 的*意义*,不是扁平模式的缺陷。
72
+
73
+ ## 快照
74
+
75
+ `createSessionStore(options)` 返回 `SessionStore` 编排器。`capture()` 组装快照但不落盘;快照模型为:
76
+
77
+ ```ts
78
+ interface SessionSnapshot {
79
+ readonly version: number;
80
+ readonly navigation?: SerializedNavigation | SessionUrlLocation; // 结构化树 | { url }
81
+ readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
82
+ readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> 状态袋
83
+ readonly capturedAt: number; // epoch ms,用于 maxAgeMs 过期判断
84
+ }
85
+ ```
86
+
87
+ `navigation` 用一个轻判别区分:`SerializedNavigation` 始终带 `kind`(leaf/stack/tabs/split),扁平的 `SessionUrlLocation` 带 `url`。用 `isUrlLocation(nav)` 区分二者。
88
+
89
+ store 暴露:
90
+
91
+ ```ts
92
+ interface SessionStore {
93
+ register(provider: SessionStateProvider): () => void; // 返回反注册函数
94
+ readonly scope: NavigationScopedState;
95
+ capture(): SessionSnapshot; // 组装(nav + slices + scoped),无 I/O
96
+ persist(snapshot?: SessionSnapshot): void; // 省略则先 capture(),再落盘
97
+ load(): SessionSnapshot | undefined; // 读取 + 校验(version / maxAge / 结构)
98
+ restore(snapshot?: SessionSnapshot): void | Promise<void>; // 省略则先 load(),再应用
99
+ clear(): void; // 清除持久化快照
100
+ save(): void; // capture + persist —— 手动逃生口
101
+ }
102
+ ```
103
+
104
+ `load()` 会丢弃版本不符、`capturedAt` 超过 `maxAgeMs`、或结构畸形的快照 —— 返回 `undefined`,绝不向应用抛错。`capture()` / `restore()` 抛错的 provider 被隔离:跳过其切片、错误走 `onError`,快照其余部分照常存活。
105
+
106
+ ## 持久化:默认 `sessionStorage`,可替换
107
+
108
+ 快照经稳定 stringify 编码,作为一条 `storage.set(key, ...)` 写入。`Storage` 是 core 既有的依赖接口,所以 durability 由**你**决定:
109
+
110
+ ```ts
111
+ import { createWebStorage } from "@finesoft/front";
112
+
113
+ createWebStorage("session"); // sessionStorage —— 标签级,关闭即清(默认)
114
+ createWebStorage("local"); // localStorage —— 跨标签、跨重启持久
115
+ ```
116
+
117
+ `createWebStorage` 把 `get`/`set`/`delete` 映射到 `getItem`/`setItem`/`removeItem`,写入时吞掉配额错(会话恢复是尽力而为,绝不打断导航),选定的 Web Storage 不可用时(如隐私模式 `SecurityError`)降级为安全 no-op。
118
+
119
+ 因为它就是 `Storage` 接口,你可以塞**任意**实现 —— 测试用内存版,或服务端同步的 `Storage` 实现跨设备恢复。框架 v1 不内建服务端端点,但接缝是开放的。
120
+
121
+ ## 接入浏览器
122
+
123
+ 向 `startBrowserApp` 传可选的 `session`。存在时,框架构建 `SessionStore`、注册你的 providers、装配 `SessionBridge`(导航变更自动捕获 + `pagehide`/`visibilitychange`),在首次导航后跑 boot 恢复,并把 `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 缺省为 createWebStorage("session")
137
+ maxAgeMs: 1000 * 60 * 60 * 24, // 丢弃超过一天的快照(可选)
138
+ },
139
+ mount(target, { session, app }) {
140
+ // session: SessionHandle(save/clear/scope/…);app:统一的 nav+session 句柄。
141
+ // 自动捕获/恢复已在跑;用 session.save() / session.clear() 作逃生口。
142
+ // ... 把 UI 挂载到 target,将 app(或 session)传给组件 ...
143
+ return () => undefined;
144
+ },
145
+ });
146
+ ```
147
+
148
+ `session` **缺省**时,以上整段不运行,原有 `startBrowserApp` 路径逐位不变。
149
+
150
+ ### 扁平 vs 结构化接线(自动)
151
+
152
+ `startBrowserApp` 替你挑选导航适配器:
153
+
154
+ - **有** `navigation` 配置 → 结构化 `createNavigationSessionAdapter(controller)`:序列化整棵树,恢复时 `hydrate` 回去。自动捕获由导航 handle 的 `subscribe` 驱动。
155
+ - **无** `navigation`(扁平单页)→ 接 `framework.perform(makeFlowAction(url))` 的 `createUrlSessionAdapter`:捕获 `{ url }`,恢复时导航过去。
156
+
157
+ 只有自己装配 store 时(如在服务端、或测试里)才需要直接选适配器。
158
+
159
+ ## 句柄:手动 save / clear / dispose
160
+
161
+ `SessionHandle`(通过 mount context 交付)给你逃生口 —— 自动捕获已在跑,但你可强制落盘、清快照、或整体拆除。统一的 `app` 句柄把导航命令与 session 的 `save`/`clear`/`scope` 合并,组件拿一个对象即可,免自己拼 controller:
162
+
163
+ ```ts
164
+ interface SessionHandle {
165
+ restore(currentUrl: string): void | Promise<void>; // boot 恢复(已替你调过)
166
+ save(): void; // 立即强制落盘
167
+ clear(): void; // 丢弃持久化快照(如登出时)
168
+ dispose(): void; // 反订阅导航 + 解绑 pagehide/visibilitychange + 清定时器
169
+ }
170
+ ```
171
+
172
+ 登出时调 `handle.clear()`,下个用户就不会继承陈旧会话;自己拆除应用实例时调 `handle.dispose()`。
173
+
174
+ ### 何时捕获?
175
+
176
+ 你很少需要调 `save()` —— 捕获是自动的:
177
+
178
+ - **导航变更时**:bridge **先**把 scoped map prune 到 `adapter.presentKeys()`(这正是「pop `B` 丢掉 `B` 状态」的落点),再**防抖**落盘(默认 `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms,合并连续导航)。用 `session.debounceMs` 调。
179
+ - **`pagehide` 与 `visibilitychange`(hidden)时**:**立即**落盘并取消挂起的防抖 —— 比 `beforeunload` 在移动端更可靠(标签切后台 / 被回收前能抓到末态)。
180
+
181
+ ## 深链策略:`shouldRestore`
182
+
183
+ boot 时 bridge 读快照,**仅当** `shouldRestore(snapshot, currentUrl)` 通过才应用 —— 整份 `nav + slices` 恢复共用一个布尔门。默认的 `defaultShouldRestore` 遵循**显式深链优先于陈旧会话**:
184
+
185
+ | 快照 `navigation` | 恢复当且仅当… |
186
+ | ------------------------------------ | --------------------------------------------------------------- |
187
+ | **扁平**(`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **或**当前路径为根 `/` |
188
+ | **结构化**(`SerializedNavigation`) | 当前路径为根 `/` |
189
+ | **无**(仅切片) | 总恢复(与 URL 无关) |
190
+
191
+ 于是重载同页(或全新进入 `/`)会恢复;打开不同深链 `/x` 则**不会**被旧会话覆盖。「根」判定为路径 `=== "/"`(剥离 query/hash)。带 base path 的应用应覆盖该门:
192
+
193
+ ```ts
194
+ session: {
195
+ providers: [themeSlice],
196
+ shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
197
+ }
198
+ ```
199
+
200
+ 恢复到与 SSR'd URL 不同的态会产生一次客户端跳变(SSR 渲染 URL 那屏,客户端再恢复)。该时机经 bridge 暴露给你掌控;纯 CSR 应用可在首次绘制前恢复、完全避免它。
201
+
202
+ ## 哪些**不**被捕获
203
+
204
+ - **你没注册的 DOM。** 框架从不扫描 DOM。状态切片就是你的 provider `capture()` 出来的那些 —— 仅此而已。
205
+ - **不注册任何 provider 时的一切。** 只注册导航(或什么都不注册)时,捕获实质为零 —— 隐私默认。
206
+ - **你排除的敏感字段。** `capture()` 是你的过滤器;token、PII 之类在此剥掉。
207
+ - **陈旧 / 过期 / 畸形的快照。** `load()` 返回 `undefined`,而非为恢复坏态崩掉应用。
208
+
209
+ ## 向后兼容
210
+
211
+ - 不向 `startBrowserApp` 传 `session` 的应用走**原路径**、零行为变化 —— 整个特性被那一个字段门控。
212
+ - 会话恢复对服务端无任何要求。提供自己的 `Storage` 即可实现服务端同步快照,但框架不内建任何东西。
213
+ - 框架恢复**状态**、绝不恢复 UI。你的 `Page` 模型与渲染方式原封不动。
214
+
215
+ ## 下一步
216
+
217
+ - [导航](./11-navigation.md) —— 其条目为逐屏状态划定作用域的结构化树
218
+ - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 首屏如何已经通过 prefetch 结果被恢复
219
+ - [DI 容器](./07-di-container.md) —— 会话恢复落盘所经的 `Storage` 依赖