@ultimat3/render 27.4.0 → 27.6.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 +1 -1
- package/README.md +25 -4
- package/package.json +6 -6
- package/src/client.ts +5 -3
- package/src/island-dispose.ts +59 -0
- package/src/island-hold.ts +5 -2
- package/src/navigation-modal.ts +4 -3
- package/src/navigation-swap.ts +1 -33
package/CLAUDE.md
CHANGED
|
@@ -63,7 +63,7 @@ axiom 6). Never `cli` (upward).
|
|
|
63
63
|
| A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
|
|
64
64
|
| `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
|
|
65
65
|
| Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
|
|
66
|
-
| A held island | `hold` on the collector (the host decides — the CLI holds realtime islands on a page-boot document); `island-hold.ts` owns the attribute, the inline `visibility:hidden`, the cap `ISLAND_HOLD_MS` and the runtime part. Emitted right after the prelude; booted at once, revealed TOGETHER on both mount arms, at the cap, or by CSS (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`) — never hidden for good. `catchUp`'s `on` lets go after a mount it did not flush. `island-hold.test.ts`, `island-hold-replay.test.ts`. |
|
|
66
|
+
| A held island | `hold` on the collector (the host decides — the CLI holds store-reading realtime islands on a page-boot document); `island-hold.ts` owns the attribute, the inline `visibility:hidden`, the cap `ISLAND_HOLD_MS` and the runtime part. Emitted right after the prelude; booted at once, revealed TOGETHER on both mount arms, at the cap, or by CSS (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`) — never hidden for good. `catchUp`'s `on` lets go after a mount it did not flush. `island-hold.test.ts`, `island-hold-replay.test.ts`. |
|
|
67
67
|
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
|
|
68
68
|
| Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
|
|
69
69
|
| Route filename | `page.tsx` under `site/`/`app/` — `ROUTE_FILENAME`, keyed by `RouteSurface`. Anything else is `X_ROUTE_FILE_INVALID`, any file under `api/` or `shared/` included. |
|
package/README.md
CHANGED
|
@@ -456,10 +456,11 @@ and a second copy of the number is a settle that shoots early and calls a health
|
|
|
456
456
|
|
|
457
457
|
**A held island** (`data-x-hold`, `As of 2026-10`) is one whose server markup must not paint before
|
|
458
458
|
it mounts — an offline reload's cached count under a queued write (#506). The collector's
|
|
459
|
-
`hold: (src) => boolean` names them (the host's call: `@ultimat3/cli` holds
|
|
460
|
-
|
|
459
|
+
`hold: (src) => boolean` names them (the host's call: `@ultimat3/cli` holds, on a document that
|
|
460
|
+
carries the page boot, the realtime islands that read the record store or the outbox — never one
|
|
461
|
+
that only follows a channel's events); the wrapper is `visibility:hidden` from its first byte, the
|
|
461
462
|
runtime boots it at once whatever the strategy, and reveals it when `mount()` settles either way or
|
|
462
|
-
at `ISLAND_HOLD_MS` (3000), whichever comes first — and at the same cap by CSS alone (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`), so a page with no script still shows the markup. Only a page holding an island pays for that part
|
|
463
|
+
at `ISLAND_HOLD_MS` (3000), whichever comes first — and at the same cap by CSS alone (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`), so a page with no script still shows the markup — and at once, not at the cap, where the browser says scripting is off (`@media (scripting: none)` in the same sheet). Only a page holding an island pays for that part
|
|
463
464
|
of the runtime, and `HYDRATE_RUNTIME_BODIES` carries both variants.
|
|
464
465
|
|
|
465
466
|
## Two entry points
|
|
@@ -486,7 +487,8 @@ side effect. Anything that loads an app's source — `x dev`, `x build`, `server
|
|
|
486
487
|
|
|
487
488
|
`@ultimat3/render/client` is the **browser** entry — what an island or a page script calls:
|
|
488
489
|
`navigate`, `refresh`, `openModal`, `closeModal`, `NavigateToOptions`,
|
|
489
|
-
`NavigationModalPathInvalidError`, `NAVIGATE_EVENT`, `NAVIGATED_EVENT`, `NAVIGATION_ERROR_EVENT
|
|
490
|
+
`NavigationModalPathInvalidError`, `NAVIGATE_EVENT`, `NAVIGATED_EVENT`, `NAVIGATION_ERROR_EVENT`,
|
|
491
|
+
`disposeIslands`.
|
|
490
492
|
The barrel bundles for the browser but its `sideEffects` keeps `errors.ts` — render's code table,
|
|
491
493
|
and core's and schema's titles tables behind it — in every chunk that reaches it; this entry's
|
|
492
494
|
graph is the navigation helpers plus `@ultimat3/core/page` (`client-bundle.test.ts`). `As of
|
|
@@ -497,6 +499,24 @@ graph is the navigation helpers plus `@ultimat3/core/page` (`client-bundle.test.
|
|
|
497
499
|
import { navigate, refresh } from '@ultimat3/render/client';
|
|
498
500
|
```
|
|
499
501
|
|
|
502
|
+
**`disposeIslands(root, kept?)`** (`island-dispose.ts`, `As of 2026-10`) is the one protocol for
|
|
503
|
+
letting go of mounted islands: every island under `root` — `root` too, when it is an island's
|
|
504
|
+
wrapper — has what its `mount` returned called once its boot resolves, an island whose boot never
|
|
505
|
+
started is settled so it mounts nothing, and the count of boots settled is returned. Idempotent
|
|
506
|
+
per island: a second call over the same subtree runs no disposer twice and returns 0. The router
|
|
507
|
+
calls it on the body it swaps out and on a modal it closes. An island that replaces markup holding
|
|
508
|
+
other islands calls it on each element it drops (attached or already detached), instead of reading
|
|
509
|
+
the runtime's private `el.__x`:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
import { disposeIslands } from '@ultimat3/render/client';
|
|
513
|
+
|
|
514
|
+
/** The nodes an island is about to take off the page for good. */
|
|
515
|
+
export function release(dropped: readonly Node[]): void {
|
|
516
|
+
for (const node of dropped) if (node instanceof Element) disposeIslands(node);
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
500
520
|
## Error classes
|
|
501
521
|
|
|
502
522
|
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
@@ -549,6 +569,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
549
569
|
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script; it stamps `data-theme` and, beside it, the fallback itself as `data-theme-default` for `@ultimat3/ui`'s `clearTheme`/`watchOsTheme` |
|
|
550
570
|
| `clientNavigationTags`, `linkVerdict`, `formVerdict`, `responseVerdict`, `reusable`, `mayPrefetch`, `NAVIGATION_*` (`NAVIGATION_ERROR_EVENT`‡), `NAVIGATE_EVENT`‡, `NAVIGATED_EVENT`‡ | the client router's document tags and its pure rules; the router itself is `@ultimat3/render/navigation`, built and served by the CLI |
|
|
551
571
|
| `navigate`‡, `refresh`‡, `openModal`‡, `closeModal`‡, `NavigateToOptions`‡, `NavigationModalPathInvalidError`‡ | navigating from code: the router when the page has one, the browser's own load when not. Import from `/client` in an island |
|
|
572
|
+
| `disposeIslands` (on `/client` only) | dispose the islands mounted under an element: what the router does on a swap, for an island that drops markup holding other islands |
|
|
552
573
|
| `modalAddress`, `modalLocation`, `addressOf`, `presentation`, `modalHistory`, `leaveModal`, `NAVIGATION_PRESENTATION_META`, `NAVIGATION_MODAL_ATTRIBUTE` | route-presented modals (`navigation: 'modal'`): the hash grammar, when an answer is a modal, and what leaving one does to history |
|
|
553
574
|
| `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
|
|
554
575
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.6.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",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/cache": "27.
|
|
44
|
-
"@ultimat3/core": "27.
|
|
45
|
-
"@ultimat3/http": "27.
|
|
46
|
-
"@ultimat3/i18n": "27.
|
|
47
|
-
"@ultimat3/seo": "27.
|
|
43
|
+
"@ultimat3/cache": "27.6.0",
|
|
44
|
+
"@ultimat3/core": "27.6.0",
|
|
45
|
+
"@ultimat3/http": "27.6.0",
|
|
46
|
+
"@ultimat3/i18n": "27.6.0",
|
|
47
|
+
"@ultimat3/seo": "27.6.0",
|
|
48
48
|
"sass": "1.105.1"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// `@ultimat3/render/client` — THE browser entry of this package: what an island or a page script
|
|
2
2
|
// may call, and nothing that drags the rest along. The `.` barrel bare-retains `errors.ts` (render's
|
|
3
3
|
// whole code table, registered at import, plus core's titles tables); this entry's graph is
|
|
4
|
-
// `navigation-api.ts`, `navigation-errors.ts`, the pure rules and
|
|
5
|
-
// by `client-bundle.test.ts`.
|
|
4
|
+
// `navigation-api.ts`, `navigation-errors.ts`, `island-dispose.ts`, the pure rules and
|
|
5
|
+
// `@ultimat3/core/page`, held there by `client-bundle.test.ts`.
|
|
6
6
|
//
|
|
7
|
-
// import { navigate, openModal, refresh } from '@ultimat3/render/client';
|
|
7
|
+
// import { disposeIslands, navigate, openModal, refresh } from '@ultimat3/render/client';
|
|
8
8
|
//
|
|
9
9
|
// The same names stay exported from `.` for compatibility; an island imports them from here.
|
|
10
10
|
|
|
11
|
+
// An island that drops markup holding other islands releases them first: `disposeIslands(node)`.
|
|
12
|
+
export { disposeIslands } from './island-dispose';
|
|
11
13
|
export type { NavigateToOptions } from './navigation-api';
|
|
12
14
|
export { closeModal, navigate, openModal, refresh } from './navigation-api';
|
|
13
15
|
export { NavigationModalPathInvalidError } from './navigation-errors';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Letting go of mounted islands: the one protocol for calling what each island's `mount` returned.
|
|
2
|
+
// The client router uses it when it swaps a page or closes a modal; an island that replaces markup
|
|
3
|
+
// holding OTHER islands (a live list redrawing rows whose menus are islands) uses it on the nodes
|
|
4
|
+
// it drops, through `@ultimat3/render/client`. Nothing else may read `el.__x`.
|
|
5
|
+
|
|
6
|
+
/** What `hydrate.ts`'s runtime leaves on an island root: the boot promise. */
|
|
7
|
+
interface IslandElement extends Element {
|
|
8
|
+
__x?: Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ISLAND_ATTRIBUTE = 'data-x-island';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Islands this module already let go of. A disposer runs ONCE: a second call over the same subtree
|
|
15
|
+
* (an island that released its rows, then the router swapping the page they were on) would
|
|
16
|
+
* otherwise schedule each `mount`'s return twice, and count as a boot the resolved promise the
|
|
17
|
+
* first call left on an island that never started. Weak, so a dropped element is not kept alive.
|
|
18
|
+
*/
|
|
19
|
+
const released = new WeakSet<Element>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Disposes every island mounted under `root` — `root` itself included when it is an island's
|
|
23
|
+
* wrapper — unless `kept` claims it, and answers how many had a boot to settle. For each one the
|
|
24
|
+
* disposer is what its `mount` returned (held by `el.__x`), called once that boot resolves; a
|
|
25
|
+
* `mount` that returned nothing has nothing to call. A rejected boot has nothing to dispose, and
|
|
26
|
+
* the rejection is already on the element as `data-x-failed`. An island whose boot has not STARTED
|
|
27
|
+
* (an `idle` one still waiting for the browser, a `visible` one never scrolled to) is settled
|
|
28
|
+
* instead: the hydration runtime's `boot` answers an element that already has `__x` with it, so
|
|
29
|
+
* the pending boot mounts nothing into markup that left the document — a mount nobody would ever
|
|
30
|
+
* dispose. Works on a detached subtree, so the order against removing `root` does not matter.
|
|
31
|
+
* Idempotent per island: one already let go of is skipped and not counted; one `kept` claimed is
|
|
32
|
+
* untouched, so a later call may still dispose it.
|
|
33
|
+
*/
|
|
34
|
+
export function disposeIslands(
|
|
35
|
+
root: Element,
|
|
36
|
+
kept: (el: Element) => boolean = () => false,
|
|
37
|
+
): number {
|
|
38
|
+
let disposed = 0;
|
|
39
|
+
const under = root.querySelectorAll<IslandElement>(`[${ISLAND_ATTRIBUTE}]`);
|
|
40
|
+
const islands: Iterable<IslandElement> = root.hasAttribute(ISLAND_ATTRIBUTE)
|
|
41
|
+
? [root, ...under]
|
|
42
|
+
: under;
|
|
43
|
+
for (const el of islands) {
|
|
44
|
+
if (released.has(el) || kept(el)) continue;
|
|
45
|
+
released.add(el);
|
|
46
|
+
if (el.__x === undefined) {
|
|
47
|
+
el.__x = Promise.resolve();
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
disposed += 1;
|
|
51
|
+
el.__x.then(
|
|
52
|
+
(dispose) => {
|
|
53
|
+
if (typeof dispose === 'function') dispose();
|
|
54
|
+
},
|
|
55
|
+
() => undefined,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return disposed;
|
|
59
|
+
}
|
package/src/island-hold.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// A HELD island (#506): server markup that must not paint before the island mounts, because what
|
|
2
2
|
// it shows can be older than what the page will show — an offline reload's cached count, under a
|
|
3
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)
|
|
4
|
+
// mount settles or at the cap. Who is held is the renderer's caller's call (`hold` on the collector):
|
|
5
|
+
// only an island with page state to restore — server markup nothing will contradict is never hidden.
|
|
5
6
|
|
|
6
7
|
/** On a held island's wrapper until the runtime reveals it; what the runtime finds it by. */
|
|
7
8
|
export const ISLAND_HOLD_ATTRIBUTE = 'data-x-hold';
|
|
@@ -17,7 +18,9 @@ export const ISLAND_HOLD_MS = 3_000;
|
|
|
17
18
|
/**
|
|
18
19
|
* The keyframes that reveal a held island with no script at all — JS off, the runtime blocked, a
|
|
19
20
|
* 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
|
+
* (this package may not import `ui`; `island-hold-replay.test.ts` holds the two spellings equal),
|
|
22
|
+
* beside the `@media (scripting: none)` rule that cancels the hold at once where the browser says
|
|
23
|
+
* scripting is off: the keyframes are the cap for everything that rule cannot see.
|
|
21
24
|
*/
|
|
22
25
|
export const ISLAND_HOLD_REVEAL = 'ultimate-hold-reveal';
|
|
23
26
|
|
package/src/navigation-modal.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `navigation.ts` decides WHEN (`navigation-modal-rules.ts`); this file only shows and hides.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { disposeIslands } from './island-dispose';
|
|
9
10
|
import { metaOf } from './navigation-fetch';
|
|
10
11
|
import { entryOf, STATE_KEY, withoutFragment } from './navigation-history';
|
|
11
12
|
import {
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
type PresentationFacts,
|
|
18
19
|
presentation,
|
|
19
20
|
} from './navigation-modal-rules';
|
|
20
|
-
import {
|
|
21
|
+
import { runScripts } from './navigation-swap';
|
|
21
22
|
|
|
22
23
|
export interface ModalController {
|
|
23
24
|
/** The open modal's address (`/runs/new`), or `undefined`. */
|
|
@@ -106,7 +107,7 @@ export function modalController(
|
|
|
106
107
|
dialog = undefined;
|
|
107
108
|
openAt = undefined;
|
|
108
109
|
leaving = false;
|
|
109
|
-
disposeIslands(open
|
|
110
|
+
disposeIslands(open);
|
|
110
111
|
if (open.open) open.close();
|
|
111
112
|
open.remove();
|
|
112
113
|
doc.title = title;
|
|
@@ -199,7 +200,7 @@ export function modalController(
|
|
|
199
200
|
// One modal at a time: the next address replaces this one's content, in the same dialog.
|
|
200
201
|
// Closed as `dismiss` closes it — no longer `dialog` — so its own `close` event, queued or
|
|
201
202
|
// (in any engine that fires it at once) synchronous, is never read as the visitor closing.
|
|
202
|
-
disposeIslands(open
|
|
203
|
+
disposeIslands(open);
|
|
203
204
|
dialog = undefined;
|
|
204
205
|
if (open.open) open.close();
|
|
205
206
|
}
|
package/src/navigation-swap.ts
CHANGED
|
@@ -5,14 +5,9 @@
|
|
|
5
5
|
* island bootstrap (`hydrate.ts`'s runtime) over the new body. There is no second mount path.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { disposeIslands } from './island-dispose';
|
|
8
9
|
import { NAVIGATION_PERSIST_ATTRIBUTE } from './navigation-rules';
|
|
9
10
|
|
|
10
|
-
/** What `hydrate.ts`'s runtime leaves on an island root: the boot promise and the visited mark. */
|
|
11
|
-
interface IslandElement extends Element {
|
|
12
|
-
__x?: Promise<unknown>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const ISLAND_SELECTOR = '[data-x-island]';
|
|
16
11
|
const STYLESHEET = 'link[rel~="stylesheet"]';
|
|
17
12
|
|
|
18
13
|
const isStylesheet = (el: Element): boolean =>
|
|
@@ -81,33 +76,6 @@ export function loadStylesheets(
|
|
|
81
76
|
).then(() => undefined);
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
/**
|
|
85
|
-
* Calls each island's disposer — what its `mount` returned, held by `el.__x` — unless the island
|
|
86
|
-
* is carried across. A rejected boot has nothing to dispose, and the rejection is already on the
|
|
87
|
-
* element as `data-x-failed`. An island whose boot has not STARTED (an `idle` one still waiting
|
|
88
|
-
* for the browser, a `visible` one never scrolled to) is settled instead: the hydration runtime's
|
|
89
|
-
* `boot` answers an element that already has `__x` with it, so the pending boot mounts nothing into
|
|
90
|
-
* a body that left the document — a mount nobody would ever dispose.
|
|
91
|
-
*/
|
|
92
|
-
export function disposeIslands(root: Element, kept: (el: Element) => boolean): number {
|
|
93
|
-
let disposed = 0;
|
|
94
|
-
for (const el of root.querySelectorAll<IslandElement>(ISLAND_SELECTOR)) {
|
|
95
|
-
if (kept(el)) continue;
|
|
96
|
-
if (el.__x === undefined) {
|
|
97
|
-
el.__x = Promise.resolve();
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
disposed += 1;
|
|
101
|
-
el.__x.then(
|
|
102
|
-
(dispose) => {
|
|
103
|
-
if (typeof dispose === 'function') dispose();
|
|
104
|
-
},
|
|
105
|
-
() => undefined,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
return disposed;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
79
|
/** `[data-x-persist="id"]` present in BOTH documents: the live element moves into the next body. */
|
|
112
80
|
export function persistedPairs(
|
|
113
81
|
current: ParentNode,
|