@ultimat3/render 13.0.0 → 14.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 +2 -1
- package/README.md +17 -10
- package/package.json +5 -5
- package/src/hydrate.ts +25 -1
- package/src/modes.ts +8 -6
package/CLAUDE.md
CHANGED
|
@@ -61,8 +61,9 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
61
61
|
| 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. |
|
|
62
62
|
| 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)`. |
|
|
63
63
|
| 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. |
|
|
64
|
-
| 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` 774, `visible` 846, `interaction` 1,
|
|
64
|
+
| 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` 774, `visible` 846, `interaction` 1,251 (`As of 2026-08-25`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from — `interaction` moved 1,067 -> 1,251 for the replay-target row below, and neither of the other two changed). |
|
|
65
65
|
| 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. |
|
|
66
|
+
| 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. `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. |
|
|
66
67
|
| `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. |
|
|
67
68
|
| 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. |
|
|
68
69
|
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`, one per surface. The URL is the directory path. Anything else is `X_ROUTE_FILE_INVALID`; never widen the table to accept a second spelling. |
|
package/README.md
CHANGED
|
@@ -198,27 +198,29 @@ production Solid, `As of 2026-08`:
|
|
|
198
198
|
| `render(() => <p>hello</p>, el)` — the floor, before an author writes a line | 12,588 |
|
|
199
199
|
| a signal, a button and reactive text | 13,663 |
|
|
200
200
|
| `settings.island.tsx`, the heaviest island this repo ships | 17,797 |
|
|
201
|
-
| one directive's hydration runtime at `hydrate: 'idle'` |
|
|
202
|
-
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets |
|
|
201
|
+
| one directive's hydration runtime at `hydrate: 'idle'` | 774 |
|
|
202
|
+
| the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 1,251 |
|
|
203
203
|
|
|
204
|
-
17,797 +
|
|
204
|
+
17,797 + 1,251 = **19,048** — the heaviest island this repo ships, plus the runtime an app pays
|
|
205
205
|
without writing a number down. `DEFAULT_ISLAND_HYDRATE` is `'interaction'`
|
|
206
206
|
([`route.ts:33`](src/route.ts)), applied at `:253` to any island route that states no `hydrate`, so
|
|
207
|
-
`idle`'s
|
|
207
|
+
`idle`'s 774 is the cheaper case and not the one a budget has to clear.
|
|
208
208
|
|
|
209
209
|
The default is **20,480** (20kb), which is not that number rounded: the next whole kilobyte above
|
|
210
|
-
it is 19,456, and clearing today's worst island by
|
|
211
|
-
writes breaks. 20kb leaves 1,
|
|
210
|
+
it is 19,456, and clearing today's worst island by 408 bytes is a ceiling the next line anyone
|
|
211
|
+
writes breaks. 20kb leaves 1,432 B, and stays under 2× 19,048 — so a route that bundles the same
|
|
212
212
|
island twice is still refused. All three clauses are assertions in
|
|
213
|
-
[`
|
|
214
|
-
table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
|
|
213
|
+
[`island-budget.test.ts`](src/island-budget.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the
|
|
214
|
+
measured table above; a default that stopped clearing the floor, or stopped being a ceiling, is red.
|
|
215
215
|
|
|
216
216
|
It was **4kb** until `As of 2026-08`, sized from `contact-sales.island.tsx` — 875 B of chunk, and
|
|
217
217
|
no `solid-js` import anywhere in it. Calibrating a JSX budget on the one island shape that does not
|
|
218
218
|
pay the JSX runtime put the default a factor of three below the floor of every island that does:
|
|
219
219
|
no `budget.js` under 4096 was reachable on any surface, because the allowance is measured ABOVE the
|
|
220
220
|
baseline and not against it. (Its second number was wrong too — one directive's hydration runtime
|
|
221
|
-
|
|
221
|
+
was 615 B at `idle` and 881 B at `interaction` when that default was set, never 1,019. The table
|
|
222
|
+
above is what it measures today: the runtime has grown three times since, for the mount markers,
|
|
223
|
+
for terminating the chain `boot` starts, and for aiming the replay.)
|
|
222
224
|
|
|
223
225
|
Still a ceiling and not a pass: exceeding it is `X_BUDGET_EXCEEDED`, naming the island. An island
|
|
224
226
|
that pulls a design system in — `@ultimat3/ui`'s `<Switch>` measures 36,335 B — writes its own
|
|
@@ -385,7 +387,12 @@ side effect. Anything that loads an app's source — `x dev`, `x build`, `server
|
|
|
385
387
|
is handed an `AbortSignal` so the work stops, and nothing more is enqueued. Solid's compiled templates and signals mean the shell costs zero
|
|
386
388
|
hydration work, so streaming buys TTFB *and* TBT here, not just TTFB.
|
|
387
389
|
- **`hydrate: 'interaction'`** replays the event that woke the island; without replay the
|
|
388
|
-
first click on a cold island is silently lost.
|
|
390
|
+
first click on a cold island is silently lost. It replays onto a node the mount left standing —
|
|
391
|
+
the original target when the mount kept it, otherwise whatever `elementFromPoint` now answers for
|
|
392
|
+
a pointer event, otherwise the island root. An island's `mount` opens with `el.textContent = ''`,
|
|
393
|
+
so the pressed node is usually gone by the time the replay runs, and dispatching at it reached
|
|
394
|
+
nothing: `hydrate: 'interaction'` is usable with a replacing island, and was not until
|
|
395
|
+
`As of 2026-08-25`.
|
|
389
396
|
- **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
|
|
390
397
|
0kb default is mechanical, not aspirational. A page that renders an island anyway is
|
|
391
398
|
`X_ISLAND_NOT_HYDRATED`, not a silently dead button.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.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": "
|
|
39
|
+
"@ultimat3/cache": "14.0.0",
|
|
40
|
+
"@ultimat3/core": "14.0.0",
|
|
41
|
+
"@ultimat3/i18n": "14.0.0",
|
|
42
|
+
"@ultimat3/seo": "14.0.0",
|
|
43
43
|
"sass": "1.102.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/hydrate.ts
CHANGED
|
@@ -148,6 +148,27 @@ io.observe(el)})
|
|
|
148
148
|
// woke the island, and re-dispatches it once mounted. Without this, the first click on a
|
|
149
149
|
// cold island is silently lost — the failure users read as "the button does nothing".
|
|
150
150
|
//
|
|
151
|
+
// `aim` is WHERE it is re-dispatched, and it is not `ev.target`. An island's `mount` opens with
|
|
152
|
+
// `el.textContent = ''` — the documented idiom, and what `settings`, `feed` and `like` all do — so
|
|
153
|
+
// by the time the replay runs, the node the visitor actually pressed has been detached and a
|
|
154
|
+
// `dispatchEvent` on it reaches nothing: "the button does nothing on the first press, and works on
|
|
155
|
+
// the second", which is indistinguishable from a slow network and is never reported as a bug.
|
|
156
|
+
//
|
|
157
|
+
// The runtime CAN tell the two mounts apart, per event, and that is what makes a repair possible
|
|
158
|
+
// instead of a refusal: `el.contains(ev.target)` after the mount answers "did this mount keep the
|
|
159
|
+
// node I caught the event on". Kept → replay there, which is what a takeover-style island
|
|
160
|
+
// (`contact-sales.island.tsx` attaches to the server's own form) needs.
|
|
161
|
+
//
|
|
162
|
+
// Replaced → the honest target is where the event would land NOW, so a pointer event is
|
|
163
|
+
// hit-tested again with `elementFromPoint`. That is the same answer the browser would have given
|
|
164
|
+
// had the visitor pressed a moment later, and it reaches a fresh descendant's own handler —
|
|
165
|
+
// dispatching at the island ROOT does not, because Solid's delegated listener sits on `document`
|
|
166
|
+
// and walks UP from the target (`solid-js/web`'s `eventHandler`), so a handler on a child of the
|
|
167
|
+
// root is never visited. The root is the last resort, not the repair: it is where an event with no
|
|
168
|
+
// coordinates goes (a `keydown` has no `clientX`), and where a hit landing outside this island goes
|
|
169
|
+
// — synthesizing a click on an element the visitor never pressed is worse than losing the replay.
|
|
170
|
+
// `typeof` and not `ev.clientX||ev.clientY`, because (0, 0) is a coordinate.
|
|
171
|
+
//
|
|
151
172
|
// `off` is BOTH arms of the `then`, and the rejection arm is the reason it is a named function.
|
|
152
173
|
// `boot` rethrows on purpose (see the prelude), so `el.__x` holds a rejected promise from the
|
|
153
174
|
// first failed mount onward — and a `.then` with no rejection handler makes a fresh rejected
|
|
@@ -157,13 +178,16 @@ io.observe(el)})
|
|
|
157
178
|
// will never mount. Swallowing here loses no signal: the DOM already carries the failure as
|
|
158
179
|
// `data-x-failed`, which is the documented observable.
|
|
159
180
|
const RUNTIME_INTERACTION = `
|
|
181
|
+
function aim(el,ev){var t=ev.target;if(t&&el.contains(t))return t;
|
|
182
|
+
var x=ev.clientX,h=typeof x==='number'?document.elementFromPoint(x,ev.clientY):null;
|
|
183
|
+
return h&&el.contains(h)?h:el}
|
|
160
184
|
each('[data-x-hydrate="interaction"]',function(el){
|
|
161
185
|
var evs=(el.getAttribute('data-x-events')||'click').split(' ');
|
|
162
186
|
var q=[],done=false;
|
|
163
187
|
var off=function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});q=[]};
|
|
164
188
|
var on=function(ev){if(done)return;q.push(ev);
|
|
165
189
|
boot(el).then(function(){var r=q;off();
|
|
166
|
-
r.forEach(function(ev){var c=new ev.constructor(ev.type,ev);ev.
|
|
190
|
+
r.forEach(function(ev){var c=new ev.constructor(ev.type,ev);aim(el,ev).dispatchEvent(c)})},off)};
|
|
167
191
|
evs.forEach(function(n){el.addEventListener(n,on,true)})})
|
|
168
192
|
`.trim();
|
|
169
193
|
|
package/src/modes.ts
CHANGED
|
@@ -225,12 +225,12 @@ 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,
|
|
228
|
+
* The number: 17,797 (the heaviest island this repo actually ships) + 1,251 (`hydrateRuntimeBytes`
|
|
229
229
|
* for one directive at `DEFAULT_ISLAND_HYDRATE`, which is `'interaction'` — `route.ts:33`, applied
|
|
230
|
-
* at `:253` to any island route declaring no `hydrate`) = **
|
|
230
|
+
* at `:253` to any island route declaring no `hydrate`) = **19,048**. That is the worst case an
|
|
231
231
|
* app reaches without writing a number down. 20,480 is NOT that rounded up — the next whole
|
|
232
|
-
* kilobyte above it is 19,456 — it is one whole kB further, leaving 1,
|
|
233
|
-
* under 2x
|
|
232
|
+
* kilobyte above it is 19,456 — it is one whole kB further, leaving 1,432 B of headroom and still
|
|
233
|
+
* under 2x 19,048, so a route bundling the same island twice is refused. `island-budget.test.ts`
|
|
234
234
|
* asserts all three. `idle` costs 774 and `visible` 846, so an island route that declares its
|
|
235
235
|
* strategy pays less; the default is what the budget has to clear.
|
|
236
236
|
*
|
|
@@ -238,8 +238,10 @@ export function defaultHydrate(surface: Surface): HydrateStrategy {
|
|
|
238
238
|
* mount's OUTCOME so `x shot` can tell an island that RAN from one that only started loading, and
|
|
239
239
|
* again on 2026-08-23 — +18 B in the shared prelude (`hush`) for all three, +39 B more on
|
|
240
240
|
* `interaction` — when each runtime learned to TERMINATE the promise chain `boot` starts rather
|
|
241
|
-
* than emit one unhandled rejection per user event.
|
|
242
|
-
*
|
|
241
|
+
* than emit one unhandled rejection per user event. `interaction` alone grew a third time on
|
|
242
|
+
* 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. The headroom absorbed all three and the conclusion is
|
|
244
|
+
* unchanged, which is the point of stating the
|
|
243
245
|
* arithmetic here rather than the answer alone. It is not
|
|
244
246
|
* derived from Solid's own size on purpose — this package may not import or name `solid-js`
|
|
245
247
|
* (`CLAUDE.md`), so a constant tracking the runtime's version would be a dependency in a comment.
|