@ultimat3/render 5.0.1 → 7.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
@@ -1,8 +1,8 @@
1
1
  # @ultimat3/render — boundary
2
2
 
3
- Owns: the `route` primitive, the five render modes, the route table, the surface boundary,
4
- islands + budgets, hydration directives, the vendored client router, `<head>` merge, **the server
5
- JSX runtime and the two Bun loaders that make an app's `.tsx` and `.scss` runnable**.
3
+ Owns: the `route` primitive, the four render modes, the route table, the surface boundary,
4
+ islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
5
+ loaders that make an app's `.tsx` and `.scss` runnable**.
6
6
 
7
7
  `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
8
8
  second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
@@ -24,13 +24,13 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
24
24
  | Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
25
25
  | Descriptor `budget` | always an object, `{}` when undeclared. Its *fields* stay optional — `budget.js === undefined` is the site/ hydration failure. |
26
26
  | No `describe()` on a route | `describeRoutes()` is the one route list. A per-route projection would be a second one. |
27
- | Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. |
27
+ | Mode invariants | `modes.ts` only. Never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component** — `spa` did not, and `renderSpa` never read `entry.component`, so a `spa` route served `<div id="x-root"></div>` in every app that ever declared one. A gated page is `ssr`; a body that belongs in the browser is an `island({ src })`. A fifth mode has to name the function that renders it. |
28
28
  | Island declaration | `island({ src })` — a **specifier**, never an import. That is the whole boundary: a string has no scope to close over and no edge for a bundler or `checkSurfaceBoundary` to follow, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
29
29
  | Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling, same rule as `page.tsx`: a module ships to the browser iff its name says so. Never widen it to accept a second, and never make it optional. |
30
30
  | Island timing | the route's `hydrate` and nothing else — derived from the declaration, never declared on the island. An island declaring its own strategy would be a second way to say "this route hydrates" (axiom 1) and a second thing `budget.js` would have to chase. `hydrate: 'never'` + an island is still `X_ISLAND_NOT_HYDRATED`, and so is an `island()` call *below* the `defineRoute` that drains it. The `fix:` names exactly ONE of the two edits — `islandNeverDrained(spec)` tells the causes apart at the throw site, and a message offering both makes half the instruction wrong for every reader. |
31
31
  | Island node shape | a **branded array** (`IslandNode extends Array<never>`), and every walker tests `isIslandNode` BEFORE `Array.isArray`. An app types JSX with `jsxImportSource: solid-js`, whose `JSX.Element` is a type ALIAS — unaugmentable — and whose only object-shaped member is `ArrayElement`; a plain object was TS2786 at every `<Island />`, so the feature only worked through `h(Island, …)`, which is what render's own tests used. The array stays empty; the shell is `props.children`. Never satisfy this by importing solid's union. |
32
32
  | Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`). Package-internal — reachable from `./island`, never re-exported by `src/index.ts`: an app calling the drain between its `island()` and its `defineRoute` would silently un-declare the islands the route derives everything from, and a public export is semver-locked the moment it ships. Ambient, and NOT the thing the collector refuses to be: that one is per RENDER, where two requests would bill each other; this one is per MODULE, evaluated once, before any request — and `src` is resolved relative to the route file, so an `island()` call is route-module-local by construction. |
33
- | Derived budget | `registry.ts`, not `defineRoute`: a ceiling is only meaningful against a surface baseline, and the surface is a fact of the file path the route table already reads. `site/` → `4kb`, `app/` → `18kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
33
+ | Derived budget | `registry.ts`, not `defineRoute`: a ceiling is only meaningful against a surface baseline, and the surface is a fact of the file path the route table already reads. `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). **Calibrated on a Solid island** `As of 2026-08`: it was `4kb`, sized from `contact-sales.island.tsx`, which imports no `solid-js` at all — and `render(() => <p>hello</p>, el)` measures 12,588 B, so the default sat a factor of three below the floor of every island that uses the JSX runtime, on every surface. A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
34
34
  | `RouteEntry.islands` | filled from `config.islands` at registration, and from nothing else — `RegisterRouteInput` has no `islands` key. It was `input.islands ?? []`, undocumented and passed by nothing, so `routeJsBytes`'s "what registration declared" half read `[]` on every route in the framework's history; keeping it as a fallback would be a second answer to one question that can only ever weaken it, since a caller passing `[]` un-weighs a declared island. |
35
35
  | Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. |
36
36
  | A prop lands via `Object.defineProperty` | never `out[key] = v`. For exactly one name — `__proto__`, which `JSON.parse` mints as a real OWN key off any request body — the assignment runs `Object.prototype`'s setter: the prop was DROPPED from the browser payload (the footgun the walk exists to prevent), the record handed back as `IslandProps` carried a prototype built from request data, so a later `bag.row.isAdmin` on the SERVER read attacker-chosen values, and `ISLAND_PROPS_MAX_BYTES` under-counted because `JSON.stringify` could not see it. Same shape `@ultimat3/mcp`'s `validate-args.ts` uses for the same class. |
@@ -40,6 +40,8 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
40
40
  | Island bytes | `routeJsBytes` unions `entry.islands` with the rendered directives' `moduleId`s. Reading either alone is a budget that counts the runtime and not the chunk. |
41
41
  | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
42
42
  | Island boot | `el.__x` holds the boot PROMISE, never a boolean. As a flag, a second interaction while the chunk was still loading got a resolved promise back and the replay queue flushed into an island that had not mounted — the events went nowhere and the listeners were already removed. |
43
+ | Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle` 744, `visible` 816, `interaction` 1,010 (`As of 2026-08-21`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
44
+ | `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string. Exported because a second reader has to agree — `x shot` waits before it photographs, and a settle shorter than this deadline reports an unhydrated page for one that hydrates perfectly. A constant the emitted string restates instead of reading is worse than no constant. |
43
45
  | Route truth | `registry.ts`. Never keep a second route list anywhere. |
44
46
  | Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`, one per surface. The URL is the directory path. Anything else is `X_ROUTE_FILE_INVALID`; never widen the table to accept a second spelling. |
45
47
  | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer of everything the declaration alone decides, and every reader downstream assumes it ran. The registry fills in exactly one value on top: the island budget, which needs the surface, which is a fact of the file path only the route table reads. |
@@ -52,17 +54,18 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
52
54
  | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `new URL(ctx.url)` inside the catch made a relative `ctx.url` — what a prerender pass, `x build` and every test harness hand in — throw a bare `TypeError` on the one path whose job is a coded error. `pathnameOf` never throws. |
53
55
  | ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
54
56
  | Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
55
- | An undecodable path segment | not a match, never a throw, on BOTH sides — `decodeSegment` in `registry.ts` is the one reader, imported by `router-client.ts`. The client half interpolated `decodeURIComponent` raw until 2026-08, and `resolve()` runs from the signal initialiser, from `navigate`, from a `<Link>` prefetch's `mouseenter` and from the popstate handler: `/blog/%zz` failed the SPA at BOOT where the server answers 404. `decodeURIComponent('%zz')` is a bare `URIError` — a 500 for a typo — and `@ultimat3/http`'s router already answers "this branch does not match" for the same input. A literal route matching the same text still wins. |
57
+ | An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts` is the one reader. `decodeURIComponent('%zz')` is a bare `URIError`, so a typo in a path segment was a 500 where `@ultimat3/http`'s router already answers "this branch does not match". A literal route matching the same text still wins. `router-client.ts` was the second reader and went with `createRouter`. |
56
58
  | ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). The store is bounded and evicts silently; `registered` and the cache graph behind it only ever grew, one edge per slug ever requested. Never a store callback — a custom `IsrStore` has none. |
57
59
  | Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15s), `holeTimeoutMs: null` to opt out. A hole is app code the framework `await`s and nothing else bounds it; one that never settles held the response and its whole closure open for the life of the process. A hole reveals exactly once — its promise, its rejection, or its deadline, whichever lands first. |
58
60
  | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
59
61
  | Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
60
- | A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is fresh-and-gated, `spa` is a gated shell. |
62
+ | A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is the one gated mode there is. |
61
63
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
62
- | Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. Inject primitives. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. |
64
+ | Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
65
+ | Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
63
66
  | The loaders | `module-loader.ts` installs them at `index.ts` module scope, once. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. |
64
67
  | `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
65
- | Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest), `render-spa.ts` (`lang`, `dir`, `buildId`, `rootId`, every chunk url) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `lang` is safe on the framework's own path (`currentLocale()` normalises against the configured `supported` list) and is escaped anyway, because `renderSpaShell` is a public export and a caller supplies it directly. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
68
+ | Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
66
69
  | Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `&lt;` corrupts the code AND leaves the hole. |
67
70
  | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
68
71
  | Stylesheets | compiled by `css-modules.ts` and served **inlined** per surface. `sass` is this package's only third-party dependency and its only reason to exist here. |
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🖼 @ultimat3/render
2
2
 
3
- The `route` primitive and the five render modes.
3
+ The `route` primitive and the four render modes.
4
4
 
5
5
  | Mode | Behavior | Use |
6
6
  |---|---|---|
@@ -8,11 +8,10 @@ The `route` primitive and the five render modes.
8
8
  | `isr` | static + background regen on tag/TTL | catalogs, profiles |
9
9
  | `ssr` | per-request full render | fresh SEO pages |
10
10
  | `stream` | static shell flushed instantly, holes streamed | **default for app pages** |
11
- | `spa` | shell only, client fetches | dashboards behind auth |
12
11
 
13
12
  ```ts
14
13
  export const config = defineRoute({
15
- render: 'isr', // static | isr | ssr | stream | spa
14
+ render: 'isr', // static | isr | ssr | stream
16
15
  revalidate: { tags: [tag.post] },
17
16
  prerender: () => db.posts.slugs(),
18
17
  offline: 'precache', // precache | runtime | network-only
@@ -104,9 +103,8 @@ a hydrating `site/` route below.
104
103
  | `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors | `X_ROUTE_MODE_INVALID` |
105
104
  | `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
106
105
  | `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
107
- | `spa` | requires a `policy` (authed dashboards only) | `X_ROUTE_MODE_INVALID` |
108
106
 
109
- Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | spa | ssr`,
107
+ Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | ssr`,
110
108
  `api/` renders nothing, and a `site/` route that opts into hydration without a `budget.js`
111
109
  is a build error.
112
110
 
@@ -186,15 +184,45 @@ out three things. `hydrate` and `budget.js` are **derived from `island()`**, `As
186
184
  | Omitted | Derived | Overridden by |
187
185
  |---|---|---|
188
186
  | `hydrate` | `'interaction'` when the module declared an island, `'never'` when it did not | stating `hydrate` — the only way to say `idle` or `visible` |
189
- | `budget.js` | the surface baseline + 4kb (`site/` → `4kb`, `app/` → `18kb`) | stating `budget: { js }` |
187
+ | `budget.js` | the surface baseline + 20kb (`site/` → `20kb`, `app/` → `34kb`) | stating `budget: { js }` |
190
188
 
191
189
  Both were required and both were punished: an island on a route still at `'never'` is
192
190
  `X_ISLAND_NOT_HYDRATED`, and a `site/` route off `'never'` with no `budget.js` is refused at
193
191
  registration. Two failures for one omission the `island()` call above had already answered.
194
192
 
195
- 4kb because the reference app's real island is 875 B of chunk plus a 1,019 B runtime — room for a
196
- second small island, not enough to hide a library. It is a ceiling, not a pass: exceeding it is
197
- `X_BUDGET_EXCEEDED`, naming the island.
193
+ 20kb because a Solid island cannot cost less. Measured through `buildIslands`, minified, against
194
+ production Solid, `As of 2026-08`:
195
+
196
+ | Island | Bytes |
197
+ |---|---|
198
+ | `render(() => <p>hello</p>, el)` — the floor, before an author writes a line | 12,588 |
199
+ | a signal, a button and reactive text | 13,663 |
200
+ | `settings.island.tsx`, the heaviest island this repo ships | 17,797 |
201
+ | one directive's hydration runtime at `hydrate: 'idle'` | 615 |
202
+ | the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 881 |
203
+
204
+ 17,797 + 881 = **18,678** — the heaviest island this repo ships, plus the runtime an app pays
205
+ without writing a number down. `DEFAULT_ISLAND_HYDRATE` is `'interaction'`
206
+ ([`route.ts:33`](src/route.ts)), applied at `:253` to any island route that states no `hydrate`, so
207
+ `idle`'s 615 is the cheaper case and not the one a budget has to clear.
208
+
209
+ The default is **20,480** (20kb), which is not that number rounded: the next whole kilobyte above
210
+ it is 19,456, and clearing today's worst island by 778 bytes is a ceiling the next line anyone
211
+ writes breaks. 20kb leaves 1,802 B, and stays under 2× 18,678 — so a route that bundles the same
212
+ island twice is still refused. All three clauses are assertions in
213
+ [`modes.test.ts`](src/modes.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the measured
214
+ table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
215
+
216
+ It was **4kb** until `As of 2026-08`, sized from `contact-sales.island.tsx` — 875 B of chunk, and
217
+ no `solid-js` import anywhere in it. Calibrating a JSX budget on the one island shape that does not
218
+ pay the JSX runtime put the default a factor of three below the floor of every island that does:
219
+ no `budget.js` under 4096 was reachable on any surface, because the allowance is measured ABOVE the
220
+ baseline and not against it. (Its second number was wrong too — one directive's hydration runtime
221
+ is 615 B at `idle` and 881 B at `interaction`, never 1,019.)
222
+
223
+ Still a ceiling and not a pass: exceeding it is `X_BUDGET_EXCEEDED`, naming the island. An island
224
+ that pulls a design system in — `@ultimat3/ui`'s `<Switch>` measures 36,335 B — writes its own
225
+ number down, which is the point of the field.
198
226
 
199
227
  `island()` goes **above** `defineRoute`, where JavaScript already puts a `const` the page uses:
200
228
  `defineRoute` drains the declarations made before it. Below it, the route resolves to `'never'` and
@@ -207,7 +235,7 @@ route's — because two islands wanting different timings would leave `RouteDesc
207
235
  | The route says | The island says |
208
236
  |---|---|
209
237
  | `hydrate` — WHEN it wakes, once, for the whole route, and only when the default is wrong | `src` — WHICH module, and `props` — what it may receive |
210
- | `budget.js` — how many bytes that is allowed to cost, when 4kb is not the number | `tag`, `events`, `rootMargin` — how the wrapper behaves |
238
+ | `budget.js` — how many bytes that is allowed to cost, when 20kb is not the number | `tag`, `events`, `rootMargin` — how the wrapper behaves |
211
239
 
212
240
  ### An island node is a JSX child
213
241
 
@@ -273,6 +301,26 @@ island (remove it), or the `island()` call sits below the `defineRoute` that wou
273
301
  be drained. A `'never'` route is also left with no derived budget, deliberately — a ceiling there
274
302
  would paper over the contradiction.
275
303
 
304
+ ### "Booted" and "mounted" are different facts, and the DOM says which
305
+
306
+ | In the DOM | Means |
307
+ |---|---|
308
+ | `data-x-island`, `data-x-hydrate` | **declared** — emitted for every island, `'never'` included |
309
+ | `el.__x` set, neither marker | **importing** — the chunk was requested, `mount()` has not settled |
310
+ | `data-x-mounted=""` | **running** — `mount()` resolved |
311
+ | `data-x-failed="<message>"` | **threw** — `mount()` rejected, and this is why |
312
+
313
+ `el.__x` is assigned when `import()` is *called*, so on its own it cannot tell a chunk still
314
+ downloading from one whose `mount()` threw — and the second is the half that gates a deploy. Both
315
+ markers are set by the runtime, never by `emitIslandAttributes`: the server does not know the
316
+ answer. A rejection still rethrows, so `el.__x` stays rejected and the `interaction` replay queue is
317
+ never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
318
+ `ISLAND_FAILED_ATTRIBUTE` are exported so a reader (`x shot`, an app's own test) names them once.
319
+
320
+ `IDLE_HYDRATE_TIMEOUT_MS` (2000) is the `requestIdleCallback` deadline the `idle` runtime is built
321
+ from, exported for the same reason: anything waiting for hydration has to wait at least this long,
322
+ and a second copy of the number is a settle that shoots early and calls a healthy page broken.
323
+
276
324
  ## Public API
277
325
 
278
326
  | Export | Owns |
@@ -284,10 +332,10 @@ would paper over the contradiction.
284
332
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
285
333
  | `renderStatic`, `enumeratePrerender` | build-time render, content hashing |
286
334
  | `createIsrController`, `invalidateAndRevalidate` | SWR + single-flight + tag triggers |
287
- | `renderSsr`, `streamResult`, `renderSpa` | the per-request modes |
335
+ | `renderSsr`, `streamResult` | the per-request modes |
288
336
  | `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
337
+ | `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
289
338
  | `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
290
- | `createRouter` | the vendored client router |
291
339
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
292
340
 
293
341
  ## Notes
@@ -311,7 +359,11 @@ would paper over the contradiction.
311
359
  - **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
312
360
  0kb default is mechanical, not aspirational. A page that renders an island anyway is
313
361
  `X_ISLAND_NOT_HYDRATED`, not a silently dead button.
314
- - **The client router is vendored** rather than depending on a moving SolidStart alpha. It
315
- imports no `solid-js`: reactive primitives and the DOM host are injected.
362
+ - **A gated page is `ssr`**, never a client-rendered shell. `spa` and `createRouter` were
363
+ deleted `As of 2026-08-20`: `renderSpa` never read the route's component and no build ever produced the
364
+ `chunks` it preloaded, so every `spa` route served an empty `<div id="x-root">`, and the router
365
+ that shell would have needed had no caller in the framework or in either tracked app. A page
366
+ whose body belongs in the browser declares `island({ src })` — one interactivity model, one
367
+ bundler entry point, one budget measured against real bytes.
316
368
  - `@ultimat3/http`'s `html()` / `stream()` turn a `RenderResult` into a `Response`; render
317
369
  never constructs one.
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "5.0.1",
3
+ "version": "7.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",
7
+ "sideEffects": [
8
+ "./src/errors.ts",
9
+ "./src/index.ts"
10
+ ],
7
11
  "repository": {
8
12
  "type": "git",
9
13
  "url": "git+https://github.com/developerz-ai/ultimate.git",
@@ -31,10 +35,10 @@
31
35
  "test": "bun test"
32
36
  },
33
37
  "dependencies": {
34
- "@ultimat3/cache": "5.0.1",
35
- "@ultimat3/core": "5.0.1",
36
- "@ultimat3/i18n": "5.0.1",
37
- "@ultimat3/seo": "5.0.1",
38
+ "@ultimat3/cache": "7.0.0",
39
+ "@ultimat3/core": "7.0.0",
40
+ "@ultimat3/i18n": "7.0.0",
41
+ "@ultimat3/seo": "7.0.0",
38
42
  "sass": "1.102.0"
39
43
  }
40
44
  }
package/src/hydrate.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * answered instead of swallowed.
6
6
  */
7
7
 
8
+ import type { HydrateStrategy } from '@ultimat3/core';
8
9
  import { escapeAttribute, escapeJsonContent } from './html';
9
- import type { HydrateStrategy } from './route';
10
10
 
11
11
  export interface IslandDirective {
12
12
  /** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
@@ -29,6 +29,28 @@ export interface IslandDirective {
29
29
 
30
30
  export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'keydown'] as const;
31
31
 
32
+ /**
33
+ * How long `idle` waits before hydrating anyway. Exported because a second reader exists and it
34
+ * has to agree: `x shot` leaves the page alone for this long before it photographs it, and a
35
+ * settle shorter than this timeout reports an unhydrated page for one that hydrates perfectly.
36
+ * The runtime string below interpolates it — two copies of one number is the drift axiom 2 refuses.
37
+ */
38
+ export const IDLE_HYDRATE_TIMEOUT_MS = 2_000;
39
+
40
+ /**
41
+ * Set by the runtime when the island's `mount()` RESOLVED. `el.__x` is set when `import()` is
42
+ * called, so it answers "the chunk was requested" and nothing more — three facts (declared,
43
+ * importing, running) had two observables between them, and the missing one is the half that gates.
44
+ */
45
+ export const ISLAND_MOUNTED_ATTRIBUTE = 'data-x-mounted';
46
+
47
+ /**
48
+ * Set by the runtime when `mount()` REJECTED, to the error's message. A failed island and one still
49
+ * loading are the two states an agent most needs to tell apart, and with a success marker alone
50
+ * they are the same absence.
51
+ */
52
+ export const ISLAND_FAILED_ATTRIBUTE = 'data-x-failed';
53
+
32
54
  /**
33
55
  * The island's markup wrapper. `never` gets attributes only, so the HTML is inert and the
34
56
  * runtime below is never emitted for that island.
@@ -84,20 +106,28 @@ export function requiredStrategies(
84
106
  // resolved promise. As a flag, a second click during the chunk's load short-circuited to
85
107
  // `Promise.resolve()`, and the interaction runtime flushed its replay queue into an island that
86
108
  // had not mounted — the events went to nothing and the listeners were already removed.
109
+ //
110
+ // The mount markers are what make the boot promise's OUTCOME readable from the DOM — by `x shot`,
111
+ // by an app's own test, by a human in devtools. `el.__x` exists from the moment `import()` is
112
+ // called, so it cannot tell a chunk that is still downloading from one whose `mount()` threw.
113
+ // The rejection handler rethrows: swallowing it would resolve `el.__x`, and the interaction
114
+ // runtime below would then flush its replay queue into an island that never mounted — the bug
115
+ // `el.__x`-as-a-promise was introduced to fix, reintroduced one layer further out.
87
116
  const RUNTIME_PRELUDE = `
88
- var Q={};
89
117
  function boot(el){var e=el.getAttribute('data-x-entry');
90
118
  if(!e)return Promise.resolve();if(el.__x)return el.__x;
91
119
  var p=document.querySelector('script[data-x-props="'+el.getAttribute('data-x-island')+'"]');
92
120
  var props=p?JSON.parse(p.textContent||'{}'):{};
93
- return el.__x=import(e).then(function(m){return m.mount(el,props)})}
121
+ return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
122
+ function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
123
+ function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
94
124
  function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
95
125
  `.trim();
96
126
 
97
127
  const RUNTIME_IDLE = `
98
128
  each('[data-x-hydrate="idle"]',function(el){
99
129
  var go=function(){boot(el)};
100
- if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:2000});else setTimeout(go,1)})
130
+ if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
101
131
  `.trim();
102
132
 
103
133
  const RUNTIME_VISIBLE = `
package/src/index.ts CHANGED
@@ -9,6 +9,15 @@ import { installRenderLoader } from './module-loader';
9
9
  // places one fact can be wrong instead of none.
10
10
  installRenderLoader();
11
11
 
12
+ /**
13
+ * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
14
+ * because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` all take these types
15
+ * in their signatures: a consumer calling this package's API should not need a second import to
16
+ * name its arguments. A re-export is not a declaration — `scripts/render-modes.test.ts` refuses a
17
+ * second declaration, which is what makes re-exporting safe where copying was not.
18
+ */
19
+ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
20
+ export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
12
21
  export type { CompiledStylesheet } from './css-modules';
13
22
  export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
14
23
  export { parseTtlMs } from './duration';
@@ -54,6 +63,9 @@ export {
54
63
  emitIslandProps,
55
64
  hydrateRuntime,
56
65
  hydrateRuntimeBytes,
66
+ IDLE_HYDRATE_TIMEOUT_MS,
67
+ ISLAND_FAILED_ATTRIBUTE,
68
+ ISLAND_MOUNTED_ATTRIBUTE,
57
69
  requiredStrategies,
58
70
  } from './hydrate';
59
71
  export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
@@ -89,7 +101,6 @@ export {
89
101
  defaultHydrate,
90
102
  defaultIslandBudget,
91
103
  MODE_SPECS,
92
- RENDER_MODES,
93
104
  } from './modes';
94
105
  export type { Stylesheet } from './module-loader';
95
106
  export {
@@ -120,7 +131,7 @@ export {
120
131
  routePathFromFile,
121
132
  } from './registry';
122
133
  export type { RenderHtmlOptions } from './render-html';
123
- export { renderComponent, renderToHtml } from './render-html';
134
+ export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
124
135
  export type {
125
136
  IsrController,
126
137
  IsrControllerOptions,
@@ -138,8 +149,6 @@ export {
138
149
  isrKey,
139
150
  memoryIsrStore,
140
151
  } from './render-isr';
141
- export type { SpaShell, SpaShellInput } from './render-spa';
142
- export { renderSpa, renderSpaShell, SPA_ROOT_ID } from './render-spa';
143
152
  export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
144
153
  export { renderSsr, ssrHeaders } from './render-ssr';
145
154
  export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
@@ -164,11 +173,8 @@ export {
164
173
  streamResult,
165
174
  } from './render-stream';
166
175
  export type {
167
- HydrateStrategy,
168
176
  LoadRequirement,
169
- OfflineStrategy,
170
177
  PrerenderFn,
171
- RenderMode,
172
178
  RenderResult,
173
179
  RevalidateConfig,
174
180
  RouteBudget,
@@ -184,30 +190,10 @@ export type {
184
190
  RouteMetaFn,
185
191
  RouteParams,
186
192
  } from './route';
187
- export {
188
- DEFAULT_ISLAND_HYDRATE,
189
- defineRoute,
190
- HYDRATE_STRATEGIES,
191
- isRouteConfig,
192
- OFFLINE_STRATEGIES,
193
- tagKeys,
194
- } from './route';
193
+ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './route';
195
194
  export type { RouteComponent } from './route-component';
196
195
  export { pageComponentOf } from './route-component';
197
196
  export { metaContextFor, routeDataFor } from './route-data';
198
- export type {
199
- NavigateOptions,
200
- NavigationGuard,
201
- PrefetchContainer,
202
- PrefetchLink,
203
- ReactivePrimitives,
204
- ResolvedRoute,
205
- Router,
206
- RouterHost,
207
- RouterOptions,
208
- RouterRoute,
209
- } from './router-client';
210
- export { createRouter } from './router-client';
211
197
  export type {
212
198
  BoundaryRule,
213
199
  BoundaryViolation,
@@ -4,6 +4,7 @@
4
4
  * one place a route says it ships JavaScript, and the directives stay a per-render fact.
5
5
  */
6
6
 
7
+ import type { HydrateStrategy } from '@ultimat3/core';
7
8
  import { IslandInvalidError, IslandNotHydratedError } from './errors';
8
9
  import type { IslandDirective } from './hydrate';
9
10
  import { DEFAULT_REPLAY_EVENTS } from './hydrate';
@@ -12,7 +13,6 @@ import { isEmittableSpecifier, islandNeverDrained } from './island';
12
13
  import type { IslandProps } from './island-props';
13
14
  import { checkIslandProps } from './island-props';
14
15
  import type { JsxProps } from './jsx';
15
- import type { HydrateStrategy } from './route';
16
16
 
17
17
  /** The distinct client entries a rendered page pulled in — one per module, however many instances. */
18
18
  export function islandModuleIds(directives: readonly IslandDirective[]): readonly string[] {
package/src/islands.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  * opt-in, budgeted island.
6
6
  */
7
7
 
8
+ import type { HydrateStrategy } from '@ultimat3/core';
8
9
  import { BudgetExceededError } from './errors';
9
10
  import type { IslandDirective } from './hydrate';
10
11
  import { hydrateRuntimeBytes } from './hydrate';
11
12
  import type { RouteEntry } from './registry';
12
- import type { HydrateStrategy } from './route';
13
13
  import type { Surface } from './surfaces';
14
14
  import { SURFACE_SPECS } from './surfaces';
15
15
 
package/src/modes.ts CHANGED
@@ -1,19 +1,18 @@
1
1
  /**
2
- * The five render modes as a table of invariants, checked at registration.
2
+ * The four render modes as a table of invariants, checked at registration.
3
3
  * Each mode has properties the framework can rely on; a config that contradicts one is
4
4
  * rejected with `X_ROUTE_MODE_INVALID` and the exact edit that fixes it. A mode whose
5
5
  * invariant is only documented is a mode that silently degrades in production.
6
6
  */
7
7
 
8
+ import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
9
+ import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
8
10
  import { parseTtlMs } from './duration';
9
11
  import { RouteModeInvalidError } from './errors';
10
- import type { HydrateStrategy, RenderMode, RouteConfig } from './route';
11
- import { HYDRATE_STRATEGIES } from './route';
12
+ import type { RouteConfig } from './route';
12
13
  import type { Surface } from './surfaces';
13
14
  import { SURFACE_SPECS, surfaceAllows } from './surfaces';
14
15
 
15
- export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream', 'spa'] as const;
16
-
17
16
  /**
18
17
  * Everything about a route except the two keys carrying its data generic. Mode invariants never
19
18
  * read metadata and never load anything, and omitting both keeps these checks free of `TData`.
@@ -35,19 +34,18 @@ export interface ModeSpec {
35
34
  readonly needsRevalidate: boolean;
36
35
  /** Does the mode need at least one `<Suspense>` boundary to mean anything? */
37
36
  readonly needsSuspense: boolean;
38
- /** Does the mode need a `policy` (it renders no data, so the shell must be gated)? */
39
- readonly needsPolicy: boolean;
40
37
  readonly description: string;
41
38
  }
42
39
 
43
- export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze({
40
+ /** Keyed with an explicit type argument for the reason `MODE_STRATEGY` in `@ultimat3/pwa`
41
+ * gives: a `const X: Record<…> = Object.freeze({…})` accepts an extra key in silence. */
42
+ export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
44
43
  static: {
45
44
  mode: 'static',
46
45
  perRequestState: false,
47
46
  prerenderable: true,
48
47
  needsRevalidate: false,
49
48
  needsSuspense: false,
50
- needsPolicy: false,
51
49
  description: 'built once, served as a file',
52
50
  },
53
51
  isr: {
@@ -56,7 +54,6 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
56
54
  prerenderable: true,
57
55
  needsRevalidate: true,
58
56
  needsSuspense: false,
59
- needsPolicy: false,
60
57
  description: 'static + background regen on tag/TTL',
61
58
  },
62
59
  ssr: {
@@ -65,7 +62,6 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
65
62
  prerenderable: false,
66
63
  needsRevalidate: false,
67
64
  needsSuspense: false,
68
- needsPolicy: false,
69
65
  description: 'per-request full render',
70
66
  },
71
67
  stream: {
@@ -74,18 +70,8 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
74
70
  prerenderable: false,
75
71
  needsRevalidate: false,
76
72
  needsSuspense: true,
77
- needsPolicy: false,
78
73
  description: 'static shell flushed instantly, holes streamed',
79
74
  },
80
- spa: {
81
- mode: 'spa',
82
- perRequestState: false,
83
- prerenderable: true,
84
- needsRevalidate: false,
85
- needsSuspense: false,
86
- needsPolicy: true,
87
- description: 'shell only, client fetches',
88
- },
89
75
  });
90
76
 
91
77
  /** Mode-local checks that need nothing but the config. Called by `defineRoute`. */
@@ -116,7 +102,7 @@ export function assertModeShape(config: RouteShape): void {
116
102
  throw new RouteModeInvalidError(
117
103
  "render: 'static' cannot read per-request state, but a `policy` was declared " +
118
104
  `(${config.policy.permission} needs an actor)`,
119
- "change render to 'ssr' (fresh, gated) or 'spa' (gated shell), or drop the policy",
105
+ "change render to 'ssr' — per-request and gated — or drop the policy",
120
106
  );
121
107
  }
122
108
  if (config.render === 'static' && config.revalidate !== undefined) {
@@ -135,7 +121,7 @@ export function assertModeShape(config: RouteShape): void {
135
121
  throw new RouteModeInvalidError(
136
122
  "render: 'isr' caches one document per URL and cannot be gated, but a `policy` was " +
137
123
  `declared (${config.policy.permission} varies the answer per actor)`,
138
- "change render to 'ssr' (fresh, gated) or 'spa' (gated shell), or drop the policy",
124
+ "change render to 'ssr' — per-request and gated — or drop the policy",
139
125
  );
140
126
  }
141
127
 
@@ -154,14 +140,6 @@ export function assertModeShape(config: RouteShape): void {
154
140
  "change render to 'isr' to prerender and regenerate, or remove prerender",
155
141
  );
156
142
  }
157
-
158
- // spa: the shell carries no data, so authz has to live on the route itself.
159
- if (config.render === 'spa' && config.policy === undefined) {
160
- throw new RouteModeInvalidError(
161
- "render: 'spa' ships a shell with no server-rendered data and requires a `policy`",
162
- "add policy: can('dashboard:read') — or use 'stream' for a public page",
163
- );
164
- }
165
143
  }
166
144
 
167
145
  function hasRevalidateTrigger(config: RouteShape): boolean {
@@ -234,14 +212,42 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
234
212
  }
235
213
 
236
214
  /**
237
- * What an island route may spend ABOVE its surface's baseline when it declares no `budget.js`.
215
+ * What an island route may spend ABOVE its surface's baseline when it declares no `budget.js` —
216
+ * the island chunk plus the hydration runtime, which is what `routeJsBytes` adds to the baseline.
217
+ *
218
+ * **20kb, and it is calibrated on a Solid island** (`As of 2026-08-21`). It was 4096, justified in
219
+ * this comment as "twice what the reference app's real island costs (875 B of chunk plus a 1,019 B
220
+ * runtime)" — and that island, `contact-sales.island.tsx`, imports no `solid-js` at all. Calibrating
221
+ * a JSX budget on the one island shape that does not pay the JSX runtime made the default
222
+ * unreachable for every island that does: measured with `buildIslands`, minified, production Solid,
223
+ * `render(() => <p>hello</p>, el)` is **12,588 B** — the floor, before an author writes anything —
224
+ * a signal + a button + reactive text is 13,663 B, and the reference app's real Solid island
225
+ * (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
226
+ * any surface, because the allowance is measured above the baseline and not against it.
227
+ *
228
+ * The number: 17,797 (the heaviest island this repo actually ships) + 1,010 (`hydrateRuntimeBytes`
229
+ * for one directive at `DEFAULT_ISLAND_HYDRATE`, which is `'interaction'` — `route.ts:33`, applied
230
+ * at `:253` to any island route declaring no `hydrate`) = **18,807**. That is the worst case an
231
+ * app reaches without writing a number down. 20,480 is NOT that rounded up — the next whole
232
+ * kilobyte above it is 19,456 — it is one whole kB further, leaving 1,673 B of headroom and still
233
+ * under 2x 18,807, so a route bundling the same island twice is refused. `island-budget.test.ts`
234
+ * asserts all three. `idle` costs 744 and `visible` 816, so an island route that declares its
235
+ * strategy pays less; the default is what the budget has to clear.
236
+ *
237
+ * All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
238
+ * mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading. The
239
+ * headroom absorbed it and the conclusion is unchanged — which is the point of stating the
240
+ * arithmetic here rather than the answer alone. It is not
241
+ * derived from Solid's own size on purpose — this package may not import or name `solid-js`
242
+ * (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
238
243
  *
239
- * A number, not "unlimited", because the guard means nothing otherwise — and 4kb because that is
240
- * roughly twice what the reference app's real island costs (875 B of chunk plus a 1,019 B runtime),
241
- * which is enough for a second small island and not enough to hide a library. A declared
242
- * `budget.js` still wins, and exceeding this one still fails with the island named.
244
+ * Still a real ceiling, still real bytes on disk: an island that pulls a design system in
245
+ * (`@ultimat3/ui`'s `<Switch>` measures 36,335 B) or ships an engine twice must write its own
246
+ * number down, which is the whole point. Two defaults keyed on "does the graph reach Solid?" is
247
+ * not an option even setting axiom 1 aside: `registry.ts` reads this at REGISTRATION, before any
248
+ * bundle exists, so the answer is not available where the constant is used.
243
249
  */
244
- export const DEFAULT_ISLAND_JS_BYTES = 4096;
250
+ export const DEFAULT_ISLAND_JS_BYTES = 20_480;
245
251
 
246
252
  /**
247
253
  * The derived ceiling for a route whose hydration came from an island. Relative to the surface
package/src/registry.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * from. Nothing downstream may keep its own list of routes.
6
6
  */
7
7
 
8
+ import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
8
9
  import {
9
10
  RouteDuplicateError,
10
11
  RouteFileInvalidError,
@@ -12,24 +13,22 @@ import {
12
13
  SurfaceBoundaryError,
13
14
  } from './errors';
14
15
  import { assertModeInvariants, defaultIslandBudget } from './modes';
15
- import type {
16
- HydrateStrategy,
17
- OfflineStrategy,
18
- RenderMode,
19
- RouteConfig,
20
- RouteData,
21
- RouteParams,
22
- } from './route';
16
+ import type { RouteConfig, RouteData, RouteParams } from './route';
23
17
  import { isRouteConfig, tagKeys } from './route';
24
18
  import type { RouteComponent } from './route-component';
25
19
  import type { Surface } from './surfaces';
26
20
  import { locateSurface } from './surfaces';
27
21
 
28
22
  /**
29
- * The one filename a route may carry, per surface. `shared/` is absent on purpose: it is a leaf
30
- * of helpers with no URL, so a route file there has nowhere to resolve to.
23
+ * The one filename a route may carry, per surface. `shared/` is `Exclude`d rather than merely
24
+ * absent: it is a leaf of helpers with no URL, so a route file there has nowhere to resolve to —
25
+ * and stating that in the key type makes the other three MANDATORY. `Partial<Record<Surface, …>>`
26
+ * said the same thing about `shared` and let any of the three go missing, which only three
27
+ * registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename`
28
+ * reads `undefined` as "this file is under shared/", so every `api/` route author would have been
29
+ * told their file is a leaf of helpers.
31
30
  */
32
- export const ROUTE_FILENAME: Readonly<Partial<Record<Surface, string>>> = Object.freeze({
31
+ export const ROUTE_FILENAME = Object.freeze<Record<Exclude<Surface, 'shared'>, string>>({
33
32
  site: 'page.tsx',
34
33
  app: 'page.tsx',
35
34
  api: 'route.ts',
@@ -137,7 +136,9 @@ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")
137
136
  * author already meant, plus the one filename that surface accepts.
138
137
  */
139
138
  function assertRouteFilename(file: string, surface: Surface, basename: string | undefined): void {
140
- const expected = ROUTE_FILENAME[surface];
139
+ // `shared` is the one surface with no filename, and the key type now says so — which is why
140
+ // this is a comparison rather than an `undefined` check on the lookup.
141
+ const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface];
141
142
  if (expected === undefined) {
142
143
  throw new RouteFileInvalidError(
143
144
  `${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
@@ -370,9 +371,9 @@ export function matchRoute(pathname: string): RouteMatch | null {
370
371
  * `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
371
372
  * `matchRoute` as a 500 and an error-monitor page for somebody's typo.
372
373
  *
373
- * Exported for `router-client.ts`, which answers the same question about the same pathname on the
374
- * other side of the wire: two decoders is how the client throws out of a popstate listener for an
375
- * address the server 404s.
374
+ * Still exported after `router-client.ts` went with `createRouter`: it is the one answer to "is
375
+ * this segment decodable?" on this side of the wire, and a second copy of it is how one of the two
376
+ * ends up throwing where the other 404s.
376
377
  */
377
378
  export function decodeSegment(value: string): string | undefined {
378
379
  try {
@@ -20,6 +20,13 @@ import { islandWithoutCollector } from './island-collector';
20
20
  import type { JsxComponent, JsxProps } from './jsx';
21
21
  import { isJsxNode } from './jsx';
22
22
 
23
+ /**
24
+ * The id of the element every document wraps its route component in — one name, because a service
25
+ * worker, a test and a client entry all address the same element. It was `SPA_ROOT_ID` in
26
+ * `render-spa.ts` and named a mode that no longer exists, while every mode has always used it.
27
+ */
28
+ export const ROOT_ELEMENT_ID = 'x-root';
29
+
23
30
  /** Depth is bounded so a component that renders itself fails with a cause instead of a stack trace. */
24
31
  const MAX_DEPTH = 500;
25
32
 
package/src/route.ts CHANGED
@@ -14,6 +14,8 @@
14
14
 
15
15
  import type { CacheTag } from '@ultimat3/cache';
16
16
  import { serializeTags } from '@ultimat3/cache';
17
+ import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
18
+ import { OFFLINE_STRATEGIES } from '@ultimat3/core';
17
19
  import type { Translator } from '@ultimat3/i18n';
18
20
  import type { RouteMeta } from '@ultimat3/seo';
19
21
  import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError } from './errors';
@@ -21,13 +23,6 @@ import type { IslandSpec } from './island';
21
23
  import { drainDeclaredIslands } from './island';
22
24
  import { assertModeShape } from './modes';
23
25
 
24
- export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
25
- export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
26
- export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never';
27
-
28
- export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const;
29
- export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const;
30
-
31
26
  /**
32
27
  * What a page that declares an island hydrates as when it says nothing. The most conservative of
33
28
  * the three that ship JavaScript: nothing runs until the visitor acts, and `interaction` is the
package/src/surfaces.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * aspirational. Violations are build errors, resolved through the whole chain.
6
6
  */
7
7
 
8
+ import type { RenderMode } from '@ultimat3/core';
8
9
  import { SurfaceBoundaryError } from './errors';
9
- import type { RenderMode } from './route';
10
10
 
11
11
  export type Surface = 'site' | 'app' | 'api' | 'shared';
12
12
 
@@ -24,7 +24,7 @@ export interface SurfaceSpec {
24
24
  readonly mayImportTypes: readonly Surface[];
25
25
  }
26
26
 
27
- export const SURFACE_SPECS: Readonly<Record<Surface, SurfaceSpec>> = Object.freeze({
27
+ export const SURFACE_SPECS = Object.freeze<Record<Surface, SurfaceSpec>>({
28
28
  site: {
29
29
  surface: 'site',
30
30
  defaultMode: 'static',
@@ -36,7 +36,7 @@ export const SURFACE_SPECS: Readonly<Record<Surface, SurfaceSpec>> = Object.free
36
36
  app: {
37
37
  surface: 'app',
38
38
  defaultMode: 'stream',
39
- allowedModes: ['stream', 'spa', 'ssr'],
39
+ allowedModes: ['stream', 'ssr'],
40
40
  jsBaselineBytes: 14_336,
41
41
  mayImport: ['shared'],
42
42
  mayImportTypes: ['shared', 'api'],
package/src/render-spa.ts DELETED
@@ -1,79 +0,0 @@
1
- /**
2
- * `spa` — shell only, client fetches everything. For dashboards behind auth, where the
3
- * shell is identical for every actor and therefore cacheable, and the data is not.
4
- * `modes.ts` requires a `policy` on this mode: nothing is server-rendered, so the route
5
- * itself is the only place authz can live.
6
- */
7
-
8
- import { RouteModeInvalidError } from './errors';
9
- import { escapeAttribute } from './html';
10
- import type { RouteEntry } from './registry';
11
- import { contentHash } from './render-static';
12
- import type { RenderResult } from './route';
13
-
14
- export const SPA_ROOT_ID = 'x-root';
15
-
16
- export interface SpaShellInput {
17
- readonly entry: RouteEntry;
18
- readonly buildId: string;
19
- /** Everything inside `<head>`, already merged by `head.ts`. */
20
- readonly head: string;
21
- /** Build-id-immutable chunk URLs to preload; order is preserved for determinism. */
22
- readonly chunks: readonly string[];
23
- readonly rootId?: string;
24
- readonly lang: string;
25
- readonly dir?: 'ltr' | 'rtl';
26
- }
27
-
28
- export interface SpaShell {
29
- readonly html: string;
30
- readonly hash: string;
31
- }
32
-
33
- export function renderSpaShell(input: SpaShellInput): SpaShell {
34
- if (input.entry.config.policy === undefined) {
35
- throw new RouteModeInvalidError(
36
- `${input.entry.file} declares render: 'spa' with no policy, so the shell would be public`,
37
- `add policy: can('…') to ${input.entry.file}`,
38
- );
39
- }
40
-
41
- // Every attribute value through `html.ts`'s ONE escaper — the package's own escaping rule, and
42
- // the same repair `emitIslandAttributes` took. `head` is the exception by construction: it is
43
- // already-merged markup from `head.ts`, which escaped it on the way in.
44
- const rootId = escapeAttribute(input.rootId ?? SPA_ROOT_ID);
45
- const preloads = input.chunks
46
- .map((chunk) => `<link rel="modulepreload" href="${escapeAttribute(chunk)}">`)
47
- .join('');
48
- const scripts = input.chunks
49
- .map((chunk) => `<script type="module" src="${escapeAttribute(chunk)}"></script>`)
50
- .join('');
51
-
52
- const html =
53
- `<!doctype html><html lang="${escapeAttribute(input.lang)}" ` +
54
- `dir="${escapeAttribute(input.dir ?? 'ltr')}">` +
55
- `<head>${input.head}${preloads}` +
56
- `<meta name="x-ultimate-build" content="${escapeAttribute(input.buildId)}">` +
57
- `</head><body><div id="${rootId}"></div>${scripts}</body></html>`;
58
-
59
- return { html, hash: contentHash(html) };
60
- }
61
-
62
- /**
63
- * The shell is identical for every actor, so it is cache-first in `sw.js` and
64
- * revalidate-on-navigation over HTTP. The build id in the document is what
65
- * `@ultimat3/pwa`'s skew detection compares against the server's.
66
- */
67
- export function renderSpa(input: SpaShellInput): RenderResult {
68
- const shell = renderSpaShell(input);
69
- return {
70
- status: 200,
71
- headers: {
72
- 'content-type': 'text/html; charset=utf-8',
73
- 'cache-control': 'private, max-age=0, must-revalidate',
74
- etag: `"${shell.hash}"`,
75
- 'x-ultimate-build': input.buildId,
76
- },
77
- body: shell.html,
78
- };
79
- }
@@ -1,236 +0,0 @@
1
- /**
2
- * The minimal client router. Vendored on purpose: the router is load-bearing for every
3
- * app page, and a moving SolidStart alpha is not something a framework should depend on.
4
- *
5
- * No `solid-js` import — reactive primitives and the DOM are injected, so this file runs
6
- * under `bun test` with no DOM and no framework runtime.
7
- */
8
-
9
- import type { CompiledPattern } from './registry';
10
- import { compilePattern, decodeSegment } from './registry';
11
- import type { RouteParams } from './route';
12
-
13
- export interface RouterRoute {
14
- readonly path: string;
15
- /** Module URL for the route chunk; used by `prefetch`. */
16
- readonly chunk?: string;
17
- }
18
-
19
- export interface ResolvedRoute {
20
- readonly route: RouterRoute;
21
- readonly params: RouteParams;
22
- readonly pathname: string;
23
- readonly search: string;
24
- }
25
-
26
- /** Injected instead of imported so there is no framework runtime dependency here. */
27
- export interface ReactivePrimitives {
28
- createSignal<T>(initial: T): readonly [() => T, (next: T) => void];
29
- }
30
-
31
- export interface RouterHost {
32
- readonly pathname: () => string;
33
- readonly search: () => string;
34
- readonly pushState: (url: string) => void;
35
- readonly replaceState: (url: string) => void;
36
- readonly onPopState: (handler: () => void) => void;
37
- readonly scrollTo: (x: number, y: number) => void;
38
- readonly scrollY: () => number;
39
- /** `document.startViewTransition` when present; falls back to running the update. */
40
- readonly viewTransition?: (update: () => void) => void;
41
- /** Warm a chunk. Defaults to a no-op host in tests. */
42
- readonly preload?: (chunk: string) => void;
43
- }
44
-
45
- export type NavigationGuard = (
46
- to: ResolvedRoute,
47
- from: ResolvedRoute | null,
48
- ) => boolean | Promise<boolean>;
49
-
50
- export interface RouterOptions {
51
- readonly routes: readonly RouterRoute[];
52
- readonly host: RouterHost;
53
- readonly primitives: ReactivePrimitives;
54
- /** Hover dwell before prefetching, ms. */
55
- readonly prefetchDelayMs?: number;
56
- }
57
-
58
- export interface NavigateOptions {
59
- readonly replace?: boolean;
60
- readonly scroll?: boolean;
61
- }
62
-
63
- export interface Router {
64
- readonly current: () => ResolvedRoute | null;
65
- readonly navigate: (to: string, options?: NavigateOptions) => Promise<boolean>;
66
- readonly resolve: (url: string) => ResolvedRoute | null;
67
- readonly prefetch: (to: string) => void;
68
- readonly guard: (guard: NavigationGuard) => () => void;
69
- /** Attach hover/visible prefetch + scroll restoration to a container element. */
70
- readonly attach: (container: PrefetchContainer) => () => void;
71
- }
72
-
73
- /** Structural view of the DOM bits the router touches, so tests can fake them. */
74
- export type DomEventHandler = (event: { target: unknown }) => void;
75
-
76
- export interface PrefetchContainer {
77
- querySelectorAll(selector: string): Iterable<PrefetchLink>;
78
- addEventListener(type: string, handler: DomEventHandler, options?: unknown): void;
79
- removeEventListener(type: string, handler: DomEventHandler, options?: unknown): void;
80
- }
81
-
82
- export interface PrefetchLink {
83
- getAttribute(name: string): string | null;
84
- }
85
-
86
- interface CompiledRoute {
87
- readonly route: RouterRoute;
88
- readonly pattern: CompiledPattern;
89
- }
90
-
91
- export function createRouter(options: RouterOptions): Router {
92
- const compiled: readonly CompiledRoute[] = options.routes
93
- .map((route) => ({ route, pattern: compilePattern(route.path) }))
94
- .sort((a, b) => b.pattern.specificity - a.pattern.specificity);
95
-
96
- const guards = new Set<NavigationGuard>();
97
- const scrollPositions = new Map<string, number>();
98
- const prefetched = new Set<string>();
99
- const host = options.host;
100
-
101
- const [current, setCurrent] = options.primitives.createSignal<ResolvedRoute | null>(
102
- resolve(`${host.pathname()}${host.search()}`),
103
- );
104
-
105
- function resolve(url: string): ResolvedRoute | null {
106
- const [rawPath = '/', rawSearch = ''] = splitUrl(url);
107
- for (const candidate of compiled) {
108
- const match = candidate.pattern.regex.exec(rawPath);
109
- if (match === null) continue;
110
- const params: Record<string, string> = {};
111
- let undecodable = false;
112
- candidate.pattern.keys.forEach((key, index) => {
113
- const value = match[index + 1];
114
- if (value === undefined) return;
115
- // `decodeSegment`, the server router's own reader: `decodeURIComponent('%zz')` is a bare
116
- // `URIError`, and `resolve` is called from the signal initialiser, from `navigate`, from a
117
- // `mouseenter` prefetch and from the popstate handler — so a typo in the address bar took
118
- // the whole SPA down at boot instead of failing this one branch.
119
- const decoded = decodeSegment(value);
120
- if (decoded === undefined) undecodable = true;
121
- else params[key] = decoded;
122
- });
123
- // Fails only the branch that would have decoded it, exactly as `matchRoute` answers: a
124
- // literal route matching the same text still wins, and an unclaimed pathname is a non-match.
125
- if (undecodable) continue;
126
- return {
127
- route: candidate.route,
128
- params,
129
- pathname: rawPath,
130
- search: rawSearch === '' ? '' : `?${rawSearch}`,
131
- };
132
- }
133
- return null;
134
- }
135
-
136
- async function navigate(to: string, navOptions: NavigateOptions = {}): Promise<boolean> {
137
- const next = resolve(to);
138
- if (next === null) return false;
139
-
140
- const from = current();
141
- for (const guard of guards) {
142
- // A guard that says no is a hard stop: no history entry, no scroll change.
143
- if (!(await guard(next, from))) return false;
144
- }
145
-
146
- if (from !== null) scrollPositions.set(keyOf(from), host.scrollY());
147
-
148
- const commit = (): void => {
149
- if (navOptions.replace === true) host.replaceState(to);
150
- else host.pushState(to);
151
- setCurrent(next);
152
- if (navOptions.scroll !== false) restoreScroll(next);
153
- };
154
-
155
- if (host.viewTransition !== undefined) host.viewTransition(commit);
156
- else commit();
157
- return true;
158
- }
159
-
160
- function restoreScroll(route: ResolvedRoute): void {
161
- host.scrollTo(0, scrollPositions.get(keyOf(route)) ?? 0);
162
- }
163
-
164
- function prefetch(to: string): void {
165
- const target = resolve(to);
166
- const chunk = target?.route.chunk;
167
- if (chunk === undefined || prefetched.has(chunk)) return;
168
- prefetched.add(chunk);
169
- host.preload?.(chunk);
170
- }
171
-
172
- host.onPopState(() => {
173
- const next = resolve(`${host.pathname()}${host.search()}`);
174
- if (next !== null) {
175
- setCurrent(next);
176
- restoreScroll(next);
177
- }
178
- });
179
-
180
- return {
181
- current,
182
- navigate,
183
- resolve,
184
- prefetch,
185
- guard(guard) {
186
- guards.add(guard);
187
- return () => guards.delete(guard);
188
- },
189
- attach(container) {
190
- let timer: ReturnType<typeof setTimeout> | null = null;
191
- const onEnter = (event: { target: unknown }): void => {
192
- const href = hrefOf(event.target);
193
- if (href === null) return;
194
- if (timer !== null) clearTimeout(timer);
195
- timer = setTimeout(() => prefetch(href), options.prefetchDelayMs ?? 80);
196
- };
197
- const onLeave = (): void => {
198
- if (timer !== null) clearTimeout(timer);
199
- timer = null;
200
- };
201
-
202
- container.addEventListener('mouseenter', onEnter, true);
203
- container.addEventListener('mouseleave', onLeave, true);
204
-
205
- // Visible prefetch: everything explicitly marked, warmed once it is on screen.
206
- for (const link of container.querySelectorAll('a[data-prefetch="visible"]')) {
207
- const href = link.getAttribute('href');
208
- if (href !== null) prefetch(href);
209
- }
210
-
211
- return () => {
212
- container.removeEventListener('mouseenter', onEnter, true);
213
- container.removeEventListener('mouseleave', onLeave, true);
214
- onLeave();
215
- };
216
- },
217
- };
218
- }
219
-
220
- function keyOf(route: ResolvedRoute): string {
221
- return `${route.pathname}${route.search}`;
222
- }
223
-
224
- function splitUrl(url: string): readonly [string, string] {
225
- const hashless = url.split('#')[0] ?? '/';
226
- const index = hashless.indexOf('?');
227
- if (index === -1) return [hashless === '' ? '/' : hashless, ''];
228
- return [hashless.slice(0, index) || '/', hashless.slice(index + 1)];
229
- }
230
-
231
- function hrefOf(target: unknown): string | null {
232
- if (typeof target !== 'object' || target === null) return null;
233
- if (!('getAttribute' in target)) return null;
234
- const link = target as PrefetchLink;
235
- return link.getAttribute('href');
236
- }