@ultimat3/render 19.1.3 → 19.3.1

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
@@ -50,7 +50,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
50
50
  | 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. |
51
51
  | 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. |
52
52
  | `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. |
53
- | 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. |
53
+ | 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. The cap is **16 KiB** `As of 2026-09-05` (was 4; a 34-row catalog at 8,812 B was a runtime 500) and its doc comment carries the arithmetic — the bag is inlined as a JSON script `measureDocumentJs` counts as zero JS, so this constant is the only ceiling on it. Over the cap, the cause names the **heaviest props with their bytes** and the fix names the endpoint pattern (`models: []` + `modelsEndpoint`), never "raise the cap". `X_ISLAND_PROPS_INVALID` has a **row** in `@ultimat3/http`'s table (500): unclassified, `problem+json` blanked the one sentence that is the instruction. |
54
54
  | 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. |
55
55
  | 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. |
56
56
  | An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` — `/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`, refused as `null`. A name is emitted VERBATIM before the `=` and is escaped nowhere, so `{ 'q onmouseover=alert(1) r': 'ok' }` shipped a live event handler out of an object KEY. The handler check on the line under it folds case for the same reason: it was `name.startsWith('on')` while the two checks below it lowercased, so `ONERROR="alert(1)"` went out on the wire. Both are reachable by `<div {...row} />` over a JSON body or a JSONB column. `head.ts`'s `renderTag` is the package's OTHER attribute sink and shares the predicate (`isAttributeName`) — it emitted `<meta name="q" r onmouseover=alert(1) s="ok">` from an `attrs` key. One predicate, never two. |
@@ -69,6 +69,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
69
69
  | 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. |
70
70
  | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer of everything the declaration alone decides, and every reader downstream assumes it ran. The registry fills in exactly one value on top: the island budget, which needs the surface, which is a fact of the file path only the route table reads. |
71
71
  | Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. |
72
+ | Every string order in this package | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare`. With no locale argument `localeCompare` answers from the runtime's ICU **default locale** and **collation version**: `'/A'` sorted after `'/a'` on one machine and before it on the next, and `'/zoo'` before `'/ärzte'` under `sv-SE` but after it under `en-US` — for the same route table. Three sites shipped it: `routeEntries()` (so `describeRoutes()`, and with it `x.manifest.json`, the sitemap and `sw.js`'s rule table), `checkSurfaceBoundary`'s report, and `pageComponentOf`'s fallback, whose own comment promised "the same one on every machine". Package-internal on purpose — a comparator is not public API. Same rule `@ultimat3/pwa`'s `precache.ts` and `service-worker.ts` state for the artifact they emit. |
72
73
  | Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
73
74
  | 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. |
74
75
  | 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. |
@@ -97,7 +98,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
97
98
  | 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. |
98
99
  | 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. |
99
100
  | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
100
- | 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. |
101
+ | Stylesheets | compiled by `css-modules.ts`, still grouped per surface, and served by the CLI as **one content-hashed file per surface** (`@ultimat3/cli`'s `style-bundle.ts`) rather than inlined — measured 2026-09-06, the inline block was 156,738 bytes inside a `no-store` document, re-sent on every navigation. `stylesFor` is unchanged and is what that file is built from. `sass` is this package's only third-party dependency and its only reason to exist here. |
101
102
  | CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`), never plain insertion order — the reset styles bare elements at the lowest specificity there is, so whichever page loaded first must not decide who wins a tie. `shared/` is carried by both graphs, like a package sheet: it is where an app's own global layer lives, and filtering it out is what made every deployed app render token-less. |
102
103
  | The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
103
104
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
package/README.md CHANGED
@@ -270,13 +270,85 @@ else is `X_ISLAND_INVALID`, and the fix is the `git mv`.
270
270
  |---|---|
271
271
  | every prop is declared in `props: [...]` | `X_ISLAND_PROPS_INVALID`, naming each undeclared key |
272
272
  | every value is JSON — no function, `Date`, class instance, `bigint`, `undefined`, cycle | `X_ISLAND_PROPS_INVALID`, naming the path and the type |
273
- | serialized props ≤ `ISLAND_PROPS_MAX_BYTES` (4096) | `X_ISLAND_PROPS_INVALID`, naming the measured size |
273
+ | serialized props ≤ `ISLAND_PROPS_MAX_BYTES` (16,384 B) | `X_ISLAND_PROPS_INVALID`, naming the heaviest props with their bytes |
274
274
 
275
275
  `<ContactModal {...post} />` fails and names `email`, `passwordHash` — every column the spread
276
276
  would have shipped. The type refuses it first (`type-pins.tsx` pins that); the render refuses it
277
277
  second, which for `static` and `isr` is build time. `children` are the server-rendered shell and
278
278
  are never serialized.
279
279
 
280
+ `X_ISLAND_PROPS_INVALID` is a **declared 500** in `@ultimat3/http`'s status table `As of 2026-09-05`.
281
+ It is the author's fault and never the caller's, so 500 is the class — but until it had a row it
282
+ was an *unclassified* 500, and `problem+json` blanks the cause of one of those outside dev. A
283
+ 34-row catalog over the cap took a page down with a document that said "the details are in this
284
+ process's logs" about an error whose whole value is the sentence naming the prop and its bytes.
285
+
286
+ ### Large data rides over a query endpoint, after mount
287
+
288
+ The props bag is inlined **verbatim** in the document, as `<script type="application/json">`, on
289
+ every request — and `x verify`'s `budgets` step counts a JSON-typed script as data, not JS, so
290
+ `budget.js` never sees a byte of it. `ISLAND_PROPS_MAX_BYTES` is the only ceiling on that channel.
291
+ It is **16 KiB** `As of 2026-09-05` (it was 4 KiB, and a 34-model catalog at 8,812 B was a 500): a bag
292
+ comparable in size to the island's own code (`DEFAULT_ISLAND_JS_BYTES` derives 20 KiB for `site/`,
293
+ 34 KiB for `app/`), ~3-4 KiB gzipped, under 100 ms on a 3G-class link. The constant's own comment
294
+ carries the arithmetic.
295
+
296
+ A list that is the same on every request is not a prop — it is a **dataset**, and under the cap
297
+ or over it the page is the wrong place for it: it renders into every document, is cached with
298
+ none of them, and is parsed before first paint. The props are what the island needs to draw its
299
+ first frame — an id, a count, and the URL of the read that answers the rest:
300
+
301
+ ```ts
302
+ // page.tsx — the props are the id and the endpoint, never the rows
303
+ import { derivePath } from '@ultimat3/query';
304
+ import { island } from '@ultimat3/render';
305
+
306
+ const Dispatch = island({
307
+ src: './dispatch.island.tsx',
308
+ props: ['hostId', 'models', 'modelsEndpoint'],
309
+ });
310
+
311
+ export const DispatchFor = (host: { readonly id: string }) =>
312
+ Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${derivePath('modelList')}?limit=100` });
313
+ ```
314
+
315
+ ```tsx
316
+ // dispatch.island.tsx — one GET after mount, cached by the browser like any read
317
+ import { createSignal } from 'solid-js';
318
+ import { render } from 'solid-js/web';
319
+
320
+ type Model = { readonly id: string; readonly label: string };
321
+ type DispatchProps = {
322
+ readonly hostId: string;
323
+ readonly models: readonly Model[];
324
+ readonly modelsEndpoint?: string;
325
+ };
326
+ const Picker = (props: { readonly models: readonly Model[] }) => <ul>{props.models.length}</ul>;
327
+
328
+ export function mount(el: HTMLElement, props: DispatchProps): void {
329
+ const [models, setModels] = createSignal<readonly Model[]>(props.models);
330
+ if (props.modelsEndpoint !== undefined) {
331
+ fetch(props.modelsEndpoint, { credentials: 'same-origin' })
332
+ .then((response) => response.json())
333
+ .then((rows: readonly Model[]) => setModels(rows));
334
+ }
335
+ render(() => <Picker models={models()} />, el);
336
+ }
337
+ ```
338
+
339
+ `models: []` keeps the island's first frame honest (an empty picker, not a missing one) and
340
+ `derivePath` is the same derivation the typed `client()` uses, so the URL cannot drift from the
341
+ route. The `fix:` line of an over-cap `X_ISLAND_PROPS_INVALID` names exactly this edit, with the
342
+ heaviest prop's own name in it.
343
+
344
+ At **verify** time the overflow is a finding, not a runtime surprise: an `app/` page with an
345
+ island derives a budget, the `budgets` step's build renders every budgeted route to weigh it, and
346
+ a render that throws `X_ISLAND_PROPS_INVALID` is reported under that code — the island, the prop,
347
+ its bytes — rather than as an `X_BUDGET_UNMEASURED` whose fix is to go and read the build's list.
348
+ A prop that only exists per request (the row behind `[id]`) is still measured at the route's
349
+ pattern with empty params, so a static over-cap prop is caught, and a per-request one is caught
350
+ by the request that carries it.
351
+
280
352
  ### It counts against the route's budget
281
353
 
282
354
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "19.1.3",
3
+ "version": "19.3.1",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "19.1.3",
40
- "@ultimat3/core": "19.1.3",
41
- "@ultimat3/i18n": "19.1.3",
42
- "@ultimat3/seo": "19.1.3",
39
+ "@ultimat3/cache": "19.3.1",
40
+ "@ultimat3/core": "19.3.1",
41
+ "@ultimat3/i18n": "19.3.1",
42
+ "@ultimat3/seo": "19.3.1",
43
43
  "sass": "1.102.0"
44
44
  }
45
45
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The one string comparator this package orders derived output with.
3
+ *
4
+ * Package-internal on purpose: it is the ordering rule, not public API. Nothing outside
5
+ * `@ultimat3/render` may need it, and a comparator on the barrel is a shape apps would depend on.
6
+ */
7
+
8
+ /**
9
+ * Compare by UTF-16 code unit — never `localeCompare`, which with no locale argument answers from
10
+ * the runtime's ICU **default locale** and **collation version**: `'/A'` sorts after `'/a'` on one
11
+ * machine and before it on the next, and `'/zoo'` before `'/ärzte'` under `sv-SE` but after it
12
+ * under `en-US`, for the same input. Everything this package orders is either diffed across
13
+ * deploys (`describeRoutes()` feeds `x.manifest.json`, the sitemap and `sw.js`'s rule table) or is
14
+ * a choice a build makes (`pageComponentOf`'s fallback), so a machine-dependent order is a no-op
15
+ * deploy that reads as a change — or a different page rendered per host. Same rule
16
+ * `@ultimat3/pwa`'s `precache.ts` and `service-worker.ts` state for the artifact they emit.
17
+ */
18
+ export const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
@@ -20,8 +20,30 @@ export type IslandProps = Readonly<Record<string, JsonValue>>;
20
20
  /**
21
21
  * Props ship inside the HTML of every response, so they are page weight the `budget` never sees
22
22
  * as JS. A cap turns "I passed the whole row" into a number and a fix instead of a slow page.
23
+ *
24
+ * What the cap protects. `hydrateRuntime` inlines the bag VERBATIM as
25
+ * `<script type="application/json" data-x-props>` in the document, and `measureDocumentJs` counts
26
+ * a JSON-typed script as data — zero JS — so `budget.js` never sees a byte of it. This constant is
27
+ * the only ceiling on that channel. Every byte of it is parsed before first paint, on every
28
+ * request, on every page that renders the island, and cannot be cached apart from the page.
29
+ *
30
+ * Why 16 KiB and not 4. It was 4096 until 2026-09-05, and a page carrying a 34-row catalog
31
+ * (8,812 B) answered 500 at RUNTIME — a legitimate medium-sized bag, a page down. What 16 KiB
32
+ * costs, measured against the budgets the route table derives (`DEFAULT_ISLAND_JS_BYTES`:
33
+ * `site/` 20 KiB, `app/` 34 KiB of JS): the ceiling is a bag comparable in size to the island's
34
+ * OWN code, and never more than it. On the wire, JSON with repeated keys gzips 4-6x, so a full
35
+ * bag is ~3-4 KiB compressed — under 100 ms on a 3G-class link (~50 KB/s), ~1 ms of `JSON.parse`
36
+ * on a phone. Uncompressed (a dev server, a proxy that skips `text/html`) it is ~320 ms on that
37
+ * link, which is why the ceiling stays a ceiling and not a warning.
38
+ *
39
+ * Why a catalog is still the wrong thing to inline, even under the cap. A list that is the same
40
+ * on every request is a DATASET, not a prop: behind a query route it is one `GET`, cached by the
41
+ * browser and by the CDN, fetched once per session instead of rendered into every page. The prop
42
+ * is the id, the initial count, the endpoint — what the island needs to draw its first frame.
43
+ * `README.md` ("large data rides over a query endpoint") is the pattern, and the `fix:` below
44
+ * names it.
23
45
  */
24
- export const ISLAND_PROPS_MAX_BYTES = 4096;
46
+ export const ISLAND_PROPS_MAX_BYTES = 16_384;
25
47
 
26
48
  /** JSX keys that are markup, not data: they stay on the server and never serialize. */
27
49
  const SERVER_ONLY_KEYS = new Set(['children']);
@@ -142,15 +164,39 @@ export function checkIslandProps(
142
164
  put(bag, key, assertJsonSafe(props[key], `props.${key}`, seen, file));
143
165
  }
144
166
 
145
- const bytes = new TextEncoder().encode(JSON.stringify(bag)).byteLength;
167
+ const bytes = utf8Bytes(JSON.stringify(bag));
146
168
  if (bytes > ISLAND_PROPS_MAX_BYTES) {
169
+ const heaviest = propBytes(bag).slice(0, HEAVIEST_NAMED);
170
+ const first = heaviest[0]?.[0] ?? 'rows';
147
171
  throw new IslandPropsInvalidError(
148
- `the ${moduleId} island in ${file} carries ${bytes} bytes of props (cap ` +
149
- `${ISLAND_PROPS_MAX_BYTES}), and every one of them ships inside the HTML on every request`,
150
- `pass an id in ${file} and fetch the rest inside the island, or raise the cap deliberately ` +
151
- 'by splitting the island',
172
+ `the ${moduleId} island in ${file} carries ${bytes} B of props (cap ${ISLAND_PROPS_MAX_BYTES} B), ` +
173
+ 'and every one of them ships inside the HTML on every request — ' +
174
+ heaviest.map(([key, size]) => `props.${key} is ${size} B of the ${bytes}`).join(', '),
175
+ `in ${file}, pass ${heaviest.map(([key]) => `\`${key}: []\``).join(' and ')} beside ` +
176
+ `\`${first}Endpoint: derivePath('<queryName>')\` (@ultimat3/query) and fetch the rows inside ` +
177
+ 'the island after mount — a list that is the same on every request is a dataset, not a ' +
178
+ 'prop; an id, a count and a URL are',
152
179
  );
153
180
  }
154
181
 
155
182
  return bag;
156
183
  }
184
+
185
+ const utf8Bytes = (text: string): number => new TextEncoder().encode(text).byteLength;
186
+
187
+ /**
188
+ * Which keys carry the weight, heaviest first — so the finding names the prop to move, not the
189
+ * bag. A page with `models` at 8,812 B beside `hostId` at 12 B gets one instruction, and the
190
+ * instruction names `models`. Per-key bytes are the value's serialisation plus its `"key":`.
191
+ */
192
+ function propBytes(bag: Record<string, JsonValue>): readonly (readonly [string, number])[] {
193
+ return Object.entries(bag)
194
+ .map(([key, value]): readonly [string, number] => [
195
+ key,
196
+ utf8Bytes(JSON.stringify(key)) + 1 + utf8Bytes(JSON.stringify(value)),
197
+ ])
198
+ .sort((a, b) => b[1] - a[1]);
199
+ }
200
+
201
+ /** At most two: one prop is the usual answer, two names a split, and a third is the whole bag. */
202
+ const HEAVIEST_NAMED = 2;
@@ -80,12 +80,30 @@ export interface Stylesheet {
80
80
 
81
81
  const stylesheets = new Map<string, Stylesheet>();
82
82
 
83
+ /**
84
+ * Bumped whenever the registry's CONTENT changes — a sheet arriving, a sheet's rules changing, the
85
+ * whole map being cleared. It exists so a caller can cache something derived from `stylesFor` (the
86
+ * CLI content-addresses the surface stylesheet and serves it as a file) without re-deriving it per
87
+ * request, and without a cache that goes stale the moment `x dev` rebuilds an island: island CSS
88
+ * registers through this same `loadStylesheet`, on every `Bun.build`.
89
+ *
90
+ * A re-registration with IDENTICAL css does not bump it. `buildIslands` re-runs its plugins on
91
+ * every watcher tick, so counting registrations rather than changes would mint a new stylesheet
92
+ * URL on every save of an unrelated file — the immutable-cache miss this counter exists to avoid.
93
+ */
94
+ let revision = 0;
95
+
96
+ export function stylesheetsRevision(): number {
97
+ return revision;
98
+ }
99
+
83
100
  export function registeredStylesheets(): readonly Stylesheet[] {
84
101
  return [...stylesheets.values()];
85
102
  }
86
103
 
87
104
  /** Test seam: the registry is process-global because the module cache it mirrors is too. */
88
105
  export function clearStylesheets(): void {
106
+ if (stylesheets.size > 0) revision += 1;
89
107
  stylesheets.clear();
90
108
  }
91
109
 
@@ -133,6 +151,7 @@ export function transformTsx(source: string): string {
133
151
  export function loadStylesheet(path: string, source: string): string {
134
152
  const compiled = compileStylesheet(path, source);
135
153
  if (compiled.css.length > 0) {
154
+ if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
136
155
  stylesheets.set(path, {
137
156
  file: path,
138
157
  surface: surfaceOf(path),
package/src/registry.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
9
9
  import { finiteCount } from '@ultimat3/core';
10
+ import { byCodeUnit } from './code-unit-order';
10
11
  import {
11
12
  RouteDuplicateError,
12
13
  RouteFileInvalidError,
@@ -322,7 +323,9 @@ export function routeCount(): number {
322
323
  }
323
324
 
324
325
  export function routeEntries(): readonly RouteEntry[] {
325
- return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
326
+ // Code units, never `localeCompare` — `describeRoutes()` below promises an order "identical for
327
+ // identical input", and `localeCompare` with no locale argument reads the runtime's ICU default.
328
+ return [...routes.values()].sort((a, b) => byCodeUnit(a.path, b.path));
326
329
  }
327
330
 
328
331
  export function routeFor(path: string): RouteEntry | undefined {
@@ -4,6 +4,7 @@
4
4
  * ship today — so the rule is a fixed precedence, evaluated once, here.
5
5
  */
6
6
 
7
+ import { byCodeUnit } from './code-unit-order';
7
8
  import type { JsxComponent } from './jsx';
8
9
 
9
10
  /** The page component of a route module: a function of props, sync or async. */
@@ -15,14 +16,17 @@ const isComponentExport = (name: string, value: unknown): value is RouteComponen
15
16
  /**
16
17
  * `Page` first, because that is the name `examples/dummy` uses and the one the generators should
17
18
  * converge on; then a single `…Page`; then a single capitalised function. Sorted before the last
18
- * fallback so a module with two components resolves to the same one on every machine.
19
+ * fallback so a module with two components resolves to the same one on every machine — by CODE
20
+ * UNIT, because `localeCompare` is what "every machine" fails on: with no locale argument it
21
+ * answers from the runtime's ICU default, which orders `A_Dash` before `ADash` where code units
22
+ * order them the other way.
19
23
  */
20
24
  export function pageComponentOf(
21
25
  module: Readonly<Record<string, unknown>>,
22
26
  ): RouteComponent | undefined {
23
27
  const components = Object.entries(module)
24
28
  .filter(([name, value]) => isComponentExport(name, value))
25
- .sort(([a], [b]) => a.localeCompare(b)) as readonly (readonly [string, RouteComponent])[];
29
+ .sort(([a], [b]) => byCodeUnit(a, b)) as readonly (readonly [string, RouteComponent])[];
26
30
  if (components.length === 0) return undefined;
27
31
 
28
32
  const exact = components.find(([name]) => name === 'Page');
package/src/server.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  loadStylesheet,
26
26
  registeredStylesheets,
27
27
  stylesFor,
28
+ stylesheetsRevision,
28
29
  transformTsx,
29
30
  } from './module-loader';
30
31
  // ---- the render pipeline: one entry point per mode ----------------------------------------------
package/src/surfaces.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { RenderMode } from '@ultimat3/core';
9
+ import { byCodeUnit } from './code-unit-order';
9
10
  import { SurfaceBoundaryError } from './errors';
10
11
 
11
12
  export type Surface = 'site' | 'app' | 'api' | 'shared';
@@ -147,7 +148,10 @@ export function checkSurfaceBoundary(graph: ImportGraph): readonly BoundaryViola
147
148
  walk(graph, entry, entrySurface, found);
148
149
  }
149
150
 
150
- return [...found.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
151
+ // Code units, never `localeCompare`: two machines running the same check must report the same
152
+ // list in the same order, and `localeCompare` with no locale argument reads the runtime's ICU
153
+ // default locale and collation version.
154
+ return [...found.values()].sort((a, b) => byCodeUnit(keyOf(a), keyOf(b)));
151
155
  }
152
156
 
153
157
  function keyOf(v: BoundaryViolation): string {