@finesoft/front 0.5.1 → 0.5.2
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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
package/docs/11-navigation.md
CHANGED
|
@@ -1,355 +1,210 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Structured navigation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tabs, Stack and Split are immutable navigation declarations. Page references derive operation targets; branch and column names remain intentional layout identities.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Infer parameters from routes
|
|
6
6
|
|
|
7
|
-
|
|
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`:
|
|
7
|
+
Declare path and query types in the route and read them separately as `params.id` and `query.tab`. Existing handlers keep path parameters and context as the first two arguments; the third receives query:
|
|
31
8
|
|
|
32
9
|
```ts
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
search: stack(leaf("search")),
|
|
49
|
-
me: stack(leaf("me")),
|
|
10
|
+
import { definePage, defineWebApp, int, optional, oneOf } from "@finesoft/front";
|
|
11
|
+
|
|
12
|
+
export const product = definePage({
|
|
13
|
+
id: "product",
|
|
14
|
+
routes: [
|
|
15
|
+
{
|
|
16
|
+
path: "/products/:id",
|
|
17
|
+
params: { id: int() },
|
|
18
|
+
query: { tab: optional(oneOf(["details", "reviews"])) },
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
handler(params, context, query) {
|
|
22
|
+
context.signal.throwIfAborted();
|
|
23
|
+
// params.id: number; query.tab?: "details" | "reviews"
|
|
24
|
+
return { id: params.id.toFixed(), pageType: "product" as const, title: "Product" };
|
|
50
25
|
},
|
|
51
26
|
});
|
|
27
|
+
export const definition = defineWebApp({
|
|
28
|
+
id: "shop",
|
|
29
|
+
pages: [product],
|
|
30
|
+
getErrorPage: (status, title) => ({ id: String(status), pageType: "error", title }),
|
|
31
|
+
});
|
|
52
32
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ id: "sidebar", content: leaf("folders") },
|
|
56
|
-
{ id: "detail", content: stack(leaf("folder", { id: "inbox" })) },
|
|
57
|
-
]);
|
|
33
|
+
product.leaf({ id: 42 });
|
|
34
|
+
// product.leaf({ id: "42" }); // Type error
|
|
58
35
|
```
|
|
59
36
|
|
|
60
|
-
`
|
|
37
|
+
Use an independent `BaseController` subclass for recovery. `execute` can return synchronously or asynchronously; `fallback` is optional and bypassed on cancellation. Existing `perform` factories remain supported. `create` does not accept objects with only `execute/fallback`.
|
|
61
38
|
|
|
62
|
-
|
|
39
|
+
### Independent controller classes
|
|
63
40
|
|
|
64
|
-
|
|
41
|
+
Keep routes and classes in separate files. Declare input types only in the routes; use `execute({ params, query, context })` and optional `fallback({ params, query, context, error })` methods:
|
|
65
42
|
|
|
66
43
|
```ts
|
|
67
|
-
//
|
|
68
|
-
import {
|
|
69
|
-
import {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
]);
|
|
44
|
+
// controllers/product.ts — no repeated input annotations when authoring
|
|
45
|
+
import { BaseController } from "@finesoft/front";
|
|
46
|
+
import type { ProductPage } from "../models/product";
|
|
47
|
+
|
|
48
|
+
export class ProductController extends BaseController {
|
|
49
|
+
execute({ params, query, context }): ProductPage {
|
|
50
|
+
return { id: params.id.toFixed(), pageType: "product", title: "Product" };
|
|
51
|
+
}
|
|
81
52
|
}
|
|
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
53
|
```
|
|
95
54
|
|
|
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
55
|
```ts
|
|
103
|
-
//
|
|
104
|
-
import {
|
|
105
|
-
import {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
},
|
|
56
|
+
// app-definition.ts
|
|
57
|
+
import { definePage, int } from "@finesoft/front";
|
|
58
|
+
import { ProductController } from "./controllers/product";
|
|
59
|
+
|
|
60
|
+
export const product = definePage({
|
|
61
|
+
id: "product",
|
|
62
|
+
routes: [{ path: "/products/:id", params: { id: int() } }],
|
|
63
|
+
create: () => new ProductController(),
|
|
119
64
|
});
|
|
120
65
|
```
|
|
121
66
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
## Driving navigation
|
|
67
|
+
The framework finds `BaseController` subclasses through the `create` return type. It maintains only type imports, parameter annotations and base-class generics. Declarations live in `.finesoft/controller-types.d.ts`; no declaration block is appended to controller files. These references appear in saved source, but application authors do not maintain them. Handwritten annotations are preserved; remove the input annotations and base input generic to let the generator manage a class. Keep explicit page return types on methods.
|
|
125
68
|
|
|
126
|
-
|
|
69
|
+
Generated source uses short type names and passes the complete input contract to the base class:
|
|
127
70
|
|
|
128
71
|
```ts
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
72
|
+
import type {
|
|
73
|
+
ProductControllerInput as Input,
|
|
74
|
+
ProductControllerFailure as Failure,
|
|
75
|
+
} from "../../../.finesoft/controller-types";
|
|
76
|
+
|
|
77
|
+
export class ProductController extends BaseController<Input, ProductPage> {
|
|
78
|
+
async execute({ params, query, context }: Input): Promise<ProductPage> {
|
|
79
|
+
/* application logic */
|
|
80
|
+
}
|
|
81
|
+
fallback({ params, error }: Failure): ProductPage {
|
|
82
|
+
/* recovery logic */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
142
85
|
```
|
|
143
86
|
|
|
144
|
-
`
|
|
87
|
+
`Input` includes `params`, `query` and `context`; `Failure` adds `error`. The generator uses class-qualified aliases when multiple controllers share a file or names are occupied. `BaseController<Input, Result>` is the only generic form; handwritten inputs can use `ControllerInput<Params, Query>`. Direct `perform` calls retain parameter and result checks.
|
|
145
88
|
|
|
146
|
-
|
|
89
|
+
Migrate existing positional `execute` / `fallback` methods to the object argument first. The generator updates type references; it does not rewrite method bodies and reports a migration error for the old multi-argument signature.
|
|
147
90
|
|
|
148
|
-
A
|
|
91
|
+
Standard TypeScript declarations provide editor completion and command-line checking. Generation never executes application modules or instantiates controllers, and adds no runtime code. A class registered against several routes receives their input union, with normal narrowing required.
|
|
149
92
|
|
|
150
|
-
|
|
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.
|
|
93
|
+
When a route registration is removed but its class remains, generation retains that class's last input contract for independent use. Registering it again updates the contract from the new routes. Solution-style `tsconfig.json` files automatically select the referenced application project containing `src`; use `controllerTypes.tsconfig` when several projects qualify.
|
|
159
94
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
A split view shows multiple columns at once — the classic sidebar + detail (+ sub-detail) layout. Selecting in one column drives the next.
|
|
95
|
+
Templates already enable generation. For existing apps, install `typescript` as a development dependency and create the framework plugin outside `lazyPlugins` so `vp check` generates types when reading the config:
|
|
163
96
|
|
|
164
97
|
```ts
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
]),
|
|
98
|
+
const front = finesoftFrontViteConfig({
|
|
99
|
+
controllerTypes: { root: import.meta.dirname },
|
|
100
|
+
});
|
|
101
|
+
export default defineConfig({
|
|
102
|
+
lint: { options: { typeAware: true, typeCheck: true } },
|
|
103
|
+
plugins: lazyPlugins(() => [front, react()]),
|
|
171
104
|
});
|
|
172
105
|
```
|
|
173
106
|
|
|
174
|
-
|
|
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
|
|
107
|
+
`vp dev` watches source changes; `vp check` and builds also generate declarations. Run one of these commands when first opening a project. Ignore `.finesoft/` in Git and commit the managed source references. With standalone `tsc` or a custom toolchain, first call `generateControllerTypes({ root })` from `@finesoft/front`. Set `controllerTypes: false` to disable maintenance.
|
|
195
108
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
| `visibility` | Visible columns |
|
|
199
|
-
| ----------------------------- | ------------------------------- |
|
|
200
|
-
| `automatic` (default) / `all` | every column |
|
|
201
|
-
| `doubleColumn` | first + last (hides the middle) |
|
|
202
|
-
| `detailOnly` | last (detail) only |
|
|
109
|
+
The app returned by `createBrowserApp({ definition, target })`, `createWebSession`, and the SSR `render(app)` callback retain the definition's parameter map:
|
|
203
110
|
|
|
204
111
|
```ts
|
|
205
|
-
|
|
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);
|
|
112
|
+
await app.perform({ kind: "push", intent: "product", params: { id: 42 } });
|
|
113
|
+
// A string id, missing id, or unknown intent produces a type error.
|
|
222
114
|
```
|
|
223
115
|
|
|
224
|
-
`
|
|
116
|
+
`route()` and page reference `.route()` results retain their declared codec types. Keep the inferred factory return types instead of annotating `definition` with the broad `WebAppDefinition`. An explicit `PageRoute` annotation permits absent codecs, so inferred inputs must also account for that possibility; use `satisfies PageRoute` to check the declaration without erasing its concrete type. Components needing an explicit app type can use `WebAppView<typeof definition>` or `ViewProps<ProductPage, typeof definition>` without repeating parameter interfaces. The default `WebAppView` remains suitable for generic layouts and Outlets.
|
|
225
117
|
|
|
226
|
-
|
|
118
|
+
String arrays such as `routes: ["/items/:id", "/products/:id"]` still declare aliases. Unspecified path codecs produce strings; `:tab?` is optional. Different alias parameter shapes produce a union that the controller must narrow. When several aliases match the same parameters, provide `url` on the structured destination to select a path, for example `product.leaf({ id: 42 }, { url: "/products/42" })`.
|
|
227
119
|
|
|
228
|
-
|
|
120
|
+
`optional`, `withDefault`, `list`, and third-party Standard Schema outputs determine receiver types. References and structured actions use this same output shape, so defaulted fields remain required there; URL navigation can omit them and let the Router apply defaults. The existing Router still validates and converts parameters, without a second schema pass.
|
|
229
121
|
|
|
230
|
-
|
|
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
|
-
```
|
|
122
|
+
### Query and path parameters
|
|
242
123
|
|
|
243
|
-
|
|
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:
|
|
124
|
+
Both use the same codecs and inference, supplied as separate properties of the controller argument. No manual URL parsing is required:
|
|
248
125
|
|
|
249
126
|
```ts
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
127
|
+
routes: [
|
|
128
|
+
{
|
|
129
|
+
path: "/products/:id",
|
|
130
|
+
params: { id: int() },
|
|
131
|
+
query: {
|
|
132
|
+
q: withDefault(str(), ""),
|
|
133
|
+
tags: optional(list(str())),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
// execute({ params, query, context }):
|
|
138
|
+
// params.id: number; query.q: string; query.tags: string[] | undefined.
|
|
139
|
+
await app.perform({
|
|
140
|
+
kind: "push",
|
|
141
|
+
intent: "product",
|
|
142
|
+
params: { id: 42 },
|
|
143
|
+
query: { q: "a & b", tags: ["new", "sale"] },
|
|
144
|
+
});
|
|
261
145
|
```
|
|
262
146
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
## Server-side rendering
|
|
147
|
+
The router encodes params in the path and query in the query string. Arrays preserve their order as repeated keys. URL navigation also accepts `/products/42?q=a%20%26%20b&tags=new&tags=sale` directly.
|
|
266
148
|
|
|
267
|
-
|
|
149
|
+
Query names are not restricted to path placeholders. `optional` permits missing values; `withDefault` supplies missing values while explicit empty strings still undergo validation. `list` collects repeated keys and yields `[]` when absent. `optional(list(...))` yields `undefined` when absent; `withDefault(list(...), [...])` uses the default array. Single-value schemas keep the last repeated value. Undeclared query fields retain string compatibility without declared-field type guarantees; same-named path and query fields retain their independent values.
|
|
268
150
|
|
|
269
|
-
|
|
151
|
+
## Tree / 导航树
|
|
270
152
|
|
|
271
153
|
```ts
|
|
272
|
-
|
|
273
|
-
import {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
id: `error-${status}`,
|
|
281
|
-
pageType: "error",
|
|
282
|
-
title: message,
|
|
283
|
-
}),
|
|
284
|
-
renderApp, // (page, framework, snapshot) => { html, head, css }
|
|
285
|
-
navigation: navigation.toSSRDefinition(),
|
|
154
|
+
import { stack, tabs, split } from "@finesoft/front";
|
|
155
|
+
import { home, product } from "./pages";
|
|
156
|
+
export const navigation = tabs({
|
|
157
|
+
active: "catalog",
|
|
158
|
+
branches: {
|
|
159
|
+
catalog: stack([home.leaf(), product.leaf({ id: 42 })]),
|
|
160
|
+
compare: split([{ id: "left", content: product.leaf({ id: 42 }) }, { id: "right" }]),
|
|
161
|
+
},
|
|
286
162
|
});
|
|
163
|
+
// defineWebApp({ ..., navigation })
|
|
287
164
|
```
|
|
288
165
|
|
|
289
|
-
|
|
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
|
-
```
|
|
166
|
+
Each equal target still receives its own EntryId and draft. ResourceKey may share explicitly cached query data without sharing view state. Standard browser start owns URL actions, redirects and popstate; obsolete URL results cannot commit over later work. Explicit tree operations use one serialized controller queue. Tabs retain branches, Stack retains present entries, Split guards each destination. Choose renderer mode `entries` when native entry lifetimes should follow the tree.
|
|
299
167
|
|
|
300
|
-
|
|
168
|
+
## Navigation actions
|
|
301
169
|
|
|
302
|
-
|
|
170
|
+
All navigation uses `app.perform(action)`. URLs use `{ kind: "flow", url }`; structured navigation uses these actions:
|
|
303
171
|
|
|
304
|
-
|
|
172
|
+
| kind | Fields |
|
|
173
|
+
| ----------------- | ------------------------------------------------------------- |
|
|
174
|
+
| push / replaceTop | intent, params?, query?, target?, url? |
|
|
175
|
+
| pop | count?, target? |
|
|
176
|
+
| popToRoot | target? |
|
|
177
|
+
| popTo | index, target? |
|
|
178
|
+
| selectTab | key, target? |
|
|
179
|
+
| selectColumn | columnId, intent (undefined clears), params?, query?, target? |
|
|
180
|
+
| setVisibility | visibility, target? |
|
|
181
|
+
| reuseEntry | entryId |
|
|
182
|
+
| refresh | — |
|
|
183
|
+
| hydrate | tree |
|
|
305
184
|
|
|
306
|
-
##
|
|
185
|
+
## Transaction policies
|
|
307
186
|
|
|
308
|
-
|
|
187
|
+
`defineWebApp({ beforeNavigate, beforeCommit })` adds optional whole-tree policies. Each policy in its array runs once per transaction, even when the target tree is empty. Definition policies run before controller-local policies. `beforeLoad` / `afterLoad` still run for each visible page.
|
|
309
188
|
|
|
310
189
|
```ts
|
|
311
|
-
import {
|
|
312
|
-
|
|
313
|
-
export const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
190
|
+
import { next, deny, type BeforeCommitPolicy } from "@finesoft/front";
|
|
191
|
+
import { hasUnsavedDraft } from "./editor-state";
|
|
192
|
+
export const preserveDraft: BeforeCommitPolicy = ({ from, candidate }) => {
|
|
193
|
+
// Inspect application-owned draft state and both trees here.
|
|
194
|
+
const leavingEditor =
|
|
195
|
+
from.destinations.some((page) => page.intent === "editor") &&
|
|
196
|
+
!candidate.destinations.some((page) => page.intent === "editor");
|
|
197
|
+
return leavingEditor && hasUnsavedDraft()
|
|
198
|
+
? deny(409, "Save your draft before leaving")
|
|
199
|
+
: next();
|
|
200
|
+
};
|
|
201
|
+
// defineWebApp({ ..., beforeCommit: [preserveDraft] })
|
|
320
202
|
```
|
|
321
203
|
|
|
322
|
-
|
|
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
|
|
204
|
+
Admission receives `from`, candidate `tree`, stable `transitionId`, active `execution`, its `signal`, and `isServer`. It returns `next`, `deny`, or `redirect`. Page redirects keep the same transaction identity and do not repeat admission. Commit policies also receive the loaded `candidate`; they return only `next` or `deny`, before cache consumption, committed state, history, events or views change. Each redirect hop retains its existing execution cleanup; the final commit policy sees the final hop's execution. Async policies must honor cancellation for their own I/O; stale results cannot commit.
|
|
347
205
|
|
|
348
|
-
|
|
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.
|
|
206
|
+
A denial returns an uncommitted snapshot with `rejection`, including for empty trees. Session restore rejects it before replacing scopes or slices. Initial browser denial renders the error without committing navigation or recording a page visit; later denial preserves the displayed draft. Both flat and navigation SSR honor these policies and emit no rejected page data or public-cache permission. CSR shells still defer navigation to the browser.
|
|
351
207
|
|
|
352
|
-
|
|
208
|
+
Owned back/forward rejection compensates to the committed history entry and restores its scroll identity. Additive history metadata preserves ownership and position across reloads. Entries without compatible ownership metadata are diagnosed; the framework cannot infer a safe traversal distance for external entries.
|
|
353
209
|
|
|
354
|
-
-
|
|
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
|
|
210
|
+
Low-level hosts configuring `createWebSession({ createContext })` return a `NavigationContext` directly. The execution supplies its DI container and cancellation signal; request cookies and headers stay in the host context. The session uses the application's `getErrorPage` unless explicitly overridden.
|