@ultimat3/render 23.0.0 → 25.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/src/html.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * HTML text and attribute serialization for the server renderer. Escaping lives here and only
3
- * here: a second escaper is how one of them ends up missing a character, and a missing character
4
- * in an attribute is an injection.
2
+ * HTML text and attribute serialization for the server renderer. The character table is
3
+ * `@ultimat3/core`'s `escapeHtml`, imported from core by every render-* file: a second escaper is
4
+ * how one of them ends up missing a character, and a missing character in an attribute is an
5
+ * injection (`X_HELPER_COPY` refuses both a copy and a re-export).
5
6
  */
6
7
 
7
- import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
8
- import { escapeAttribute } from '@ultimat3/seo';
8
+ import { escapeHtml, safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
9
9
  import type { JsxProps } from './jsx';
10
10
 
11
11
  /** Elements that never carry children, so the writer must not emit a closing tag. */
@@ -26,21 +26,9 @@ export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
26
26
  'wbr',
27
27
  ]);
28
28
 
29
- export function escapeText(value: string): string {
30
- return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
31
- }
32
-
33
- /**
34
- * Re-exported, never re-implemented: `@ultimat3/seo` (tier 1) owns the one attribute escaper, and
35
- * the copy that lived here was a second place for a character to go missing. It stays reachable
36
- * from `./html` so this module remains the single import site every render-* file already uses —
37
- * one implementation, one place to look.
38
- */
39
- export { escapeAttribute };
40
-
41
29
  /**
42
30
  * `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
43
- * `escapeText` there would ship `&lt;` to a JS or CSS parser and corrupt the code without closing
31
+ * `escapeHtml` there would ship `&lt;` to a JS or CSS parser and corrupt the code without closing
44
32
  * the hole. What actually ends the element is `</` followed by its tag name, and — inside a script
45
33
  * only — `<!--` switches the tokenizer into the escaped state where the element's own `</script>`
46
34
  * no longer closes it and the rest of the document becomes script text.
@@ -113,7 +101,7 @@ const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
113
101
 
114
102
  /**
115
103
  * Attributes that carry MARKUP, not text or a URL, and are therefore never emitted. `srcdoc` is
116
- * entity-DECODED and then parsed as HTML, so `escapeAttribute`'s `&lt;script&gt;` becomes a live
104
+ * entity-DECODED and then parsed as HTML, so an escaped `&lt;script&gt;` becomes a live
117
105
  * `<script>` inside the iframe — on this origin, with this session's cookie. Escaping cannot make
118
106
  * it inert, so the attribute is refused instead, the same way `innerHTML` above stays the one
119
107
  * explicit escape hatch rather than a prop anyone can spread in from a row.
@@ -167,7 +155,7 @@ export function attributePair(name: string, value: unknown): string | null {
167
155
  if (value === true) return attribute;
168
156
  if (attribute === 'style') {
169
157
  const style = styleValue(value);
170
- return style === null ? null : `style="${escapeAttribute(style)}"`;
158
+ return style === null ? null : `style="${escapeHtml(style)}"`;
171
159
  }
172
160
  const text = String(value);
173
161
  // Escaping makes a value inert inside the quotes; it cannot make a SCHEME inert, because
@@ -178,9 +166,9 @@ export function attributePair(name: string, value: unknown): string | null {
178
166
  if (URL_BEARING_ATTRIBUTES.has(attribute.toLowerCase())) {
179
167
  const url = safeUrl(text, attribute.toLowerCase());
180
168
  if (url === null) return null;
181
- return `${attribute}="${escapeAttribute(url)}"`;
169
+ return `${attribute}="${escapeHtml(url)}"`;
182
170
  }
183
- return `${attribute}="${escapeAttribute(text)}"`;
171
+ return `${attribute}="${escapeHtml(text)}"`;
184
172
  }
185
173
 
186
174
  export function renderAttributes(props: JsxProps): string {
package/src/hydrate.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy } from '@ultimat3/core';
9
- import { HYDRATE_STRATEGIES } from '@ultimat3/core';
10
- import { escapeAttribute, escapeJsonContent } from './html';
9
+ import { escapeHtml, HYDRATE_STRATEGIES } from '@ultimat3/core';
10
+ import { escapeJsonContent } from './html';
11
+ import { ISLAND_HOLD_ATTRIBUTES, RUNTIME_HOLD } from './island-hold';
11
12
 
12
13
  export interface IslandDirective {
13
14
  /** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
@@ -26,6 +27,11 @@ export interface IslandDirective {
26
27
  readonly events?: readonly string[];
27
28
  /** `rootMargin` for `visible`. */
28
29
  readonly rootMargin?: string;
30
+ /**
31
+ * The server markup must not paint before the island mounts (`island-hold.ts`, #506): hidden
32
+ * from its first byte, booted at once, revealed by the mount or the cap. Never on `never`.
33
+ */
34
+ readonly hold?: boolean;
29
35
  }
30
36
 
31
37
  export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'keydown'] as const;
@@ -61,7 +67,7 @@ export function emitIslandAttributes(directive: IslandDirective): string {
61
67
  // and the rest of the string is markup. Author-controlled today — which is why it costs nothing
62
68
  // to route through the ONE escaper now, rather than after a build id or a prop-derived margin
63
69
  // starts carrying something the author did not type.
64
- const attr = (name: string, value: string): string => `${name}="${escapeAttribute(value)}"`;
70
+ const attr = (name: string, value: string): string => `${name}="${escapeHtml(value)}"`;
65
71
  const attrs = [
66
72
  attr('data-x-island', directive.islandId),
67
73
  attr('data-x-hydrate', directive.strategy),
@@ -74,6 +80,8 @@ export function emitIslandAttributes(directive: IslandDirective): string {
74
80
  if (directive.events !== undefined && directive.events.length > 0) {
75
81
  attrs.push(attr('data-x-events', directive.events.join(' ')));
76
82
  }
83
+ // A constant, so it needs no escaper — and nothing a caller supplies reaches it.
84
+ if (directive.hold === true) attrs.push(ISLAND_HOLD_ATTRIBUTES);
77
85
  }
78
86
  return attrs.join(' ');
79
87
  }
@@ -89,7 +97,7 @@ export function emitIslandProps(directive: IslandDirective): string {
89
97
  // The id goes through the SAME escaper every other attribute in this file does. Safe today —
90
98
  // `islandModuleId` reduces to `[a-z0-9-]` — which is exactly why it costs nothing to route it
91
99
  // now, rather than after an id starts being derived from something an author did not type.
92
- `<script type="application/json" data-x-props="${escapeAttribute(directive.islandId)}">` +
100
+ `<script type="application/json" data-x-props="${escapeHtml(directive.islandId)}">` +
93
101
  `${escapeJsonContent(JSON.stringify(directive.props))}</script>`
94
102
  );
95
103
  }
@@ -205,6 +213,9 @@ io.observe(el)})
205
213
  // `go` chains on the same `el.__x`, and the first flush empties `q` and sets `done`, so however many
206
214
  // ran, each caught event is replayed exactly once and an untouched island still lets go of its
207
215
  // listeners at mount — a listener left behind would replay every later click a second time.
216
+ // An island something ELSE booted (a held island: `island-hold.ts` boots it at once) had no flush
217
+ // to let go in, so `on` checks the mount marker — set in the same `.then` that settles `el.__x` —
218
+ // and detaches instead of queueing: the press reaches its target natively, once.
208
219
  const RUNTIME_CATCH_UP = `
209
220
  function path(el,t){var p=[t&&t.tagName];
210
221
  for(;t&&t!==el&&t.parentNode;t=t.parentNode)p.unshift(Array.prototype.indexOf.call(t.parentNode.children,t));
@@ -219,7 +230,8 @@ var q=[],done=false;
219
230
  var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
220
231
  var go=function(){boot(el).then(function(){var r=q;off();
221
232
  r.forEach(function(e){var ev=e[0],c=new ev.constructor(ev.type,ev);aim(el,ev,e[1]).dispatchEvent(c)})},off)};
222
- var on=function(ev){if(done)return;q.push([ev,path(el,ev.target)]);go()};
233
+ var on=function(ev){if(done)return;if(el.getAttribute('${ISLAND_MOUNTED_ATTRIBUTE}')!==null){off();return}
234
+ q.push([ev,path(el,ev.target)]);go()};
223
235
  evs.forEach(function(n){el.addEventListener(n,on,true)});return go}
224
236
  `.trim();
225
237
 
@@ -249,9 +261,14 @@ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRA
249
261
  * moment the runtime changes, and the failure it produces is an island that never boots on a page
250
262
  * that otherwise looks correct.
251
263
  */
252
- const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
264
+ const runtimeBody = (
265
+ needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>,
266
+ held: boolean,
267
+ ): string =>
253
268
  [
254
269
  RUNTIME_PRELUDE,
270
+ // Right after the prelude: no strategy part below that throws can strand a held island.
271
+ ...(held ? [RUNTIME_HOLD] : []),
255
272
  // Emitted once for whichever of the two catch-up strategies the page uses; a `visible`-only
256
273
  // page never pays for it.
257
274
  ...(needed.has('idle') || needed.has('interaction') ? [RUNTIME_CATCH_UP] : []),
@@ -260,18 +277,22 @@ const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): st
260
277
  ),
261
278
  ].join('\n');
262
279
 
280
+ /** Whether any island the runtime boots is held — a `never` island boots nothing to reveal it. */
281
+ const holds = (directives: readonly IslandDirective[]): boolean =>
282
+ directives.some((directive) => directive.hold === true && directive.strategy !== 'never');
283
+
263
284
  /**
264
- * Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, seven in
265
- * all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
285
+ * Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, with and
286
+ * without a held island — fourteen in all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
266
287
  * socket opens — the alternative is a per-response nonce, which a `render: 'static'` page (a file
267
288
  * on disk) can never receive. Enumerated rather than derived from the route table because the
268
289
  * runtime is a function of the SET a document needs, and a table read at boot cannot answer for
269
290
  * a document assembled later.
270
291
  */
271
- export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
272
- { length: 2 ** RUNTIME_ORDER.length - 1 },
273
- (_unused, index) =>
274
- runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1))),
292
+ export const HYDRATE_RUNTIME_BODIES: readonly string[] = [false, true].flatMap((held) =>
293
+ Array.from({ length: 2 ** RUNTIME_ORDER.length - 1 }, (_unused, index) =>
294
+ runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1)), held),
295
+ ),
275
296
  );
276
297
 
277
298
  /**
@@ -281,7 +302,7 @@ export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
281
302
  export function hydrateRuntime(directives: readonly IslandDirective[]): string {
282
303
  const needed = requiredStrategies(directives);
283
304
  if (needed.size === 0) return '';
284
- return `<script type="module">${runtimeBody(needed)}</script>`;
305
+ return `<script type="module">${runtimeBody(needed, holds(directives))}</script>`;
285
306
  }
286
307
 
287
308
  /** Rough emitted size of the hydration runtime for a page, for the budget check. */
package/src/index.ts CHANGED
@@ -5,26 +5,21 @@
5
5
  */
6
6
 
7
7
  /**
8
- * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
9
- * because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` all take these types
10
- * in their signatures: a consumer calling this package's API should not need a second import to
11
- * name its arguments. A re-export is not a declaration — `scripts/render-modes.test.ts` refuses a
12
- * second declaration, which is what makes re-exporting safe where copying was not.
8
+ * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`). Its TYPES are re-exported
9
+ * here because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` take them in
10
+ * their signatures; its VALUES (`RENDER_MODES`, `HYDRATE_STRATEGIES`, `OFFLINE_STRATEGIES`) and
11
+ * `formatBytes` are imported from core by every caller — a value re-export is a second import path
12
+ * (`X_HELPER_COPY`, `bun run flight-copies`). Nor are core's page-meta names (`CLIENT_*_META`)
13
+ * re-published beside the tag writers below.
13
14
  */
14
15
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
15
- // `formatBytes` moved to `@ultimat3/core` (one formatter, with the `mb` branch this package's copy
16
- // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
17
- // table it prints against.
18
- export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
19
16
  /** `asset('assets/x.avif')` → the content-hashed URL the site asset surface serves it at. */
20
17
  export type { AssetExtension, AssetPath, AssetResolver } from './asset';
21
18
  export { ASSET_DIR, ASSET_EXTENSIONS, asset, assetPathProblem, setAssetResolver } from './asset';
22
19
  /** The `<meta name="ultimate-path-style">` core's `actionPath` reads — a non-default style only. */
23
- export { CLIENT_PATH_STYLE_META, clientPathStyleTags } from './client-path-style-tag';
20
+ export { clientPathStyleTags } from './client-path-style-tag';
24
21
  /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
25
22
  export {
26
- CLIENT_PERSIST_META,
27
- CLIENT_SCOPE_META,
28
23
  clientPersistTags,
29
24
  clientScopeTag,
30
25
  documentCarriesScope,
@@ -32,9 +27,6 @@ export {
32
27
  /** Where the page's one socket dials, and the worker that hosts it (plan 101, slice 11). */
33
28
  export type { ClientSyncHead } from './client-sync-tags';
34
29
  export {
35
- CLIENT_BUILD_META,
36
- CLIENT_SYNC_META,
37
- CLIENT_SYNC_WORKER_META,
38
30
  clientBootTags,
39
31
  clientSyncTags,
40
32
  } from './client-sync-tags';
@@ -75,7 +67,6 @@ export {
75
67
  headFromMeta,
76
68
  mergeHead,
77
69
  renderHead,
78
- THEME_STORAGE_KEY,
79
70
  themeScript,
80
71
  themeScriptBody,
81
72
  } from './head';
@@ -99,7 +90,8 @@ export {
99
90
  islandModuleId,
100
91
  } from './island';
101
92
  export type { IslandCollector, IslandCollectorInput } from './island-collector';
102
- export { createIslandCollector, islandModuleIds } from './island-collector';
93
+ export { islandCollector, islandModuleIds } from './island-collector';
94
+ export { ISLAND_HOLD_ATTRIBUTE, ISLAND_HOLD_MS, ISLAND_HOLD_REVEAL } from './island-hold';
103
95
  export type { IslandProps, JsonValue } from './island-props';
104
96
  export { ISLAND_PROPS_MAX_BYTES } from './island-props';
105
97
  export { parseByteBudget } from './islands';
@@ -137,8 +129,6 @@ export {
137
129
  NAVIGATING_ATTRIBUTE,
138
130
  NAVIGATION_CACHE_TTL_MS,
139
131
  NAVIGATION_ERROR_EVENT,
140
- NAVIGATION_HEADER,
141
- NAVIGATION_LOCATION_HEADER,
142
132
  NAVIGATION_MAX_HOPS,
143
133
  NAVIGATION_META,
144
134
  NAVIGATION_NO_PREFETCH_ATTRIBUTE,
@@ -147,15 +137,12 @@ export {
147
137
  NAVIGATION_PREFETCH_DELAY_MS,
148
138
  NAVIGATION_PROGRESS_DELAY_MS,
149
139
  NAVIGATION_RELOAD_ATTRIBUTE,
150
- NAVIGATION_SCOPE_HEADER,
151
- NAVIGATION_SURFACE_HEADER,
152
140
  responseVerdict,
153
141
  reusable,
154
142
  } from './navigation-rules';
155
143
  export type { ClientNavigationHead } from './navigation-tags';
156
144
  export { clientNavigationTags } from './navigation-tags';
157
145
  export type {
158
- CompiledPattern,
159
146
  MountedRouteInput,
160
147
  RegisterRouteInput,
161
148
  RouteDescriptor,
@@ -165,8 +152,7 @@ export type {
165
152
  } from './registry';
166
153
  export {
167
154
  clearRoutes,
168
- compilePattern,
169
- describeRoutes,
155
+ describePages,
170
156
  ROUTE_FILENAME,
171
157
  registerMountedRoutes,
172
158
  registerRoute,
@@ -200,11 +186,12 @@ export {
200
186
  defineRoute,
201
187
  isRouteConfig,
202
188
  ROUTE_NAVIGATION_MODES,
203
- tagKeys,
204
189
  } from './route';
205
190
  export type { RouteComponent } from './route-component';
206
191
  export { pageComponentOf } from './route-component';
207
192
  export { metaContextFor, routeDataFor } from './route-data';
193
+ export type { CompiledPattern } from './route-pattern';
194
+ export { compilePattern } from './route-pattern';
208
195
  export { routeStatusOf, withStatus } from './route-status';
209
196
  export type { SpeculationRules } from './speculation-rules';
210
197
  export {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { HydrateStrategy } from '@ultimat3/core';
8
+ import { renderCauseValue } from '@ultimat3/core';
8
9
  import { IslandInvalidError, IslandNotHydratedError } from './errors';
9
10
  import type { IslandDirective } from './hydrate';
10
11
  import { DEFAULT_REPLAY_EVENTS } from './hydrate';
@@ -28,6 +29,12 @@ export interface IslandCollectorInput {
28
29
  readonly hydrate: HydrateStrategy;
29
30
  /** Specifier → built chunk URL. Identity in dev and in tests; the build supplies the real one. */
30
31
  readonly resolve?: (src: string) => string;
32
+ /**
33
+ * Which islands (by `src`, as the page wrote it) must not paint their server markup before they
34
+ * mount (`island-hold.ts`). The caller's to answer — the renderer cannot see what an island's
35
+ * graph imports. Absent, nothing is held.
36
+ */
37
+ readonly hold?: (src: string) => boolean;
31
38
  }
32
39
 
33
40
  /**
@@ -42,7 +49,7 @@ export interface IslandCollector {
42
49
  record(spec: IslandSpec, props: JsxProps): IslandDirective;
43
50
  }
44
51
 
45
- export function createIslandCollector(input: IslandCollectorInput): IslandCollector {
52
+ export function islandCollector(input: IslandCollectorInput): IslandCollector {
46
53
  const directives: IslandDirective[] = [];
47
54
  const entries = new Map<string, string>();
48
55
  const resolve = input.resolve ?? ((src: string) => src);
@@ -71,7 +78,8 @@ export function createIslandCollector(input: IslandCollectorInput): IslandCollec
71
78
 
72
79
  const bag = checkIslandProps(props, spec.propKeys, input.file, spec.moduleId);
73
80
  const instance = directives.filter((d) => d.moduleId === spec.moduleId).length + 1;
74
- const directive = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
81
+ const built = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
82
+ const directive = input.hold?.(spec.src) === true ? { ...built, hold: true } : built;
75
83
  directives.push(directive);
76
84
  return directive;
77
85
  },
@@ -131,9 +139,9 @@ function assertHydrates(strategy: HydrateStrategy, spec: IslandSpec, file: strin
131
139
  function assertEntry(entry: string, spec: IslandSpec, file: string): void {
132
140
  if (isEmittableSpecifier(entry)) return;
133
141
  throw new IslandInvalidError(
134
- `the resolver returned ${JSON.stringify(entry)} for the ${spec.moduleId} island in ${file}, ` +
142
+ `the resolver returned ${renderCauseValue(entry)} for the ${spec.moduleId} island in ${file}, ` +
135
143
  'which cannot be emitted as a module URL',
136
- 'fix the resolve() passed to createIslandCollector — it must return a plain URL path',
144
+ 'fix the resolve() passed to islandCollector — it must return a plain URL path',
137
145
  );
138
146
  }
139
147
 
@@ -142,7 +150,7 @@ export function islandWithoutCollector(spec: IslandSpec): IslandNotHydratedError
142
150
  return new IslandNotHydratedError(
143
151
  `the ${spec.moduleId} island was rendered outside a render that collects islands, so no ` +
144
152
  'hydration runtime is emitted and its chunk is never requested',
145
- 'render the page through renderToHtml(tree, { islands: createIslandCollector({ file, ' +
153
+ 'render the page through renderToHtml(tree, { islands: islandCollector({ file, ' +
146
154
  'hydrate }) }) so the island is counted, booted and budgeted',
147
155
  );
148
156
  }
@@ -0,0 +1,56 @@
1
+ // A HELD island (#506): server markup that must not paint before the island mounts, because what
2
+ // it shows can be older than what the page will show — an offline reload's cached count, under a
3
+ // queued write the mount rebuilds. Hidden from its first byte, booted at once, revealed when the
4
+ // mount settles or at the cap. Who is held is the renderer's caller's call (`hold` on the collector).
5
+
6
+ /** On a held island's wrapper until the runtime reveals it; what the runtime finds it by. */
7
+ export const ISLAND_HOLD_ATTRIBUTE = 'data-x-hold';
8
+
9
+ /**
10
+ * The longest a held island stays hidden, whatever its chunk does — a download on a slow network,
11
+ * a mount that never settles. Above `@ultimat3/realtime`'s `FIRST_PAINT_HOLD_MS` (1 s), the cap on
12
+ * the disk restore a realtime island waits for, so a slow disk meets that cap and still mounts
13
+ * before this one shows the server's markup. Interpolated into the runtime: one number, one copy.
14
+ */
15
+ export const ISLAND_HOLD_MS = 3_000;
16
+
17
+ /**
18
+ * The keyframes that reveal a held island with no script at all — JS off, the runtime blocked, a
19
+ * strategy part that threw before the hold could run. Defined in `@ultimat3/ui`'s `global.scss`
20
+ * (this package may not import `ui`; `island-hold-replay.test.ts` holds the two spellings equal).
21
+ */
22
+ export const ISLAND_HOLD_REVEAL = 'ultimate-hold-reveal';
23
+
24
+ /**
25
+ * The wrapper's attributes. An inline `style` and not a stylesheet rule: it applies from the byte
26
+ * the parser reads (no sheet can have loaded yet), and `@ultimat3/http`'s policy admits a style
27
+ * ATTRIBUTE (`style-src-attr`) where an inline `<style>` would need a hash. `visibility`, never
28
+ * `display`: the island keeps its box, so the reveal shifts no layout. The animation is the reveal
29
+ * that needs no script: a zero-length step to `visible` after the cap, held by `forwards` (reduced
30
+ * motion shortens durations, never delays). The runtime takes both properties off when it reveals.
31
+ */
32
+ export const ISLAND_HOLD_ATTRIBUTES =
33
+ `${ISLAND_HOLD_ATTRIBUTE} ` +
34
+ `style="visibility:hidden;animation:${ISLAND_HOLD_REVEAL} 0s ${ISLAND_HOLD_MS}ms forwards"`;
35
+
36
+ // Booted at once, whatever the route's strategy: a hidden island receives no press and sits in no
37
+ // idle slot worth waiting for, so `interaction` would hold it until the cap and show the stale
38
+ // markup anyway. `boot` is the prelude's, so the strategy below chains on the SAME `el.__x` and
39
+ // mounts nothing twice. `el.__h`, not `each`'s `el.__v`: `each` would mark the root visited and
40
+ // the strategy's own pass would then skip it. Emitted FIRST after the prelude (`hydrate.ts`), so
41
+ // a strategy part that throws — an old WebView with no IntersectionObserver — cannot strand it.
42
+ // The strategy's capture listeners let go on the first event after the mount (`catchUp`'s `on`),
43
+ // since the hold, not a caught event, is what booted the island.
44
+ //
45
+ // Revealed TOGETHER, once every held island of this pass has settled (or at the cap): one record
46
+ // shown in two islands is rebuilt by whichever island holds its writer — a queued write's overlay
47
+ // is restored inside THAT island's mount — so a reader revealed at its own mount painted the
48
+ // record without it (#506, measured in `offline-like.e2e.test.ts`). A failed mount counts as
49
+ // settled (`hush`): its server markup is shown rather than hidden forever.
50
+ export const RUNTIME_HOLD = `
51
+ var hs=[];Array.prototype.forEach.call(document.querySelectorAll('[${ISLAND_HOLD_ATTRIBUTE}]'),function(el){
52
+ if(!el.__h){el.__h=1;hs.push(el)}});
53
+ if(hs.length){var t,r=function(){clearTimeout(t);hs.forEach(function(el){el.removeAttribute('${ISLAND_HOLD_ATTRIBUTE}');
54
+ el.style.removeProperty('visibility');el.style.removeProperty('animation')})};
55
+ t=setTimeout(r,${ISLAND_HOLD_MS});Promise.all(hs.map(function(el){return boot(el).then(null,hush)})).then(r)}
56
+ `.trim();
@@ -173,7 +173,7 @@ export function checkIslandProps(
173
173
  'and every one of them ships inside the HTML on every request — ' +
174
174
  heaviest.map(([key, size]) => `props.${key} is ${size} B of the ${bytes}`).join(', '),
175
175
  `in ${file}, pass ${heaviest.map(([key]) => `\`${key}: []\``).join(' and ')} beside ` +
176
- `\`${first}Endpoint: derivePath('<queryName>')\` (@ultimat3/query) and fetch the rows inside ` +
176
+ `\`${first}Endpoint: queryPath('<queryName>')\` (@ultimat3/core) and fetch the rows inside ` +
177
177
  'the island after mount — a list that is the same on every request is a dataset, not a ' +
178
178
  'prop; an id, a count and a URL are',
179
179
  );
package/src/island.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * graph (axiom 6). WHEN it wakes is the route's `hydrate`, never a second declaration here.
7
7
  */
8
8
 
9
+ import { renderFixShellArg } from '@ultimat3/core';
9
10
  import { IslandInvalidError } from './errors';
10
11
  import type { JsonValue } from './island-props';
11
12
  import type { JsxProps } from './jsx';
@@ -25,7 +26,7 @@ const UNSAFE_SPECIFIER = /["'`<>\s\\]/;
25
26
 
26
27
  /** The same test the resolver's output has to pass: one rule, applied at both ends of the seam. */
27
28
  export function isEmittableSpecifier(value: string): boolean {
28
- return value.length > 0 && !UNSAFE_SPECIFIER.test(value);
29
+ return typeof value === 'string' && value.length > 0 && !UNSAFE_SPECIFIER.test(value);
29
30
  }
30
31
 
31
32
  export interface IslandDeclaration<TKeys extends readonly string[] = readonly string[]> {
@@ -195,7 +196,10 @@ function normalizeIsland(declaration: IslandDeclaration): IslandSpec {
195
196
  throw new IslandInvalidError(
196
197
  `island src ${JSON.stringify(src)} is not an island file: a module ships to the browser ` +
197
198
  `only if its name says so, and ${ISLAND_EXTENSION} is the one spelling that says it`,
198
- `git mv -- ${src} ${stem}${ISLAND_EXTENSION}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
199
+ // Screened (security audit of plan 101 sweep 1c, M1): `UNSAFE_SPECIFIER` lets `$(…)`, `;`,
200
+ // `|` and `&` through. A leading `./` is dropped first — the same path to a shell, and
201
+ // `renderFixShellArg` refuses a leading `.` — so the common specifier stays pasteable.
202
+ `git mv -- ${renderFixShellArg(src.replace(/^\.\//, ''), "'<module>'")} ${renderFixShellArg(`${stem.replace(/^\.\//, '')}${ISLAND_EXTENSION}`, `'<name>${ISLAND_EXTENSION}'`)}, then pass src: '${stem}${ISLAND_EXTENSION}'`,
199
203
  );
200
204
  }
201
205
 
package/src/modes.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
9
- import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
9
+ import { HYDRATE_STRATEGIES, RENDER_MODES, renderCauseValue } from '@ultimat3/core';
10
10
  import { parseTtlMs } from './duration';
11
11
  import { RouteModeInvalidError } from './errors';
12
12
  import type { RouteConfig } from './route';
@@ -84,14 +84,14 @@ export function assertModeShape(config: RouteShape): void {
84
84
  : undefined;
85
85
  if (spec === undefined) {
86
86
  throw new RouteModeInvalidError(
87
- `render: ${JSON.stringify(config.render)} is not a render mode`,
87
+ `render: ${renderCauseValue(config.render)} is not a render mode`,
88
88
  `use one of ${RENDER_MODES.join(' | ')}`,
89
89
  );
90
90
  }
91
91
 
92
92
  if (!HYDRATE_STRATEGIES.includes(config.hydrate)) {
93
93
  throw new RouteModeInvalidError(
94
- `hydrate: ${JSON.stringify(config.hydrate)} is not a hydration strategy`,
94
+ `hydrate: ${renderCauseValue(config.hydrate)} is not a hydration strategy`,
95
95
  `use one of ${HYDRATE_STRATEGIES.join(' | ')}`,
96
96
  );
97
97
  }
@@ -200,6 +200,17 @@ export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext):
200
200
  );
201
201
  }
202
202
 
203
+ // `js` is the one budget anything measures. `lcp`, `css`, `cls` and `tbt` were each accepted,
204
+ // stored and compared by nothing that had a measurement — a false green — so a key the type no
205
+ // longer declares, arriving by a cast or from plain JS, is refused rather than carried.
206
+ for (const key of Object.keys(config.budget)) {
207
+ if (key === 'js') continue;
208
+ throw new RouteModeInvalidError(
209
+ `${ctx.file} declares budget.${key}, which nothing in the framework measures — only budget.js is weighed (x verify's budgets step). In ${ctx.file}, delete ${key}: the whole budget is the js key`,
210
+ "budget: { js: '10kb' }",
211
+ );
212
+ }
213
+
203
214
  // `budget` is always on the descriptor; `budget.js` is the field that stays optional,
204
215
  // and its absence is the failure — site/ is 0kb until a route says otherwise, in bytes.
205
216
  if (ctx.surface === 'site' && config.hydrate !== 'never' && config.budget.js === undefined) {
@@ -4,12 +4,10 @@
4
4
  * `bun test` all load a component the same way and there is no separate "bundled" behaviour.
5
5
  */
6
6
 
7
- // why: Bun ships no path API, and a relative app root has to be resolved against the working
8
- // directory before it can be compared with the absolute paths the Bun plugin hands this loader.
9
- import { resolve } from 'node:path';
10
7
  import { renderThrowable } from '@ultimat3/core';
11
8
  import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
12
9
  import { PrerenderFailedError } from './errors';
10
+ import { comparablePath, isPathBelow, pathBelow } from './stylesheet-path';
13
11
  import type { Surface } from './surfaces';
14
12
  import { surfaceUnder } from './surfaces';
15
13
 
@@ -42,7 +40,8 @@ const RENDER_PACKAGE = '@ultimat3/render';
42
40
  * `Bun.plugin` ignores `onResolve` outright — the callback never fires, from module scope or from
43
41
  * a `--preload`, and a virtual namespace never resolves — and ignores `resolveDir` on an `onLoad`
44
42
  * result. Neither seam can carry the resolution, so the specifier must. Revisit if Bun wires
45
- * either: an `onResolve` hook would leave every compiled module byte-identical.
43
+ * either: an `onResolve` hook would leave every compiled module byte-identical. Upstream:
44
+ * https://github.com/oven-sh/bun/issues/40579.
46
45
  *
47
46
  * The fallback is the status quo, for the one frame that cannot answer: inside a
48
47
  * `bun build --compile` binary `import.meta.url` is `file:///$bunfs/root/<name>` and
@@ -120,9 +119,12 @@ export function registeredStylesheets(): readonly Stylesheet[] {
120
119
  /**
121
120
  * The directory a sheet's surface is read below. Absent, the process's working directory — which
122
121
  * is the app root in the container (`WORKDIR /app`) and under every `x` command run from one.
122
+ * Held as a `comparablePath`, like every claim: on Windows each arrives as `D:\app\…`.
123
123
  */
124
124
  let stylesheetRoot: string | undefined;
125
125
 
126
+ const rootOfSheets = (): string => stylesheetRoot ?? comparablePath(process.cwd());
127
+
126
128
  /**
127
129
  * Directories a package claimed for ONE surface, longest first. A package's sheet has no surface
128
130
  * of its own and rides both graphs — right for a design system, and the reason an app's static
@@ -130,11 +132,13 @@ let stylesheetRoot: string | undefined;
130
132
  */
131
133
  const claims: { readonly dir: string; readonly surface: Surface }[] = [];
132
134
 
133
- const claimedSurface = (path: string): Surface | undefined =>
134
- claims.find((claim) => path.startsWith(`${claim.dir}/`))?.surface;
135
+ const claimedSurface = (path: string): Surface | undefined => {
136
+ const comparable = comparablePath(path);
137
+ return claims.find((claim) => isPathBelow(comparable, claim.dir))?.surface;
138
+ };
135
139
 
136
140
  const surfaceOfSheet = (path: string): Surface | null =>
137
- claimedSurface(path) ?? surfaceUnder(stylesheetRoot ?? process.cwd(), path);
141
+ claimedSurface(path) ?? surfaceUnder(rootOfSheets(), comparablePath(path));
138
142
 
139
143
  /** Every registered sheet, classified again; the revision moves only when an answer does. */
140
144
  const reclassify = (): void => {
@@ -157,7 +161,7 @@ const reclassify = (): void => {
157
161
  * it standing, because the module that made it is cached and will not make it again.
158
162
  */
159
163
  export function claimStylesheets(dir: string, surface: Surface): void {
160
- const claimed = resolve(dir);
164
+ const claimed = comparablePath(dir);
161
165
  if (claims.some((claim) => claim.dir === claimed && claim.surface === surface)) return;
162
166
  claims.push({ dir: claimed, surface });
163
167
  claims.sort((a, b) => b.dir.length - a.dir.length);
@@ -174,7 +178,7 @@ export function claimStylesheets(dir: string, surface: Surface): void {
174
178
  */
175
179
  export function setStylesheetRoot(root: string | undefined): void {
176
180
  // Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
177
- stylesheetRoot = root === undefined ? undefined : resolve(root);
181
+ stylesheetRoot = root === undefined ? undefined : comparablePath(root);
178
182
  reclassify();
179
183
  }
180
184
 
@@ -221,11 +225,12 @@ const OWNER_RANK = (sheet: Stylesheet): number =>
221
225
  // A claimed sheet is a package's whichever surface carries it: library first.
222
226
  sheet.surface === null || sheet.claimed ? 0 : sheet.surface === 'shared' ? 1 : 2;
223
227
 
224
- /** Relative to the app root, so a container at `/app` and a laptop order one app alike. */
225
- const orderPath = (sheet: Stylesheet): string => {
226
- const root = stylesheetRoot ?? process.cwd();
227
- return sheet.file.startsWith(`${root}/`) ? sheet.file.slice(root.length + 1) : sheet.file;
228
- };
228
+ /**
229
+ * Relative to the app root and `/`-separated, so a container at `/app`, a laptop and a Windows
230
+ * checkout at `D:\app` order one app alike — and so mint one stylesheet hash.
231
+ */
232
+ const orderPath = (sheet: Stylesheet): string =>
233
+ pathBelow(comparablePath(sheet.file), rootOfSheets());
229
234
 
230
235
  /** See `stylesFor`: global layer, then owner, then path. Exported for the tests that pin it. */
231
236
  export function stylesheetOrder(a: Stylesheet, b: Stylesheet): number {
@@ -39,6 +39,28 @@ export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAr
39
39
  export const fieldText = (value: unknown): string =>
40
40
  value instanceof File ? value.name : String(value);
41
41
 
42
+ /** Every lone CR, lone LF and CRLF in `text`, as CRLF. */
43
+ const crlf = (text: string): string => text.replace(/\r\n|\r|\n/g, '\r\n');
44
+
45
+ /**
46
+ * The fields as a GET query or a urlencoded body carries them — names and values with their line
47
+ * breaks normalised to CRLF, as the browser's own submit does (HTML's "convert to a list of
48
+ * name-value pairs"). A `<textarea>`'s value holds bare LFs; sent as they were, the router's
49
+ * submit and a native one delivered two different bodies for one form. A multipart body needs
50
+ * none of this: `fetch` encodes a `FormData` with the same normalisation.
51
+ */
52
+ export const formPairs = (fields: FormData): readonly (readonly [string, string])[] =>
53
+ [...fields].map(([name, value]: [string, unknown]) => [crlf(name), crlf(fieldText(value))]);
54
+
55
+ /**
56
+ * A form's action, resolved against the document — or the raw text when it does not resolve, which
57
+ * `formVerdict` leaves to the browser (`'unparsable'`) rather than throwing from a submit handler.
58
+ */
59
+ export function formAction(raw: string, fallback: string, base: string): string {
60
+ const action = raw || fallback;
61
+ return URL.canParse(action, base) ? new URL(action, base).href : action;
62
+ }
63
+
42
64
  /** The submitted fields, the submitter's own name/value included, as the browser would send. */
43
65
  export function formFields(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
44
66
  try {
@@ -59,7 +81,7 @@ const hush = (): void => undefined;
59
81
  export async function transition(
60
82
  win: Window,
61
83
  apply: () => void,
62
- track?: (running: RunningTransition | undefined) => void,
84
+ track?: (running: RunningTransition, finished: boolean) => void,
63
85
  ): Promise<void> {
64
86
  const doc = win.document as Document & {
65
87
  startViewTransition?: (update: () => void) => RunningTransition;
@@ -70,12 +92,12 @@ export async function transition(
70
92
  return;
71
93
  }
72
94
  const running = doc.startViewTransition(apply);
73
- track?.(running);
95
+ track?.(running, false);
74
96
  running.ready.catch(hush);
75
- running.finished.then(
76
- () => track?.(undefined),
77
- () => track?.(undefined),
78
- );
97
+ // The transition itself, never "whichever is running": an older one finishing after a newer one
98
+ // started must not clear the newer one (the caller compares by identity).
99
+ const done = (): void => track?.(running, true);
100
+ running.finished.then(done, done);
79
101
  await running.updateCallbackDone;
80
102
  }
81
103