@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 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,067 (`As of 2026-08-23`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
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'` | 615 |
202
- | the same at `'interaction'`, which is what an island route declaring no `hydrate` gets | 881 |
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 + 881 = **18,678** — the heaviest island this repo ships, plus the runtime an app pays
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 615 is the cheaper case and not the one a budget has to clear.
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 778 bytes is a ceiling the next line anyone
211
- writes breaks. 20kb leaves 1,802 B, and stays under 2× 18,678 — so a route that bundles the same
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
- [`modes.test.ts`](src/modes.test.ts)'s `DEFAULT_ISLAND_JS_BYTES` block, against the measured
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
- is 615 B at `idle` and 881 B at `interaction`, never 1,019.)
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": "13.0.0",
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": "13.0.0",
40
- "@ultimat3/core": "13.0.0",
41
- "@ultimat3/i18n": "13.0.0",
42
- "@ultimat3/seo": "13.0.0",
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.target.dispatchEvent(c)})},off)};
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,067 (`hydrateRuntimeBytes`
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`) = **18,864**. That is the worst case an
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,616 B of headroom and still
233
- * under 2x 18,864, so a route bundling the same island twice is refused. `island-budget.test.ts`
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. The headroom absorbed both and the conclusion
242
- * is unchanged, which is the point of stating the
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.