@ultimat3/render 20.2.0 → 21.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 +5 -2
- package/README.md +9 -9
- package/package.json +6 -6
- package/src/client-scope-tag.ts +49 -0
- package/src/client-sync-tags.ts +44 -0
- package/src/hydrate.ts +43 -12
- package/src/index.ts +17 -0
- package/src/modes.ts +11 -10
package/CLAUDE.md
CHANGED
|
@@ -62,9 +62,10 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
62
62
|
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit, one per non-empty subset of the three strategies, seven in all. It is emitted INLINE in every document carrying an island, and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so under the enforced policy a container serves (`dev: false`) **no island ever booted** — invisible in `x dev`, where the policy is report-only. `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot, the mirror of `style-csp.ts`. Hashes and not a nonce, for `cspHashSource`'s own reason: a `render: 'static'` page is a file on disk. Never restate the concatenation — `runtimeBody` is the one place the served text and the hashed text are the same string. **Still uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>`, whose body is per-response and cannot be hashed. Unreachable today (`dev-render.ts` passes `holes: []`), and the first real hole needs a nonce, not a hash. |
|
|
63
63
|
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
|
|
64
64
|
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. As a flag, a second interaction while the chunk was still loading got a resolved promise back and the replay queue flushed into an island that had not mounted — the events went nowhere and the listeners were already removed. |
|
|
65
|
-
| Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle`
|
|
65
|
+
| Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle` 1,744, `visible` 846, `interaction` 1,629 (`As of 2026-09-22`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from — `idle` moved 774 -> 1,744 and `interaction` 1,251 -> 1,629 when both began sharing `catchUp` and aiming by path, the rows below). |
|
|
66
66
|
| A runtime that calls `boot` | TERMINATES the chain, because `boot` rethrows. `idle` and `visible` end in `.catch(hush)`; `interaction` passes `off` as the rejection arm of its `then`. A bare `boot(el)` produced a fresh rejected promise per call — one unhandled rejection per user event on an island whose `mount()` threw — and, on `interaction`, left `done` false, the listeners attached and the queue growing by one retained `Event` (each with a live `target`) per click, for an island that will never mount. Nothing is lost by swallowing here: the DOM already carries the failure as `data-x-failed`, which is the row above and the documented observable. `hydrate-runtime.test.ts` runs all three against a real module; Bun's runner fails a test on an unhandled rejection, so the omission reds the suite by itself. |
|
|
67
|
-
|
|
|
67
|
+
| `idle` replays too | An `idle` island is pressable for the idle wait plus its chunk's download, and a press in that window reached a node with no handler: every app's first click could vanish. `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` — capture listeners for `data-x-events` (default `click`), a queue, `aim`, one flush — which answers `go`: a caught event calls it (so a press wakes an `idle` island early), and `idle` calls it again from `requestIdleCallback`. Every `go` chains on the same `el.__x` and the first flush sets `done`, so each event replays once and an untouched island still drops its listeners at mount. Emitted once when either strategy is on the page; a `visible`-only page pays nothing. `hydrate-replay.test.ts`'s idle block holds it. |
|
|
68
|
+
| Where `interaction` replays | `aim(el, ev)` in `hydrate.ts`, never `ev.target`. Every island's `mount` opens with `el.textContent = ''` — the documented idiom, and what `settings`, `feed` and `like` all do — so the node the visitor pressed is DETACHED by the time the replay runs and `ev.target.dispatchEvent(c)` reached nothing: the first press did nothing and the second worked, which reads as a slow network and is never filed as a bug. `examples/dummy/apps/web/app/posts/[id]/page.tsx` declares `hydrate: 'idle'` in writing to avoid it. The runtime CAN tell the two mounts apart — `el.contains(ev.target)` AFTER the mount is the exact question — so this is a repair and not a refusal: refusing the pairing would delete a strategy that works today for a takeover-style island (`contact-sales.island.tsx` attaches to the server's own form and replaces nothing). Kept → the original target. Replaced → `document.elementFromPoint(ev.clientX, ev.clientY)`, which is where the event would land had the visitor pressed a moment later. The island ROOT is the last resort and never the repair: Solid's delegated listener sits on `document` and walks UP from the target (`solid-js/web`'s `eventHandler`), so a handler on a CHILD of the root is never visited and dispatching at the root fixes nothing for the canonical island. A hit landing outside this island falls back to the root too — synthesizing a click on an element the visitor never pressed is worse than losing the replay. `typeof ev.clientX === 'number'`, never `ev.clientX || ev.clientY`: (0, 0) is a coordinate — unless `ev.detail === 0`, a keyboard-activated or scripted `click()`, which fires at (0, 0) and names no point. Between the hit test and the root sits the STRUCTURAL answer: `path(el, target)` records the child-element indices and tag when the event is caught, and the replay walks them in the mounted tree — same tag at the same place is the island's render of the pressed control; a different tag falls to the root. `hydrate-replay.test.ts` holds it, and it is a separate file because `hydrate-runtime.test.ts`'s element is BOTH the island root and every event's target — the two answers are the same node there, which is how this survived. |
|
|
68
69
|
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string. Exported because a second reader has to agree — `x shot` waits before it photographs, and a settle shorter than this deadline reports an unhydrated page for one that hydrates perfectly. A constant the emitted string restates instead of reading is worse than no constant. |
|
|
69
70
|
| Route truth | `registry.ts`. Never keep a second route list anywhere, and never a second *matcher*: this package's `matchRoute` was deleted in 2026-08 with zero consumers, because `@ultimat3/http`'s trie (`stages.ts`) is the one that serves requests and two matchers with different precedence rules is two answers to "which route is this?". `routeFor` is an exact-path `Map` lookup, not a pattern matcher. |
|
|
70
71
|
| 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. |
|
|
@@ -95,6 +96,8 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
95
96
|
| Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
|
|
96
97
|
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
|
|
97
98
|
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once — `index.ts` until the barrel split, and it cannot be there again: the client barrel would carry `sass` and `node:url`. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. Anything that loads an app's `.tsx` reaches `@ultimat3/render/server` first, which is why `packages/cli/src/app-load.ts` imports it for the side effect and nothing else. |
|
|
99
|
+
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync` (the socket URL: `/_x/sync` or a declared `wss://…`), `ultimate-build` (the build id a script cannot read off `x-ultimate-build`) and `ultimate-sync-worker` (absent when no worker was built). Principal-free, so every document carries them. The three names are literals here until core or realtime owns constants a reader imports. `As of 2026-09-22`. |
|
|
100
|
+
| Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope" content="<opaque>">`, read once by core's `pageClient()` (plan 101). `documentCarriesScope(headers)` is the ONE rule for which documents may carry it: `cache-control` says `private` — a gated `ssr` page and every `stream`. A `static`/`isr`/ungated-`ssr` document is shareable and carries NO tag; absent means "not rendered for anyone", never `''` (anonymous). The literal `ultimate-scope` is duplicated in core's reader because core cannot import this package — the pin is owed once core exports its constant. `As of 2026-09-22`. |
|
|
98
101
|
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
|
|
99
102
|
| 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. |
|
|
100
103
|
| 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 `<` corrupts the code AND leaves the hole. |
|
package/README.md
CHANGED
|
@@ -210,17 +210,17 @@ production Solid, `As of 2026-08`:
|
|
|
210
210
|
| `render(() => <p>hello</p>, el)` — the floor, before an author writes a line | 12,588 |
|
|
211
211
|
| a signal, a button and reactive text | 13,663 |
|
|
212
212
|
| `settings.island.tsx`, the heaviest island this repo ships | 17,797 |
|
|
213
|
-
| one directive's hydration runtime at `hydrate: 'idle'` |
|
|
214
|
-
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 1,
|
|
213
|
+
| one directive's hydration runtime at `hydrate: 'idle'`, what an `app/` island gets from `defaultHydrate` | 1,744 |
|
|
214
|
+
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 1,629 |
|
|
215
215
|
|
|
216
|
-
17,797 + 1,
|
|
217
|
-
without writing a number down. `
|
|
218
|
-
|
|
219
|
-
`
|
|
216
|
+
17,797 + 1,744 = **19,541** — the heaviest island this repo ships, plus the costlier of the two
|
|
217
|
+
runtimes an app pays without writing a number down. `idle` became the costlier on 2026-09-22, when
|
|
218
|
+
it learned to catch a press made before it mounted and replay it through the same `catchUp` as
|
|
219
|
+
`interaction`, and both learned to aim a keyboard press by the pressed node's path (774 -> 1,744);
|
|
220
|
+
`DEFAULT_ISLAND_HYDRATE` is `'interaction'` ([`route.ts:34`](src/route.ts)) at 1,629.
|
|
220
221
|
|
|
221
|
-
The default is **20,480** (20kb),
|
|
222
|
-
|
|
223
|
-
writes breaks. 20kb leaves 1,432 B, and stays under 2× 19,048 — so a route that bundles the same
|
|
222
|
+
The default is **20,480** (20kb), the next whole kilobyte above that number: 939 B of headroom,
|
|
223
|
+
and under 2× 19,541 — so a route that bundles the same
|
|
224
224
|
island twice is still refused. All three clauses are assertions in
|
|
225
225
|
[`island-budget.test.ts`](src/island-budget.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the
|
|
226
226
|
measured table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/i18n": "
|
|
42
|
-
"@ultimat3/seo": "
|
|
43
|
-
"sass": "1.
|
|
39
|
+
"@ultimat3/cache": "21.0.0",
|
|
40
|
+
"@ultimat3/core": "21.0.0",
|
|
41
|
+
"@ultimat3/i18n": "21.0.0",
|
|
42
|
+
"@ultimat3/seo": "21.0.0",
|
|
43
|
+
"sass": "1.104.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's client scope, as a `<head>` tag: `<meta name="ultimate-scope" content="…">`, read
|
|
3
|
+
* once by `@ultimat3/core`'s `pageClient()` when the page's client handle is created. Only a
|
|
4
|
+
* PRIVATE document carries one (`documentCarriesScope`): anything a shared cache may hold would
|
|
5
|
+
* serve one visitor's scope to the next, and a store fenced as the wrong principal is the leak the
|
|
6
|
+
* fence exists to stop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CLIENT_PERSIST_META, CLIENT_SCOPE_META } from '@ultimat3/core';
|
|
10
|
+
import type { HeadTag } from './head';
|
|
11
|
+
|
|
12
|
+
/** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
|
|
13
|
+
export { CLIENT_PERSIST_META, CLIENT_SCOPE_META };
|
|
14
|
+
|
|
15
|
+
/** `scope` is opaque (`@ultimat3/auth`'s `clientScopeOf`), and `''` is the anonymous page. */
|
|
16
|
+
export function clientScopeTag(scope: string): HeadTag {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'meta',
|
|
19
|
+
key: `meta:${CLIENT_SCOPE_META}`,
|
|
20
|
+
attrs: { name: CLIENT_SCOPE_META, content: scope },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The record types this app keeps on disk (`entity(name, { persist: true })`), for the page's
|
|
26
|
+
* persister. Beside the scope and ONLY beside it: persistence is per principal, so a document with
|
|
27
|
+
* no scope has nothing to persist under. None persisted is no tag — absent already means "none".
|
|
28
|
+
*/
|
|
29
|
+
export function clientPersistTags(types: readonly string[]): readonly HeadTag[] {
|
|
30
|
+
if (types.length === 0) return [];
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
kind: 'meta',
|
|
34
|
+
key: `meta:${CLIENT_PERSIST_META}`,
|
|
35
|
+
attrs: { name: CLIENT_PERSIST_META, content: [...types].sort().join(',') },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a document with these response headers may carry a scope: its `cache-control` says
|
|
42
|
+
* `private`. Read off the headers the mode ALREADY decided — `ssrHeaders` makes a gated page
|
|
43
|
+
* private and an ungated one `public, s-maxage`, a stream is always private, `static`/`isr` never —
|
|
44
|
+
* so this is not a second opinion on what is shareable.
|
|
45
|
+
*/
|
|
46
|
+
export function documentCarriesScope(headers: Readonly<Record<string, string>>): boolean {
|
|
47
|
+
const cacheControl = headers['cache-control'] ?? '';
|
|
48
|
+
return cacheControl.split(',').some((directive) => directive.trim().toLowerCase() === 'private');
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's sync target as `<head>` tags, read by `@ultimat3/realtime`'s tab-side socket host:
|
|
3
|
+
* where the one socket dials, which build the page was rendered by, and which worker script hosts
|
|
4
|
+
* the socket. Principal-free, so a shareable document may carry them — unlike the scope tag.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core';
|
|
8
|
+
import type { HeadTag } from './head';
|
|
9
|
+
|
|
10
|
+
/** Core's names (`page-meta.ts`), so the writer here and every reader share one literal each. */
|
|
11
|
+
export { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META };
|
|
12
|
+
|
|
13
|
+
export interface ClientSyncHead {
|
|
14
|
+
readonly syncUrl: string;
|
|
15
|
+
readonly buildId: string;
|
|
16
|
+
readonly workerUrl?: string | undefined;
|
|
17
|
+
/** `/_x/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
|
|
18
|
+
readonly bootUrl?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function clientSyncTags(head: ClientSyncHead): readonly HeadTag[] {
|
|
22
|
+
return [
|
|
23
|
+
meta(CLIENT_SYNC_META, head.syncUrl),
|
|
24
|
+
meta(CLIENT_BUILD_META, head.buildId),
|
|
25
|
+
...(head.workerUrl === undefined ? [] : [meta(CLIENT_SYNC_WORKER_META, head.workerUrl)]),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const meta = (name: string, content: string): HeadTag => ({
|
|
30
|
+
kind: 'meta',
|
|
31
|
+
key: `meta:${name}`,
|
|
32
|
+
attrs: { name, content },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The page boot as one deferred classic script: it runs before every island module that follows it
|
|
37
|
+
* in the document, and ONCE per page — the disk restore and the outbox are the page's job, never
|
|
38
|
+
* each island's. Rendered only where there is a principal to restore for (`dev-render.ts`).
|
|
39
|
+
*/
|
|
40
|
+
export function clientBootTags(head: ClientSyncHead): readonly HeadTag[] {
|
|
41
|
+
return head.bootUrl === undefined
|
|
42
|
+
? []
|
|
43
|
+
: [{ kind: 'script', key: 'script:ultimate-boot', attrs: { src: head.bootUrl, defer: true } }];
|
|
44
|
+
}
|
package/src/hydrate.ts
CHANGED
|
@@ -135,9 +135,14 @@ function hush(){}
|
|
|
135
135
|
// `hush` above: `boot` rethrows, so every runtime below has to terminate the chain it starts or
|
|
136
136
|
// the page reports an unhandled rejection for a failure it already recorded on the element.
|
|
137
137
|
|
|
138
|
+
// `idle` hydrates on the browser's schedule, not the visitor's, so its server-rendered controls
|
|
139
|
+
// are pressable for the idle wait plus the chunk's download — up to IDLE_HYDRATE_TIMEOUT_MS and
|
|
140
|
+
// more. A press in that window reached a node with no handler and was lost, which is every app's
|
|
141
|
+
// first click. It catches up exactly as `interaction` does, through the one `catchUp` below; the
|
|
142
|
+
// only difference left between the two is that `idle` also boots when the browser goes idle.
|
|
138
143
|
const RUNTIME_IDLE = `
|
|
139
144
|
each('[data-x-hydrate="idle"]',function(el){
|
|
140
|
-
var go=
|
|
145
|
+
var go=catchUp(el);
|
|
141
146
|
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:${IDLE_HYDRATE_TIMEOUT_MS}});else setTimeout(go,1)})
|
|
142
147
|
`.trim();
|
|
143
148
|
|
|
@@ -171,7 +176,16 @@ io.observe(el)})
|
|
|
171
176
|
// root is never visited. The root is the last resort, not the repair: it is where an event with no
|
|
172
177
|
// coordinates goes (a `keydown` has no `clientX`), and where a hit landing outside this island goes
|
|
173
178
|
// — synthesizing a click on an element the visitor never pressed is worse than losing the replay.
|
|
174
|
-
// `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate
|
|
179
|
+
// `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate — but not when
|
|
180
|
+
// `ev.detail` is 0: a keyboard-activated or scripted `click()` fires at (0, 0) and names no point,
|
|
181
|
+
// so hit-testing the page's corner found nothing of the island and the press went to the root.
|
|
182
|
+
//
|
|
183
|
+
// Between the hit test and the root sits the STRUCTURAL answer: `path` records, when the event is
|
|
184
|
+
// caught, the child-element indices from the root to the target plus the target's tag, and `aim`
|
|
185
|
+
// walks the same indices in the mounted tree. Same tag at the same place is the island's own render
|
|
186
|
+
// of the control that was pressed — which is what a keyboard press, a `keydown`, and a hit landing
|
|
187
|
+
// on a sticky header all lack otherwise. A different tag there is a different control and falls
|
|
188
|
+
// through to the root, for the stranger rule above.
|
|
175
189
|
//
|
|
176
190
|
// `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
|
|
177
191
|
// `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
|
|
@@ -181,18 +195,32 @@ io.observe(el)})
|
|
|
181
195
|
// grew by one retained `Event` — each holding a live `target` — per click, for an island that
|
|
182
196
|
// will never mount. Swallowing here loses no signal: the DOM already carries the failure as
|
|
183
197
|
// `data-x-failed`, which is the documented observable.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
each
|
|
189
|
-
|
|
198
|
+
//
|
|
199
|
+
// `catchUp(el)` attaches the capture listeners and answers `go`, the one boot-then-flush both
|
|
200
|
+
// strategies call: a caught event calls it, and `idle` calls it again from its idle callback. Every
|
|
201
|
+
// `go` chains on the same `el.__x`, and the first flush empties `q` and sets `done`, so however many
|
|
202
|
+
// ran, each caught event is replayed exactly once and an untouched island still lets go of its
|
|
203
|
+
// listeners at mount — a listener left behind would replay every later click a second time.
|
|
204
|
+
const RUNTIME_CATCH_UP = `
|
|
205
|
+
function path(el,t){var p=[t&&t.tagName];
|
|
206
|
+
for(;t&&t!==el&&t.parentNode;t=t.parentNode)p.unshift(Array.prototype.indexOf.call(t.parentNode.children,t));
|
|
207
|
+
return t===el?p:null}
|
|
208
|
+
function aim(el,ev,p){var t=ev.target;if(t&&el.contains(t))return t;
|
|
209
|
+
var x=ev.clientX,h=typeof x==='number'&&ev.detail!==0?document.elementFromPoint(x,ev.clientY):null;
|
|
210
|
+
if(h&&el.contains(h))return h;
|
|
211
|
+
for(var n=el,i=0;p&&n&&i<p.length-1;i++)n=n.children[p[i]];
|
|
212
|
+
return p&&n&&n.tagName===p[p.length-1]?n:el}
|
|
213
|
+
function catchUp(el){var evs=(el.getAttribute('data-x-events')||'click').split(' ');
|
|
190
214
|
var q=[],done=false;
|
|
191
215
|
var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
|
|
192
|
-
var
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
evs.forEach(function(n){el.addEventListener(n,on,true)})}
|
|
216
|
+
var go=function(){boot(el).then(function(){var r=q;off();
|
|
217
|
+
r.forEach(function(e){var ev=e[0],c=new ev.constructor(ev.type,ev);aim(el,ev,e[1]).dispatchEvent(c)})},off)};
|
|
218
|
+
var on=function(ev){if(done)return;q.push([ev,path(el,ev.target)]);go()};
|
|
219
|
+
evs.forEach(function(n){el.addEventListener(n,on,true)});return go}
|
|
220
|
+
`.trim();
|
|
221
|
+
|
|
222
|
+
const RUNTIME_INTERACTION = `
|
|
223
|
+
each('[data-x-hydrate="interaction"]',catchUp)
|
|
196
224
|
`.trim();
|
|
197
225
|
|
|
198
226
|
const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>> = {
|
|
@@ -220,6 +248,9 @@ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRA
|
|
|
220
248
|
const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
|
|
221
249
|
[
|
|
222
250
|
RUNTIME_PRELUDE,
|
|
251
|
+
// Emitted once for whichever of the two catch-up strategies the page uses; a `visible`-only
|
|
252
|
+
// page never pays for it.
|
|
253
|
+
...(needed.has('idle') || needed.has('interaction') ? [RUNTIME_CATCH_UP] : []),
|
|
223
254
|
...RUNTIME_ORDER.filter((strategy) => needed.has(strategy)).map(
|
|
224
255
|
(strategy) => RUNTIME_PARTS[strategy],
|
|
225
256
|
),
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,23 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
|
|
|
16
16
|
// never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
|
|
17
17
|
// table it prints against.
|
|
18
18
|
export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
19
|
+
/** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
|
|
20
|
+
export {
|
|
21
|
+
CLIENT_PERSIST_META,
|
|
22
|
+
CLIENT_SCOPE_META,
|
|
23
|
+
clientPersistTags,
|
|
24
|
+
clientScopeTag,
|
|
25
|
+
documentCarriesScope,
|
|
26
|
+
} from './client-scope-tag';
|
|
27
|
+
/** Where the page's one socket dials, and the worker that hosts it (plan 101, slice 11). */
|
|
28
|
+
export type { ClientSyncHead } from './client-sync-tags';
|
|
29
|
+
export {
|
|
30
|
+
CLIENT_BUILD_META,
|
|
31
|
+
CLIENT_SYNC_META,
|
|
32
|
+
CLIENT_SYNC_WORKER_META,
|
|
33
|
+
clientBootTags,
|
|
34
|
+
clientSyncTags,
|
|
35
|
+
} from './client-sync-tags';
|
|
19
36
|
export { parseTtlMs } from './duration';
|
|
20
37
|
export type { RenderErrorCode } from './errors';
|
|
21
38
|
export {
|
package/src/modes.ts
CHANGED
|
@@ -225,14 +225,13 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
225
225
|
* (`settings.island.tsx`) is 17,797 B. No `budget.js` under 4096 was reachable by any of them, on
|
|
226
226
|
* any surface, because the allowance is measured above the baseline and not against it.
|
|
227
227
|
*
|
|
228
|
-
* The number: 17,797 (the heaviest island this repo actually ships) + 1,
|
|
229
|
-
* for one directive
|
|
230
|
-
*
|
|
231
|
-
* app reaches without writing a number down. 20,480 is
|
|
232
|
-
* kilobyte above it
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* strategy pays less; the default is what the budget has to clear.
|
|
228
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,744 (`hydrateRuntimeBytes`
|
|
229
|
+
* for one `idle` directive — `defaultHydrate('app')`, and since 2026-09-22 the costlier of the two
|
|
230
|
+
* runtimes an island gets unasked; `DEFAULT_ISLAND_HYDRATE`'s `'interaction'` is 1,629) =
|
|
231
|
+
* **19,541**. That is the worst case an app reaches without writing a number down. 20,480 is the
|
|
232
|
+
* next whole kilobyte above it, leaving 939 B of headroom and still under 2x 19,541, so a route
|
|
233
|
+
* bundling the same island twice is refused. `island-budget.test.ts` asserts all three. `visible` costs 846, so an island route
|
|
234
|
+
* that declares it pays less; the default is what the budget has to clear.
|
|
236
235
|
*
|
|
237
236
|
* All three grew by 129 B on 2026-08-21 (from 881 / 615 / 687), when the prelude learned to mark a
|
|
238
237
|
* mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
|
|
@@ -240,8 +239,10 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
240
239
|
* `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
|
|
241
240
|
* than emit one unhandled rejection per user event. `interaction` alone grew a third time on
|
|
242
241
|
* 2026-08-25 (+184 B, `aim`), when the replay learned that the node it was dispatching at had been
|
|
243
|
-
* detached by the mount it was waiting for.
|
|
244
|
-
*
|
|
242
|
+
* detached by the mount it was waiting for. On 2026-09-22 `idle` learned the same replay through a
|
|
243
|
+
* shared `catchUp`, and both learned to aim a keyboard press by the pressed node's path (774 -> 1,744;
|
|
244
|
+
* `interaction` 1,251 -> 1,629), and `idle` became the worst case. The
|
|
245
|
+
* headroom absorbed all four and the conclusion is unchanged, which is the point of stating the
|
|
245
246
|
* arithmetic here rather than the answer alone. It is not
|
|
246
247
|
* derived from Solid's own size on purpose — this package may not import or name `solid-js`
|
|
247
248
|
* (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
|