@ultimat3/render 2.0.0 → 4.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
@@ -33,6 +33,9 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
33
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. |
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
+ | 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. |
37
+ | 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. |
38
+ | 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. |
36
39
  | 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. |
37
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. |
38
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)`. |
@@ -44,18 +47,22 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
44
47
  | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
45
48
  | Stream cancellation | the underlying source has a `cancel()`, and `write` is guarded on it. A client that disconnects mid-stream aborts `StreamHole.resolve(signal)` and every later `write`/`close` is a no-op — `settle()` on a cancelled controller threw out of a `void`ed promise, one unhandled rejection per response, while the resolved holes kept doing their database work with nowhere to write. |
46
49
  | ISR detach | `attach()`'s returned function clears the revalidator as well as the dependents — and only if the slot is still its own, tracked in `installedRevalidator` because `@ultimat3/cache` holds ONE and offers no read back. Left installed, a detached controller and its whole store stayed reachable and kept receiving revalidations while the live one's pages never went stale. |
50
+ | "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below both `modes.ts` and `render-isr.ts` (importing the latter from the former is a cycle through `registry.ts`). `hasRevalidateTrigger` accepted any non-empty string, so `revalidate: { ttl: '5 minutes' }` passed registration and parsed to `null` at serve time: generated ONCE, served for the life of the process, while the CDN was told `s-maxage=60` — the exact costume the `isr`-needs-a-trigger check refuses. |
51
+ | A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts` — never `error instanceof Error ? error.message : String(error)`. A component that throws `Object.create(null)` escaped as a bare `TypeError` and one whose `message` getter throws as a bare `Error`, where `X_PRERENDER_FAILED` naming the file belongs. `bun run error-render` does NOT see this shape: it follows a direct interpolation, not a value laundered through a file-local `describe()` helper. |
52
+ | `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. |
47
53
  | 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. |
48
54
  | 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. |
49
- | An undecodable path segment | not a match, never a throw. `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. |
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. |
50
56
  | 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. |
51
57
  | 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. |
52
58
  | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
53
59
  | 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. |
54
61
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
55
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. |
56
63
  | 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. |
57
64
  | `<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. |
58
- | Escaping | `html.ts` only. 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. |
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. |
59
66
  | 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. |
60
67
  | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
61
68
  | 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
@@ -101,7 +101,7 @@ a hydrating `site/` route below.
101
101
  | Mode | Invariant | Error if violated |
102
102
  |---|---|---|
103
103
  | `static` | no per-request state — no `policy`, no `revalidate` | `X_ROUTE_MODE_INVALID` |
104
- | `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl` | `X_ROUTE_MODE_INVALID` |
104
+ | `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
105
  | `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
106
106
  | `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
107
107
  | `spa` | requires a `policy` (authed dashboards only) | `X_ROUTE_MODE_INVALID` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "2.0.0",
3
+ "version": "4.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",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/cache": "2.0.0",
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/i18n": "2.0.0",
37
- "@ultimat3/seo": "2.0.0",
34
+ "@ultimat3/cache": "4.0.0",
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/i18n": "4.0.0",
37
+ "@ultimat3/seo": "4.0.0",
38
38
  "sass": "1.102.0"
39
39
  }
40
40
  }
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'node:fs';
8
8
  import { basename, dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import { renderThrowable } from '@ultimat3/core';
10
11
  import * as sass from 'sass';
11
12
  import { PrerenderFailedError } from './errors';
12
13
  import { contentHash } from './render-static';
@@ -124,7 +125,10 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
124
125
  style: 'compressed',
125
126
  }).css;
126
127
  } catch (error) {
127
- const first = error instanceof Error ? error.message.split('\n')[0] : String(error);
128
+ // `renderThrowable`, never `.message`/`String()`: an importer, a plugin or a future Sass
129
+ // release can throw a value whose own read raises, and this frame is what turns a failed
130
+ // compile into `X_PRERENDER_FAILED` naming the file — a laundered read leaves it a bare one.
131
+ const first = renderThrowable(error).split('\n')[0];
128
132
  throw new PrerenderFailedError(
129
133
  `${file} did not compile: ${first}`,
130
134
  `edit ${file}: ${TOKEN_FIX}`,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The one reader of a TTL string. It sits below both `modes.ts` (which asks "is this a TTL at
3
+ * all?" when it refuses an `isr` route with no regeneration trigger) and `render-isr.ts` (which
4
+ * asks "how many ms?"), because a second answer is what let `revalidate: { ttl: '5 minutes' }`
5
+ * pass registration and then parse to `null` — a page generated once and served for the life of
6
+ * the process while the CDN was told `s-maxage=60`.
7
+ */
8
+
9
+ const DURATION_UNITS: Readonly<Record<string, number>> = {
10
+ ms: 1,
11
+ s: 1_000,
12
+ m: 60_000,
13
+ h: 3_600_000,
14
+ d: 86_400_000,
15
+ };
16
+
17
+ /** `'5m'` → 300000. Numbers pass through as milliseconds. */
18
+ export function parseTtlMs(ttl: string | number | null | undefined): number | null {
19
+ if (ttl === null || ttl === undefined) return null;
20
+ if (typeof ttl === 'number') return Number.isFinite(ttl) && ttl > 0 ? ttl : null;
21
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(ttl.trim());
22
+ const amount = match?.[1];
23
+ const unit = match?.[2];
24
+ if (amount === undefined || unit === undefined) return null;
25
+ const factor = DURATION_UNITS[unit];
26
+ return factor === undefined ? null : Number(amount) * factor;
27
+ }
package/src/head.ts CHANGED
@@ -134,10 +134,20 @@ function contentOf(tag: HeadTag, raw: string): string {
134
134
  return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
135
135
  }
136
136
 
137
- /** `application/ld+json`, `application/json`, any `…+json`: the body is data, not code. */
137
+ /**
138
+ * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code.
139
+ *
140
+ * The MIME parameter is cut before the suffix test, because a real document writes
141
+ * `type="application/ld+json; charset=utf-8"` and that does not end in `json` — so the block built
142
+ * from route data, which is the path attacker text takes, silently took the raw-text escaper
143
+ * instead of the total one. `</` is escaped either way, so this was a weakened boundary rather than
144
+ * a break-out; the JSON rule is total on purpose, and a `charset` is not a reason to leave it.
145
+ */
138
146
  function carriesJson(tag: HeadTag): boolean {
139
- const type = tag.attrs?.['type'];
140
- return typeof type === 'string' && type.trim().toLowerCase().endsWith('json');
147
+ const declared = tag.attrs?.['type'];
148
+ if (typeof declared !== 'string') return false;
149
+ const [type = ''] = declared.split(';');
150
+ return type.trim().toLowerCase().endsWith('json');
141
151
  }
142
152
 
143
153
  export interface ThemeScriptOptions {
@@ -158,10 +168,14 @@ export const THEME_SCRIPT_MAX_BYTES = 512;
158
168
  export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
159
169
  const attribute = options.attribute ?? 'data-theme';
160
170
  const storageKey = options.storageKey ?? 'x-theme';
171
+ // `JSON.stringify`, never a value pasted between two quotes: both options land INSIDE a JS
172
+ // string in a `<script>` body, where one `"` ends the string and the rest is code the page runs.
173
+ // Author-supplied today — the same status every `emitIslandAttributes` value had before it was
174
+ // routed through `html.ts`. The element's own raw-text rule is applied by `renderTag` below.
161
175
  const source =
162
- `try{var t=localStorage.getItem("${storageKey}")||` +
176
+ `try{var t=localStorage.getItem(${JSON.stringify(storageKey)})||` +
163
177
  `(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");` +
164
- `document.documentElement.setAttribute("${attribute}",t)}catch(e){}`;
178
+ `document.documentElement.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`;
165
179
 
166
180
  const bytes = new TextEncoder().encode(source).byteLength;
167
181
  const cap = options.maxBytes ?? THEME_SCRIPT_MAX_BYTES;
package/src/html.ts CHANGED
@@ -72,11 +72,15 @@ export function escapeJsonContent(json: string): string {
72
72
  * JSX prop name → attribute name. Solid authors write the HTML spelling (`class`, `for`), but the
73
73
  * React spellings compile too, and an author who writes one and gets no attribute has a bug with
74
74
  * no error message.
75
+ *
76
+ * A `Map` like the two sets above, never a record: an object lookup walks the prototype chain, so
77
+ * `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and the URL
78
+ * check below called `.toLowerCase()` on it — a bare TypeError, no code, no fix, and the page 500s.
75
79
  */
76
- const ATTRIBUTE_ALIASES: Readonly<Record<string, string>> = {
77
- className: 'class',
78
- htmlFor: 'for',
79
- };
80
+ const ATTRIBUTE_ALIASES: ReadonlyMap<string, string> = new Map([
81
+ ['className', 'class'],
82
+ ['htmlFor', 'for'],
83
+ ]);
80
84
 
81
85
  /** Props the tree consumes rather than emits. `innerHTML` is emitted as content, not an attribute. */
82
86
  const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
@@ -87,6 +91,30 @@ const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
87
91
  'textContent',
88
92
  ]);
89
93
 
94
+ /**
95
+ * Attributes a browser FOLLOWS, beyond the four `@ultimat3/core` names for the anchor case. Kept
96
+ * here rather than in `safe-url.ts` only because that file is another package's; the scheme check
97
+ * is one rule and every attribute a scheme can execute from belongs under it. `data` is
98
+ * `<object data>`, `poster` is `<video poster>`, `xlink:href` executes on click inside inline SVG,
99
+ * and `ping` is a URL the browser POSTs to on activation.
100
+ */
101
+ const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
102
+ ...URL_ATTRIBUTES,
103
+ 'data',
104
+ 'ping',
105
+ 'poster',
106
+ 'xlink:href',
107
+ ]);
108
+
109
+ /**
110
+ * Attributes that carry MARKUP, not text or a URL, and are therefore never emitted. `srcdoc` is
111
+ * entity-DECODED and then parsed as HTML, so `escapeAttribute`'s `&lt;script&gt;` becomes a live
112
+ * `<script>` inside the iframe — on this origin, with this session's cookie. Escaping cannot make
113
+ * it inert, so the attribute is refused instead, the same way `innerHTML` above stays the one
114
+ * explicit escape hatch rather than a prop anyone can spread in from a row.
115
+ */
116
+ const REFUSED_ATTRIBUTES: ReadonlySet<string> = new Set(['srcdoc']);
117
+
90
118
  const cssProperty = (name: string): string =>
91
119
  name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
92
120
 
@@ -111,7 +139,8 @@ export function attributePair(name: string, value: unknown): string | null {
111
139
  if (typeof value === 'function') return null;
112
140
  if (name.startsWith('on') && name.length > 2) return null;
113
141
 
114
- const attribute = ATTRIBUTE_ALIASES[name] ?? name;
142
+ const attribute = ATTRIBUTE_ALIASES.get(name) ?? name;
143
+ if (REFUSED_ATTRIBUTES.has(attribute.toLowerCase())) return null;
115
144
  if (value === true) return attribute;
116
145
  if (attribute === 'style') {
117
146
  const style = styleValue(value);
@@ -123,7 +152,7 @@ export function attributePair(name: string, value: unknown): string | null {
123
152
  // module is the single place injection is prevented and an href off a database row is the shape
124
153
  // every app writes. A refused URL emits no attribute at all — an anchor with no `href` is inert
125
154
  // and still renders its text, where a blanked one is a live link nobody checked.
126
- if (URL_ATTRIBUTES.includes(attribute.toLowerCase())) {
155
+ if (URL_BEARING_ATTRIBUTES.has(attribute.toLowerCase())) {
127
156
  const url = safeUrl(text, attribute.toLowerCase());
128
157
  if (url === null) return null;
129
158
  return `${attribute}="${escapeAttribute(url)}"`;
package/src/hydrate.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * answered instead of swallowed.
6
6
  */
7
7
 
8
- import { escapeJsonContent } from './html';
8
+ import { escapeAttribute, escapeJsonContent } from './html';
9
9
  import type { HydrateStrategy } from './route';
10
10
 
11
11
  export interface IslandDirective {
@@ -34,14 +34,22 @@ export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'key
34
34
  * runtime below is never emitted for that island.
35
35
  */
36
36
  export function emitIslandAttributes(directive: IslandDirective): string {
37
- const attrs = [`data-x-island="${directive.islandId}"`, `data-x-hydrate="${directive.strategy}"`];
37
+ // `html.ts`'s escaper, not raw interpolation: a `"` in any of these values closes the attribute
38
+ // and the rest of the string is markup. Author-controlled today — which is why it costs nothing
39
+ // to route through the ONE escaper now, rather than after a build id or a prop-derived margin
40
+ // starts carrying something the author did not type.
41
+ const attr = (name: string, value: string): string => `${name}="${escapeAttribute(value)}"`;
42
+ const attrs = [
43
+ attr('data-x-island', directive.islandId),
44
+ attr('data-x-hydrate', directive.strategy),
45
+ ];
38
46
  if (directive.strategy !== 'never') {
39
- attrs.push(`data-x-entry="${directive.entry}"`);
47
+ attrs.push(attr('data-x-entry', directive.entry));
40
48
  if (directive.rootMargin !== undefined) {
41
- attrs.push(`data-x-margin="${directive.rootMargin}"`);
49
+ attrs.push(attr('data-x-margin', directive.rootMargin));
42
50
  }
43
51
  if (directive.events !== undefined && directive.events.length > 0) {
44
- attrs.push(`data-x-events="${directive.events.join(' ')}"`);
52
+ attrs.push(attr('data-x-events', directive.events.join(' ')));
45
53
  }
46
54
  }
47
55
  return attrs.join(' ');
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ installRenderLoader();
11
11
 
12
12
  export type { CompiledStylesheet } from './css-modules';
13
13
  export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
14
+ export { parseTtlMs } from './duration';
14
15
  export type { RenderErrorCode } from './errors';
15
16
  export {
16
17
  BudgetExceededError,
@@ -134,8 +135,8 @@ export {
134
135
  createIsrController,
135
136
  DEFAULT_ISR_MAX_ENTRIES,
136
137
  invalidateAndRevalidate,
138
+ isrKey,
137
139
  memoryIsrStore,
138
- parseTtlMs,
139
140
  } from './render-isr';
140
141
  export type { SpaShell, SpaShellInput } from './render-spa';
141
142
  export { renderSpa, renderSpaShell, SPA_ROOT_ID } from './render-spa';
@@ -68,7 +68,7 @@ function assertJsonSafe(value: unknown, path: string, seen: Set<object>, file: s
68
68
  guardCycle(value, path, seen, file);
69
69
  const out: Record<string, JsonValue> = {};
70
70
  for (const [key, item] of Object.entries(value)) {
71
- out[key] = assertJsonSafe(item, `${path}.${key}`, seen, file);
71
+ put(out, key, assertJsonSafe(item, `${path}.${key}`, seen, file));
72
72
  }
73
73
  seen.delete(value);
74
74
  return out;
@@ -82,6 +82,18 @@ function assertJsonSafe(value: unknown, path: string, seen: Set<object>, file: s
82
82
  );
83
83
  }
84
84
 
85
+ /**
86
+ * One walked value onto the bag. `out[key] = value` is not an assignment for exactly one name:
87
+ * `__proto__` runs `Object.prototype`'s setter and REPLACES the prototype instead of adding a key,
88
+ * so the prop never reaches the browser — the exact footgun the walk above exists to prevent — and
89
+ * the record the server keeps reading answers whatever the request body chose. `JSON.parse` mints a
90
+ * real own `__proto__` key, so a row off the wire is enough. Same shape as `validate-args.ts`'s
91
+ * `put` in `@ultimat3/mcp`; `defineProperty` writes a plain own data property whatever the name is.
92
+ */
93
+ function put(out: Record<string, JsonValue>, key: string, value: JsonValue): void {
94
+ Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
95
+ }
96
+
85
97
  function isPlainObject(value: unknown): value is Record<string, unknown> {
86
98
  if (typeof value !== 'object' || value === null) return false;
87
99
  const proto: unknown = Object.getPrototypeOf(value);
@@ -127,7 +139,7 @@ export function checkIslandProps(
127
139
  const bag: Record<string, JsonValue> = {};
128
140
  const seen = new Set<object>();
129
141
  for (const key of passed) {
130
- bag[key] = assertJsonSafe(props[key], `props.${key}`, seen, file);
142
+ put(bag, key, assertJsonSafe(props[key], `props.${key}`, seen, file));
131
143
  }
132
144
 
133
145
  const bytes = new TextEncoder().encode(JSON.stringify(bag)).byteLength;
package/src/modes.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * invariant is only documented is a mode that silently degrades in production.
6
6
  */
7
7
 
8
+ import { parseTtlMs } from './duration';
8
9
  import { RouteModeInvalidError } from './errors';
9
10
  import type { HydrateStrategy, RenderMode, RouteConfig } from './route';
10
11
  import { HYDRATE_STRATEGIES } from './route';
@@ -89,8 +90,12 @@ export const MODE_SPECS: Readonly<Record<RenderMode, ModeSpec>> = Object.freeze(
89
90
 
90
91
  /** Mode-local checks that need nothing but the config. Called by `defineRoute`. */
91
92
  export function assertModeShape(config: RouteShape): void {
92
- // Widened on purpose: JS callers reach `defineRoute` with unvalidated strings.
93
- const spec: ModeSpec | undefined = MODE_SPECS[config.render];
93
+ // Widened on purpose: JS callers reach `defineRoute` with unvalidated strings — and an object
94
+ // lookup walks the prototype chain, so `render: 'constructor'` used to find a `ModeSpec` that
95
+ // does not exist and hand back a frozen descriptor for a mode nothing implements.
96
+ const spec: ModeSpec | undefined = Object.hasOwn(MODE_SPECS, config.render)
97
+ ? MODE_SPECS[config.render]
98
+ : undefined;
94
99
  if (spec === undefined) {
95
100
  throw new RouteModeInvalidError(
96
101
  `render: ${JSON.stringify(config.render)} is not a render mode`,
@@ -121,6 +126,19 @@ export function assertModeShape(config: RouteShape): void {
121
126
  );
122
127
  }
123
128
 
129
+ // isr: a CACHED document, so it may not be gated. The symmetry with `static` above is the
130
+ // point — an ISR route resolves `load` with the request's own `Ctx`, renders that actor's
131
+ // document, and the cache stores it under the pathname alone. Every later actor who passes the
132
+ // same policy is then served the first actor's HTML. Keying the cache on more is a trap, not a
133
+ // fix: the key would have to enumerate everything a policy and a `load` can read.
134
+ if (config.render === 'isr' && config.policy !== undefined) {
135
+ throw new RouteModeInvalidError(
136
+ "render: 'isr' caches one document per URL and cannot be gated, but a `policy` was " +
137
+ `declared (${config.policy.permission} varies the answer per actor)`,
138
+ "change render to 'ssr' (fresh, gated) or 'spa' (gated shell), or drop the policy",
139
+ );
140
+ }
141
+
124
142
  // isr: needs a trigger, otherwise it is `static` wearing a costume.
125
143
  if (config.render === 'isr' && !hasRevalidateTrigger(config)) {
126
144
  throw new RouteModeInvalidError(
@@ -150,7 +168,10 @@ function hasRevalidateTrigger(config: RouteShape): boolean {
150
168
  const revalidate = config.revalidate;
151
169
  if (revalidate === undefined) return false;
152
170
  const hasTags = revalidate.tags !== undefined && revalidate.tags.length > 0;
153
- const hasTtl = revalidate.ttl !== undefined && revalidate.ttl !== '';
171
+ // `parseTtlMs`, never "a non-empty string": the ISR clock is the one that has to read this value,
172
+ // and `ttl: '5 minutes'` passed here and parsed to `null` there — a page generated once and
173
+ // served for the life of the process, which is the exact costume this check refuses below.
174
+ const hasTtl = parseTtlMs(revalidate.ttl) !== null;
154
175
  return hasTags || hasTtl;
155
176
  }
156
177
 
@@ -4,6 +4,7 @@
4
4
  * `bun test` all load a component the same way and there is no separate "bundled" behaviour.
5
5
  */
6
6
 
7
+ import { renderThrowable } from '@ultimat3/core';
7
8
  import { compileStylesheet, isGlobalStylesheet } from './css-modules';
8
9
  import { PrerenderFailedError } from './errors';
9
10
  import type { Surface } from './surfaces';
@@ -134,7 +135,10 @@ export function installRenderLoader(): void {
134
135
  } catch (error) {
135
136
  if (error instanceof PrerenderFailedError) throw error;
136
137
  throw new PrerenderFailedError(
137
- `${path} could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
138
+ // `renderThrowable` from core, never `.message`/`String()`: this catch is the loader's
139
+ // last frame, and a value that fights being read would replace the coded failure with
140
+ // a bare throw out of a Bun plugin — where no route and no file name survives.
141
+ `${path} could not be loaded: ${renderThrowable(error)}`,
138
142
  `open ${path} and fix the stylesheet it @use-s`,
139
143
  );
140
144
  }
package/src/registry.ts CHANGED
@@ -369,8 +369,12 @@ export function matchRoute(pathname: string): RouteMatch | null {
369
369
  * `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
370
370
  * `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
371
371
  * `matchRoute` as a 500 and an error-monitor page for somebody's typo.
372
+ *
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.
372
376
  */
373
- function decodeSegment(value: string): string | undefined {
377
+ export function decodeSegment(value: string): string | undefined {
374
378
  try {
375
379
  return decodeURIComponent(value);
376
380
  } catch {
@@ -4,6 +4,7 @@
4
4
  * component — `static` at build time, `ssr`/`stream` per request, all through `renderToHtml`.
5
5
  */
6
6
 
7
+ import { renderThrowable } from '@ultimat3/core';
7
8
  import {
8
9
  IslandInvalidError,
9
10
  IslandNotHydratedError,
@@ -30,9 +31,6 @@ export interface RenderHtmlOptions {
30
31
  readonly islands?: IslandCollector;
31
32
  }
32
33
 
33
- const describe = (error: unknown): string =>
34
- error instanceof Error ? error.message : String(error);
35
-
36
34
  /**
37
35
  * A thunk is called, not stringified. Solid's reactive reads are accessors (`count()`), and a
38
36
  * `children` prop is routinely a function — evaluating it once is exactly the server's job.
@@ -142,7 +140,10 @@ export async function renderComponent(
142
140
  if (error instanceof IslandPropsInvalidError) throw error;
143
141
  if (error instanceof IslandNotHydratedError) throw error;
144
142
  throw new PrerenderFailedError(
145
- `rendering the component in ${file} threw: ${describe(error)}`,
143
+ // `renderThrowable`, never `.message`/`String()`: a component is app code and may throw a
144
+ // null-prototype object (`String()` raises) or an Error whose `message` getter does — and
145
+ // this frame is the last thing between that and a build failure with no code at all.
146
+ `rendering the component in ${file} threw: ${renderThrowable(error)}`,
146
147
  `run \`bun test ${file.replace(/\.tsx?$/, '.test.ts')}\` to reproduce, then fix ${file}`,
147
148
  );
148
149
  }
package/src/render-isr.ts CHANGED
@@ -13,7 +13,8 @@ import {
13
13
  registerRevalidator,
14
14
  unregisterDependent,
15
15
  } from '@ultimat3/cache';
16
- import { logger } from '@ultimat3/core';
16
+ import { logger, renderThrowable } from '@ultimat3/core';
17
+ import { parseTtlMs } from './duration';
17
18
  import type { RouteDescriptor } from './registry';
18
19
  import { describeRoutes } from './registry';
19
20
  import { contentHash, staticHeaders } from './render-static';
@@ -22,6 +23,11 @@ import type { RenderResult } from './route';
22
23
  export type IsrState = 'miss' | 'hit' | 'stale';
23
24
 
24
25
  export interface IsrEntry {
26
+ /**
27
+ * The store key: the request's pathname AND its query, params sorted. Not the route's pattern
28
+ * and not the bare pathname — `/blog?page=2` and `/blog?page=3` render different documents, and
29
+ * keying both as `/blog` served the second visitor the first one's HTML (#171).
30
+ */
25
31
  readonly path: string;
26
32
  readonly html: string;
27
33
  readonly hash: string;
@@ -74,24 +80,35 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
74
80
  };
75
81
  }
76
82
 
77
- const DURATION_UNITS: Readonly<Record<string, number>> = {
78
- ms: 1,
79
- s: 1_000,
80
- m: 60_000,
81
- h: 3_600_000,
82
- d: 86_400_000,
83
- };
84
-
85
- /** `'5m'` → 300000. Numbers pass through as milliseconds. */
86
- export function parseTtlMs(ttl: string | number | null | undefined): number | null {
87
- if (ttl === null || ttl === undefined) return null;
88
- if (typeof ttl === 'number') return Number.isFinite(ttl) && ttl > 0 ? ttl : null;
89
- const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(ttl.trim());
90
- const amount = match?.[1];
91
- const unit = match?.[2];
92
- if (amount === undefined || unit === undefined) return null;
93
- const factor = DURATION_UNITS[unit];
94
- return factor === undefined ? null : Number(amount) * factor;
83
+ /**
84
+ * The ISR store key for one request URL: pathname plus the query, **params sorted**.
85
+ *
86
+ * Exported because deriving it is the caller's job and there may only be ONE derivation — a
87
+ * server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
88
+ * of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
89
+ *
90
+ * A query-carrying URL therefore gets its own entry, which is correct and is not free: a crawler
91
+ * appending `?utm_source=…` mints one entry per value. That is bounded, not unbounded —
92
+ * `DEFAULT_ISR_MAX_ENTRIES` evicts least-recently-generated first — and a bounded cache that
93
+ * thrashes is the right failure next to an unbounded one that answers the wrong document.
94
+ */
95
+ export function isrKey(url: URL): string {
96
+ if (url.search === '') return url.pathname;
97
+ const params = new URLSearchParams(url.search);
98
+ params.sort();
99
+ return `${url.pathname}?${params.toString()}`;
100
+ }
101
+
102
+ /**
103
+ * The route pattern a key belongs to. Every lookup that asks the ROUTE TABLE a question — the
104
+ * descriptor, and therefore the TTL — has to strip the query first: `descriptorFor('/blog?page=2')`
105
+ * matches no route, so the entry would silently fall back to `ttlMs: null` and a declared
106
+ * `revalidate: { ttl: '5m' }` would become tag-only. Keying without this split is a half-fix that
107
+ * trades a leak for a wrong TTL.
108
+ */
109
+ function routePathOf(key: string): string {
110
+ const query = key.indexOf('?');
111
+ return query === -1 ? key : key.slice(0, query);
95
112
  }
96
113
 
97
114
  export type IsrRenderFn = (path: string) => string | Promise<string>;
@@ -150,7 +167,8 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
150
167
  const pending = new Map<string, Promise<IsrEntry>>();
151
168
  const registered = new Set<string>();
152
169
 
153
- function descriptorFor(path: string): RouteDescriptor | undefined {
170
+ function descriptorFor(key: string): RouteDescriptor | undefined {
171
+ const path = routePathOf(key);
154
172
  const table = routes();
155
173
  return table.find((r) => r.path === path) ?? table.find((r) => matchesRoute(path, r.path));
156
174
  }
@@ -248,10 +266,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
248
266
  // stale-while-revalidate: answer from the stale copy now, refresh behind the request.
249
267
  const already = pending.has(path);
250
268
  void regenerate(path, render).catch((error: unknown) => {
251
- logger.warn('isr.regenerate.failed', {
252
- path,
253
- error: error instanceof Error ? error.message : String(error),
254
- });
269
+ // `renderThrowable`, never `.message`/`String()`: this `.catch` is the last frame under a
270
+ // route's own render function, and `String()` raises on a null-prototype object — the
271
+ // handler that exists to REPORT the failure became a second, unhandled rejection.
272
+ logger.warn('isr.regenerate.failed', { path, error: renderThrowable(error) });
255
273
  });
256
274
  return {
257
275
  state: 'stale',
package/src/render-spa.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { RouteModeInvalidError } from './errors';
9
+ import { escapeAttribute } from './html';
9
10
  import type { RouteEntry } from './registry';
10
11
  import { contentHash } from './render-static';
11
12
  import type { RenderResult } from './route';
@@ -37,18 +38,22 @@ export function renderSpaShell(input: SpaShellInput): SpaShell {
37
38
  );
38
39
  }
39
40
 
40
- const rootId = input.rootId ?? SPA_ROOT_ID;
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);
41
45
  const preloads = input.chunks
42
- .map((chunk) => `<link rel="modulepreload" href="${chunk}">`)
46
+ .map((chunk) => `<link rel="modulepreload" href="${escapeAttribute(chunk)}">`)
43
47
  .join('');
44
48
  const scripts = input.chunks
45
- .map((chunk) => `<script type="module" src="${chunk}"></script>`)
49
+ .map((chunk) => `<script type="module" src="${escapeAttribute(chunk)}"></script>`)
46
50
  .join('');
47
51
 
48
52
  const html =
49
- `<!doctype html><html lang="${input.lang}" dir="${input.dir ?? 'ltr'}">` +
53
+ `<!doctype html><html lang="${escapeAttribute(input.lang)}" ` +
54
+ `dir="${escapeAttribute(input.dir ?? 'ltr')}">` +
50
55
  `<head>${input.head}${preloads}` +
51
- `<meta name="x-ultimate-build" content="${input.buildId}">` +
56
+ `<meta name="x-ultimate-build" content="${escapeAttribute(input.buildId)}">` +
52
57
  `</head><body><div id="${rootId}"></div>${scripts}</body></html>`;
53
58
 
54
59
  return { html, hash: contentHash(html) };
@@ -4,7 +4,7 @@
4
4
  * the precache revision in `sw.js`, and the asset filename suffix.
5
5
  */
6
6
 
7
- import { useContext } from '@ultimat3/core';
7
+ import { renderThrowable, useContext } from '@ultimat3/core';
8
8
  import { PrerenderFailedError, RouteModeInvalidError } from './errors';
9
9
  import type { RouteEntry } from './registry';
10
10
  import type { RenderResult, RouteParams } from './route';
@@ -69,7 +69,9 @@ export async function enumeratePrerender(entry: RouteEntry): Promise<readonly Ro
69
69
  produced = await prerender();
70
70
  } catch (error) {
71
71
  throw new PrerenderFailedError(
72
- `prerender() for ${entry.path} threw: ${describe(error)}`,
72
+ // `renderThrowable`, never `.message`/`String()`: `prerender` is app code and may throw a
73
+ // value whose read raises in turn — this frame is what makes the build failure a coded one.
74
+ `prerender() for ${entry.path} threw: ${renderThrowable(error)}`,
73
75
  `fix prerender in ${entry.file} — it runs at build time with no request context`,
74
76
  );
75
77
  }
@@ -119,8 +121,8 @@ export async function renderStatic(
119
121
  html = await render({ path, params });
120
122
  } catch (error) {
121
123
  throw new PrerenderFailedError(
122
- `rendering ${path} failed: ${describe(error)}`,
123
- `run \`x build --route ${path}\` to reproduce, then fix ${entry.file}`,
124
+ `rendering ${path} failed: ${renderThrowable(error)}`,
125
+ `x build --target static --json # reproduces ${path}, then fix ${entry.file}`,
124
126
  );
125
127
  }
126
128
  const hash = contentHash(html);
@@ -164,7 +166,3 @@ export function fillPath(pattern: string, params: RouteParams): string {
164
166
  .replace(/\/+$/, '') || '/'
165
167
  );
166
168
  }
167
-
168
- function describe(error: unknown): string {
169
- return error instanceof Error ? error.message : String(error);
170
- }
@@ -11,7 +11,8 @@
11
11
  * contains no interactive island costs literally zero JS.
12
12
  */
13
13
 
14
- import { logger } from '@ultimat3/core';
14
+ import { logger, renderThrowable } from '@ultimat3/core';
15
+ import { escapeAttribute, escapeRawTextContent } from './html';
15
16
  import type { RenderResult } from './route';
16
17
 
17
18
  export interface StreamHole {
@@ -42,9 +43,16 @@ export function holeId(id: string): string {
42
43
  return `${HOLE_PREFIX}${id}`;
43
44
  }
44
45
 
45
- /** The placeholder that sits in the first flush, holding the fallback markup. */
46
+ /**
47
+ * The placeholder that sits in the first flush, holding the fallback markup.
48
+ *
49
+ * `html.ts`'s escaper, not raw interpolation, for the reason `emitIslandAttributes` states: a `"`
50
+ * in the id closes the attribute and the rest is markup. Author-controlled today — which is why it
51
+ * costs nothing to route through the ONE escaper now, rather than after an id starts being derived
52
+ * from a param. `fallback` is already-rendered HTML and stays verbatim.
53
+ */
46
54
  export function holeMarker(id: string, fallback: string): string {
47
- return `<x-hole id="${holeId(id)}">${fallback}</x-hole>`;
55
+ return `<x-hole id="${escapeAttribute(holeId(id))}">${fallback}</x-hole>`;
48
56
  }
49
57
 
50
58
  /**
@@ -57,7 +65,15 @@ export const REVEAL_SCRIPT =
57
65
 
58
66
  export function revealChunk(id: string, html: string): string {
59
67
  const key = holeId(id);
60
- return `<template data-x-hole="${key}">${html}</template><script>$X("${key}")</script>`;
68
+ // Two contexts, two encoders — the attribute takes `escapeAttribute`, and the script argument is
69
+ // built by `JSON.stringify` so the id is a JS string LITERAL rather than text pasted between two
70
+ // quotes: `a");alert(1);//` closed the call and ran on the page's own origin. `</script` inside
71
+ // it would still end the element, so the raw-text rule applies over the top, as `html.ts` says.
72
+ const argument = escapeRawTextContent(JSON.stringify(key));
73
+ return (
74
+ `<template data-x-hole="${escapeAttribute(key)}">${html}</template>` +
75
+ `<script>$X(${argument})</script>`
76
+ );
61
77
  }
62
78
 
63
79
  /**
@@ -166,9 +182,10 @@ export function renderStreamHtml(
166
182
  reveal(html);
167
183
  },
168
184
  (error: unknown) => {
169
- logger.warn(
170
- `stream hole ${hole.id} rejected: ${error instanceof Error ? error.message : String(error)}`,
171
- );
185
+ // `renderThrowable`, never `.message`/`String()`: the value is whatever the hole threw,
186
+ // and a read that raises here skips the `reveal` below — the hole never fills and the
187
+ // response is held to its deadline for a failure that was already handled.
188
+ logger.warn(`stream hole ${hole.id} rejected: ${renderThrowable(error)}`);
172
189
  reveal(errorFallback(hole.id));
173
190
  },
174
191
  );
package/src/route-data.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * the body does not contain.
6
6
  */
7
7
 
8
- import { isUltimateError } from '@ultimat3/core';
8
+ import { isUltimateError, renderThrowable } from '@ultimat3/core';
9
9
  import { useI18n } from '@ultimat3/i18n';
10
10
  import { RouteLoadFailedError } from './errors';
11
11
  import type { RouteConfig, RouteContext, RouteData, RouteMetaContext } from './route';
@@ -26,6 +26,11 @@ export async function routeDataFor<TData = RouteData>(
26
26
  // `RouteContext` satisfies `TData` on this line. One narrowing assertion backed by a build
27
27
  // error, in place of the `as unknown as` that used to hand back any shape a caller named.
28
28
  if (config.load === undefined) return ctx as RouteContext & TData;
29
+ // Read BEFORE the try, and never inside the catch: `new URL('/posts/7')` is a bare `TypeError`,
30
+ // and `ctx.url` is relative on every path that builds one by hand — a prerender pass, `x build`,
31
+ // a test harness. Computing it in the catch replaced `X_ROUTE_LOAD_FAILED` with that TypeError
32
+ // on the ONE path whose job is to produce a coded error.
33
+ const path = pathnameOf(ctx.url);
29
34
  try {
30
35
  return await config.load(ctx);
31
36
  } catch (cause) {
@@ -35,14 +40,29 @@ export async function routeDataFor<TData = RouteData>(
35
40
  // `code` property: an ENOENT off `Bun.file` carries `code: 'ENOENT'`, and the duck-type let
36
41
  // every one of them out of here unwrapped — no fix line, and no mention of the route to fix.
37
42
  if (isUltimateError(cause)) throw cause;
38
- const detail = cause instanceof Error ? cause.message : String(cause);
43
+ // `renderThrowable`, not `.message`/`String()`: `load` is app code and may throw a value
44
+ // whose `message` getter or `toString` throws in turn — this frame is the last thing between
45
+ // that and a page with no error at all.
46
+ const detail = renderThrowable(cause);
39
47
  throw new RouteLoadFailedError(
40
- `load() threw while rendering ${new URL(ctx.url).pathname}: ${detail}`,
41
- `fix the load function for ${new URL(ctx.url).pathname}, or return a fallback so the page can render`,
48
+ `load() threw while rendering ${path}: ${detail}`,
49
+ `fix the load function for ${path}, or return a fallback so the page can render`,
42
50
  );
43
51
  }
44
52
  }
45
53
 
54
+ /**
55
+ * The pathname of a route URL, absolute or relative, and never a throw: this value only ever
56
+ * appears in an error message, so a reader that raises would cost the caller the whole error.
57
+ */
58
+ function pathnameOf(url: string): string {
59
+ try {
60
+ return new URL(url).pathname;
61
+ } catch {
62
+ return url.split(/[?#]/)[0] ?? url;
63
+ }
64
+ }
65
+
46
66
  /**
47
67
  * The argument `meta` is called with. Built here rather than at each call site so `x dev` and the
48
68
  * build's prerenderer cannot disagree about what `meta` receives — two builders is a `<title>`
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { CompiledPattern } from './registry';
10
- import { compilePattern } from './registry';
10
+ import { compilePattern, decodeSegment } from './registry';
11
11
  import type { RouteParams } from './route';
12
12
 
13
13
  export interface RouterRoute {
@@ -108,10 +108,21 @@ export function createRouter(options: RouterOptions): Router {
108
108
  const match = candidate.pattern.regex.exec(rawPath);
109
109
  if (match === null) continue;
110
110
  const params: Record<string, string> = {};
111
+ let undecodable = false;
111
112
  candidate.pattern.keys.forEach((key, index) => {
112
113
  const value = match[index + 1];
113
- if (value !== undefined) params[key] = decodeURIComponent(value);
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;
114
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;
115
126
  return {
116
127
  route: candidate.route,
117
128
  params,