@ultimat3/render 22.7.0 → 22.8.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.
package/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ axiom 6). Never `cli` (upward).
25
25
  |---|---|
26
26
  | `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
27
27
  | `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
28
- | `defineRoute` shape | exactly the contract's 11 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot). New route *metadata* goes inside `meta`. |
28
+ | `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`, `'prefetch' \| 'document'`, the twelfth — read by the server's navigation gate, refused off a `navigation.client` surface at boot, `X_ROUTE_NAVIGATION_INVALID`). New route *metadata* goes inside `meta`. |
29
29
  | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
30
30
  | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
31
31
  | A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
@@ -54,7 +54,7 @@ axiom 6). Never `cli` (upward).
54
54
  | The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
55
55
  | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
56
56
  | Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
57
- | Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-22`: `idle` 1,744, `visible` 846, `interaction` 1,629 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
57
+ | Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-09-28`: `idle` 1,784, `visible` 886, `interaction` 1,669 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
58
58
  | A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
59
59
  | `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
60
60
  | Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
@@ -100,6 +100,10 @@ axiom 6). Never `cli` (upward).
100
100
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
101
101
  | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
102
102
  | `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
103
+ | Client navigation | `navigation*.ts` — soft navigation over server-rendered documents, NOT a mode: the router renders nothing, it swaps the document the route already rendered. Rules are pure (`navigation-rules.ts`: `linkVerdict`, `formVerdict`, `responseVerdict`, `mayPrefetch`) and unit-tested; the DOM half (`navigation-swap.ts`, `navigation.ts`) is proven in Chrome by `packages/cli/e2e/client-navigation-*.e2e.test.ts`. `./navigation` (`navigation-entry.ts`) is the script the CLI builds; importing it starts it, and a document without `ultimate-navigation` is never intercepted. |
104
+ | Router ↔ islands | ONE bootstrap: the swap re-runs the inline hydration runtime, whose `each` visits a root once per tab (`el.__v`). Never add a second mount path for the router. Dispose = call what `el.__x` resolved to. |
105
+ | Router head diff | only elements a SERVER document put in `<head>` (`documentHead`'s set) may be removed; a script-added one is the tab's. Scripts and stylesheets are never diffed. |
106
+ | Router: nothing is sent twice | the router follows no redirect (`redirect: 'manual'`; the server hands it over as `204` + `x-ultimate-location`), never re-submits a POST (a failure is `ultimate:navigation-error`), and hands a non-page answer to the browser from the bytes it already has. Its cache never answers with a non-2xx, a hand-over, or a `no-store` page older than 5 s, and is emptied by any POST, `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
103
107
 
104
108
  Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
105
109
  Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.7.0",
3
+ "version": "22.8.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": [
8
8
  "./src/errors.ts",
9
- "./src/server.ts"
9
+ "./src/server.ts",
10
+ "./src/navigation-entry.ts"
10
11
  ],
11
12
  "repository": {
12
13
  "type": "git",
@@ -19,7 +20,8 @@
19
20
  },
20
21
  "exports": {
21
22
  ".": "./src/index.ts",
22
- "./server": "./src/server.ts"
23
+ "./server": "./src/server.ts",
24
+ "./navigation": "./src/navigation-entry.ts"
23
25
  },
24
26
  "files": [
25
27
  "src",
@@ -36,11 +38,11 @@
36
38
  "test": "bun test"
37
39
  },
38
40
  "dependencies": {
39
- "@ultimat3/cache": "22.7.0",
40
- "@ultimat3/core": "22.7.0",
41
- "@ultimat3/http": "22.7.0",
42
- "@ultimat3/i18n": "22.7.0",
43
- "@ultimat3/seo": "22.7.0",
41
+ "@ultimat3/cache": "22.8.0",
42
+ "@ultimat3/core": "22.8.0",
43
+ "@ultimat3/http": "22.8.0",
44
+ "@ultimat3/i18n": "22.8.0",
45
+ "@ultimat3/seo": "22.8.0",
44
46
  "sass": "1.104.0"
45
47
  }
46
48
  }
package/src/errors.ts CHANGED
@@ -27,6 +27,7 @@ export const RENDER_ERROR_CODES = [
27
27
  'X_STYLES_GLOBAL_MISSING',
28
28
  'X_ASSET_MISSING',
29
29
  'X_ROUTE_POST_INVALID',
30
+ 'X_ROUTE_NAVIGATION_INVALID',
30
31
  ] as const;
31
32
 
32
33
  export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
@@ -51,6 +52,8 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
51
52
  'a surface renders documents whose CSS defines no :root custom properties',
52
53
  X_ASSET_MISSING: 'a page names a site asset the app does not have',
53
54
  X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
55
+ X_ROUTE_NAVIGATION_INVALID:
56
+ "a route's navigation is not 'prefetch' or 'document', or its surface has no client navigation",
54
57
  };
55
58
 
56
59
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -200,6 +203,21 @@ export class RoutePostInvalidError extends UltimateError {
200
203
  }
201
204
  }
202
205
 
206
+ /**
207
+ * `defineRoute({ navigation })` that says nothing a process can honour: a value other than
208
+ * `'prefetch'` or `'document'`, or — at boot — a page whose surface has no client navigation.
209
+ */
210
+ export class RouteNavigationInvalidError extends UltimateError {
211
+ static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
212
+ constructor(cause: string) {
213
+ super({
214
+ code: RouteNavigationInvalidError.code,
215
+ cause,
216
+ fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
217
+ });
218
+ }
219
+ }
220
+
203
221
  export class RouteLoadInvalidError extends UltimateError {
204
222
  static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
205
223
  constructor(cause: string, fix: string) {
package/src/hydrate.ts CHANGED
@@ -129,9 +129,13 @@ var props=p?JSON.parse(p.textContent||'{}'):{};
129
129
  return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
130
130
  function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
131
131
  function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
132
- function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
132
+ function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),function(el){if(!el.__v){el.__v=1;f(el)}})}
133
133
  function hush(){}
134
134
  `.trim();
135
+ // `each` visits an island root ONCE per tab (`el.__v`): the client router (`navigation-swap.ts`)
136
+ // re-runs this same runtime over every body it swaps in, and an island it carried across
137
+ // (`data-x-persist`) must not get a second `catchUp` — its listeners would replay every later click
138
+ // twice. One bootstrap path, safe to run again, instead of a second mount API for the router.
135
139
  // `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
136
140
  // the page reports an unhandled rejection for a failure it already recorded on the element.
137
141
 
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export {
53
53
  RouteLoadInvalidError,
54
54
  RouteMetaMissingError,
55
55
  RouteModeInvalidError,
56
+ RouteNavigationInvalidError,
56
57
  RouteOfflineMissingError,
57
58
  RoutePostInvalidError,
58
59
  RouteStatusInvalidError,
@@ -111,6 +112,46 @@ export {
111
112
  defaultIslandBudget,
112
113
  MODE_SPECS,
113
114
  } from './modes';
115
+ /** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
116
+ export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
117
+ export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
118
+ export type {
119
+ CachedFacts,
120
+ FormFacts,
121
+ FormVerdict,
122
+ LinkFacts,
123
+ LinkVerdict,
124
+ NativeReason,
125
+ PrefetchFacts,
126
+ ResponseFacts,
127
+ ResponseVerdict,
128
+ } from './navigation-rules';
129
+ export {
130
+ formVerdict,
131
+ linkVerdict,
132
+ mayPrefetch,
133
+ NAVIGATE_EVENT,
134
+ NAVIGATED_EVENT,
135
+ NAVIGATING_ATTRIBUTE,
136
+ NAVIGATION_CACHE_TTL_MS,
137
+ NAVIGATION_ERROR_EVENT,
138
+ NAVIGATION_HEADER,
139
+ NAVIGATION_LOCATION_HEADER,
140
+ NAVIGATION_MAX_HOPS,
141
+ NAVIGATION_META,
142
+ NAVIGATION_NO_PREFETCH_ATTRIBUTE,
143
+ NAVIGATION_NO_STORE_REUSE_MS,
144
+ NAVIGATION_PERSIST_ATTRIBUTE,
145
+ NAVIGATION_PREFETCH_DELAY_MS,
146
+ NAVIGATION_PROGRESS_DELAY_MS,
147
+ NAVIGATION_RELOAD_ATTRIBUTE,
148
+ NAVIGATION_SCOPE_HEADER,
149
+ NAVIGATION_SURFACE_HEADER,
150
+ responseVerdict,
151
+ reusable,
152
+ } from './navigation-rules';
153
+ export type { ClientNavigationHead } from './navigation-tags';
154
+ export { clientNavigationTags } from './navigation-tags';
114
155
  export type {
115
156
  CompiledPattern,
116
157
  RegisterRouteInput,
@@ -145,9 +186,16 @@ export type {
145
186
  RouteMetaAsyncFn,
146
187
  RouteMetaContext,
147
188
  RouteMetaFn,
189
+ RouteNavigationMode,
148
190
  RouteParams,
149
191
  } from './route';
150
- export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './route';
192
+ export {
193
+ DEFAULT_ISLAND_HYDRATE,
194
+ defineRoute,
195
+ isRouteConfig,
196
+ ROUTE_NAVIGATION_MODES,
197
+ tagKeys,
198
+ } from './route';
151
199
  export type { RouteComponent } from './route-component';
152
200
  export { pageComponentOf } from './route-component';
153
201
  export { metaContextFor, routeDataFor } from './route-data';
package/src/modes.ts CHANGED
@@ -246,12 +246,12 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
246
246
  * (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
247
247
  * any surface, because the allowance is measured above the baseline and not against it.
248
248
  *
249
- * The number: 17,797 (the heaviest island this repo actually ships) + 1,744 (`hydrateRuntimeBytes`
249
+ * The number: 17,797 (the heaviest island this repo actually ships) + 1,784 (`hydrateRuntimeBytes`
250
250
  * for one `idle` directive — `defaultHydrate('app')`, and since 2026-09-22 the costlier of the two
251
- * runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,629) =
252
- * **19,541**. That is the worst case an app reaches without writing a number down. 20,480 is the
253
- * next whole kilobyte above it, leaving 939 B of headroom and still under 2x 19,541, so a route
254
- * bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 846, so an island route
251
+ * runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,669) =
252
+ * **19,581**. That is the worst case an app reaches without writing a number down. 20,480 is the
253
+ * next whole kilobyte above it, leaving 899 B of headroom and still under 2x 19,581, so a route
254
+ * bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 886, so an island route
255
255
  * that declares it pays less; the default is what the budget has to clear.
256
256
  *
257
257
  * All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The client router's per-tab memory of documents it fetched: a prefetch on intent, answered by
3
+ * the click that follows. Memory only, never storage — a gated page rendered for this principal
4
+ * must not outlive the tab — for `NAVIGATION_CACHE_TTL_MS`, and emptied by any POST, because a
5
+ * write is the one thing this tab knows makes every page it holds suspect.
6
+ */
7
+
8
+ import { NAVIGATION_CACHE_TTL_MS } from './navigation-rules';
9
+
10
+ /** One fetched document: the promise, so a click during the prefetch waits for the same fetch. */
11
+ export interface CachedDocument<T> {
12
+ readonly at: number;
13
+ readonly value: Promise<T>;
14
+ }
15
+
16
+ export interface NavigationCache<T> {
17
+ /** A fresh entry for `url`, or `undefined` — an expired one is dropped on the way. */
18
+ get(url: string): Promise<T> | undefined;
19
+ /** A fresh entry's answer and how old it is, or `undefined`. */
20
+ peek(url: string): { readonly value: Promise<T>; readonly ageMs: number } | undefined;
21
+ set(url: string, value: Promise<T>): void;
22
+ delete(url: string): void;
23
+ clear(): void;
24
+ readonly size: number;
25
+ }
26
+
27
+ export interface NavigationCacheOptions {
28
+ /** The clock, for a test. The TTL and the cap are constants: nothing has a reason to vary them. */
29
+ readonly now?: () => number;
30
+ }
31
+
32
+ /** Bounded: a visitor hovering a 200-row table must not hold 200 documents. */
33
+ export const NAVIGATION_CACHE_MAX_ENTRIES = 20;
34
+
35
+ /**
36
+ * The fragment never reaches the server, so `/a#x` and `/a#y` are one document; and `?b=2&a=1` is
37
+ * `?a=1&b=2` — one query, however a link happened to spell it.
38
+ */
39
+ export const cacheKey = (url: string): string => {
40
+ const parsed = new URL(url, 'http://x.invalid');
41
+ parsed.hash = '';
42
+ parsed.searchParams.sort();
43
+ return url.startsWith('/') ? `${parsed.pathname}${parsed.search}` : parsed.href;
44
+ };
45
+
46
+ export function navigationCache<T>(options: NavigationCacheOptions = {}): NavigationCache<T> {
47
+ const ttl = NAVIGATION_CACHE_TTL_MS;
48
+ const now = options.now ?? Date.now;
49
+ const max = NAVIGATION_CACHE_MAX_ENTRIES;
50
+ const entries = new Map<string, CachedDocument<T>>();
51
+ return {
52
+ get(url) {
53
+ const key = cacheKey(url);
54
+ const entry = entries.get(key);
55
+ if (entry === undefined) return undefined;
56
+ if (now() - entry.at >= ttl) {
57
+ entries.delete(key);
58
+ return undefined;
59
+ }
60
+ return entry.value;
61
+ },
62
+ peek(url) {
63
+ const value = this.get(url);
64
+ const entry = entries.get(cacheKey(url));
65
+ return value === undefined || entry === undefined
66
+ ? undefined
67
+ : { value, ageMs: now() - entry.at };
68
+ },
69
+ set(url, value) {
70
+ const key = cacheKey(url);
71
+ entries.delete(key);
72
+ entries.set(key, { at: now(), value });
73
+ // Oldest first: a Map iterates in insertion order and `set` re-inserts.
74
+ for (const oldest of entries.keys()) {
75
+ if (entries.size <= max) break;
76
+ entries.delete(oldest);
77
+ }
78
+ // A failed fetch is not a document: the next click must ask again, not replay the failure.
79
+ value.catch(() => {
80
+ if (entries.get(key)?.value === value) entries.delete(key);
81
+ });
82
+ },
83
+ delete(url) {
84
+ entries.delete(cacheKey(url));
85
+ },
86
+ clear() {
87
+ entries.clear();
88
+ },
89
+ get size() {
90
+ return entries.size;
91
+ },
92
+ };
93
+ }