@ultimat3/render 24.0.0 → 25.1.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 +11 -10
- package/README.md +20 -11
- package/package.json +8 -8
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +3 -2
- package/src/errors.ts +1 -1
- package/src/head.ts +4 -11
- package/src/html.ts +3 -9
- package/src/hydrate.ts +32 -11
- package/src/index.ts +10 -23
- package/src/island-collector.ts +11 -4
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +5 -1
- package/src/module-loader.ts +19 -14
- package/src/navigation-fetch.ts +24 -14
- package/src/navigation-rules.ts +15 -21
- package/src/navigation.ts +19 -10
- package/src/registry.ts +7 -7
- package/src/render-html.ts +5 -3
- package/src/render-isr.ts +7 -7
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +2 -2
- package/src/render-stream.ts +2 -3
- package/src/route.ts +3 -1
- package/src/server.ts +1 -1
- package/src/stylesheet-path.ts +34 -0
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 `
|
|
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 | `
|
|
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-
|
|
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 `
|
|
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 | `
|
|
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 `
|
|
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: `
|
|
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,
|
|
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 | every router fetch is `cache: 'no-cache'` — the browser's HTTP cache never answers, the router's own is the only reuse layer (#693; `navigation-fetch.test.ts`, `client-navigation-cache.e2e.test.ts`). 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 `
|
|
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 {
|
|
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: `${
|
|
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
|
-
`
|
|
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 =
|
|
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`, `
|
|
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`, `
|
|
493
|
-
| `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `
|
|
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
|
-
| `
|
|
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; `
|
|
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 `
|
|
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": "
|
|
3
|
+
"version": "25.1.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.
|
|
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": "
|
|
43
|
-
"@ultimat3/core": "
|
|
44
|
-
"@ultimat3/http": "
|
|
45
|
-
"@ultimat3/i18n": "
|
|
46
|
-
"@ultimat3/seo": "
|
|
47
|
-
"sass": "1.
|
|
42
|
+
"@ultimat3/cache": "25.1.0",
|
|
43
|
+
"@ultimat3/core": "25.1.0",
|
|
44
|
+
"@ultimat3/http": "25.1.0",
|
|
45
|
+
"@ultimat3/i18n": "25.1.0",
|
|
46
|
+
"@ultimat3/seo": "25.1.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 [];
|
package/src/client-scope-tag.ts
CHANGED
|
@@ -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 {
|
package/src/client-sync-tags.ts
CHANGED
|
@@ -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;
|
package/src/code-unit-order.ts
CHANGED
|
@@ -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 (`
|
|
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.
|
package/src/css-modules.ts
CHANGED
|
@@ -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
|
-
|
|
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 (`
|
|
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`
|
|
14
|
-
|
|
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`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 `<` 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 {
|
|
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;
|
|
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 = (
|
|
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,
|
|
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[] =
|
|
272
|
-
{ length: 2 ** RUNTIME_ORDER.length - 1 },
|
|
273
|
-
|
|
274
|
-
|
|
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`)
|
|
9
|
-
* because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor`
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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';
|
package/src/island-collector.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|