@takazudo/zfb 2.22.1 → 3.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.
Files changed (85) hide show
  1. package/README.md +5 -9
  2. package/dist/config.d.ts +62 -19
  3. package/dist/config.js +27 -6
  4. package/dist/config.js.map +1 -1
  5. package/dist/content.d.ts +8 -22
  6. package/dist/content.js +6 -28
  7. package/dist/content.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/island-boundary.d.ts +4 -0
  12. package/dist/island-boundary.js +42 -0
  13. package/dist/island-boundary.js.map +1 -0
  14. package/dist/island.d.ts +2 -118
  15. package/dist/island.js +4 -284
  16. package/dist/island.js.map +1 -1
  17. package/dist/jsx-types.d.ts +2 -38
  18. package/dist/jsx-types.js +3 -10
  19. package/dist/jsx-types.js.map +1 -1
  20. package/dist/plugins.d.ts +40 -0
  21. package/dist/plugins.js.map +1 -1
  22. package/dist/runtime.d.ts +23 -77
  23. package/dist/runtime.js +149 -320
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/zudo-react/client.d.ts +3 -0
  26. package/dist/zudo-react/client.js +3 -0
  27. package/dist/zudo-react/client.js.map +1 -0
  28. package/dist/zudo-react/description.d.ts +16 -0
  29. package/dist/zudo-react/description.js +64 -0
  30. package/dist/zudo-react/description.js.map +1 -0
  31. package/dist/zudo-react/dom-bindings.d.ts +13 -0
  32. package/dist/zudo-react/dom-bindings.js +58 -0
  33. package/dist/zudo-react/dom-bindings.js.map +1 -0
  34. package/dist/zudo-react/escape.d.ts +2 -0
  35. package/dist/zudo-react/escape.js +7 -0
  36. package/dist/zudo-react/escape.js.map +1 -0
  37. package/dist/zudo-react/forms.d.ts +29 -0
  38. package/dist/zudo-react/forms.js +371 -0
  39. package/dist/zudo-react/forms.js.map +1 -0
  40. package/dist/zudo-react/hydrate.d.ts +4 -0
  41. package/dist/zudo-react/hydrate.js +909 -0
  42. package/dist/zudo-react/hydrate.js.map +1 -0
  43. package/dist/zudo-react/index.d.ts +47 -0
  44. package/dist/zudo-react/index.js +13 -0
  45. package/dist/zudo-react/index.js.map +1 -0
  46. package/dist/zudo-react/island-root-type.d.ts +1 -0
  47. package/dist/zudo-react/island-root-type.js +2 -0
  48. package/dist/zudo-react/island-root-type.js.map +1 -0
  49. package/dist/zudo-react/jsx-dev-runtime.d.ts +8 -0
  50. package/dist/zudo-react/jsx-dev-runtime.js +6 -0
  51. package/dist/zudo-react/jsx-dev-runtime.js.map +1 -0
  52. package/dist/zudo-react/jsx-runtime.d.ts +5 -0
  53. package/dist/zudo-react/jsx-runtime.js +7 -0
  54. package/dist/zudo-react/jsx-runtime.js.map +1 -0
  55. package/dist/zudo-react/jsx-types.d.ts +203 -0
  56. package/dist/zudo-react/jsx-types.js +2 -0
  57. package/dist/zudo-react/jsx-types.js.map +1 -0
  58. package/dist/zudo-react/props-transport.d.ts +2 -0
  59. package/dist/zudo-react/props-transport.js +95 -0
  60. package/dist/zudo-react/props-transport.js.map +1 -0
  61. package/dist/zudo-react/reactive-types.d.ts +8 -0
  62. package/dist/zudo-react/reactive-types.js +2 -0
  63. package/dist/zudo-react/reactive-types.js.map +1 -0
  64. package/dist/zudo-react/reactive.d.ts +20 -0
  65. package/dist/zudo-react/reactive.js +176 -0
  66. package/dist/zudo-react/reactive.js.map +1 -0
  67. package/dist/zudo-react/render-html.d.ts +3 -0
  68. package/dist/zudo-react/render-html.js +537 -0
  69. package/dist/zudo-react/render-html.js.map +1 -0
  70. package/dist/zudo-react/root.d.ts +20 -0
  71. package/dist/zudo-react/root.js +74 -0
  72. package/dist/zudo-react/root.js.map +1 -0
  73. package/dist/zudo-react/scheduler.d.ts +12 -0
  74. package/dist/zudo-react/scheduler.js +113 -0
  75. package/dist/zudo-react/scheduler.js.map +1 -0
  76. package/dist/zudo-react/scope.d.ts +42 -0
  77. package/dist/zudo-react/scope.js +220 -0
  78. package/dist/zudo-react/scope.js.map +1 -0
  79. package/dist/zudo-react/server.d.ts +15 -0
  80. package/dist/zudo-react/server.js +11 -0
  81. package/dist/zudo-react/server.js.map +1 -0
  82. package/dist/zudo-react/structure.d.ts +14 -0
  83. package/dist/zudo-react/structure.js +35 -0
  84. package/dist/zudo-react/structure.js.map +1 -0
  85. package/package.json +28 -24
package/dist/runtime.js CHANGED
@@ -20,6 +20,8 @@
20
20
  // happy-dom or bare Node, and the absence of `IntersectionObserver` /
21
21
  // `requestIdleCallback` / `matchMedia` is handled gracefully.
22
22
  import { resolveWhen } from "./types.js";
23
+ import { ROOT_KEY } from "./zudo-react/root.js";
24
+ import { parseProps as parseOwnedProps } from "./zudo-react/props-transport.js";
23
25
  const g = globalThis;
24
26
  /**
25
27
  * Internal variant of `scheduleHydrate` that also reports whether the fire
@@ -195,53 +197,87 @@ const PERSIST_ATTR = "data-zfb-transition-persist";
195
197
  // string). Consumed by clearMountedForRemount(). See #1389.
196
198
  const ISLAND_REMOUNT_ATTR = "data-zfb-island-remount";
197
199
  /**
198
- * Public DOM signal written after an island's mount function returns.
200
+ * Public observation marker; the symbol handle is the live-root guard.
199
201
  *
200
- * State table (the marker is observational only and is never a mount guard):
201
- *
202
- * - initial: absent; `mountIslands` / `mountNewIslands` strip a marker that is
203
- * stale relative to this module instance's `mounted` map before scheduling.
204
- * - deferred idle / visible / media: absent while the scheduler is waiting.
205
- * - importing: absent while the URL module is in `pending`.
206
- * - mounted via URL: `scheduleMount`'s URL success handler writes it only after
207
- * `fn(propsForMount, element, mode)` returns, alongside the `mounted` entry.
208
- * - mounted via inline module: `fireInlineMount` writes it only after
209
- * `fn(props, element, mode)` returns, alongside the `mounted` entry.
210
- * - missing manifest entry: absent; `scheduleMount` returns without writing.
211
- * - no `mount` export: absent; both manifest paths return without writing.
212
- * - synchronous mount throw: absent; the `mounted` entry is not written, so a
213
- * later walk can retry the element.
214
- * - rejected import: absent; the URL rejection handler clears `pending` and
215
- * any defensive `mounted` entry.
216
- * - detached during import: absent; the URL success handler clears `pending`
217
- * and returns before calling mount.
218
- * - unmounted (discarded): `unmountIslands` clears the marker and `mounted`
219
- * entry in `finally`, even when the unmount thunk throws.
220
- * - unmounted (persisted-lifted): retained together with the `mounted` entry;
221
- * `unmountIslands` skips elements whose persist id exists in the incoming body.
222
- * - props-changed remount: `clearMountedForRemount` clears the marker and map
223
- * entry in `finally`, then the forced mount writes it again after mount returns.
224
- * - dev hot-swap over a marked DOM: a fresh module's `mountIslands` strips the
225
- * stale marker before scheduling, then writes it after its own mount returns.
202
+ * State table:
203
+ * - initial/deferred/missing entry/failed mount: marker and handle absent;
204
+ * - successful mount: both present;
205
+ * - discarded root: disposal leaves DOM for the body swap, then clears both;
206
+ * - unchanged persisted root: both survive with the same DOM node;
207
+ * - changed persisted root: dispose, clear, then mount in render mode;
208
+ * - bundle re-import: dispose the old symbol handle before render mode replaces it.
226
209
  */
227
210
  export const ISLAND_MOUNTED_ATTR = "data-zfb-island-mounted";
228
- // WeakMap<Element, unmount thunk> — replaces the old WeakSet.
229
- // Value is a per-element function that calls the bundle's unmount(element)
230
- // (or a noop if the bundle does not expose one). Used by unmountIslands()
231
- // to fire framework lifecycle cleanups before a body swap.
232
- const mounted = new WeakMap();
211
+ const COMPOSITION_KEY = Symbol.for("@takazudo/zfb/zudo-react/composition-v1");
212
+ const TRACKER_KEY = Symbol.for("@takazudo/zfb/zudo-react/composition-tracker-v1");
213
+ // A fresh bundle instance replaces roots installed by a previous instance.
214
+ // Ordinary repeat scans in the same instance keep their live handles.
215
+ const owned = new WeakSet();
216
+ function rootHandle(element) {
217
+ return element[ROOT_KEY];
218
+ }
219
+ function setRootHandle(element, handle) {
220
+ const target = element;
221
+ if (handle)
222
+ target[ROOT_KEY] = handle;
223
+ else
224
+ delete target[ROOT_KEY];
225
+ }
226
+ function reportIslandError(element, phase, error) {
227
+ const name = element.getAttribute("data-zfb-island") ??
228
+ element.getAttribute("data-zfb-island-skip-ssr") ??
229
+ "unknown";
230
+ try {
231
+ console.error(`[zfb] island "${name}" ${phase} failed`, error);
232
+ }
233
+ catch {
234
+ // A broken reporter must not stop other roots.
235
+ }
236
+ }
237
+ function disposeIsland(element, phase) {
238
+ const handle = rootHandle(element);
239
+ try {
240
+ handle?.dispose();
241
+ }
242
+ catch (error) {
243
+ reportIslandError(element, phase, error);
244
+ }
245
+ finally {
246
+ if (rootHandle(element) === handle)
247
+ setRootHandle(element, undefined);
248
+ element.removeAttribute(ISLAND_MOUNTED_ATTR);
249
+ }
250
+ }
251
+ function installCompositionTracker(doc) {
252
+ const target = doc;
253
+ if (target[TRACKER_KEY])
254
+ return;
255
+ const set = (event, value) => {
256
+ if (event.target instanceof Element) {
257
+ event.target[COMPOSITION_KEY] = value;
258
+ }
259
+ };
260
+ const start = (event) => set(event, "active");
261
+ const end = (event) => set(event, "idle");
262
+ const input = (event) => {
263
+ if (event.isComposing)
264
+ set(event, "active");
265
+ };
266
+ doc.addEventListener("compositionstart", start, true);
267
+ doc.addEventListener("compositionend", end, true);
268
+ doc.addEventListener("input", input, true);
269
+ doc.addEventListener("blur", end, true);
270
+ target[TRACKER_KEY] = () => {
271
+ doc.removeEventListener("compositionstart", start, true);
272
+ doc.removeEventListener("compositionend", end, true);
273
+ doc.removeEventListener("input", input, true);
274
+ doc.removeEventListener("blur", end, true);
275
+ delete target[TRACKER_KEY];
276
+ };
277
+ }
233
278
  // Elements for which the nested-island self-wrap warning has already been
234
279
  // emitted. Guards against repeated warn spam across re-walks (e.g. SPA swaps).
235
280
  const warnedNested = new WeakSet();
236
- // Elements with an in-flight dynamic import that has not yet resolved.
237
- // Two concurrent `mountIslands` invocations (or two `scheduleMount`
238
- // calls hitting the same element through different code paths) could
239
- // otherwise both pass the `mounted` guard and both spawn an
240
- // `importIsland(url)` -> `fn()` chain, double-mounting the component.
241
- // Adding the element to `pending` synchronously, before the import is
242
- // fired, closes that window; the entry is removed in both the success
243
- // (after `mounted.set`) and failure branches.
244
- const pending = new WeakSet();
245
281
  // Module-level captured manifest — set by the first `mountIslands` call and reused by
246
282
  // `mountNewIslands()` so the client-router does not need to know the manifest directly.
247
283
  // Named technical cause (W1B §12.1): the router lives in @takazudo/zfb-runtime; the
@@ -260,7 +296,7 @@ const pendingCancels = new Map();
260
296
  *
261
297
  * No-op when `document` is undefined (SSR, edge runtime). Safe to call
262
298
  * multiple times: each element is mounted at most once thanks to the
263
- * `mounted` WeakSet guard.
299
+ * symbol-handle guard.
264
300
  *
265
301
  * The manifest is captured at module level so `mountNewIslands()` can re-use
266
302
  * it after an SPA body swap without needing the caller to re-supply it.
@@ -270,9 +306,10 @@ export function mountIslands(manifest) {
270
306
  return;
271
307
  // Capture the manifest for post-swap re-walks via mountNewIslands().
272
308
  capturedManifest = manifest;
309
+ installCompositionTracker(document);
273
310
  const ssrIslands = document.querySelectorAll("[data-zfb-island]");
274
311
  for (const el of Array.from(ssrIslands)) {
275
- stripStaleMountedMarker(el);
312
+ const replacing = prepareMount(el);
276
313
  // Skip the empty-skeleton case left behind when the server-side
277
314
  // rewriter has not run yet (data-zfb-island="" with no component
278
315
  // name). The hydration emit step is expected to fill this in
@@ -282,16 +319,16 @@ export function mountIslands(manifest) {
282
319
  if (!name)
283
320
  continue;
284
321
  warnIfNestedIsland(el, name);
285
- scheduleMount(manifest, el, name, "hydrate");
322
+ scheduleMount(manifest, el, name, replacing ? "render" : "hydrate", { force: replacing });
286
323
  }
287
324
  const skipSsrIslands = document.querySelectorAll("[data-zfb-island-skip-ssr]");
288
325
  for (const el of Array.from(skipSsrIslands)) {
289
- stripStaleMountedMarker(el);
326
+ const replacing = prepareMount(el);
290
327
  const name = el.getAttribute("data-zfb-island-skip-ssr");
291
328
  if (!name)
292
329
  continue;
293
330
  warnIfNestedIsland(el, name);
294
- scheduleMount(manifest, el, name, "render");
331
+ scheduleMount(manifest, el, name, "render", { force: replacing });
295
332
  }
296
333
  }
297
334
  /**
@@ -313,81 +350,46 @@ export function mountNewIslands() {
313
350
  const manifest = capturedManifest;
314
351
  const ssrIslands = document.querySelectorAll("[data-zfb-island]");
315
352
  for (const el of Array.from(ssrIslands)) {
316
- stripStaleMountedMarker(el);
353
+ const replacing = prepareMount(el);
317
354
  const name = el.getAttribute("data-zfb-island");
318
355
  if (!name)
319
356
  continue;
320
- // A persisted island whose props changed across the body swap is flagged
321
- // for remount by swap-functions.swapBodyElement. Clear its surviving mounted
322
- // entry BEFORE scheduleMount's already-mounted guard so it re-mounts fresh
323
- // with the refreshed data-props. No-op for every other element.
324
- const forceRemount = clearMountedForRemount(el);
357
+ // The router marks changed persisted identity/props on the surviving node.
358
+ const forceRemount = clearMountedForRemount(el) || replacing;
325
359
  warnIfNestedIsland(el, name);
326
- scheduleMount(manifest, el, name, "hydrate", { force: forceRemount });
360
+ scheduleMount(manifest, el, name, forceRemount ? "render" : "hydrate", { force: forceRemount });
327
361
  }
328
362
  const skipSsrIslands = document.querySelectorAll("[data-zfb-island-skip-ssr]");
329
363
  for (const el of Array.from(skipSsrIslands)) {
330
- stripStaleMountedMarker(el);
364
+ const replacing = prepareMount(el);
331
365
  const name = el.getAttribute("data-zfb-island-skip-ssr");
332
366
  if (!name)
333
367
  continue;
334
368
  warnIfNestedIsland(el, name);
335
- scheduleMount(manifest, el, name, "render");
369
+ const forceRemount = clearMountedForRemount(el) || replacing;
370
+ scheduleMount(manifest, el, name, "render", { force: forceRemount });
336
371
  }
337
372
  }
338
- /**
339
- * Consume the cross-package "needs-remount" signal for the persist-props hybrid
340
- * path (port-spec §12.3.1 hybrid case / §12.3.2). When a persisted island's
341
- * props differ from the incoming markup, `swapBodyElement` refreshes the
342
- * surviving element's `data-props` and marks it with `ISLAND_REMOUNT_ATTR`.
343
- * That attribute is the ONLY channel that crosses the zfb-runtime → zfb package
344
- * boundary — the `mounted` map is module-private to this file, so a shared
345
- * in-memory "needs-remount" queue between the two packages is impossible; the
346
- * live DOM node carrying the flag IS the queue.
347
- *
348
- * On a flagged mounted element: fire the old instance's unmount thunk (so its
349
- * useEffect/framework cleanups run against the still-connected node), drop the
350
- * `mounted` entry so `scheduleMount`'s guard no longer short-circuits, strip the
351
- * flag, and ask the caller to force the replacement mount through immediately
352
- * instead of re-entering any deferred scheduler. This keeps a deferred persisted
353
- * island from blanking while it waits for idle/visible/media to fire again.
354
- *
355
- * On a flagged element whose URL import is still pending, leave the flag in
356
- * place. The already-running import's success handler consumes it after the
357
- * module resolves and re-reads `data-props` at that point, so a props refresh
358
- * that happened during the import wins without starting a duplicate import.
359
- *
360
- * A no-op for elements without the flag (the common case: fresh markers and
361
- * props-unchanged persisted islands).
362
- *
363
- * Scope: only the `[data-zfb-island]` (hydrated) loop calls this, mirroring the
364
- * writer side — swapBodyElement sets the flag only for `newTarget.matches(
365
- * "[data-zfb-island]")`, never for skip-ssr islands.
366
- */
373
+ /** Consume a persisted root's remount flag and dispose its previous resources. */
367
374
  function clearMountedForRemount(el) {
368
375
  if (!el.hasAttribute(ISLAND_REMOUNT_ATTR))
369
376
  return false;
370
- if (pending.has(el))
371
- return false;
372
- const thunk = mounted.get(el);
373
- if (thunk) {
374
- try {
375
- thunk();
376
- }
377
- finally {
378
- mounted.delete(el);
379
- el.removeAttribute(ISLAND_MOUNTED_ATTR);
380
- el.removeAttribute(ISLAND_REMOUNT_ATTR);
381
- }
382
- return true;
383
- }
384
- el.removeAttribute(ISLAND_MOUNTED_ATTR);
385
377
  el.removeAttribute(ISLAND_REMOUNT_ATTR);
386
- return false;
378
+ if (rootHandle(el))
379
+ disposeIsland(el, "remount disposal");
380
+ else
381
+ el.removeAttribute(ISLAND_MOUNTED_ATTR);
382
+ return true;
387
383
  }
388
- function stripStaleMountedMarker(el) {
389
- if (!mounted.has(el))
384
+ function prepareMount(el) {
385
+ let replacing = false;
386
+ if (rootHandle(el) && !owned.has(el)) {
387
+ disposeIsland(el, "dev replacement disposal");
388
+ replacing = true;
389
+ }
390
+ if (!rootHandle(el))
390
391
  el.removeAttribute(ISLAND_MOUNTED_ATTR);
392
+ return replacing;
391
393
  }
392
394
  /**
393
395
  * Cancel deferred-hydration callbacks for all islands in the old body before a
@@ -438,10 +440,7 @@ function warnIfNestedIsland(el, componentName) {
438
440
  `and apply <Island when="..."> at the call site instead.`);
439
441
  }
440
442
  function scheduleMount(manifest, element, componentName, mode, options = {}) {
441
- // Skip elements already mounted OR currently importing — the latter
442
- // prevents two concurrent `mountIslands` calls from each firing a
443
- // separate dynamic import for the same element.
444
- if (mounted.has(element) || pending.has(element))
443
+ if (rootHandle(element))
445
444
  return;
446
445
  const entry = manifest[componentName];
447
446
  if (entry == null) {
@@ -452,140 +451,21 @@ function scheduleMount(manifest, element, componentName, mode, options = {}) {
452
451
  }
453
452
  return;
454
453
  }
455
- const when = element.getAttribute("data-when") ?? undefined;
456
- // Two manifest shapes:
457
- //
458
- // - `string` (per-island bundle URL): fetch via dynamic `import()`
459
- // and call `mount` / `default` on the resolved module.
460
- // - `IslandModule` (inline descriptor): the shared-bundle path has
461
- // already imported every island's source into the same bundle and
462
- // constructed a mount function for it. Skip the dynamic import
463
- // and call the supplied function directly.
464
- if (typeof entry !== "string") {
465
- fireInlineMount(element, entry, mode, options);
466
- return;
467
- }
468
- const url = entry;
469
- const fire = () => {
470
- // Re-check both guards in case `fire` is invoked from a deferred
471
- // scheduler (rIC/rAF/visibility) after a sibling caller already
472
- // mounted or started importing for this element.
473
- if (mounted.has(element) || pending.has(element))
474
- return;
475
- // When the deferred fire actually runs, the cancel handle is no longer
476
- // needed — remove it so pendingCancels doesn't hold stale entries.
477
- pendingCancels.delete(element);
478
- // Lazy props parse: read and parse data-props only now that we know we
479
- // are actually going to mount this island. For deferred strategies
480
- // (media, visible, idle) this avoids JSON.parse work at boot time for
481
- // islands that may never hydrate (e.g. media query never matches).
482
- const props = readProps(element);
483
- // Mark as pending BEFORE firing the import so any concurrent
484
- // `mountIslands` invocation that arrives during the await window
485
- // is short-circuited by `scheduleMount`'s guard.
486
- pending.add(element);
487
- // Dynamic-import is cached by the JS runtime, so repeat hits for
488
- // the same URL share the resolved module — module-level
489
- // singletons are fine.
490
- //
491
- // We move the element from `pending` to `mounted` only on the
492
- // success path so a failed import (e.g. transient network blip
493
- // in dev) doesn't permanently block a retry of the same element.
494
- let started;
495
- try {
496
- started = importIsland(url);
497
- }
498
- catch (err) {
499
- // Some implementations of dynamic-import wrappers can throw
500
- // synchronously (e.g. URL parsing errors). Treat the same as
501
- // an async rejection.
502
- pending.delete(element);
503
- // eslint-disable-next-line no-console
504
- console.error(`[zfb] failed to start dynamic import for ${url}`, err);
505
- return;
506
- }
507
- started.then((mod) => {
508
- const fn = mod.mount ?? mod.default;
509
- if (typeof fn !== "function") {
510
- pending.delete(element);
511
- if (typeof process !== "undefined" &&
512
- process.env &&
513
- process.env["NODE_ENV"] !== "production") {
514
- // eslint-disable-next-line no-console
515
- console.warn(`[zfb] island bundle at ${url} did not export mount() or default()`);
516
- }
517
- return;
518
- }
519
- // Stale-mount race guard: if the element was detached while the
520
- // dynamic import was in-flight (e.g. a body swap happened), skip
521
- // mounting — the element is no longer in the live document and
522
- // its useEffect listeners would never receive a cleanup call.
523
- if (!element.isConnected) {
524
- pending.delete(element);
525
- return;
526
- }
527
- const shouldRefreshProps = element.hasAttribute(ISLAND_REMOUNT_ATTR);
528
- const propsForMount = shouldRefreshProps ? readProps(element) : props;
529
- if (shouldRefreshProps)
530
- element.removeAttribute(ISLAND_REMOUNT_ATTR);
531
- const unmountThunk = mod.unmount
532
- ? () => mod.unmount(element)
533
- : () => {
534
- // noop — bundle does not expose unmount
535
- };
536
- try {
537
- fn(propsForMount, element, mode);
538
- mounted.set(element, unmountThunk);
539
- element.setAttribute(ISLAND_MOUNTED_ATTR, "");
540
- }
541
- finally {
542
- pending.delete(element);
543
- }
544
- }, (err) => {
545
- // Surface the error in dev so the user notices, then clear
546
- // both guards so a later retry (e.g. another scheduleHydrate
547
- // fire) can attempt the import again.
548
- pending.delete(element);
549
- mounted.delete(element);
550
- // eslint-disable-next-line no-console
551
- console.error(`[zfb] failed to load island bundle ${url}`, err);
552
- });
553
- };
554
- if (mode === "render") {
555
- // SSR-skip islands ignore data-when: there is nothing to defer
556
- // hydration of, just an empty container we paint into. Mount
557
- // immediately so the user sees output.
558
- fire();
559
- return;
560
- }
561
- if (options.force) {
562
- fire();
563
- return;
564
- }
565
- const { fired, cancel } = scheduleHydrateInternal(element, when, fire);
566
- // Track deferred-hydration cancel handle so cancelPendingIslands() can abort
567
- // idle / visibility callbacks before a body swap. (W1B §12.5)
568
- // Only register when the scheduler did NOT fire synchronously — a synchronous
569
- // fire means the island is already handling its import and there is no
570
- // deferred callback to cancel. Registering noop after a sync fire would leave
571
- // a stale pendingCancels entry for an already-handled element. (#743)
572
- if (when && when !== "load" && !fired) {
573
- pendingCancels.set(element, cancel);
574
- }
454
+ fireInlineMount(element, entry, mode, options);
575
455
  }
576
456
  /**
577
457
  * Run the mount step for the inline-module manifest shape used by the
578
458
  * shared-bundle path. The module is already in memory (it was imported
579
459
  * into the bundle at build time), so there is no async window to
580
460
  * coordinate around — we just call `mount` / `default` directly,
581
- * gated by the same `data-when` semantics as the URL path.
461
+ * gated by `data-when` semantics.
582
462
  */
583
463
  function fireInlineMount(element, mod, mode, options = {}) {
584
- const fn = mod.mount ?? mod.default;
464
+ const fn = mod.mount;
585
465
  if (typeof fn !== "function") {
586
466
  if (typeof process !== "undefined" && process.env && process.env["NODE_ENV"] !== "production") {
587
467
  // eslint-disable-next-line no-console
588
- console.warn("[zfb] inline island manifest entry did not export mount() or default()");
468
+ console.warn("[zfb] owned island manifest entry did not export mount()");
589
469
  }
590
470
  return;
591
471
  }
@@ -593,7 +473,7 @@ function fireInlineMount(element, mod, mode, options = {}) {
593
473
  // Re-check the guard in case `fire` is invoked from a deferred
594
474
  // scheduler (rIC/rAF/visibility) after a sibling caller already
595
475
  // mounted this element.
596
- if (mounted.has(element))
476
+ if (rootHandle(element))
597
477
  return;
598
478
  // When the deferred fire actually runs, the cancel handle is no longer
599
479
  // needed — remove it so pendingCancels doesn't hold stale entries.
@@ -605,20 +485,24 @@ function fireInlineMount(element, mod, mode, options = {}) {
605
485
  // Lazy props parse: read and parse data-props only at mount time.
606
486
  // For deferred strategies (media, visible, idle) this avoids JSON.parse
607
487
  // work at boot time for islands that may never hydrate.
608
- const props = readProps(element);
609
- const unmountThunk = mod.unmount
610
- ? () => mod.unmount(element)
611
- : () => {
612
- // noop — inline module does not expose unmount
613
- };
614
- fn(props, element, mode);
615
- mounted.set(element, unmountThunk);
616
- element.setAttribute(ISLAND_MOUNTED_ATTR, "");
488
+ try {
489
+ const props = readProps(element, mod.identity);
490
+ const result = fn(props, element, mode);
491
+ if (result === null)
492
+ return;
493
+ if (!result || typeof result.dispose !== "function" || typeof result.unmount !== "function") {
494
+ throw new TypeError(`ZR_ROOT_HANDLE: island ${mod.identity.component} returned no root handle`);
495
+ }
496
+ const handle = result;
497
+ setRootHandle(element, handle);
498
+ owned.add(element);
499
+ element.setAttribute(ISLAND_MOUNTED_ATTR, "");
500
+ }
501
+ catch (error) {
502
+ element.removeAttribute(ISLAND_MOUNTED_ATTR);
503
+ reportIslandError(element, "mount", error);
504
+ }
617
505
  };
618
- if (mode === "render") {
619
- fire();
620
- return;
621
- }
622
506
  if (options.force) {
623
507
  fire();
624
508
  return;
@@ -636,54 +520,19 @@ function fireInlineMount(element, mod, mode, options = {}) {
636
520
  }
637
521
  }
638
522
  /**
639
- * Unmount the mounted islands within `root` (default: `document.body`) that will
640
- * NOT survive the body swap.
641
- *
642
- * Walks `root` for `[data-zfb-island]` and `[data-zfb-island-skip-ssr]` elements,
643
- * looks up each element's unmount thunk in the `mounted` WeakMap, calls it (which
644
- * triggers `render(null, element)` for Preact or `root.unmount()` for React), and
645
- * removes the entry from the map so `mountNewIslands()` can re-mount later.
646
- *
647
- * Call this before `swapBodyElement(...)` so the OLD body's islands receive proper
648
- * framework lifecycle cleanup (useEffect teardowns, etc.) before being discarded.
649
- *
650
- * When `incomingBody` is supplied (the client-router passes the parsed incoming
651
- * document body), any island whose `data-zfb-transition-persist` id matches a
652
- * marker in that body is DELIBERATELY SKIPPED: swapBodyElement will physically
653
- * lift the node into the new body, so its component instance and internal state
654
- * must survive — unmounting it here would empty the container before the lift and
655
- * defeat the persist contract (issue #1389). Omit `incomingBody` (or pass null)
656
- * to unmount everything, the pre-#1389 behavior.
657
- *
658
- * No-op for elements not in the `mounted` map (e.g. never-mounted or already cleaned up).
523
+ * Dispose roots that the incoming body will discard. Persisted roots keep their
524
+ * handles and DOM until the post-swap scan determines whether to recreate them.
659
525
  */
660
526
  export function unmountIslands(root = document.body, incomingBody) {
661
527
  const selector = "[data-zfb-island],[data-zfb-island-skip-ssr]";
662
- // Persist ids that `swapBodyElement` will physically LIFT from the old body
663
- // into the incoming body — an old marker survives iff the incoming body has a
664
- // marker with the same `data-zfb-transition-persist` id. Those DOM nodes are
665
- // moved, not discarded, so their component instance and internal state MUST
666
- // survive the swap: skip their framework unmount here or the persist contract
667
- // preserves nothing (port-spec §12.3.1 case (a) / issue #1389). A persisted
668
- // island whose props changed is skipped here too — its refreshed remount runs
669
- // later in mountNewIslands via the `data-zfb-island-remount` flag (see
670
- // `clearMountedForRemount`) swapBodyElement sets. With no incoming body (a
671
- // call outside a swap) nothing is preserved, so the walk is byte-identical to
672
- // the pre-#1389 behavior.
528
+ // Persisted nodes are lifted by the router and retain their live resources.
673
529
  const preservedPersistIds = collectPersistIds(incomingBody);
674
530
  const elements = root.querySelectorAll(selector);
675
531
  for (const el of Array.from(elements)) {
676
532
  const persistId = el.getAttribute(PERSIST_ATTR);
677
533
  if (persistId !== null && preservedPersistIds.has(persistId))
678
534
  continue;
679
- const thunk = mounted.get(el);
680
- try {
681
- thunk?.();
682
- }
683
- finally {
684
- mounted.delete(el);
685
- el.removeAttribute(ISLAND_MOUNTED_ATTR);
686
- }
535
+ disposeIsland(el, "disposal");
687
536
  }
688
537
  }
689
538
  /**
@@ -703,48 +552,28 @@ function collectPersistIds(incomingBody) {
703
552
  }
704
553
  return ids;
705
554
  }
706
- function readProps(element) {
555
+ function readProps(element, identity) {
707
556
  const raw = element.getAttribute("data-props");
708
- if (!raw)
709
- return {};
557
+ const hasHydrate = element.hasAttribute("data-zfb-island");
558
+ const hasSkipSsr = element.hasAttribute("data-zfb-island-skip-ssr");
559
+ const component = element.getAttribute("data-zfb-island") ?? element.getAttribute("data-zfb-island-skip-ssr");
560
+ if (hasHydrate === hasSkipSsr ||
561
+ component !== identity.component ||
562
+ element.getAttribute("data-zfb-transport") !== "json/1" ||
563
+ element.getAttribute("data-zfb-protocol") !== "zudo-react/1" ||
564
+ element.getAttribute("data-zfb-build") !== identity.build ||
565
+ raw === null) {
566
+ throw new TypeError(`ZR_IDENTITY: island ${identity.component} has invalid transport identity`);
567
+ }
568
+ if (element.querySelector("[data-zfb-island],[data-zfb-island-skip-ssr]")) {
569
+ throw new TypeError(`ZR_NESTED_ISLAND: island ${identity.component} contains another island`);
570
+ }
710
571
  try {
711
- const parsed = JSON.parse(raw);
712
- // Reject arrays explicitly: `typeof [] === "object"` is true but
713
- // an array is not a valid props bag, and passing it through would
714
- // mean the component receives index-keyed values where it
715
- // expected a record. Fall through to the empty-object default
716
- // instead of forwarding a malformed shape.
717
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
718
- return parsed;
719
- }
572
+ return parseOwnedProps(raw);
720
573
  }
721
- catch {
722
- // fall through
574
+ catch (error) {
575
+ throw new TypeError(`ZR_PROPS: island ${identity.component}: ${String(error)}`);
723
576
  }
724
- return {};
725
- }
726
- /**
727
- * Indirection so tests can stub the dynamic import without intercepting
728
- * the global `import()`. In production this is a thin wrapper over
729
- * native `import(url)`.
730
- */
731
- let importImpl = (url) =>
732
- // Modern bundlers (esbuild, Vite, Rollup, webpack) preserve a plain
733
- // `import(<dynamic>)` call when the argument isn't a static literal,
734
- // so we no longer need the `new Function(...)` indirection — which
735
- // also failed under strict CSPs that disallow `unsafe-eval`.
736
- import(/* @vite-ignore */ /* webpackIgnore: true */ url);
737
- function importIsland(url) {
738
- return importImpl(url);
739
- }
740
- /**
741
- * Test-only seam. Replace the module dynamic-import with a fake.
742
- * Returns the previous implementation so tests can restore it.
743
- */
744
- export function __setIslandImporterForTests(impl) {
745
- const prev = importImpl;
746
- importImpl = impl;
747
- return prev;
748
577
  }
749
578
  /**
750
579
  * Test-only seam. Returns whether the given element has an entry in the