@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/CLAUDE.md +18 -11
- package/README.md +38 -15
- package/package.json +8 -8
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +59 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +3 -3
- package/src/head.ts +12 -20
- package/src/html.ts +10 -22
- package/src/hydrate.ts +34 -13
- package/src/index.ts +12 -25
- package/src/island-collector.ts +13 -5
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +6 -2
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +19 -14
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-fetch.ts +12 -11
- package/src/navigation-rules.ts +37 -27
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +44 -63
- package/src/registry.ts +37 -46
- package/src/render-html.ts +8 -6
- package/src/render-isr.ts +131 -69
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +21 -7
- package/src/render-stream.ts +7 -7
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +13 -19
- package/src/server.ts +2 -1
- package/src/static-path.ts +15 -2
- package/src/stylesheet-path.ts +34 -0
- package/src/type-pins.tsx +4 -3
package/src/html.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML text and attribute serialization for the server renderer.
|
|
3
|
-
*
|
|
4
|
-
* in an attribute is an
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
-
* `
|
|
31
|
+
* `escapeHtml` there would ship `<` 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
|
|
104
|
+
* entity-DECODED and then parsed as HTML, so an escaped `<script>` 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="${
|
|
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}="${
|
|
169
|
+
return `${attribute}="${escapeHtml(url)}"`;
|
|
182
170
|
}
|
|
183
|
-
return `${attribute}="${
|
|
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 {
|
|
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}="${
|
|
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="${
|
|
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;
|
|
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 = (
|
|
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,
|
|
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[] =
|
|
272
|
-
{ length: 2 ** RUNTIME_ORDER.length - 1 },
|
|
273
|
-
|
|
274
|
-
|
|
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`)
|
|
9
|
-
* because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor`
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
package/src/island-collector.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 ${
|
|
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
|
|
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:
|
|
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();
|
package/src/island-props.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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: ${
|
|
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: ${
|
|
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) {
|
package/src/module-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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 :
|
|
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
|
-
/**
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 {
|
package/src/navigation-dom.ts
CHANGED
|
@@ -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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|