@ultimat3/render 24.0.0 → 25.0.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
@@ -29,12 +29,12 @@ axiom 6). Never `cli` (upward).
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`. |
32
- | A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `dev-render.ts`. |
32
+ | A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `runtime-render.ts`. |
33
33
  | Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests. `.tsx` so the island-as-JSX claim is checked against the JSX an author writes. |
34
34
  | Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
35
35
  | Descriptor `budget` | always an object, `{}` when undeclared. `budget.js === undefined` is the site/ hydration failure. |
36
36
  | `RouteBudget` keys | **`js` only, PROJECTED as `budgetJs`** — `lcp` went with `budgetLcp`, measured by nothing. `assertModeInvariants` refuses any other key that arrives by a cast (`X_ROUTE_MODE_INVALID`); `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s `budget*` keys, so an unprojected key is a build error; `scripts/declaration-readers.ts` finds the class across every declaration. |
37
- | No `describe()` on a route | `describeRoutes()` is the one route list. |
37
+ | No `describe()` on a route | `describePages()` is the one route list. |
38
38
  | Mode invariants | `modes.ts` only; never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component.** A fifth mode has to name the function that renders it. |
39
39
  | Island declaration | `island({ src })` — a **specifier**, never an import, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
40
40
  | Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling. Never widen it. |
@@ -51,19 +51,20 @@ axiom 6). Never `cli` (upward).
51
51
  | Island collection | per render, `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context. |
52
52
  | A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. |
53
53
  | Island bytes | **not this package's**: `@ultimat3/cli`'s `packages/cli/src/budgets.ts` measures the EMITTED document and is the gate. What stays here is the budget grammar (`parseByteBudget`) and `defaultIslandBudget`. |
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). |
54
+ | The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets, each with and without the hold part — fourteen). 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-28`: `idle` 1,784, `visible` 886, `interaction` 1,669 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-10-05`: `idle` 1,843, `visible` 886, `interaction` 1,728 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`. |
61
+ | A held island | `hold` on the collector (the host decides — the CLI holds realtime islands on a page-boot document); `island-hold.ts` owns the attribute, the inline `visibility:hidden`, the cap `ISLAND_HOLD_MS` and the runtime part. Emitted right after the prelude; booted at once, revealed TOGETHER on both mount arms, at the cap, or by CSS (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`) — never hidden for good. `catchUp`'s `on` lets go after a mount it did not flush. `island-hold.test.ts`, `island-hold-replay.test.ts`. |
61
62
  | `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
62
63
  | Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
63
64
  | Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
64
- | Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `describeRoutes()` with `mount: { by, permissions }`, never in `routeEntries()`: described, not rendered here. Re-declaring a `key` replaces its set; a path a file route also claims is `X_ROUTE_DUPLICATE` in both orders. `RouteDescriptor.mount` is absent on a file route. |
65
+ | Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `describePages()` with `mount: { by, permissions }`, never in `routeEntries()`: described, not rendered here. Re-declaring a `key` replaces its set; a path a file route also claims is `X_ROUTE_DUPLICATE` in both orders. `RouteDescriptor.mount` is absent on a file route. |
65
66
  | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
66
- | Descriptors | `describeRoutes()` stays JSON-safe, sorted by path, deterministic. |
67
+ | Descriptors | `describePages()` stays JSON-safe, sorted by path, deterministic. |
67
68
  | Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
68
69
  | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
69
70
  | Stream cancellation | the source has a `cancel()` and `write` is guarded on it; a disconnect aborts `StreamHole.resolve(signal)`. |
@@ -73,8 +74,8 @@ axiom 6). Never `cli` (upward).
73
74
  | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `pathnameOf` never throws on a relative `ctx.url`. |
74
75
  | The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`, and `surrogate-key`/`cache-tag` from `@ultimat3/cache`'s `surrogateKeys()` over the route's `revalidateTags` — kept only while the response stays shared: http's `applyCacheHeaders` strips both on a rewrite to `private`/`no-store`. `registry.ts`'s `assertPurgeableTags` screens the tags at registration (`X_ROUTE_MODE_INVALID`); the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. The one shipped emitter of purge keys; file and mounted routes are both screened, so `toResult` never refuses on the request path |
75
76
  | A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set`. `registerPath` runs BEFORE the render. |
76
- | ISR regeneration | `@ultimat3/core`'s `createSingleFlight`, `deadlineMs` = `regenerateDeadlineMs` (`DEFAULT_ISR_REGENERATE_DEADLINE_MS`, 30 s; `finiteCount` ≥ 1, no opt-out), `schedule` injectable. Past it the PATH is free; the hung render runs on, and `latest` (by identity) drops its result once a newer run started. `render-isr-flight.test.ts`, on a hand-fired scheduler. |
77
- | Which route a stored ISR path is | exact path first, else the MOST SPECIFIC pattern: `route-pattern.ts`'s `compilePattern` (never a second compiler), sorted on `CompiledPattern.specificity`. Never table order: `describeRoutes()` sorts `*` before `:`. |
77
+ | ISR regeneration | `@ultimat3/core`'s `singleFlight`, `deadlineMs` = `regenerateDeadlineMs` (`DEFAULT_ISR_REGENERATE_DEADLINE_MS`, 30 s; `finiteCount` ≥ 1, no opt-out), `schedule` injectable. Past it the PATH is free; the hung render runs on, and `latest` (by identity) drops its result once a newer run started. `render-isr-flight.test.ts`, on a hand-fired scheduler. |
78
+ | Which route a stored ISR path is | exact path first, else the MOST SPECIFIC pattern: `route-pattern.ts`'s `compilePattern` (never a second compiler), sorted on `CompiledPattern.specificity`. Never table order: `describePages()` sorts `*` before `:`. |
78
79
  | `CompiledPattern.specificity` | the ONE ordering rule, read by ISR, `@ultimat3/cli`'s sitemap extras and `@ultimat3/admin`'s `adminRouteMatch`: `@ultimat3/core`'s `routeRank(path)` — the request trie's order as one integer, shared with `@ultimat3/pwa`'s worker rule order. Never a local rank. |
79
80
  | `compilePattern`'s regex | a `*catch-all` owns its separator, `(?:/(.*))?`, so the bare prefix `static-path.ts` writes for an empty rest (`/docs`) matches; a literal character matches raw OR percent-encoded, either hex case — a store key is `URL.pathname`, and `precios-españa` arrives as `precios-espa%C3%B1a`. `route-pattern.test.ts`. |
80
81
  | Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })` (`set` means "just generated" and orders eviction). |
@@ -97,7 +98,7 @@ axiom 6). Never `cli` (upward).
97
98
  | Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
98
99
  | Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope">`, read by core's `pageClient()`. `documentCarriesScope(headers)` is the one rule: only a `private` document carries it; absent means "not rendered for anyone". The literal is duplicated in core until core exports a constant. |
99
100
  | `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
100
- | Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker`, its default `errorFallback` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeHtml` — text and attributes alike, five characters — is `@ultimat3/core`'s, re-exported by `html.ts`; `bun run flight-copies` refuses a second table (`X_HELPER_COPY`). |
101
+ | Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker`, its default `errorFallback` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeHtml` — text and attributes alike, five characters — is `@ultimat3/core`'s, imported by `html.ts` (never re-exported); `bun run flight-copies` refuses a second table (`X_HELPER_COPY`). |
101
102
  | Script and style CONTENT | never raw: `escapeHtml`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
102
103
  | Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
103
104
  | CSS-module scoping | `scopeClasses` rewrites class selectors in qualified-rule PRELUDES only (`inPreludes`) — never a declaration (`local(Inter.Regular)`), a comment or an at-rule prelude; a class is a whole CSS identifier, escapes included, and the map key is its unescaped name. The suffix hashes the basename + the COMPILED CSS, so two modules with one source and different partials never share a class name. |
@@ -112,7 +113,7 @@ axiom 6). Never `cli` (upward).
112
113
  | Router ↔ the page's lifecycle | `navigation-scroll.ts` owns `scrollRestoration = 'manual'` and RESTORES the entry's saved offset at start, before its first save (a reload, or Back after a full load); `navigation-tabs.ts` owns the `BroadcastChannel`, closed on `pagehide` (it blocks the back/forward cache), reopened and the cache emptied on a persisted `pageshow`. A sheet `loadStylesheets` appends is `owned` at once, so an aborted navigation's sheets are retired by the next swap. A view transition is tracked by identity: an older one finishing never clears the running one. |
113
114
  | 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. |
114
115
  | 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. |
115
- | 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 (when sent AND when it settles), `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
116
+ | 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 2xx non-page answer to the browser from the bytes it already has. A 4xx/5xx non-page answer (an offline worker's empty 503) is never handed over (it became a `blob:` URL): a POST fails, a GET is loaded by the browser — the ONE deliberate re-send, as there is nothing to show (`responseVerdict`). 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 (when sent AND when it settles), `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
116
117
 
117
118
  Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
118
119
  Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
package/README.md CHANGED
@@ -143,7 +143,7 @@ is a build error.
143
143
 
144
144
  ## The route table is the single source of route truth
145
145
 
146
- `registry.ts` maps file paths to URLs and `describeRoutes()` projects the table into a
146
+ `registry.ts` maps file paths to URLs and `describePages()` projects the table into a
147
147
  sorted, JSON-safe descriptor list. Every downstream generator reads that one table.
148
148
 
149
149
  | File | URL |
@@ -333,7 +333,7 @@ first frame — an id, a count, and the URL of the read that answers the rest:
333
333
 
334
334
  ```ts
335
335
  // page.tsx — the props are the id and the endpoint, never the rows
336
- import { derivePath } from '@ultimat3/query';
336
+ import { queryPath } from '@ultimat3/core';
337
337
  import { island } from '@ultimat3/render';
338
338
 
339
339
  const Dispatch = island({
@@ -342,7 +342,7 @@ const Dispatch = island({
342
342
  });
343
343
 
344
344
  export const DispatchFor = (host: { readonly id: string }) =>
345
- Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${derivePath('modelList')}?limit=100` });
345
+ Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${queryPath('modelList')}?limit=100` });
346
346
  ```
347
347
 
348
348
  ```tsx
@@ -370,7 +370,7 @@ export function mount(el: HTMLElement, props: DispatchProps): void {
370
370
  ```
371
371
 
372
372
  `models: []` keeps the island's first frame honest (an empty picker, not a missing one) and
373
- `derivePath` is the same derivation the typed `client()` uses, so the URL cannot drift from the
373
+ `queryPath` is the same derivation the typed `client()` uses, so the URL cannot drift from the
374
374
  route. The `fix:` line of an over-cap `X_ISLAND_PROPS_INVALID` names exactly this edit, with the
375
375
  heaviest prop's own name in it.
376
376
 
@@ -385,7 +385,7 @@ by the request that carries it.
385
385
  ### It counts against the route's budget
386
386
 
387
387
  ```ts
388
- const collector = createIslandCollector({ file, hydrate: config.hydrate, resolve });
388
+ const collector = islandCollector({ file, hydrate: config.hydrate, resolve });
389
389
  const html = await renderToHtml(page, { islands: collector });
390
390
  document.body += hydrateRuntime(collector.directives); // the one thing left to remember
391
391
  ```
@@ -433,6 +433,14 @@ never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
433
433
  from, exported for the same reason: anything waiting for hydration has to wait at least this long,
434
434
  and a second copy of the number is a settle that shoots early and calls a healthy page broken.
435
435
 
436
+ **A held island** (`data-x-hold`, `As of 2026-10`) is one whose server markup must not paint before
437
+ it mounts — an offline reload's cached count under a queued write (#506). The collector's
438
+ `hold: (src) => boolean` names them (the host's call: `@ultimat3/cli` holds realtime islands on a
439
+ document that carries the page boot); the wrapper is `visibility:hidden` from its first byte, the
440
+ runtime boots it at once whatever the strategy, and reveals it when `mount()` settles either way or
441
+ at `ISLAND_HOLD_MS` (3000), whichever comes first — and at the same cap by CSS alone (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`), so a page with no script still shows the markup. Only a page holding an island pays for that part
442
+ of the runtime, and `HYDRATE_RUNTIME_BODIES` carries both variants.
443
+
436
444
  ## Two entry points
437
445
 
438
446
  **Split 2026-08-22, and every claim in this section holds `As of 2026-08`.**
@@ -486,14 +494,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
486
494
  |---|---|
487
495
  | `defineRoute` | the `route` primitive |
488
496
  | `withStatus`, `routeStatusOf` | the status a loader answers, carried on its data; 200 when nothing asked |
489
- | `island`, `createIslandCollector` | one interactive component on a static page |
497
+ | `island`, `islandCollector` | one interactive component on a static page |
490
498
  | `asset`, `setAssetResolver`, `assetPathProblem`, `AssetPath` | `asset('assets/x.avif')` → the content-hashed URL of a public site file; the table is the CLI's ([Static Assets](../../wiki/Static-Assets.md)) |
491
499
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
492
- | `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
493
- | `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `describeRoutes()` with `mount: { by, permissions }`, absent from `routeEntries()` |
500
+ | `registerRoute`, `describePages`, `routeFor`, `routePathFromFile` | the route table |
501
+ | `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `describePages()` with `mount: { by, permissions }`, absent from `routeEntries()` |
494
502
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
495
503
  | `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
496
- | `createIsrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
504
+ | `isrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
497
505
  | `renderSsr`†, `streamResult`† | the per-request modes |
498
506
  | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
499
507
  | `claimStylesheets`† | a package claims its own directory's stylesheets for ONE surface, so the other surface's documents never carry them |
@@ -501,6 +509,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
501
509
  | `setSassCacheDir`†, `sassCompilations`† | where the Sass compile cache lives (`.x/cache/sass` under cwd; a container's is the image's prebuilt store) and how many compiles this process ran instead of reading one |
502
510
  | `emitIslandAttributes`, `hydrateRuntime`, `HYDRATE_RUNTIME_BODIES` | the four hydration strategies, and every body the runtime script can hold — a host hashes that list into `script-src`, because the runtime is emitted inline and no `render: 'static'` file can receive a nonce |
503
511
  | `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
512
+ | `ISLAND_HOLD_ATTRIBUTE`, `ISLAND_HOLD_MS`, `ISLAND_HOLD_REVEAL` | a held island's marker, and the cap after which it shows its server markup regardless |
504
513
  | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
505
514
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script; it stamps `data-theme` and, beside it, the fallback itself as `data-theme-default` for `@ultimat3/ui`'s `clearTheme`/`watchOsTheme` |
506
515
  | `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
@@ -517,7 +526,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
517
526
  dependents and the revalidator slot, the latter only while it is still this controller's.
518
527
  The default store (`memoryIsrStore`) is capped at `DEFAULT_ISR_MAX_ENTRIES` (1,000) pages,
519
528
  least recently generated evicted first. One regeneration holds its page for at most
520
- `DEFAULT_ISR_REGENERATE_DEADLINE_MS` (30 s; `createIsrController({ regenerateDeadlineMs })`),
529
+ `DEFAULT_ISR_REGENERATE_DEADLINE_MS` (30 s; `isrController({ regenerateDeadlineMs })`),
521
530
  so a render that never settles no longer pins the page for the life of the process. A stored
522
531
  path takes its TTL and tags from the most specific matching route (static > `:param` >
523
532
  `*catch-all`, per segment), never the first in table order.
@@ -545,7 +554,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
545
554
  - **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
546
555
  0kb default is mechanical, not aspirational. A page that renders an island anyway is
547
556
  `X_ISLAND_NOT_HYDRATED`, not a silently dead button.
548
- - **A gated page is `ssr`**, never a client-rendered shell. `spa` and `createRouter` were
557
+ - **A gated page is `ssr`**, never a client-rendered shell. `spa` and `httpRouter` were
549
558
  deleted `As of 2026-08-20`: `renderSpa` never read the route's component and no build ever produced the
550
559
  `chunks` it preloaded, so every `spa` route served an empty `<div id="x-root">`, and the router
551
560
  that shell would have needed had no caller in the framework or in either tracked app. A page
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "24.0.0",
3
+ "version": "25.0.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",
@@ -32,18 +32,18 @@
32
32
  "LICENSE"
33
33
  ],
34
34
  "engines": {
35
- "bun": ">=1.4.0"
35
+ "bun": ">=1.4.2"
36
36
  },
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit -p tsconfig.json",
39
39
  "test": "bun test"
40
40
  },
41
41
  "dependencies": {
42
- "@ultimat3/cache": "24.0.0",
43
- "@ultimat3/core": "24.0.0",
44
- "@ultimat3/http": "24.0.0",
45
- "@ultimat3/i18n": "24.0.0",
46
- "@ultimat3/seo": "24.0.0",
47
- "sass": "1.104.0"
42
+ "@ultimat3/cache": "25.0.0",
43
+ "@ultimat3/core": "25.0.0",
44
+ "@ultimat3/http": "25.0.0",
45
+ "@ultimat3/i18n": "25.0.0",
46
+ "@ultimat3/seo": "25.0.0",
47
+ "sass": "1.105.1"
48
48
  }
49
49
  }
@@ -9,9 +9,6 @@ import type { ActionPathStyle } from '@ultimat3/core';
9
9
  import { CLIENT_PATH_STYLE_META } from '@ultimat3/core';
10
10
  import type { HeadTag } from './head';
11
11
 
12
- /** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
13
- export { CLIENT_PATH_STYLE_META };
14
-
15
12
  /** `style` is `@ultimat3/action`'s `actionPathStyle()`: what `defineApi` declared, read per render. */
16
13
  export function clientPathStyleTags(style: ActionPathStyle): readonly HeadTag[] {
17
14
  if (style === 'resource') return [];
@@ -9,9 +9,6 @@
9
9
  import { CLIENT_PERSIST_META, CLIENT_SCOPE_META } from '@ultimat3/core';
10
10
  import type { HeadTag } from './head';
11
11
 
12
- /** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
13
- export { CLIENT_PERSIST_META, CLIENT_SCOPE_META };
14
-
15
12
  /** `scope` is opaque (`@ultimat3/auth`'s `clientScopeOf`), and `''` is the anonymous page. */
16
13
  export function clientScopeTag(scope: string): HeadTag {
17
14
  return {
@@ -7,9 +7,6 @@
7
7
  import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core';
8
8
  import type { HeadTag } from './head';
9
9
 
10
- /** Core's names (`page-meta.ts`), so the writer here and every reader share one literal each. */
11
- export { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META };
12
-
13
10
  export interface ClientSyncHead {
14
11
  readonly syncUrl: string;
15
12
  readonly buildId: string;
@@ -10,7 +10,7 @@
10
10
  * the runtime's ICU **default locale** and **collation version**: `'/A'` sorts after `'/a'` on one
11
11
  * machine and before it on the next, and `'/zoo'` before `'/ärzte'` under `sv-SE` but after it
12
12
  * under `en-US`, for the same input. Everything this package orders is either diffed across
13
- * deploys (`describeRoutes()` feeds `x.manifest.json`, the sitemap and `sw.js`'s rule table) or is
13
+ * deploys (`describePages()` feeds `x.manifest.json`, the sitemap and `sw.js`'s rule table) or is
14
14
  * a choice a build makes (`pageComponentOf`'s fallback), so a machine-dependent order is a no-op
15
15
  * deploy that reads as a change — or a different page rendered per host. Same rule
16
16
  * `@ultimat3/pwa`'s `precache.ts` and `service-worker.ts` state for the artifact they emit.
@@ -359,7 +359,8 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
359
359
  // Content-addressed, not path-addressed: a checkout at a different absolute path must produce
360
360
  // byte-identical CSS, which a hash over the absolute filename would not. The COMPILED CSS, not
361
361
  // the source: two `page.module.scss` with one source and different `@use`d partials hashed
362
- // alike, emitted one class name with two bodies, and the later sheet won on both pages.
363
- const scoped = scopeClasses(css, contentHash(`${file.split('/').pop() ?? file} ${css}`));
362
+ // alike, emitted one class name with two bodies, and the later sheet won on both pages. Split on
363
+ // both separators: on Windows Bun hands the path over as `D:\…`, and `/` alone kept all of it.
364
+ const scoped = scopeClasses(css, contentHash(`${file.split(/[\\/]/).pop() ?? file} ${css}`));
364
365
  return { css: scoped.css, classes: scoped.classes, dependencies };
365
366
  }
package/src/errors.ts CHANGED
@@ -109,7 +109,7 @@ export class RouteMetaMissingError extends UltimateError {
109
109
  /**
110
110
  * The route table holds descriptors, never declarations. `defineRoute` is the one normalizer, so
111
111
  * a raw declaration reaching the registry means `budget` and `meta` are whatever the author wrote
112
- * — and every reader downstream (`describeRoutes`, `sw.js`, the sitemap) assumes they are not.
112
+ * — and every reader downstream (`describePages`, `sw.js`, the sitemap) assumes they are not.
113
113
  */
114
114
  export class RouteUnnormalizedError extends UltimateError {
115
115
  static readonly code = 'X_ROUTE_UNNORMALIZED' as const;
package/src/head.ts CHANGED
@@ -7,11 +7,12 @@
7
7
  * catalog and so the tag vocabulary keeps exactly one owner (`@ultimat3/seo`).
8
8
  */
9
9
 
10
- import { finiteCount } from '@ultimat3/core';
10
+ import { escapeHtml, finiteCount, THEME_STORAGE_KEY } from '@ultimat3/core';
11
11
  import type { RouteMeta } from '@ultimat3/seo';
12
12
  import { BudgetExceededError } from './errors';
13
- // `html.ts` is this package's one escaper — a second one is how a character ends up missing.
14
- import { escapeHtml, escapeJsonContent, escapeRawTextContent, isAttributeName } from './html';
13
+ // The raw-text escapers are `html.ts`'s and the text/attribute table is core's `escapeHtml` — a
14
+ // second one is how a character ends up missing.
15
+ import { escapeJsonContent, escapeRawTextContent, isAttributeName } from './html';
15
16
 
16
17
  export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
17
18
 
@@ -172,14 +173,6 @@ export interface ThemeScriptOptions {
172
173
 
173
174
  export const THEME_SCRIPT_MAX_BYTES = 512;
174
175
 
175
- /**
176
- * The storage key `@ultimat3/ui`'s `ThemeToggle` reads (`THEME_STORAGE_KEY` there). One literal on
177
- * each side, pinned equal by a test in `@ultimat3/cli`: render sits below ui in the tier table and
178
- * cannot import it, and the two keys disagreeing was exactly the bug — the boot stamped one key,
179
- * the toggle wrote another, and a visitor's choice never survived a reload.
180
- */
181
- export const THEME_STORAGE_KEY = 'ultimate.theme';
182
-
183
176
  /**
184
177
  * The script's text alone, so the CSP hash and the tag come from one string: a policy hashed from
185
178
  * a restatement admits a script that is not the one the document carries.
package/src/html.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * HTML text and attribute serialization for the server renderer. The character table is
3
- * `@ultimat3/core`'s `escapeHtml`, reached from here and nowhere else in this package: a second
4
- * escaper is how one of them ends up missing a character, and a missing character in an attribute
5
- * is an injection.
3
+ * `@ultimat3/core`'s `escapeHtml`, imported from core by every render-* file: a second escaper is
4
+ * how one of them ends up missing a character, and a missing character in an attribute is an
5
+ * injection (`X_HELPER_COPY` refuses both a copy and a re-export).
6
6
  */
7
7
 
8
8
  import { escapeHtml, safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
@@ -26,12 +26,6 @@ export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
26
26
  'wbr',
27
27
  ]);
28
28
 
29
- /**
30
- * Re-exported, never re-implemented: one set of five characters for text AND attributes, so a
31
- * value is inert wherever it lands. Every render-* file escapes through this import.
32
- */
33
- export { escapeHtml };
34
-
35
29
  /**
36
30
  * `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
37
31
  * `escapeHtml` there would ship `&lt;` to a JS or CSS parser and corrupt the code without closing
package/src/hydrate.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy } from '@ultimat3/core';
9
- import { HYDRATE_STRATEGIES } from '@ultimat3/core';
10
- import { escapeHtml, escapeJsonContent } from './html';
9
+ import { escapeHtml, HYDRATE_STRATEGIES } from '@ultimat3/core';
10
+ import { escapeJsonContent } from './html';
11
+ import { ISLAND_HOLD_ATTRIBUTES, RUNTIME_HOLD } from './island-hold';
11
12
 
12
13
  export interface IslandDirective {
13
14
  /** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
@@ -26,6 +27,11 @@ export interface IslandDirective {
26
27
  readonly events?: readonly string[];
27
28
  /** `rootMargin` for `visible`. */
28
29
  readonly rootMargin?: string;
30
+ /**
31
+ * The server markup must not paint before the island mounts (`island-hold.ts`, #506): hidden
32
+ * from its first byte, booted at once, revealed by the mount or the cap. Never on `never`.
33
+ */
34
+ readonly hold?: boolean;
29
35
  }
30
36
 
31
37
  export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'keydown'] as const;
@@ -74,6 +80,8 @@ export function emitIslandAttributes(directive: IslandDirective): string {
74
80
  if (directive.events !== undefined && directive.events.length > 0) {
75
81
  attrs.push(attr('data-x-events', directive.events.join(' ')));
76
82
  }
83
+ // A constant, so it needs no escaper — and nothing a caller supplies reaches it.
84
+ if (directive.hold === true) attrs.push(ISLAND_HOLD_ATTRIBUTES);
77
85
  }
78
86
  return attrs.join(' ');
79
87
  }
@@ -205,6 +213,9 @@ io.observe(el)})
205
213
  // `go` chains on the same `el.__x`, and the first flush empties `q` and sets `done`, so however many
206
214
  // ran, each caught event is replayed exactly once and an untouched island still lets go of its
207
215
  // listeners at mount — a listener left behind would replay every later click a second time.
216
+ // An island something ELSE booted (a held island: `island-hold.ts` boots it at once) had no flush
217
+ // to let go in, so `on` checks the mount marker — set in the same `.then` that settles `el.__x` —
218
+ // and detaches instead of queueing: the press reaches its target natively, once.
208
219
  const RUNTIME_CATCH_UP = `
209
220
  function path(el,t){var p=[t&&t.tagName];
210
221
  for(;t&&t!==el&&t.parentNode;t=t.parentNode)p.unshift(Array.prototype.indexOf.call(t.parentNode.children,t));
@@ -219,7 +230,8 @@ var q=[],done=false;
219
230
  var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
220
231
  var go=function(){boot(el).then(function(){var r=q;off();
221
232
  r.forEach(function(e){var ev=e[0],c=new ev.constructor(ev.type,ev);aim(el,ev,e[1]).dispatchEvent(c)})},off)};
222
- var on=function(ev){if(done)return;q.push([ev,path(el,ev.target)]);go()};
233
+ var on=function(ev){if(done)return;if(el.getAttribute('${ISLAND_MOUNTED_ATTRIBUTE}')!==null){off();return}
234
+ q.push([ev,path(el,ev.target)]);go()};
223
235
  evs.forEach(function(n){el.addEventListener(n,on,true)});return go}
224
236
  `.trim();
225
237
 
@@ -249,9 +261,14 @@ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRA
249
261
  * moment the runtime changes, and the failure it produces is an island that never boots on a page
250
262
  * that otherwise looks correct.
251
263
  */
252
- const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
264
+ const runtimeBody = (
265
+ needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>,
266
+ held: boolean,
267
+ ): string =>
253
268
  [
254
269
  RUNTIME_PRELUDE,
270
+ // Right after the prelude: no strategy part below that throws can strand a held island.
271
+ ...(held ? [RUNTIME_HOLD] : []),
255
272
  // Emitted once for whichever of the two catch-up strategies the page uses; a `visible`-only
256
273
  // page never pays for it.
257
274
  ...(needed.has('idle') || needed.has('interaction') ? [RUNTIME_CATCH_UP] : []),
@@ -260,18 +277,22 @@ const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): st
260
277
  ),
261
278
  ].join('\n');
262
279
 
280
+ /** Whether any island the runtime boots is held — a `never` island boots nothing to reveal it. */
281
+ const holds = (directives: readonly IslandDirective[]): boolean =>
282
+ directives.some((directive) => directive.hold === true && directive.strategy !== 'never');
283
+
263
284
  /**
264
- * Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, seven in
265
- * all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
285
+ * Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, with and
286
+ * without a held island — fourteen in all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
266
287
  * socket opens — the alternative is a per-response nonce, which a `render: 'static'` page (a file
267
288
  * on disk) can never receive. Enumerated rather than derived from the route table because the
268
289
  * runtime is a function of the SET a document needs, and a table read at boot cannot answer for
269
290
  * a document assembled later.
270
291
  */
271
- export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
272
- { length: 2 ** RUNTIME_ORDER.length - 1 },
273
- (_unused, index) =>
274
- runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1))),
292
+ export const HYDRATE_RUNTIME_BODIES: readonly string[] = [false, true].flatMap((held) =>
293
+ Array.from({ length: 2 ** RUNTIME_ORDER.length - 1 }, (_unused, index) =>
294
+ runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1)), held),
295
+ ),
275
296
  );
276
297
 
277
298
  /**
@@ -281,7 +302,7 @@ export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
281
302
  export function hydrateRuntime(directives: readonly IslandDirective[]): string {
282
303
  const needed = requiredStrategies(directives);
283
304
  if (needed.size === 0) return '';
284
- return `<script type="module">${runtimeBody(needed)}</script>`;
305
+ return `<script type="module">${runtimeBody(needed, holds(directives))}</script>`;
285
306
  }
286
307
 
287
308
  /** Rough emitted size of the hydration runtime for a page, for the budget check. */
package/src/index.ts CHANGED
@@ -5,26 +5,21 @@
5
5
  */
6
6
 
7
7
  /**
8
- * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
9
- * because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` all take these types
10
- * in their signatures: a consumer calling this package's API should not need a second import to
11
- * name its arguments. A re-export is not a declaration — `scripts/render-modes.test.ts` refuses a
12
- * second declaration, which is what makes re-exporting safe where copying was not.
8
+ * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`). Its TYPES are re-exported
9
+ * here because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` take them in
10
+ * their signatures; its VALUES (`RENDER_MODES`, `HYDRATE_STRATEGIES`, `OFFLINE_STRATEGIES`) and
11
+ * `formatBytes` are imported from core by every caller — a value re-export is a second import path
12
+ * (`X_HELPER_COPY`, `bun run flight-copies`). Nor are core's page-meta names (`CLIENT_*_META`)
13
+ * re-published beside the tag writers below.
13
14
  */
14
15
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
15
- // `formatBytes` moved to `@ultimat3/core` (one formatter, with the `mb` branch this package's copy
16
- // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
17
- // table it prints against.
18
- export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
19
16
  /** `asset('assets/x.avif')` → the content-hashed URL the site asset surface serves it at. */
20
17
  export type { AssetExtension, AssetPath, AssetResolver } from './asset';
21
18
  export { ASSET_DIR, ASSET_EXTENSIONS, asset, assetPathProblem, setAssetResolver } from './asset';
22
19
  /** The `<meta name="ultimate-path-style">` core's `actionPath` reads — a non-default style only. */
23
- export { CLIENT_PATH_STYLE_META, clientPathStyleTags } from './client-path-style-tag';
20
+ export { clientPathStyleTags } from './client-path-style-tag';
24
21
  /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
25
22
  export {
26
- CLIENT_PERSIST_META,
27
- CLIENT_SCOPE_META,
28
23
  clientPersistTags,
29
24
  clientScopeTag,
30
25
  documentCarriesScope,
@@ -32,9 +27,6 @@ export {
32
27
  /** Where the page's one socket dials, and the worker that hosts it (plan 101, slice 11). */
33
28
  export type { ClientSyncHead } from './client-sync-tags';
34
29
  export {
35
- CLIENT_BUILD_META,
36
- CLIENT_SYNC_META,
37
- CLIENT_SYNC_WORKER_META,
38
30
  clientBootTags,
39
31
  clientSyncTags,
40
32
  } from './client-sync-tags';
@@ -75,7 +67,6 @@ export {
75
67
  headFromMeta,
76
68
  mergeHead,
77
69
  renderHead,
78
- THEME_STORAGE_KEY,
79
70
  themeScript,
80
71
  themeScriptBody,
81
72
  } from './head';
@@ -99,7 +90,8 @@ export {
99
90
  islandModuleId,
100
91
  } from './island';
101
92
  export type { IslandCollector, IslandCollectorInput } from './island-collector';
102
- export { createIslandCollector, islandModuleIds } from './island-collector';
93
+ export { islandCollector, islandModuleIds } from './island-collector';
94
+ export { ISLAND_HOLD_ATTRIBUTE, ISLAND_HOLD_MS, ISLAND_HOLD_REVEAL } from './island-hold';
103
95
  export type { IslandProps, JsonValue } from './island-props';
104
96
  export { ISLAND_PROPS_MAX_BYTES } from './island-props';
105
97
  export { parseByteBudget } from './islands';
@@ -137,8 +129,6 @@ export {
137
129
  NAVIGATING_ATTRIBUTE,
138
130
  NAVIGATION_CACHE_TTL_MS,
139
131
  NAVIGATION_ERROR_EVENT,
140
- NAVIGATION_HEADER,
141
- NAVIGATION_LOCATION_HEADER,
142
132
  NAVIGATION_MAX_HOPS,
143
133
  NAVIGATION_META,
144
134
  NAVIGATION_NO_PREFETCH_ATTRIBUTE,
@@ -147,8 +137,6 @@ export {
147
137
  NAVIGATION_PREFETCH_DELAY_MS,
148
138
  NAVIGATION_PROGRESS_DELAY_MS,
149
139
  NAVIGATION_RELOAD_ATTRIBUTE,
150
- NAVIGATION_SCOPE_HEADER,
151
- NAVIGATION_SURFACE_HEADER,
152
140
  responseVerdict,
153
141
  reusable,
154
142
  } from './navigation-rules';
@@ -164,7 +152,7 @@ export type {
164
152
  } from './registry';
165
153
  export {
166
154
  clearRoutes,
167
- describeRoutes,
155
+ describePages,
168
156
  ROUTE_FILENAME,
169
157
  registerMountedRoutes,
170
158
  registerRoute,
@@ -198,7 +186,6 @@ export {
198
186
  defineRoute,
199
187
  isRouteConfig,
200
188
  ROUTE_NAVIGATION_MODES,
201
- tagKeys,
202
189
  } from './route';
203
190
  export type { RouteComponent } from './route-component';
204
191
  export { pageComponentOf } from './route-component';
@@ -29,6 +29,12 @@ export interface IslandCollectorInput {
29
29
  readonly hydrate: HydrateStrategy;
30
30
  /** Specifier → built chunk URL. Identity in dev and in tests; the build supplies the real one. */
31
31
  readonly resolve?: (src: string) => string;
32
+ /**
33
+ * Which islands (by `src`, as the page wrote it) must not paint their server markup before they
34
+ * mount (`island-hold.ts`). The caller's to answer — the renderer cannot see what an island's
35
+ * graph imports. Absent, nothing is held.
36
+ */
37
+ readonly hold?: (src: string) => boolean;
32
38
  }
33
39
 
34
40
  /**
@@ -43,7 +49,7 @@ export interface IslandCollector {
43
49
  record(spec: IslandSpec, props: JsxProps): IslandDirective;
44
50
  }
45
51
 
46
- export function createIslandCollector(input: IslandCollectorInput): IslandCollector {
52
+ export function islandCollector(input: IslandCollectorInput): IslandCollector {
47
53
  const directives: IslandDirective[] = [];
48
54
  const entries = new Map<string, string>();
49
55
  const resolve = input.resolve ?? ((src: string) => src);
@@ -72,7 +78,8 @@ export function createIslandCollector(input: IslandCollectorInput): IslandCollec
72
78
 
73
79
  const bag = checkIslandProps(props, spec.propKeys, input.file, spec.moduleId);
74
80
  const instance = directives.filter((d) => d.moduleId === spec.moduleId).length + 1;
75
- const directive = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
81
+ const built = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
82
+ const directive = input.hold?.(spec.src) === true ? { ...built, hold: true } : built;
76
83
  directives.push(directive);
77
84
  return directive;
78
85
  },
@@ -134,7 +141,7 @@ function assertEntry(entry: string, spec: IslandSpec, file: string): void {
134
141
  throw new IslandInvalidError(
135
142
  `the resolver returned ${renderCauseValue(entry)} for the ${spec.moduleId} island in ${file}, ` +
136
143
  'which cannot be emitted as a module URL',
137
- 'fix the resolve() passed to createIslandCollector — it must return a plain URL path',
144
+ 'fix the resolve() passed to islandCollector — it must return a plain URL path',
138
145
  );
139
146
  }
140
147
 
@@ -143,7 +150,7 @@ export function islandWithoutCollector(spec: IslandSpec): IslandNotHydratedError
143
150
  return new IslandNotHydratedError(
144
151
  `the ${spec.moduleId} island was rendered outside a render that collects islands, so no ` +
145
152
  'hydration runtime is emitted and its chunk is never requested',
146
- 'render the page through renderToHtml(tree, { islands: createIslandCollector({ file, ' +
153
+ 'render the page through renderToHtml(tree, { islands: islandCollector({ file, ' +
147
154
  'hydrate }) }) so the island is counted, booted and budgeted',
148
155
  );
149
156
  }
@@ -0,0 +1,56 @@
1
+ // A HELD island (#506): server markup that must not paint before the island mounts, because what
2
+ // it shows can be older than what the page will show — an offline reload's cached count, under a
3
+ // queued write the mount rebuilds. Hidden from its first byte, booted at once, revealed when the
4
+ // mount settles or at the cap. Who is held is the renderer's caller's call (`hold` on the collector).
5
+
6
+ /** On a held island's wrapper until the runtime reveals it; what the runtime finds it by. */
7
+ export const ISLAND_HOLD_ATTRIBUTE = 'data-x-hold';
8
+
9
+ /**
10
+ * The longest a held island stays hidden, whatever its chunk does — a download on a slow network,
11
+ * a mount that never settles. Above `@ultimat3/realtime`'s `FIRST_PAINT_HOLD_MS` (1 s), the cap on
12
+ * the disk restore a realtime island waits for, so a slow disk meets that cap and still mounts
13
+ * before this one shows the server's markup. Interpolated into the runtime: one number, one copy.
14
+ */
15
+ export const ISLAND_HOLD_MS = 3_000;
16
+
17
+ /**
18
+ * The keyframes that reveal a held island with no script at all — JS off, the runtime blocked, a
19
+ * strategy part that threw before the hold could run. Defined in `@ultimat3/ui`'s `global.scss`
20
+ * (this package may not import `ui`; `island-hold-replay.test.ts` holds the two spellings equal).
21
+ */
22
+ export const ISLAND_HOLD_REVEAL = 'ultimate-hold-reveal';
23
+
24
+ /**
25
+ * The wrapper's attributes. An inline `style` and not a stylesheet rule: it applies from the byte
26
+ * the parser reads (no sheet can have loaded yet), and `@ultimat3/http`'s policy admits a style
27
+ * ATTRIBUTE (`style-src-attr`) where an inline `<style>` would need a hash. `visibility`, never
28
+ * `display`: the island keeps its box, so the reveal shifts no layout. The animation is the reveal
29
+ * that needs no script: a zero-length step to `visible` after the cap, held by `forwards` (reduced
30
+ * motion shortens durations, never delays). The runtime takes both properties off when it reveals.
31
+ */
32
+ export const ISLAND_HOLD_ATTRIBUTES =
33
+ `${ISLAND_HOLD_ATTRIBUTE} ` +
34
+ `style="visibility:hidden;animation:${ISLAND_HOLD_REVEAL} 0s ${ISLAND_HOLD_MS}ms forwards"`;
35
+
36
+ // Booted at once, whatever the route's strategy: a hidden island receives no press and sits in no
37
+ // idle slot worth waiting for, so `interaction` would hold it until the cap and show the stale
38
+ // markup anyway. `boot` is the prelude's, so the strategy below chains on the SAME `el.__x` and
39
+ // mounts nothing twice. `el.__h`, not `each`'s `el.__v`: `each` would mark the root visited and
40
+ // the strategy's own pass would then skip it. Emitted FIRST after the prelude (`hydrate.ts`), so
41
+ // a strategy part that throws — an old WebView with no IntersectionObserver — cannot strand it.
42
+ // The strategy's capture listeners let go on the first event after the mount (`catchUp`'s `on`),
43
+ // since the hold, not a caught event, is what booted the island.
44
+ //
45
+ // Revealed TOGETHER, once every held island of this pass has settled (or at the cap): one record
46
+ // shown in two islands is rebuilt by whichever island holds its writer — a queued write's overlay
47
+ // is restored inside THAT island's mount — so a reader revealed at its own mount painted the
48
+ // record without it (#506, measured in `offline-like.e2e.test.ts`). A failed mount counts as
49
+ // settled (`hush`): its server markup is shown rather than hidden forever.
50
+ export const RUNTIME_HOLD = `
51
+ var hs=[];Array.prototype.forEach.call(document.querySelectorAll('[${ISLAND_HOLD_ATTRIBUTE}]'),function(el){
52
+ if(!el.__h){el.__h=1;hs.push(el)}});
53
+ if(hs.length){var t,r=function(){clearTimeout(t);hs.forEach(function(el){el.removeAttribute('${ISLAND_HOLD_ATTRIBUTE}');
54
+ el.style.removeProperty('visibility');el.style.removeProperty('animation')})};
55
+ t=setTimeout(r,${ISLAND_HOLD_MS});Promise.all(hs.map(function(el){return boot(el).then(null,hush)})).then(r)}
56
+ `.trim();
@@ -173,7 +173,7 @@ export function checkIslandProps(
173
173
  'and every one of them ships inside the HTML on every request — ' +
174
174
  heaviest.map(([key, size]) => `props.${key} is ${size} B of the ${bytes}`).join(', '),
175
175
  `in ${file}, pass ${heaviest.map(([key]) => `\`${key}: []\``).join(' and ')} beside ` +
176
- `\`${first}Endpoint: derivePath('<queryName>')\` (@ultimat3/query) and fetch the rows inside ` +
176
+ `\`${first}Endpoint: queryPath('<queryName>')\` (@ultimat3/core) and fetch the rows inside ` +
177
177
  'the island after mount — a list that is the same on every request is a dataset, not a ' +
178
178
  'prop; an id, a count and a URL are',
179
179
  );
package/src/island.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * graph (axiom 6). WHEN it wakes is the route's `hydrate`, never a second declaration here.
7
7
  */
8
8
 
9
+ import { renderFixShellArg } from '@ultimat3/core';
9
10
  import { IslandInvalidError } from './errors';
10
11
  import type { JsonValue } from './island-props';
11
12
  import type { JsxProps } from './jsx';
@@ -195,7 +196,10 @@ function normalizeIsland(declaration: IslandDeclaration): IslandSpec {
195
196
  throw new IslandInvalidError(
196
197
  `island src ${JSON.stringify(src)} is not an island file: a module ships to the browser ` +
197
198
  `only if its name says so, and ${ISLAND_EXTENSION} is the one spelling that says it`,
198
- `git mv -- ${src} ${stem}${ISLAND_EXTENSION}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
199
+ // Screened (security audit of plan 101 sweep 1c, M1): `UNSAFE_SPECIFIER` lets `$(…)`, `;`,
200
+ // `|` and `&` through. A leading `./` is dropped first — the same path to a shell, and
201
+ // `renderFixShellArg` refuses a leading `.` — so the common specifier stays pasteable.
202
+ `git mv -- ${renderFixShellArg(src.replace(/^\.\//, ''), "'<module>'")} ${renderFixShellArg(`${stem.replace(/^\.\//, '')}${ISLAND_EXTENSION}`, `'<name>${ISLAND_EXTENSION}'`)}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
199
203
  );
200
204
  }
201
205
 
@@ -4,12 +4,10 @@
4
4
  * `bun test` all load a component the same way and there is no separate "bundled" behaviour.
5
5
  */
6
6
 
7
- // why: Bun ships no path API, and a relative app root has to be resolved against the working
8
- // directory before it can be compared with the absolute paths the Bun plugin hands this loader.
9
- import { resolve } from 'node:path';
10
7
  import { renderThrowable } from '@ultimat3/core';
11
8
  import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
12
9
  import { PrerenderFailedError } from './errors';
10
+ import { comparablePath, isPathBelow, pathBelow } from './stylesheet-path';
13
11
  import type { Surface } from './surfaces';
14
12
  import { surfaceUnder } from './surfaces';
15
13
 
@@ -42,7 +40,8 @@ const RENDER_PACKAGE = '@ultimat3/render';
42
40
  * `Bun.plugin` ignores `onResolve` outright — the callback never fires, from module scope or from
43
41
  * a `--preload`, and a virtual namespace never resolves — and ignores `resolveDir` on an `onLoad`
44
42
  * result. Neither seam can carry the resolution, so the specifier must. Revisit if Bun wires
45
- * either: an `onResolve` hook would leave every compiled module byte-identical.
43
+ * either: an `onResolve` hook would leave every compiled module byte-identical. Upstream:
44
+ * https://github.com/oven-sh/bun/issues/40579.
46
45
  *
47
46
  * The fallback is the status quo, for the one frame that cannot answer: inside a
48
47
  * `bun build --compile` binary `import.meta.url` is `file:///$bunfs/root/<name>` and
@@ -120,9 +119,12 @@ export function registeredStylesheets(): readonly Stylesheet[] {
120
119
  /**
121
120
  * The directory a sheet's surface is read below. Absent, the process's working directory — which
122
121
  * is the app root in the container (`WORKDIR /app`) and under every `x` command run from one.
122
+ * Held as a `comparablePath`, like every claim: on Windows each arrives as `D:\app\…`.
123
123
  */
124
124
  let stylesheetRoot: string | undefined;
125
125
 
126
+ const rootOfSheets = (): string => stylesheetRoot ?? comparablePath(process.cwd());
127
+
126
128
  /**
127
129
  * Directories a package claimed for ONE surface, longest first. A package's sheet has no surface
128
130
  * of its own and rides both graphs — right for a design system, and the reason an app's static
@@ -130,11 +132,13 @@ let stylesheetRoot: string | undefined;
130
132
  */
131
133
  const claims: { readonly dir: string; readonly surface: Surface }[] = [];
132
134
 
133
- const claimedSurface = (path: string): Surface | undefined =>
134
- claims.find((claim) => path.startsWith(`${claim.dir}/`))?.surface;
135
+ const claimedSurface = (path: string): Surface | undefined => {
136
+ const comparable = comparablePath(path);
137
+ return claims.find((claim) => isPathBelow(comparable, claim.dir))?.surface;
138
+ };
135
139
 
136
140
  const surfaceOfSheet = (path: string): Surface | null =>
137
- claimedSurface(path) ?? surfaceUnder(stylesheetRoot ?? process.cwd(), path);
141
+ claimedSurface(path) ?? surfaceUnder(rootOfSheets(), comparablePath(path));
138
142
 
139
143
  /** Every registered sheet, classified again; the revision moves only when an answer does. */
140
144
  const reclassify = (): void => {
@@ -157,7 +161,7 @@ const reclassify = (): void => {
157
161
  * it standing, because the module that made it is cached and will not make it again.
158
162
  */
159
163
  export function claimStylesheets(dir: string, surface: Surface): void {
160
- const claimed = resolve(dir);
164
+ const claimed = comparablePath(dir);
161
165
  if (claims.some((claim) => claim.dir === claimed && claim.surface === surface)) return;
162
166
  claims.push({ dir: claimed, surface });
163
167
  claims.sort((a, b) => b.dir.length - a.dir.length);
@@ -174,7 +178,7 @@ export function claimStylesheets(dir: string, surface: Surface): void {
174
178
  */
175
179
  export function setStylesheetRoot(root: string | undefined): void {
176
180
  // Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
177
- stylesheetRoot = root === undefined ? undefined : resolve(root);
181
+ stylesheetRoot = root === undefined ? undefined : comparablePath(root);
178
182
  reclassify();
179
183
  }
180
184
 
@@ -221,11 +225,12 @@ const OWNER_RANK = (sheet: Stylesheet): number =>
221
225
  // A claimed sheet is a package's whichever surface carries it: library first.
222
226
  sheet.surface === null || sheet.claimed ? 0 : sheet.surface === 'shared' ? 1 : 2;
223
227
 
224
- /** Relative to the app root, so a container at `/app` and a laptop order one app alike. */
225
- const orderPath = (sheet: Stylesheet): string => {
226
- const root = stylesheetRoot ?? process.cwd();
227
- return sheet.file.startsWith(`${root}/`) ? sheet.file.slice(root.length + 1) : sheet.file;
228
- };
228
+ /**
229
+ * Relative to the app root and `/`-separated, so a container at `/app`, a laptop and a Windows
230
+ * checkout at `D:\app` order one app alike — and so mint one stylesheet hash.
231
+ */
232
+ const orderPath = (sheet: Stylesheet): string =>
233
+ pathBelow(comparablePath(sheet.file), rootOfSheets());
229
234
 
230
235
  /** See `stylesFor`: global layer, then owner, then path. Exported for the tests that pin it. */
231
236
  export function stylesheetOrder(a: Stylesheet, b: Stylesheet): number {
@@ -5,14 +5,15 @@
5
5
  * page's text, or a non-page's bytes to hand over (a prefetch's are dropped unread).
6
6
  */
7
7
 
8
- import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
9
8
  import {
10
- NAVIGATION_HEADER,
11
- NAVIGATION_LOCATION_HEADER,
12
- NAVIGATION_META,
13
- NAVIGATION_SCOPE_HEADER,
14
- NAVIGATION_SURFACE_HEADER,
15
- } from './navigation-rules';
9
+ CLIENT_BUILD_META,
10
+ CLIENT_NAVIGATION_HEADER,
11
+ CLIENT_NAVIGATION_LOCATION_HEADER,
12
+ CLIENT_NAVIGATION_SCOPE_HEADER,
13
+ CLIENT_NAVIGATION_SURFACE_HEADER,
14
+ CLIENT_SCOPE_META,
15
+ } from '@ultimat3/core/page';
16
+ import { NAVIGATION_META } from './navigation-rules';
16
17
 
17
18
  /** One fetched answer, read once — a prefetch and the click after it share it. */
18
19
  export interface Answer {
@@ -41,11 +42,11 @@ export function fetchDocument(
41
42
  ): Promise<Answer> {
42
43
  const headers: Record<string, string> = {
43
44
  accept: 'text/html,application/xhtml+xml',
44
- [NAVIGATION_HEADER]: purpose,
45
- [NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
45
+ [CLIENT_NAVIGATION_HEADER]: purpose,
46
+ [CLIENT_NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
46
47
  };
47
48
  const scope = metaOf(doc, CLIENT_SCOPE_META);
48
- if (scope !== null) headers[NAVIGATION_SCOPE_HEADER] = scope;
49
+ if (scope !== null) headers[CLIENT_NAVIGATION_SCOPE_HEADER] = scope;
49
50
  // GET only: a skewed GET is refused before any route runs; a POST is never refused mid-submit.
50
51
  const build = metaOf(doc, CLIENT_BUILD_META);
51
52
  if ((init.method ?? 'GET') === 'GET' && build !== null) headers[CLIENT_BUILD_META] = build;
@@ -61,7 +62,7 @@ export function fetchDocument(
61
62
  if (purpose === 'prefetch') await response.body?.cancel();
62
63
  else body = await response.blob();
63
64
  }
64
- const location = response.headers.get(NAVIGATION_LOCATION_HEADER);
65
+ const location = response.headers.get(CLIENT_NAVIGATION_LOCATION_HEADER);
65
66
  return {
66
67
  status: response.status,
67
68
  opaqueRedirect,
@@ -1,33 +1,19 @@
1
1
  /**
2
2
  * Which clicks and submits the client router takes, and which it leaves to the browser — as pure
3
3
  * functions over plain facts, so every rule is testable without a DOM and the router
4
- * (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute, header and
5
- * event name the router shares with the documents the server renders and the apps that listen.
4
+ * (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute and event
5
+ * name the router shares with the documents the server renders and the apps that listen; the
6
+ * router's headers are core's (`CLIENT_NAVIGATION_*_HEADER`), imported where they are sent.
6
7
  */
7
8
 
8
- import {
9
- CLIENT_NAVIGATION_HEADER,
10
- CLIENT_NAVIGATION_LOCATION_HEADER,
11
- CLIENT_NAVIGATION_SCOPE_HEADER,
12
- CLIENT_NAVIGATION_SURFACE_HEADER,
13
- } from '@ultimat3/core/page';
14
-
15
9
  /**
16
10
  * `<meta name="ultimate-navigation" content="<app>:<surface>">` — present only on an opted-in
17
11
  * surface. The app's name is part of it, so two apps on one origin (web and admin) are two routers.
18
12
  */
19
13
  export const NAVIGATION_META = 'ultimate-navigation';
20
- /**
21
- * Sent on every router fetch: `soft` when a visitor asked for the page, `prefetch` when the router
22
- * guessed they would. The server answers a route that did not opt in before running it.
23
- */
24
- export const NAVIGATION_HEADER = CLIENT_NAVIGATION_HEADER;
25
- /** The `<app>:<surface>` of the document the router runs in, on every router fetch. */
26
- export const NAVIGATION_SURFACE_HEADER = CLIENT_NAVIGATION_SURFACE_HEADER;
27
- /** The document's principal (`ultimate-scope`), when it carries one. */
28
- export const NAVIGATION_SCOPE_HEADER = CLIENT_NAVIGATION_SCOPE_HEADER;
29
- /** On a `204`: load this URL with a real navigation — the server's hand-over. */
30
- export const NAVIGATION_LOCATION_HEADER = CLIENT_NAVIGATION_LOCATION_HEADER;
14
+ // The four router HEADERS are core's (`CLIENT_NAVIGATION_HEADER`, `…_SURFACE_HEADER`,
15
+ // `…_SCOPE_HEADER`, `…_LOCATION_HEADER`), imported by the fetcher and by `@ultimat3/http`'s gate —
16
+ // 25.0.0 deleted the `NAVIGATION_*_HEADER` aliases this file published for them (`X_HELPER_COPY`).
31
17
  /** On a link or form: always a full document load. */
32
18
  export const NAVIGATION_RELOAD_ATTRIBUTE = 'data-x-reload';
33
19
  /** On a link: followed softly, never fetched before it is clicked. */
@@ -275,7 +261,15 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
275
261
  facts.build !== null && facts.nextBuild !== null && facts.build !== facts.nextBuild;
276
262
  // A GET refused for skew (409 before any route ran) or rendered by another build: load it.
277
263
  if (get && skewed) return load(facts.requested, 'another build');
278
- if (!/^\s*text\/html\b/i.test(facts.contentType)) return { kind: 'hand-over' };
264
+ const html = /^\s*text\/html\b/i.test(facts.contentType);
265
+ // An error that is not a page (an offline service worker's empty 503, a JSON 404) is nothing to
266
+ // show — handed over, it became a `blob:` URL in the address bar. A GET is the browser's to
267
+ // load (a worker answers a real navigation with its offline page); a POST is never re-sent.
268
+ if (facts.status >= 400 && !html) {
269
+ const reason = 'an error answer that is not a page';
270
+ return get ? load(facts.requested, reason) : { kind: 'failed', reason };
271
+ }
272
+ if (!html) return { kind: 'hand-over' };
279
273
  // A POST answered in place IS the page the browser would have shown, whatever rendered it —
280
274
  // and when that is another principal's or build's, the caller stops trusting this tab
281
275
  // (`answerMovesTab`): every later navigation is a real load.
package/src/navigation.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  NAVIGATED_EVENT,
47
47
  NAVIGATING_ATTRIBUTE,
48
48
  NAVIGATION_ERROR_EVENT,
49
+ NAVIGATION_MAX_HOPS,
49
50
  NAVIGATION_META,
50
51
  NAVIGATION_NO_PREFETCH_ATTRIBUTE,
51
52
  NAVIGATION_PREFETCH_DELAY_MS,
@@ -103,7 +104,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
103
104
  let rendered = withoutFragment(win.location.href);
104
105
  /** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
105
106
  let navigatingTo: string | undefined;
106
- /** The view transition still animating, which a press skips to the new page. */
107
+ /** The view transition still animating — skipped only for a click it swallowed (`onClick`). */
107
108
  let animating: RunningTransition | undefined;
108
109
  /** Between a press and its click: the focus the press gives a link is not a hover. */
109
110
  let pressed = false;
@@ -172,6 +173,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
172
173
  };
173
174
 
174
175
  const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
176
+ // The redirect chain's only exit is `hops >= NAVIGATION_MAX_HOPS`, which `NaN` never meets.
177
+ // `hops` is this router's own recursion counter (nothing outside it can reach `navigate`), so a
178
+ // non-count is a broken caller, not input: it fails CLOSED as an exhausted chain — one request,
179
+ // then the browser loads the page — instead of shipping the error renderer (~2 KB) to every page.
180
+ const asked = options.hops ?? 0;
181
+ const hops = Number.isSafeInteger(asked) && asked >= 0 ? asked : NAVIGATION_MAX_HOPS;
175
182
  const method = options.method ?? 'GET';
176
183
  // After an answer for another principal or build was shown in place, this tab is no longer
177
184
  // trusted to swap: every navigation is a real load (`answerMovesTab`).
@@ -212,7 +219,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
212
219
  opaqueRedirect: answer.opaqueRedirect,
213
220
  location: answer.location,
214
221
  contentType: answer.contentType,
215
- hops: options.hops ?? 0,
222
+ hops,
216
223
  surface: metaOf(doc, NAVIGATION_META),
217
224
  nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
218
225
  build: metaOf(doc, CLIENT_BUILD_META),
@@ -236,7 +243,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
236
243
  case 'follow':
237
244
  await navigate(verdict.url, {
238
245
  history: options.history === 'none' ? 'replace' : 'push',
239
- hops: (options.hops ?? 0) + 1,
246
+ hops: hops + 1,
240
247
  });
241
248
  return;
242
249
  case 'hand-over':
@@ -315,9 +322,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
315
322
  pressed = true;
316
323
  pressedOverTransition = animating !== undefined;
317
324
  // A press is not a hover: the click it becomes navigates, and a guess on its heels is a
318
- // second request for the same page. And a press during the animation skips to the new page.
325
+ // second request for the same page. A press alone never skips the running transition — that
326
+ // snapped every named element to its end mid-glide (#621); only the click below may.
319
327
  win.clearTimeout(intent);
320
- animating?.skipTransition?.();
321
328
  };
322
329
  const onRelease = (): void => {
323
330
  pressed = false;
@@ -325,13 +332,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
325
332
 
326
333
  const onClick = (event: MouseEvent): void => {
327
334
  win.clearTimeout(intent);
328
- // While a view transition paints, the browser hit-tests every press to `<html>`: the press
329
- // skipped the animation (`onPress`), the release landed on the real element, and the click —
330
- // aimed at their common ancestor — reached nothing. The visitor's first click after a swap was
331
- // lost. It is given to the element under the pointer NOW, whatever it is: a link, a submit
332
- // button, an island's own control.
335
+ // While a view transition paints, the browser hit-tests every press to `<html>` — Chrome does
336
+ // so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
337
+ // nothing: the visitor's first click after a swap was lost. It is given to the element under
338
+ // the pointer, whatever it is: a link, a submit button, an island's own control. Only a
339
+ // skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
340
+ // the press; when the animation already ended between the two, there is nothing to skip.
333
341
  if (pressedOverTransition && event.target === doc.documentElement) {
334
342
  pressedOverTransition = false;
343
+ animating?.skipTransition?.();
335
344
  const hit = doc.elementFromPoint(event.clientX, event.clientY);
336
345
  if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
337
346
  return;
package/src/registry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The route table — the single source of route truth. File path → URL conventions for
3
- * `site/`, `app/` and `api/`, plus `describeRoutes()`, the serializable projection that
3
+ * `site/`, `app/` and `api/`, plus `describePages()`, the serializable projection that
4
4
  * `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
5
5
  * from. Nothing downstream may keep its own list of routes.
6
6
  */
@@ -27,7 +27,7 @@ import {
27
27
  setMountedRoutes,
28
28
  } from './mounted-routes';
29
29
  import type { RouteConfig, RouteData } from './route';
30
- import { isRouteConfig, tagKeys } from './route';
30
+ import { isRouteConfig, routeTagKeys } from './route';
31
31
  import type { RouteComponent } from './route-component';
32
32
  import type { CompiledPattern } from './route-pattern';
33
33
  import { compilePattern } from './route-pattern';
@@ -206,7 +206,7 @@ function toUrlSegment(segment: string): string {
206
206
  }
207
207
 
208
208
  const routes = new Map<string, RouteEntry>();
209
- /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
209
+ /** The table `describePages()` last built, dropped whenever a route registers or the registry clears. */
210
210
  let described: readonly RouteDescriptor[] | undefined;
211
211
 
212
212
  export interface RegisterRouteInput<TData = RouteData> {
@@ -250,7 +250,7 @@ export function registerRoute<TData = RouteData>(
250
250
  input: RegisterRouteInput<TData>,
251
251
  ): RouteEntry<TData> {
252
252
  // The type already refuses a declaration; this catches the JS caller and the cast. Without it a
253
- // raw declaration registers, and `describeRoutes()` is where it surfaces — as a bare TypeError
253
+ // raw declaration registers, and `describePages()` is where it surfaces — as a bare TypeError
254
254
  // on `config.budget.js`, one build step away from the file that caused it.
255
255
  if (!isRouteConfig(input.config)) {
256
256
  throw new RouteUnnormalizedError(
@@ -367,7 +367,7 @@ export function routeCount(): number {
367
367
  }
368
368
 
369
369
  export function routeEntries(): readonly RouteEntry[] {
370
- // Code units, never `localeCompare` — `describeRoutes()` below promises an order "identical for
370
+ // Code units, never `localeCompare` — `describePages()` below promises an order "identical for
371
371
  // identical input", and `localeCompare` with no locale argument reads the runtime's ICU default.
372
372
  return [...routes.values()].sort((a, b) => byCodeUnit(a.path, b.path));
373
373
  }
@@ -380,7 +380,7 @@ export function routeFor(path: string): RouteEntry | undefined {
380
380
  * The manifest projection: JSON-safe, sorted by path, identical for identical input.
381
381
  * Determinism matters because `sw.js` and the sitemap are diffed across deploys.
382
382
  */
383
- export function describeRoutes(): readonly RouteDescriptor[] {
383
+ export function describePages(): readonly RouteDescriptor[] {
384
384
  // Built once per registry change and handed out as the SAME frozen array: an ISR regeneration
385
385
  // looked its route up through this on every request, re-sorting the whole table each time, and
386
386
  // a stable identity is what lets `render-isr.ts` compile its matchers once per table.
@@ -422,7 +422,7 @@ const descriptorOf = (
422
422
  mode: config.render,
423
423
  offline: config.offline,
424
424
  hydrate: config.hydrate,
425
- revalidateTags: tagKeys(config.revalidate?.tags),
425
+ revalidateTags: routeTagKeys(config.revalidate?.tags),
426
426
  revalidateTtl: config.revalidate?.ttl ?? null,
427
427
  prerenderable: config.prerender !== undefined,
428
428
  dynamic: params > 0,
@@ -4,14 +4,14 @@
4
4
  * component — `static` at build time, `ssr`/`stream` per request, all through `renderToHtml`.
5
5
  */
6
6
 
7
- import { renderThrowable } from '@ultimat3/core';
7
+ import { escapeHtml, renderFixShellArg, renderThrowable } from '@ultimat3/core';
8
8
  import {
9
9
  IslandInvalidError,
10
10
  IslandNotHydratedError,
11
11
  IslandPropsInvalidError,
12
12
  PrerenderFailedError,
13
13
  } from './errors';
14
- import { escapeHtml, renderAttributes, VOID_ELEMENTS } from './html';
14
+ import { renderAttributes, VOID_ELEMENTS } from './html';
15
15
  import { emitIslandAttributes, emitIslandProps } from './hydrate';
16
16
  import type { IslandNode } from './island';
17
17
  import { isIslandNode } from './island';
@@ -164,7 +164,9 @@ export async function renderComponent(
164
164
  // null-prototype object (`String()` raises) or an Error whose `message` getter does — and
165
165
  // this frame is the last thing between that and a build failure with no code at all.
166
166
  `rendering the component in ${file} threw: ${renderThrowable(error)}`,
167
- `run \`bun test ${file.replace(/\.tsx?$/, '.test.ts')}\` to reproduce, then fix ${file}`,
167
+ // The route file is author text and `$(…)` is a legal name: screened where it enters the
168
+ // reproduce command (security audit of plan 101 sweep 1c).
169
+ `run \`bun test ${renderFixShellArg(file.replace(/\.tsx?$/, '.test.ts'), "'<the route's test file>'")}\` to reproduce, then fix ${file}`,
168
170
  );
169
171
  }
170
172
  }
package/src/render-isr.ts CHANGED
@@ -17,11 +17,11 @@ import {
17
17
  unregisterDependent,
18
18
  } from '@ultimat3/cache';
19
19
  import type { Scheduler } from '@ultimat3/core';
20
- import { createSingleFlight, finiteCount, logger, renderThrowable } from '@ultimat3/core';
20
+ import { finiteCount, logger, renderThrowable, singleFlight } from '@ultimat3/core';
21
21
  import { parseTtlMs } from './duration';
22
22
  import { finiteStatus, isRenderStatus } from './finite-status';
23
23
  import type { RouteDescriptor } from './registry';
24
- import { describeRoutes } from './registry';
24
+ import { describePages } from './registry';
25
25
  import type { IsrEntry, IsrState, IsrStore } from './render-isr-store';
26
26
  import { memoryIsrStore } from './render-isr-store';
27
27
  import { contentHash, staticHeaders } from './render-static';
@@ -153,16 +153,16 @@ let installedRevalidator: Revalidator | undefined;
153
153
  /** What `registerRevalidator` is handed on detach: the framework's "nothing to revalidate". */
154
154
  const NO_REVALIDATION: Revalidator = () => undefined;
155
155
 
156
- export function createIsrController(options: IsrControllerOptions = {}): IsrController {
156
+ export function isrController(options: IsrControllerOptions = {}): IsrController {
157
157
  const store = options.store ?? memoryIsrStore();
158
158
  const now = options.now ?? (() => Date.now());
159
- const routes = options.routes ?? describeRoutes;
159
+ const routes = options.routes ?? describePages;
160
160
  const isrDependents =
161
161
  options.isrDependents ?? ((tags: readonly CacheTag[]) => dependentsOfKind(tags, 'isr-route'));
162
162
  const buildId = options.buildId ?? 'dev';
163
- const flight = createSingleFlight({
163
+ const flight = singleFlight({
164
164
  deadlineMs: finiteCount(
165
- 'createIsrController',
165
+ 'isrController',
166
166
  'regenerateDeadlineMs',
167
167
  options.regenerateDeadlineMs ?? DEFAULT_ISR_REGENERATE_DEADLINE_MS,
168
168
  1,
@@ -380,7 +380,7 @@ interface RouteMatcher {
380
380
  test(storedPath: string): boolean;
381
381
  }
382
382
 
383
- /** One compiled set per route TABLE — `describeRoutes()` hands out one array per registry change. */
383
+ /** One compiled set per route TABLE — `describePages()` hands out one array per registry change. */
384
384
  const compiledTables = new WeakMap<readonly RouteDescriptor[], readonly RouteMatcher[]>();
385
385
 
386
386
  /**
package/src/render-ssr.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
+ import { BUILD_ID_HEADER } from '@ultimat3/core';
8
9
  import { cacheControl } from '@ultimat3/http';
9
10
  import { finiteStatus } from './finite-status';
10
11
  import type { RouteEntry } from './registry';
@@ -81,6 +82,6 @@ export function ssrHeaders(
81
82
  ? PRIVATE_NO_STORE
82
83
  : 'public, max-age=0, s-maxage=30, stale-while-revalidate=300',
83
84
  vary: [...vary].sort().join(', '),
84
- 'x-ultimate-build': options.buildId,
85
+ [BUILD_ID_HEADER]: options.buildId,
85
86
  };
86
87
  }
@@ -4,7 +4,7 @@
4
4
  * the precache revision in `sw.js`, and the asset filename suffix.
5
5
  */
6
6
 
7
- import { renderCauseValue, renderThrowable, useContext } from '@ultimat3/core';
7
+ import { BUILD_ID_HEADER, renderCauseValue, renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
9
  import type { RouteEntry } from './registry';
10
10
  import type { RenderResult, RouteParams } from './route';
@@ -161,7 +161,7 @@ export function staticHeaders(hash: string, buildId: string): Readonly<Record<st
161
161
  // Revalidate cheaply: the HTML URL is stable, its content hash is not.
162
162
  'cache-control': 'public, max-age=0, must-revalidate',
163
163
  etag: `"${hash}"`,
164
- 'x-ultimate-build': buildId,
164
+ [BUILD_ID_HEADER]: buildId,
165
165
  };
166
166
  }
167
167
 
@@ -11,9 +11,8 @@
11
11
  * contains no interactive island costs literally zero JS.
12
12
  */
13
13
 
14
- import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
14
+ import { BUILD_ID_HEADER, escapeHtml, finiteCount, logger, renderThrowable } from '@ultimat3/core';
15
15
  import { finiteStatus } from './finite-status';
16
- import { escapeHtml } from './html';
17
16
  import type { RenderResult } from './route';
18
17
  import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
19
18
 
@@ -225,7 +224,7 @@ export function streamResult(plan: StreamPlan, options: StreamOptions, status =
225
224
  // Proxies that buffer defeat the entire mode.
226
225
  'x-accel-buffering': 'no',
227
226
  'transfer-encoding': 'chunked',
228
- 'x-ultimate-build': options.buildId,
227
+ [BUILD_ID_HEADER]: options.buildId,
229
228
  },
230
229
  body: renderStreamHtml(plan, options),
231
230
  };
package/src/route.ts CHANGED
@@ -265,8 +265,10 @@ export interface RenderResult {
265
265
  /**
266
266
  * Route descriptors carry tags in `@ultimat3/cache`'s wire form (`post`, `post:123`), the
267
267
  * same strings every cache tier speaks. Render never invents a key convention of its own.
268
+ * Declaration order is kept — unlike `@ultimat3/cache`'s `tagKeys`, the sorted identity form —
269
+ * so the name says which list this is, and it stays out of the public barrel.
268
270
  */
269
- export function tagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
271
+ export function routeTagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
270
272
  return tags === undefined ? [] : serializeTags(tags);
271
273
  }
272
274
 
package/src/server.ts CHANGED
@@ -47,10 +47,10 @@ export type {
47
47
  IsrServeResult,
48
48
  } from './render-isr';
49
49
  export {
50
- createIsrController,
51
50
  DEFAULT_ISR_REGENERATE_DEADLINE_MS,
52
51
  ISR_LOCALE_PARAM,
53
52
  invalidateAndRevalidate,
53
+ isrController,
54
54
  isrKey,
55
55
  } from './render-isr';
56
56
  export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
@@ -0,0 +1,34 @@
1
+ // One responsibility: the ONE spelling of a filesystem path the stylesheet registry compares — `/`
2
+ // separators, `..` folded, a Windows drive letter upper-cased — so "is this sheet below that
3
+ // directory" means the same on Windows, where Bun hands every path over as `D:\app\…`, as on POSIX.
4
+
5
+ // why: Bun ships no path API, and a relative root must be resolved against the working directory.
6
+ import { posix, resolve } from 'node:path';
7
+
8
+ /** POSIX-absolute (`/srv/app`), drive-absolute (`D:\app`, `d:/app`) or UNC (`\\host\share`). */
9
+ const ABSOLUTE = /^(?:[A-Za-z]:)?[\\/]/;
10
+
11
+ /**
12
+ * Comparable, never displayed: the registry keeps every sheet under the path it was handed and only
13
+ * COMPARES through this. An absolute path is not re-resolved, because `resolve('/app')` on Windows
14
+ * is `D:\app` — a drive the sheets a caller handed as `/app/…` never carry.
15
+ */
16
+ export function comparablePath(path: string): string {
17
+ const absolute = ABSOLUTE.test(path) ? path : resolve(path);
18
+ const slashed = posix.normalize(absolute.replaceAll('\\', '/'));
19
+ const drive = slashed.replace(/^([a-z]):/, (_, letter: string) => `${letter.toUpperCase()}:`);
20
+ // A trailing separator is dropped so `${dir}/` is one prefix — except the filesystem root itself.
21
+ const trimmed = drive.replace(/\/+$/, '');
22
+ return trimmed === '' || trimmed.endsWith(':') ? `${trimmed}/` : trimmed;
23
+ }
24
+
25
+ /** `path` sits below `dir` — both already `comparablePath`. A sibling sharing a prefix does not. */
26
+ export function isPathBelow(path: string, dir: string): boolean {
27
+ return path.startsWith(dir.endsWith('/') ? dir : `${dir}/`);
28
+ }
29
+
30
+ /** `path` relative to `dir` when it sits below it, else `path` — both already `comparablePath`. */
31
+ export function pathBelow(path: string, dir: string): string {
32
+ if (!isPathBelow(path, dir)) return path;
33
+ return path.slice(dir.endsWith('/') ? dir.length : dir.length + 1);
34
+ }