@ultimat3/render 8.0.0 → 10.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
@@ -4,6 +4,20 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
4
4
  islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
5
5
  loaders that make an app's `.tsx` and `.scss` runnable**.
6
6
 
7
+ **Two entry points, disjoint, split 2026-08-22** — the same `"."` / `"./server"` shape
8
+ `@ultimat3/realtime` took, for the same reason. `"."` (`index.ts`) is the CLIENT half and BUNDLES
9
+ for the browser; `"./server"` (`server.ts`) is the build-time half — `css-modules`,
10
+ `module-loader`, `render-html`, `render-isr`, `render-ssr`, `render-static`, `render-stream` — and
11
+ does not. `css-modules.ts` imports `fileURLToPath`/`pathToFileURL` from `node:url`, which Bun's
12
+ browser polyfill exports NEITHER of, so the single barrel was not a fat bundle, it was a
13
+ `bun build --target=browser` that FAILED at link time on any app entry that reached this package.
14
+ Measured: no `sideEffects` value repairs it (`false`, `[]`, an array naming only `errors.ts` — all
15
+ fail identically), which is where this split differs from realtime's, where the array alone was
16
+ enough. Only not importing the module does. Never re-export a name from both barrels: disjointness
17
+ is what makes "which half does this live in" a mechanical fact, and `index.test.ts` asserts both
18
+ the empty name intersection AND that `index.ts`'s transitive runtime import graph reaches none of
19
+ the seven modules above. `scripts/browser-barrel.test.ts` holds the end property, both directions.
20
+
7
21
  `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
8
22
  second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
9
23
  `defineRoute`.
@@ -34,7 +48,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
34
48
  | 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. |
35
49
  | 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. |
36
50
  | 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. |
37
- | `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. |
51
+ | `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 the now-deleted `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-declares an island. The field survives its one former reader: it is the only record a build has of an island a page declared but did not render on a given pass. |
38
52
  | 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. |
39
53
  | 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
54
  | An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
@@ -42,10 +56,11 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
42
56
  | Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four (`href`, `src`, `action`, `formaction`) plus `data`, `poster`, `ping` and `xlink:href`, each of which a browser FOLLOWS. `srcdoc` is refused outright: its value is entity-decoded and THEN parsed as HTML, so `escapeAttribute`'s `&lt;script&gt;` becomes a live `<script>` on this origin — escaping cannot make markup inert, so the attribute is never emitted, the same way `innerHTML` stays the one explicit escape hatch. |
43
57
  | Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
44
58
  | A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. This package's copy stopped at `kb`, so a 5 MB route read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa`'s own copy said `5mb` for the same bytes — two halves of one build disagreeing about the size of one artifact. Still on this barrel, because `@ultimat3/cli`'s budget reporter reads it beside the route table. |
45
- | 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. |
59
+ | Island bytes | **not this package's**, `As of 2026-08-23`. `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets`, `assertBudget` and the `Island` / `BundleGraph` / `RouteBytes` / `BudgetReport` types were exported from the barrel and called by NOTHING outside this package's own tests — the near-miss is `@ultimat3/cli`'s own `checkBudgets` in `packages/cli/src/budgets.ts`, which measures the EMITTED document and is the gate that runs. Deleted rather than wired, because two answers to "what does this route weigh" — one of them never asked — is axiom 1, and a build error nothing calls is not a build error. What survives here is the budget GRAMMAR (`parseByteBudget`, which the CLI does import) and `defaultIslandBudget`, which `registerRoute` reaches. **Breaking**: the six functions and five types are gone from the public API. |
46
60
  | 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)`. |
47
61
  | 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. |
48
- | 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). |
62
+ | 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` 774, `visible` 846, `interaction` 1,067 (`As of 2026-08-23`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
63
+ | A runtime that calls `boot` | TERMINATES the chain, because `boot` rethrows. `idle` and `visible` end in `.catch(hush)`; `interaction` passes `off` as the rejection arm of its `then`. A bare `boot(el)` produced a fresh rejected promise per call — one unhandled rejection per user event on an island whose `mount()` threw — and, on `interaction`, left `done` false, the listeners attached and the queue growing by one retained `Event` (each with a live `target`) per click, for an island that will never mount. Nothing is lost by swallowing here: the DOM already carries the failure as `data-x-failed`, which is the row above and the documented observable. `hydrate-runtime.test.ts` runs all three against a real module; Bun's runner fails a test on an unhandled rejection, so the omission reds the suite by itself. |
49
64
  | `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. |
50
65
  | Route truth | `registry.ts`. Never keep a second route list anywhere, and never a second *matcher*: this package's `matchRoute` was deleted in 2026-08 with zero consumers, because `@ultimat3/http`'s trie (`stages.ts`) is the one that serves requests and two matchers with different precedence rules is two answers to "which route is this?". `routeFor` is an exact-path `Map` lookup, not a pattern matcher. |
51
66
  | 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. |
@@ -68,7 +83,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
68
83
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
69
84
  | 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. |
70
85
  | 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. |
71
- | 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. |
86
+ | The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once — `index.ts` until the barrel split, and it cannot be there again: the client barrel would carry `sass` and `node:url`. 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. Anything that loads an app's `.tsx` reaches `@ultimat3/render/server` first, which is why `packages/cli/src/app-load.ts` imports it for the side effect and nothing else. |
72
87
  | `<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. |
73
88
  | 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. `escapeAttribute` itself is `@ultimat3/seo`'s (tier 1), re-exported by `html.ts` rather than reimplemented — the copy that lived here was the second escaper this row forbids, and `pwa/CLAUDE.md` already named seo's as the one. `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. |
74
89
  | 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. |
package/README.md CHANGED
@@ -280,18 +280,22 @@ are never serialized.
280
280
  const collector = createIslandCollector({ file, hydrate: config.hydrate, resolve });
281
281
  const html = await renderToHtml(page, { islands: collector });
282
282
  document.body += hydrateRuntime(collector.directives); // the one thing left to remember
283
- assertBudget(entry, measuredIslands, collector.directives);
284
283
  ```
285
284
 
286
- `routeJsBytes` reads **both** sources — what registration declared in `entry.islands` and what the
287
- render actually pulled in. Reading only the first is how a page could be charged for the hydration
288
- runtime and not for the chunk it boots: a budget that counts the wrapper and not the code.
285
+ Declaring an island is what puts a `budget.js` on the route — `defaultIslandBudget(surface)`,
286
+ applied by `registerRoute`, so a page that declares one is charged without saying so. **Weighing it
287
+ is `x verify`'s `budgets` step**, which measures the emitted document against the manifest's
288
+ per-route budget and fails with `X_BUDGET_EXCEEDED`.
289
+
290
+ **Removed `As of 2026-08-23`** (breaking): `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets`, `assertBudget` and
291
+ the `Island` / `BundleGraph` / `RouteBytes` / `BudgetReport` types. They were a second, graph-based
292
+ answer to the same question that nothing in the framework ever asked — the gate has always been the
293
+ CLI's. `parseByteBudget` (the `'40kb'` grammar) and `defaultIslandBudget` stay.
289
294
 
290
295
  `entry.islands` is filled from `config.islands` at registration and from nothing else, so a declared
291
- island is weighed even on a route no render has touched. It was `input.islands ?? []` and nothing
292
- ever passed `islands`, which left that half of the union reading nothing at all; `RegisterRouteInput`
293
- no longer carries the key, because the only thing a caller could do with it was un-weigh a
294
- declaration.
296
+ island is on the record even on a route no render has touched. It was `input.islands ?? []` and
297
+ nothing ever passed `islands`; `RegisterRouteInput` no longer carries the key, because the only
298
+ thing a caller could do with it was un-declare an island.
295
299
 
296
300
  An island on a route that resolves to `hydrate: 'never'`, or rendered with no collector, is
297
301
  `X_ISLAND_NOT_HYDRATED` — inert markup either way. With `hydrate` derived it means one of exactly
@@ -321,8 +325,32 @@ never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
321
325
  from, exported for the same reason: anything waiting for hydration has to wait at least this long,
322
326
  and a second copy of the number is a settle that shoots early and calls a healthy page broken.
323
327
 
328
+ ## Two entry points
329
+
330
+ **Split 2026-08-22, and every claim in this section holds `As of 2026-08`.**
331
+
332
+ `@ultimat3/render` is the **client** half — the `route` primitive, the JSX factory, islands,
333
+ hydration, `<head>`, the route table. It bundles for the browser, and
334
+ `scripts/browser-barrel.test.ts` builds it that way and asserts it.
335
+
336
+ `@ultimat3/render/server` is the **build-time** half — the `.tsx`/`.scss` Bun loaders and the
337
+ render pipeline. It imports `sass` and `node:url`, so it never reaches a browser bundle.
338
+
339
+ The two are **disjoint**: no name is on both, and a file needing both imports both. That is the
340
+ price of the split and it is the point of it — a single barrel could not be bundled for the
341
+ browser at all, because `node:url`'s browser polyfill exports neither `fileURLToPath` nor
342
+ `pathToFileURL` and the build fails at link time. No `sideEffects` value fixes that (measured:
343
+ `false`, `[]` and an array naming only `errors.ts` all fail identically) — only not importing it
344
+ does.
345
+
346
+ **Importing `@ultimat3/render/server` installs the `.tsx`/`.scss` loaders**, once, as a module
347
+ side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
348
+ `await import()`s a `page.tsx` — reaches it before the module it loads.
349
+
324
350
  ## Public API
325
351
 
352
+ `†` marks a name on `@ultimat3/render/server`.
353
+
326
354
  | Export | Owns |
327
355
  |---|---|
328
356
  | `defineRoute` | the `route` primitive |
@@ -330,12 +358,14 @@ and a second copy of the number is a settle that shoots early and calls a health
330
358
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
331
359
  | `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
332
360
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
333
- | `renderStatic`, `enumeratePrerender` | build-time render, content hashing |
334
- | `createIsrController`, `invalidateAndRevalidate` | SWR + single-flight + tag triggers |
335
- | `renderSsr`, `streamResult` | the per-request modes |
361
+ | `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
362
+ | `createIsrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
363
+ | `renderSsr`†, `streamResult`† | the per-request modes |
364
+ | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
365
+ | `installRenderLoader`†, `compileStylesheet`† | the `.tsx`/`.scss` loaders, installed on import |
336
366
  | `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
337
367
  | `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
338
- | `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
368
+ | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
339
369
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
340
370
 
341
371
  ## Notes
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "8.0.0",
3
+ "version": "10.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
7
  "sideEffects": [
8
8
  "./src/errors.ts",
9
- "./src/index.ts"
9
+ "./src/server.ts"
10
10
  ],
11
11
  "repository": {
12
12
  "type": "git",
@@ -18,7 +18,8 @@
18
18
  "provenance": true
19
19
  },
20
20
  "exports": {
21
- ".": "./src/index.ts"
21
+ ".": "./src/index.ts",
22
+ "./server": "./src/server.ts"
22
23
  },
23
24
  "files": [
24
25
  "src",
@@ -35,10 +36,10 @@
35
36
  "test": "bun test"
36
37
  },
37
38
  "dependencies": {
38
- "@ultimat3/cache": "8.0.0",
39
- "@ultimat3/core": "8.0.0",
40
- "@ultimat3/i18n": "8.0.0",
41
- "@ultimat3/seo": "8.0.0",
39
+ "@ultimat3/cache": "10.0.0",
40
+ "@ultimat3/core": "10.0.0",
41
+ "@ultimat3/i18n": "10.0.0",
42
+ "@ultimat3/seo": "10.0.0",
42
43
  "sass": "1.102.0"
43
44
  }
44
45
  }
package/src/errors.ts CHANGED
@@ -54,7 +54,12 @@ registerErrorCodes(
54
54
  Object.fromEntries(Object.entries(RENDER_ERROR_TITLES).map(([code, title]) => [code, { title }])),
55
55
  );
56
56
 
57
- const docsFor = (code: RenderErrorCode): string => `https://ultimate.dev/errors/${code}`;
57
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
58
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
59
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
60
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
61
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
62
+ // would be the same constant in eight places waiting to drift again.
58
63
 
59
64
  /** A render mode's invariant was violated at registration (see `modes.ts`). */
60
65
  export class RouteModeInvalidError extends UltimateError {
@@ -64,7 +69,6 @@ export class RouteModeInvalidError extends UltimateError {
64
69
  code: RouteModeInvalidError.code,
65
70
  cause,
66
71
  fix,
67
- docs: docsFor(RouteModeInvalidError.code),
68
72
  });
69
73
  }
70
74
  }
@@ -77,7 +81,6 @@ export class RouteOfflineMissingError extends UltimateError {
77
81
  code: RouteOfflineMissingError.code,
78
82
  cause,
79
83
  fix,
80
- docs: docsFor(RouteOfflineMissingError.code),
81
84
  });
82
85
  }
83
86
  }
@@ -90,7 +93,6 @@ export class RouteMetaMissingError extends UltimateError {
90
93
  code: RouteMetaMissingError.code,
91
94
  cause,
92
95
  fix,
93
- docs: docsFor(RouteMetaMissingError.code),
94
96
  });
95
97
  }
96
98
  }
@@ -107,7 +109,6 @@ export class RouteUnnormalizedError extends UltimateError {
107
109
  code: RouteUnnormalizedError.code,
108
110
  cause,
109
111
  fix,
110
- docs: docsFor(RouteUnnormalizedError.code),
111
112
  });
112
113
  }
113
114
  }
@@ -120,7 +121,6 @@ export class RouteDuplicateError extends UltimateError {
120
121
  code: RouteDuplicateError.code,
121
122
  cause,
122
123
  fix,
123
- docs: docsFor(RouteDuplicateError.code),
124
124
  });
125
125
  }
126
126
  }
@@ -138,7 +138,6 @@ export class RouteFileInvalidError extends UltimateError {
138
138
  code: RouteFileInvalidError.code,
139
139
  cause,
140
140
  fix,
141
- docs: docsFor(RouteFileInvalidError.code),
142
141
  });
143
142
  }
144
143
  }
@@ -151,7 +150,6 @@ export class SurfaceBoundaryError extends UltimateError {
151
150
  code: SurfaceBoundaryError.code,
152
151
  cause,
153
152
  fix,
154
- docs: docsFor(SurfaceBoundaryError.code),
155
153
  });
156
154
  }
157
155
  }
@@ -164,7 +162,6 @@ export class BudgetExceededError extends UltimateError {
164
162
  code: BudgetExceededError.code,
165
163
  cause,
166
164
  fix,
167
- docs: docsFor(BudgetExceededError.code),
168
165
  });
169
166
  }
170
167
  }
@@ -177,7 +174,6 @@ export class PrerenderFailedError extends UltimateError {
177
174
  code: PrerenderFailedError.code,
178
175
  cause,
179
176
  fix,
180
- docs: docsFor(PrerenderFailedError.code),
181
177
  });
182
178
  }
183
179
  }
@@ -190,7 +186,6 @@ export class RouteLoadInvalidError extends UltimateError {
190
186
  code: RouteLoadInvalidError.code,
191
187
  cause,
192
188
  fix,
193
- docs: docsFor(RouteLoadInvalidError.code),
194
189
  });
195
190
  }
196
191
  }
@@ -207,7 +202,6 @@ export class IslandInvalidError extends UltimateError {
207
202
  code: IslandInvalidError.code,
208
203
  cause,
209
204
  fix,
210
- docs: docsFor(IslandInvalidError.code),
211
205
  });
212
206
  }
213
207
  }
@@ -224,7 +218,6 @@ export class IslandPropsInvalidError extends UltimateError {
224
218
  code: IslandPropsInvalidError.code,
225
219
  cause,
226
220
  fix,
227
- docs: docsFor(IslandPropsInvalidError.code),
228
221
  });
229
222
  }
230
223
  }
@@ -242,7 +235,6 @@ export class IslandNotHydratedError extends UltimateError {
242
235
  code: IslandNotHydratedError.code,
243
236
  cause,
244
237
  fix,
245
- docs: docsFor(IslandNotHydratedError.code),
246
238
  });
247
239
  }
248
240
  }
@@ -259,7 +251,6 @@ export class RouteLoadFailedError extends UltimateError {
259
251
  code: RouteLoadFailedError.code,
260
252
  cause,
261
253
  fix,
262
- docs: docsFor(RouteLoadFailedError.code),
263
254
  });
264
255
  }
265
256
  }
package/src/hydrate.ts CHANGED
@@ -122,31 +122,44 @@ return el.__x=import(e).then(function(m){return m.mount(el,props)}).then(
122
122
  function(r){el.setAttribute('${ISLAND_MOUNTED_ATTRIBUTE}','');return r},
123
123
  function(x){el.setAttribute('${ISLAND_FAILED_ATTRIBUTE}',x&&x.message||'1');throw x})}
124
124
  function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
125
+ function hush(){}
125
126
  `.trim();
127
+ // `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
128
+ // the page reports an unhandled rejection for a failure it already recorded on the element.
126
129
 
127
130
  const RUNTIME_IDLE = `
128
131
  each('[data-x-hydrate="idle"]',function(el){
129
- var go=function(){boot(el)};
132
+ var go=function(){boot(el).catch(hush)};
130
133
  if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
131
134
  `.trim();
132
135
 
133
136
  const RUNTIME_VISIBLE = `
134
137
  each('[data-x-hydrate="visible"]',function(el){
135
138
  var io=new IntersectionObserver(function(es){es.forEach(function(en){
136
- if(en.isIntersecting){io.disconnect();boot(el)}})},{rootMargin:el.getAttribute('data-x-margin')||'200px'});
139
+ if(en.isIntersecting){io.disconnect();boot(el).catch(hush)}})},{rootMargin:el.getAttribute('data-x-margin')||'200px'});
137
140
  io.observe(el)})
138
141
  `.trim();
139
142
 
140
143
  // Event replay: the listener is registered before the chunk exists, records the event that
141
144
  // woke the island, and re-dispatches it once mounted. Without this, the first click on a
142
145
  // cold island is silently lost — the failure users read as "the button does nothing".
146
+ //
147
+ // `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
148
+ // `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
149
+ // first failed mount onward — and a `.then` with no rejection handler makes a fresh rejected
150
+ // promise out of it on EVERY event, i.e. one unhandled rejection per user click, forever. The
151
+ // queue was the second half: nothing ever set `done`, so the listeners stayed attached and `q`
152
+ // grew by one retained `Event` — each holding a live `target` — per click, for an island that
153
+ // will never mount. Swallowing here loses no signal: the DOM already carries the failure as
154
+ // `data-x-failed`, which is the documented observable.
143
155
  const RUNTIME_INTERACTION = `
144
156
  each('[data-x-hydrate="interaction"]',function(el){
145
157
  var evs=(el.getAttribute('data-x-events')||'click').split(' ');
146
158
  var q=[],done=false;
159
+ var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
147
160
  var on=function(ev){if(done)return;q.push(ev);
148
- boot(el).then(function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});
149
- q.forEach(function(ev){var c=new ev.constructor(ev.type,ev);ev.target.dispatchEvent(c)});q=[]})};
161
+ boot(el).then(function(){var r=q;off();
162
+ r.forEach(function(ev){var c=new ev.constructor(ev.type,ev);ev.target.dispatchEvent(c)})},off)};
150
163
  evs.forEach(function(n){el.addEventListener(n,on,true)})})
151
164
  `.trim();
152
165
 
package/src/index.ts CHANGED
@@ -1,13 +1,8 @@
1
- /** Public API of `@ultimat3/render`: the `route` primitive, the five modes, the table. */
2
-
3
- import { installRenderLoader } from './module-loader';
4
-
5
- // A side effect on import, deliberately: a Bun plugin only transforms modules loaded AFTER it, and
6
- // every consumer that will ever load a `.tsx` route or a `.scss` module imports this package first
7
- // (an app's route file imports `defineRoute` from here before it imports anything else it owns).
8
- // Any later hook — `x dev`, `x build`, `server.ts` — would each have to remember, which is four
9
- // places one fact can be wrong instead of none.
10
- installRenderLoader();
1
+ /**
2
+ * The CLIENT half of `@ultimat3/render` — the `route` primitive, the JSX factory, islands, and the
3
+ * tables describing them — kept disjoint from `@ultimat3/render/server` because everything here
4
+ * must bundle for a browser, which the loaders cannot (axiom 6).
5
+ */
11
6
 
12
7
  /**
13
8
  * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
@@ -21,8 +16,6 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
21
16
  // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
22
17
  // table it prints against.
23
18
  export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
24
- export type { CompiledStylesheet } from './css-modules';
25
- export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
26
19
  export { parseTtlMs } from './duration';
27
20
  export type { RenderErrorCode } from './errors';
28
21
  export {
@@ -84,15 +77,7 @@ export type { IslandCollector, IslandCollectorInput } from './island-collector';
84
77
  export { createIslandCollector, islandModuleIds } from './island-collector';
85
78
  export type { IslandProps, JsonValue } from './island-props';
86
79
  export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
87
- export type { BudgetReport, BundleGraph, GraphName, Island, RouteBytes } from './islands';
88
- export {
89
- assertBudget,
90
- checkBudget,
91
- checkBudgets,
92
- graphFor,
93
- parseByteBudget,
94
- routeJsBytes,
95
- } from './islands';
80
+ export { parseByteBudget } from './islands';
96
81
  export type { JsxComponent, JsxNode, JsxProps } from './jsx';
97
82
  export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
98
83
  export type { ModeCheckContext, ModeSpec, RouteShape } from './modes';
@@ -104,15 +89,6 @@ export {
104
89
  defaultIslandBudget,
105
90
  MODE_SPECS,
106
91
  } from './modes';
107
- export type { Stylesheet } from './module-loader';
108
- export {
109
- clearStylesheets,
110
- installRenderLoader,
111
- loadStylesheet,
112
- registeredStylesheets,
113
- stylesFor,
114
- transformTsx,
115
- } from './module-loader';
116
92
  export type {
117
93
  CompiledPattern,
118
94
  RegisterRouteInput,
@@ -130,48 +106,6 @@ export {
130
106
  routeFor,
131
107
  routePathFromFile,
132
108
  } from './registry';
133
- export type { RenderHtmlOptions } from './render-html';
134
- export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
135
- export type {
136
- IsrController,
137
- IsrControllerOptions,
138
- IsrEntry,
139
- IsrRenderFn,
140
- IsrServeResult,
141
- IsrState,
142
- IsrStore,
143
- MemoryIsrStoreOptions,
144
- } from './render-isr';
145
- export {
146
- createIsrController,
147
- DEFAULT_ISR_MAX_ENTRIES,
148
- invalidateAndRevalidate,
149
- isrKey,
150
- memoryIsrStore,
151
- } from './render-isr';
152
- export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
153
- export { renderSsr, ssrHeaders } from './render-ssr';
154
- export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
155
- export {
156
- assertNoPerRequestState,
157
- contentHash,
158
- enumeratePrerender,
159
- fillPath,
160
- renderStatic,
161
- staticHeaders,
162
- staticResult,
163
- } from './render-static';
164
- export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
165
- export {
166
- collectStream,
167
- DEFAULT_HOLE_TIMEOUT_MS,
168
- holeId,
169
- holeMarker,
170
- REVEAL_SCRIPT,
171
- renderStreamHtml,
172
- revealChunk,
173
- streamResult,
174
- } from './render-stream';
175
109
  export type {
176
110
  LoadRequirement,
177
111
  PrerenderFn,
package/src/islands.ts CHANGED
@@ -1,50 +1,19 @@
1
1
  /**
2
- * Island boundaries and the two separate bundle graphs. `site/` and `app/` never share a
3
- * graph: that is the mechanical half of axiom 6 (the surface boundary in `surfaces.ts` is
4
- * the other half). `site/` starts at a 0kb baseline and every byte after that is an
5
- * opt-in, budgeted island.
2
+ * How a byte budget is WRITTEN — `'40kb'` — and nothing else.
3
+ *
4
+ * It was the whole graph-based budget API: `Island`, `BundleGraph`, `graphFor`, `routeJsBytes`,
5
+ * `checkBudget`, `checkBudgets`, `assertBudget`. Every one of them was exported from the barrel and
6
+ * called by nothing outside this package's own tests. The real gate is `@ultimat3/cli`'s
7
+ * `checkBudgets` (`packages/cli/src/budgets.ts`), which measures the EMITTED document against the
8
+ * manifest's per-route `budget` and has its own `parseByteBudget` caller — the near-miss that made
9
+ * the dead half look reached. Deleted 2026-08-23 rather than wired: two answers to "what does this
10
+ * route weigh", one of which never ran, is the ambiguity axiom 1 refuses, and a build error nothing
11
+ * calls is not a build error.
12
+ *
13
+ * `defaultIslandBudget` in `modes.ts` is the surviving half of the island-budget story: it is
14
+ * reached, through `registerRoute`, and it is what puts a number on a route that declares none.
6
15
  */
7
16
 
8
- import type { HydrateStrategy } from '@ultimat3/core';
9
- // One formatter, in `@ultimat3/core`: the copy that lived here had no `mb` branch, so a 5 MB route
10
- // read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa` said `5mb` for the same bytes.
11
- import { formatBytes } from '@ultimat3/core';
12
- import { BudgetExceededError } from './errors';
13
- import type { IslandDirective } from './hydrate';
14
- import { hydrateRuntimeBytes } from './hydrate';
15
- import type { RouteEntry } from './registry';
16
- import type { Surface } from './surfaces';
17
- import { SURFACE_SPECS } from './surfaces';
18
-
19
- /** A bundle graph is per surface. There are exactly two that ship JS: `site` and `app`. */
20
- export type GraphName = 'site' | 'app';
21
-
22
- export interface Island {
23
- readonly id: string;
24
- readonly file: string;
25
- readonly graph: GraphName;
26
- readonly strategy: HydrateStrategy;
27
- /** Measured from the real bundle, gzip-before-brotli agnostic: raw bytes. */
28
- readonly bytes: number;
29
- /** The import chain that pulled the heaviest dependency in, for error messages. */
30
- readonly heaviestChain?: readonly string[];
31
- }
32
-
33
- export interface BundleGraph {
34
- readonly name: GraphName;
35
- readonly baselineBytes: number;
36
- readonly islands: readonly Island[];
37
- }
38
-
39
- export function graphFor(surface: Surface, islands: readonly Island[]): BundleGraph {
40
- const name: GraphName = surface === 'site' ? 'site' : 'app';
41
- return {
42
- name,
43
- baselineBytes: SURFACE_SPECS[name].jsBaselineBytes,
44
- islands: islands.filter((island) => island.graph === name && island.strategy !== 'never'),
45
- };
46
- }
47
-
48
17
  const UNITS: Readonly<Record<string, number>> = { b: 1, kb: 1024, mb: 1024 * 1024 };
49
18
 
50
19
  /** `'40kb'` → 40960. Throws nothing: an unparseable budget is `null` and skipped. */
@@ -57,120 +26,3 @@ export function parseByteBudget(budget: string | undefined): number | null {
57
26
  const factor = UNITS[unit];
58
27
  return factor === undefined ? null : Math.round(Number(amount) * factor);
59
28
  }
60
-
61
- export interface RouteBytes {
62
- readonly total: number;
63
- readonly baseline: number;
64
- readonly islandBytes: number;
65
- readonly runtimeBytes: number;
66
- readonly heaviest: Island | null;
67
- }
68
-
69
- export function routeJsBytes(
70
- entry: RouteEntry,
71
- islands: readonly Island[],
72
- directives: readonly IslandDirective[] = [],
73
- ): RouteBytes {
74
- const graph = graphFor(entry.surface, islands);
75
- // Two sources, unioned: `entry.islands` is what registration declared, and the directives are
76
- // what the page actually rendered. Reading only the first is how the runtime bytes of an island
77
- // could be charged while its chunk was not — a budget that counts the wrapper and not the code.
78
- const onRoute = graph.islands.filter(
79
- (island) =>
80
- entry.islands.includes(island.id) ||
81
- directives.some((directive) => (directive.moduleId ?? directive.islandId) === island.id),
82
- );
83
- const islandBytes = onRoute.reduce((sum, island) => sum + island.bytes, 0);
84
- const runtimeBytes = directives.length > 0 ? hydrateRuntimeBytes(directives) : 0;
85
- const baseline = islandBytes === 0 && runtimeBytes === 0 ? 0 : graph.baselineBytes;
86
- const heaviest = onRoute.reduce<Island | null>(
87
- (max, island) => (max === null || island.bytes > max.bytes ? island : max),
88
- null,
89
- );
90
- return {
91
- total: baseline + islandBytes + runtimeBytes,
92
- baseline,
93
- islandBytes,
94
- runtimeBytes,
95
- heaviest,
96
- };
97
- }
98
-
99
- export interface BudgetReport {
100
- readonly path: string;
101
- readonly measured: number;
102
- readonly limit: number | null;
103
- readonly ok: boolean;
104
- readonly cause: string | null;
105
- }
106
-
107
- /**
108
- * The build gate. Failure names the *cause* — the island and the transitive import that
109
- * added the bytes — because "bundle too big" without a chain is not an instruction.
110
- */
111
- export function checkBudget(
112
- entry: RouteEntry,
113
- islands: readonly Island[],
114
- directives: readonly IslandDirective[] = [],
115
- ): BudgetReport {
116
- const bytes = routeJsBytes(entry, islands, directives);
117
- const limit = parseByteBudget(entry.config.budget.js);
118
-
119
- // site/ has a 0kb default: shipping JS there without declaring a budget is the failure.
120
- if (limit === null && entry.surface === 'site' && bytes.total > 0) {
121
- return {
122
- path: entry.path,
123
- measured: bytes.total,
124
- limit: 0,
125
- ok: false,
126
- cause:
127
- `${entry.path} is in site/ (0kb JS baseline) and ships ${formatBytes(bytes.total)} ` +
128
- `with no budget.js${chainOf(bytes.heaviest)}`,
129
- };
130
- }
131
-
132
- if (limit === null) {
133
- return { path: entry.path, measured: bytes.total, limit: null, ok: true, cause: null };
134
- }
135
-
136
- const ok = bytes.total <= limit;
137
- return {
138
- path: entry.path,
139
- measured: bytes.total,
140
- limit,
141
- ok,
142
- cause: ok
143
- ? null
144
- : `${entry.path} js ${formatBytes(bytes.total)} > ${formatBytes(limit)}${chainOf(bytes.heaviest)}`,
145
- };
146
- }
147
-
148
- function chainOf(island: Island | null): string {
149
- if (island === null) return '';
150
- const chain = island.heaviestChain;
151
- if (chain === undefined || chain.length === 0) return ` (heaviest island: ${island.file})`;
152
- return ` (${chain.join(' → ')})`;
153
- }
154
-
155
- export function assertBudget(
156
- entry: RouteEntry,
157
- islands: readonly Island[],
158
- directives: readonly IslandDirective[] = [],
159
- ): void {
160
- const report = checkBudget(entry, islands, directives);
161
- if (report.ok || report.cause === null) return;
162
- throw new BudgetExceededError(
163
- report.cause,
164
- `raise budget.js in ${entry.file} deliberately, or remove the import that added the bytes`,
165
- );
166
- }
167
-
168
- /** `x verify` prints every route, not just the first failure. */
169
- export function checkBudgets(
170
- entries: readonly RouteEntry[],
171
- islands: readonly Island[],
172
- ): readonly BudgetReport[] {
173
- return entries
174
- .map((entry) => checkBudget(entry, islands))
175
- .sort((a, b) => a.path.localeCompare(b.path));
176
- }
package/src/modes.ts CHANGED
@@ -225,18 +225,21 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
225
225
  * (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
226
226
  * any surface, because the allowance is measured above the baseline and not against it.
227
227
  *
228
- * The number: 17,797 (the heaviest island this repo actually ships) + 1,010 (`hydrateRuntimeBytes`
228
+ * The number: 17,797 (the heaviest island this repo actually ships) + 1,067 (`hydrateRuntimeBytes`
229
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
230
+ * at `:253` to any island route declaring no `hydrate`) = **18,864**. That is the worst case an
231
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
232
+ * kilobyte above it is 19,456 — it is one whole kB further, leaving 1,616 B of headroom and still
233
+ * under 2x 18,864, so a route bundling the same island twice is refused. `island-budget.test.ts`
234
+ * asserts all three. `idle` costs 774 and `visible` 846, so an island route that declares its
235
235
  * strategy pays less; the default is what the budget has to clear.
236
236
  *
237
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
238
+ * mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
239
+ * again on 2026-08-23 — +18 B in the shared prelude (`hush`) for all three, +39 B more on
240
+ * `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
241
+ * than emit one unhandled rejection per user event. The headroom absorbed both and the conclusion
242
+ * is unchanged, which is the point of stating the
240
243
  * arithmetic here rather than the answer alone. It is not
241
244
  * derived from Solid's own size on purpose — this package may not import or name `solid-js`
242
245
  * (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
package/src/registry.ts CHANGED
@@ -269,9 +269,11 @@ export function registerRoute<TData = RouteData>(
269
269
  config,
270
270
  suspenseBoundaries,
271
271
  // The declaration is the ONLY source. It was `input.islands ?? []`, which nothing ever passed,
272
- // so `routeJsBytes`'s "what registration declared" half read `[]` on every route in the
273
- // framework's history — and keeping the input as a fallback would be a second answer to one
274
- // question that can only ever weaken it: a caller passing `[]` un-weighs a declared island.
272
+ // so the now-deleted `routeJsBytes`'s "what registration declared" half read `[]` on every
273
+ // route in the framework's history — and keeping the input as a fallback would be a second
274
+ // answer to one question that can only ever weaken it: a caller passing `[]` un-declares an
275
+ // island. The field outlives that reader: it is the only record a build has of an island a
276
+ // page declared but did not render on a given pass.
275
277
  islands: config.islands.map((spec) => spec.moduleId),
276
278
  pattern: compilePattern(path),
277
279
  // Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
@@ -30,6 +30,20 @@ export const ROOT_ELEMENT_ID = 'x-root';
30
30
  /** Depth is bounded so a component that renders itself fails with a cause instead of a stack trace. */
31
31
  const MAX_DEPTH = 500;
32
32
 
33
+ /**
34
+ * Asserted at the top of `unwrap`, not only in `renderNode`. The element path was the only one
35
+ * bounded, and it is not the only one that recurses: `unwrap` walks arrays and calls thunks, both
36
+ * of which a component's `children` routinely are, so a self-referencing array or a self-returning
37
+ * accessor escaped `renderToHtml` as a bare `RangeError` — no code, no `fix:`, nothing to catch by.
38
+ */
39
+ function assertDepth(depth: number): void {
40
+ if (depth <= MAX_DEPTH) return;
41
+ throw new PrerenderFailedError(
42
+ `component tree exceeded ${MAX_DEPTH} levels, so it renders itself`,
43
+ 'remove the self-reference from the component that renders its own tag',
44
+ );
45
+ }
46
+
33
47
  /**
34
48
  * What the walk carries besides depth. One object per render, never module-global: two concurrent
35
49
  * requests render different params, and a shared collector would bill one page for the other's JS.
@@ -41,8 +55,12 @@ export interface RenderHtmlOptions {
41
55
  /**
42
56
  * A thunk is called, not stringified. Solid's reactive reads are accessors (`count()`), and a
43
57
  * `children` prop is routinely a function — evaluating it once is exactly the server's job.
58
+ *
59
+ * Which is also why the depth bound belongs HERE and not only on the element path: the array and
60
+ * thunk branches below recurse, and both are ordinary shapes for a component's children.
44
61
  */
45
62
  async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
63
+ assertDepth(depth);
46
64
  if (value === null || value === undefined || value === false || value === true) return '';
47
65
  if (typeof value === 'string') return escapeText(value);
48
66
  if (typeof value === 'number' || typeof value === 'bigint') return escapeText(String(value));
@@ -106,12 +124,7 @@ async function renderNode(
106
124
  depth: number,
107
125
  walk: RenderHtmlOptions,
108
126
  ): Promise<string> {
109
- if (depth > MAX_DEPTH) {
110
- throw new PrerenderFailedError(
111
- `component tree exceeded ${MAX_DEPTH} levels, so it renders itself`,
112
- 'remove the self-reference from the component that renders its own tag',
113
- );
114
- }
127
+ assertDepth(depth);
115
128
  if (typeof type === 'string') return renderElement(type, props, depth, walk);
116
129
  return unwrap(type(props), depth + 1, walk);
117
130
  }
package/src/server.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The BUILD-TIME half of `@ultimat3/render` — the loaders and the route → bytes pipeline — split
3
+ * off because `css-modules.ts` imports `node:url`, whose browser polyfill exports neither name it
4
+ * asks for: one barrel carrying both halves could not be bundled for a browser at all (axiom 6).
5
+ * Disjoint from `@ultimat3/render` by construction, which `server.test.ts` checks.
6
+ */
7
+
8
+ import { installRenderLoader } from './module-loader';
9
+
10
+ // A side effect on import, deliberately, and the reason this barrel is named in `sideEffects`: a
11
+ // Bun plugin only transforms modules loaded AFTER it, so the install has to happen before any
12
+ // `.tsx` route or `.scss` module is imported. It lives on THIS barrel rather than on
13
+ // `@ultimat3/render` because the loader is build-time code — a browser bundle that reached it
14
+ // would carry `sass` and `node:fs`, which is the defect this split closes.
15
+ installRenderLoader();
16
+
17
+ // ---- scss → css, and the scoped class map every `import styles from` receives -------------------
18
+ export type { CompiledStylesheet } from './css-modules';
19
+ export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
20
+ // ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
21
+ export type { Stylesheet } from './module-loader';
22
+ export {
23
+ clearStylesheets,
24
+ installRenderLoader,
25
+ loadStylesheet,
26
+ registeredStylesheets,
27
+ stylesFor,
28
+ transformTsx,
29
+ } from './module-loader';
30
+ // ---- the render pipeline: one entry point per mode ----------------------------------------------
31
+ export type { RenderHtmlOptions } from './render-html';
32
+ export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
33
+ export type {
34
+ IsrController,
35
+ IsrControllerOptions,
36
+ IsrEntry,
37
+ IsrRenderFn,
38
+ IsrServeResult,
39
+ IsrState,
40
+ IsrStore,
41
+ MemoryIsrStoreOptions,
42
+ } from './render-isr';
43
+ export {
44
+ createIsrController,
45
+ DEFAULT_ISR_MAX_ENTRIES,
46
+ invalidateAndRevalidate,
47
+ isrKey,
48
+ memoryIsrStore,
49
+ } from './render-isr';
50
+ export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
51
+ export { renderSsr, ssrHeaders } from './render-ssr';
52
+ export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
53
+ export {
54
+ assertNoPerRequestState,
55
+ contentHash,
56
+ enumeratePrerender,
57
+ fillPath,
58
+ renderStatic,
59
+ staticHeaders,
60
+ staticResult,
61
+ } from './render-static';
62
+ export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
63
+ export {
64
+ collectStream,
65
+ DEFAULT_HOLE_TIMEOUT_MS,
66
+ holeId,
67
+ holeMarker,
68
+ REVEAL_SCRIPT,
69
+ renderStreamHtml,
70
+ revealChunk,
71
+ streamResult,
72
+ } from './render-stream';