@askrjs/askr 0.0.99 → 0.2.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/README.md CHANGED
@@ -91,8 +91,9 @@ Query and mutation helpers live in `@askrjs/askr/data`.
91
91
  ### Developer error boundaries
92
92
 
93
93
  `ErrorBoundary` is the opt-in boundary primitive for render-time failures. It
94
- renders a visible fallback in development, still logs the underlying error, and
95
- can reset when your app state changes. The boundary protects both initial mount
94
+ renders a visible fallback in every environment (with error details expanded
95
+ by default in development), still logs the underlying error, and can reset via
96
+ a `resetKey` tied to your app state. The boundary protects both initial mount
96
97
  and scheduled post-mount updates. Portal content follows its logical writer
97
98
  boundary and can also recover through a boundary around its host.
98
99
 
package/dist/benchmark.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { r as flushRuntimeScheduler } from "./access-d_DCdwqn.js";
2
2
  import { t as state } from "./state-DmkGM_LN.js";
3
3
  import { t as selector } from "./selector-CyK-sDwB.js";
4
- import { t as installRendererBridge } from "./renderer-tXX_xj86.js";
4
+ import { t as installRendererBridge } from "./renderer-Cg92QKOm.js";
5
5
  import { i as For } from "./control-C0klPV7Q.js";
6
6
  import { jsx, jsxs } from "./jsx-runtime.js";
7
- import { t as createIsland } from "./boot-DE-JutXH.js";
7
+ import { t as createIsland } from "./boot-BjldNXqF.js";
8
8
  //#region src/bench/components/benchmark-row.tsx
9
9
  function BenchmarkRow({ item, isSelected, onSelect, onRemove }) {
10
10
  return /* @__PURE__ */ jsxs("tr", {
@@ -63,8 +63,8 @@ function BenchmarkTable({ rows, isSelected, onSelect, onRemove }) {
63
63
  installRendererBridge();
64
64
  const benchmarkMetadata = {
65
65
  packageName: "@askrjs/askr",
66
- packageVersion: "0.0.99",
67
- buildLabel: "0.0.99-local"
66
+ packageVersion: "0.2.0",
67
+ buildLabel: "0.2.0-local"
68
68
  };
69
69
  function getBenchmarkMetadata() {
70
70
  return { ...benchmarkMetadata };
@@ -1,2 +1,2 @@
1
- import { a as cleanupApp, i as hydrateSPA, n as createIslands, o as hasApp, r as createSPA, t as createIsland } from "../boot-DE-JutXH.js";
1
+ import { a as cleanupApp, i as hydrateSPA, n as createIslands, o as hasApp, r as createSPA, t as createIsland } from "../boot-BjldNXqF.js";
2
2
  export { cleanupApp, createIsland, createIslands, createSPA, hasApp, hydrateSPA };
@@ -6,7 +6,7 @@ import { p as isPromiseLike } from "./lifecycle-batch-BH0fax4F.js";
6
6
  import { _ as teardownNodeSubtree, d as removeAllListeners } from "./cleanup-D7uN-Z1s.js";
7
7
  import { r as assertExecutionModel, t as _applyManifest } from "./manifest-D6rfj_jC.js";
8
8
  import { d as stopHydrationRenderPhase, l as startHydrationRenderPhase, y as pageRenderEnvelope } from "./render-context-B7ohlh6t.js";
9
- import { a as setStaticChildSlotsCacheEnabled, c as endHydrationDirectListenerMode, d as discardHydrationListenerTransaction, i as registerDeferredHydrationBoundary, l as beginHydrationListenerTransaction, n as activateHydrationBoundary$1, o as withIntrinsicHydrationAdoption, r as clearDeferredHydrationBoundaries, s as beginHydrationDirectListenerMode, t as installRendererBridge, u as commitHydrationListenerTransaction } from "./renderer-tXX_xj86.js";
9
+ import { a as setStaticChildSlotsCacheEnabled, c as endHydrationDirectListenerMode, d as discardHydrationListenerTransaction, i as registerDeferredHydrationBoundary, l as beginHydrationListenerTransaction, n as activateHydrationBoundary$1, o as withIntrinsicHydrationAdoption, r as clearDeferredHydrationBoundaries, s as beginHydrationDirectListenerMode, t as installRendererBridge, u as commitHydrationListenerTransaction } from "./renderer-Cg92QKOm.js";
10
10
  import { r as validateCspNonce, t as CspNonceScope } from "./csp-nonce-BVybpV1v.js";
11
11
  import { i as getDefaultDataRuntime } from "./data-runtime-7t_Y0iv3.js";
12
12
  import { a as hydrateDataRuntime } from "./query-registry-Bua3cDyb.js";
@@ -108,7 +108,7 @@ function shouldVerifyHydrationMarkup(config) {
108
108
  if (typeof explicit === "boolean") return explicit;
109
109
  return !isProductionEnvironment();
110
110
  }
111
- async function applySelectiveHydration(rootElement, resolved, path, cleanupStrict, hydrateOptions, source, hooks) {
111
+ async function applySelectiveHydration(rootElement, resolved, path, cleanupStrict, hydrateOptions, source, hooks, interactionReplay) {
112
112
  const hasPermanentSkips = (hydrateOptions.skipSelectors?.length ?? 0) > 0;
113
113
  const hasBelowFoldDeferral = !!hydrateOptions.deferBelowFold;
114
114
  const hasSelectiveBoundaries = hasPermanentSkips || hasBelowFoldDeferral;
@@ -126,6 +126,7 @@ async function applySelectiveHydration(rootElement, resolved, path, cleanupStric
126
126
  staticChildSlotsCacheSuspended = true;
127
127
  const foldY = hydrateOptions.foldThreshold ?? window.innerHeight;
128
128
  deferredBoundaries = collectDeferredBelowFoldBoundaries(rootElement, foldY);
129
+ interactionReplay.registerDeferredBoundaries(deferredBoundaries);
129
130
  let selectiveHydrationResourcesReleased = false;
130
131
  let unregisterRootCleanupCallback = () => {};
131
132
  function handleScroll() {
@@ -139,7 +140,9 @@ async function applySelectiveHydration(rootElement, resolved, path, cleanupStric
139
140
  unregisterRootCleanupCallback();
140
141
  window.removeEventListener("scroll", handleScroll);
141
142
  restoreStaticChildSlotsCache();
143
+ interactionReplay.clearDeferredBoundaries();
142
144
  };
145
+ interactionReplay.setOnDeferredBoundariesDrained(releaseSelectiveHydrationResources);
143
146
  unregisterRootCleanupCallback = hooks.registerRootCleanupCallback(rootElement, releaseSelectiveHydrationResources);
144
147
  window.addEventListener("scroll", handleScroll, { passive: true });
145
148
  }
@@ -440,6 +443,175 @@ async function resolveInitialRoute(auth, source) {
440
443
  throw new Error(`[Askr] Route redirect limit exceeded (${MAX_INITIAL_ROUTE_REDIRECTS}).`);
441
444
  }
442
445
  //#endregion
446
+ //#region src/boot/hydration-interaction-replay.ts
447
+ const HYDRATION_INTERACTION_TYPES = [
448
+ "click",
449
+ "dblclick",
450
+ "pointerdown",
451
+ "pointerup",
452
+ "keydown",
453
+ "keyup",
454
+ "input",
455
+ "change",
456
+ "submit"
457
+ ];
458
+ function cloneInteractionEvent(event) {
459
+ const EventConstructor = event.constructor;
460
+ const eventWithDetails = event;
461
+ const init = {
462
+ bubbles: event.bubbles,
463
+ cancelable: event.cancelable,
464
+ composed: event.composed,
465
+ detail: eventWithDetails.detail,
466
+ view: eventWithDetails.view,
467
+ key: eventWithDetails.key,
468
+ code: eventWithDetails.code,
469
+ location: eventWithDetails.location,
470
+ repeat: eventWithDetails.repeat,
471
+ isComposing: eventWithDetails.isComposing,
472
+ ctrlKey: eventWithDetails.ctrlKey,
473
+ shiftKey: eventWithDetails.shiftKey,
474
+ altKey: eventWithDetails.altKey,
475
+ metaKey: eventWithDetails.metaKey,
476
+ button: eventWithDetails.button,
477
+ buttons: eventWithDetails.buttons,
478
+ clientX: eventWithDetails.clientX,
479
+ clientY: eventWithDetails.clientY,
480
+ screenX: eventWithDetails.screenX,
481
+ screenY: eventWithDetails.screenY,
482
+ pointerId: eventWithDetails.pointerId,
483
+ pointerType: eventWithDetails.pointerType,
484
+ isPrimary: eventWithDetails.isPrimary,
485
+ data: eventWithDetails.data,
486
+ inputType: eventWithDetails.inputType
487
+ };
488
+ try {
489
+ return new EventConstructor(event.type, init);
490
+ } catch {
491
+ return new Event(event.type, {
492
+ bubbles: event.bubbles,
493
+ cancelable: event.cancelable,
494
+ composed: event.composed
495
+ });
496
+ }
497
+ }
498
+ /**
499
+ * Capture discrete interactions before hydrated handlers exist, then replay
500
+ * them after the listener transaction commits. Deferred boundaries remain
501
+ * interaction-activatable until they are revealed or the root is cleaned up.
502
+ */
503
+ function beginHydrationInteractionReplay(root, activateBoundary, permanentSkipSelectors = []) {
504
+ const queued = [];
505
+ const deferredBoundaries = /* @__PURE__ */ new Set();
506
+ let active = true;
507
+ let hydratingRoot = true;
508
+ let replaying = false;
509
+ let hadDeferredBoundaries = false;
510
+ let onDeferredBoundariesDrained;
511
+ function isPermanentlySkipped(target) {
512
+ const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
513
+ if (!element) return false;
514
+ return permanentSkipSelectors.some((selector) => {
515
+ try {
516
+ return element.closest(selector) !== null;
517
+ } catch {
518
+ return false;
519
+ }
520
+ });
521
+ }
522
+ function findDeferredBoundary(target) {
523
+ if (!(target instanceof Node)) return;
524
+ for (const boundary of deferredBoundaries) if (boundary.hasAttribute("data-skip-hydrate") && boundary.contains(target)) return boundary;
525
+ }
526
+ function removeCaptureListeners() {
527
+ if (!active) return;
528
+ active = false;
529
+ for (const type of HYDRATION_INTERACTION_TYPES) root.removeEventListener(type, captureInteraction, true);
530
+ }
531
+ function releaseIfFinished() {
532
+ if (!hydratingRoot && deferredBoundaries.size === 0) removeCaptureListeners();
533
+ }
534
+ function notifyIfDeferredBoundariesDrained() {
535
+ if (hadDeferredBoundaries && deferredBoundaries.size === 0) onDeferredBoundariesDrained?.();
536
+ }
537
+ function replayInteraction(interaction) {
538
+ if (!(interaction.target instanceof Node) || !root.contains(interaction.target)) return;
539
+ const boundary = findDeferredBoundary(interaction.target);
540
+ if (boundary) {
541
+ try {
542
+ if (!activateBoundary(boundary)) return;
543
+ } catch {
544
+ return;
545
+ }
546
+ deferredBoundaries.delete(boundary);
547
+ notifyIfDeferredBoundariesDrained();
548
+ }
549
+ replaying = true;
550
+ try {
551
+ interaction.target.dispatchEvent(interaction.event);
552
+ } finally {
553
+ replaying = false;
554
+ }
555
+ releaseIfFinished();
556
+ }
557
+ function captureInteraction(event) {
558
+ if (!active || replaying) return;
559
+ const target = event.target;
560
+ if (!target || isPermanentlySkipped(target)) return;
561
+ const boundary = findDeferredBoundary(target);
562
+ if (!hydratingRoot && !boundary) return;
563
+ event.preventDefault();
564
+ event.stopImmediatePropagation();
565
+ const interaction = {
566
+ target,
567
+ event: cloneInteractionEvent(event)
568
+ };
569
+ if (hydratingRoot) {
570
+ queued.push(interaction);
571
+ return;
572
+ }
573
+ replayInteraction(interaction);
574
+ }
575
+ const installedTypes = [];
576
+ try {
577
+ for (const type of HYDRATION_INTERACTION_TYPES) {
578
+ root.addEventListener(type, captureInteraction, true);
579
+ installedTypes.push(type);
580
+ }
581
+ } catch (error) {
582
+ active = false;
583
+ for (const type of installedTypes) root.removeEventListener(type, captureInteraction, true);
584
+ throw error;
585
+ }
586
+ return {
587
+ registerDeferredBoundaries(boundaries) {
588
+ for (const boundary of boundaries) deferredBoundaries.add(boundary);
589
+ hadDeferredBoundaries ||= boundaries.length > 0;
590
+ },
591
+ setOnDeferredBoundariesDrained(callback) {
592
+ onDeferredBoundariesDrained = callback;
593
+ },
594
+ clearDeferredBoundaries() {
595
+ deferredBoundaries.clear();
596
+ releaseIfFinished();
597
+ },
598
+ complete() {
599
+ if (!active || !hydratingRoot) return;
600
+ hydratingRoot = false;
601
+ const pending = queued.splice(0);
602
+ for (const interaction of pending) replayInteraction(interaction);
603
+ releaseIfFinished();
604
+ },
605
+ abort() {
606
+ queued.length = 0;
607
+ deferredBoundaries.clear();
608
+ onDeferredBoundariesDrained = void 0;
609
+ hydratingRoot = false;
610
+ removeCaptureListeners();
611
+ }
612
+ };
613
+ }
614
+ //#endregion
443
615
  //#region src/boot/index.ts
444
616
  /**
445
617
  * App bootstrap and mount
@@ -570,97 +742,105 @@ async function hydrateSPA(config) {
570
742
  if (manifest.records.length === 0) throw new Error("hydrateSPA requires a route registry with at least one route. If you are enhancing existing HTML, use createIsland instead.");
571
743
  const rootElement = resolveRootElement(config.root);
572
744
  if (!rootElement) throw new Error(`Root element not found: ${config.root}`);
573
- applyDeferredStreamPatches(rootElement);
574
- const hydrationRenderData = takeHydrationRenderData(rootElement);
575
- const hydrationQueryCache = hydrationRenderData?.resources;
576
- if (hydrationQueryCache) hydrateDataRuntime(config.dataRuntime ?? getDefaultDataRuntime(), hydrationQueryCache);
577
- const hydrationRenderDataForApp = hydrationRenderData;
578
- configureScrollRestoration(config.scrollRestoration);
579
- clearRouteState();
580
- _applyManifest(manifest);
581
- const routeAuth = config.auth ?? manifest.auth;
582
- const appRouteSource = {
583
- registry: config.registry,
584
- auth: routeAuth,
585
- runtime: createAppRenderRuntime({
586
- framework: hydrationRenderData?.framework,
587
- route: hydrationRenderData?.route,
588
- hasRoute: hydrationRenderData !== null,
589
- routeRegistry: config.registry,
590
- routeAuth
591
- })
592
- };
593
- _setActiveRouteAuthOptions(routeAuth);
594
- const { path, href: currentUrl, resolved } = await resolveInitialRoute(routeAuth, {
595
- registry: config.registry,
596
- load: false
597
- });
598
- setServerLocation(currentUrl);
599
- if (isProductionEnvironment()) lockRouteRegistration();
600
- if (!resolved) throw new Error(`hydrateSPA: no route found for current path (${path}).`);
601
- if (resolved.kind === "redirect") throw new Error(`hydrateSPA: unresolved redirect for current path (${path}).`);
602
- await reconcileInitialRouteMetadata(resolved);
603
- const hydrationResolved = resolved.kind === "deny" ? {
604
- handler: bindDeniedRouteHandler(resolved.status),
605
- params: {}
606
- } : {
607
- handler: resolved.handler,
608
- params: resolved.params
609
- };
610
- const mountHydratedRoot = (...args) => withIntrinsicHydrationAdoption(() => mountOrUpdate(args[0], args[1], {
611
- ...args[2],
612
- cspNonce: config.cspNonce
613
- }));
614
- if (shouldVerifyHydrationMarkup(config)) {
615
- const { verifyHydrationSyncForUrl } = await import("./verify-hydration-Bo5_tpen.js");
616
- if (!verifyHydrationSyncForUrl({
617
- root: rootElement,
618
- url: currentUrl,
745
+ const interactionReplay = beginHydrationInteractionReplay(rootElement, (boundary) => activateHydrationBoundary(rootElement, boundary), config.hydrate?.skipSelectors);
746
+ try {
747
+ applyDeferredStreamPatches(rootElement);
748
+ const hydrationRenderData = takeHydrationRenderData(rootElement);
749
+ const hydrationQueryCache = hydrationRenderData?.resources;
750
+ if (hydrationQueryCache) hydrateDataRuntime(config.dataRuntime ?? getDefaultDataRuntime(), hydrationQueryCache);
751
+ const hydrationRenderDataForApp = hydrationRenderData;
752
+ configureScrollRestoration(config.scrollRestoration);
753
+ clearRouteState();
754
+ _applyManifest(manifest);
755
+ const routeAuth = config.auth ?? manifest.auth;
756
+ const appRouteSource = {
619
757
  registry: config.registry,
620
- resolved: hydrationResolved,
621
- options: {
622
- data: hydrationRenderDataForApp?.resources,
623
- dataRuntime: config.dataRuntime ?? getDefaultDataRuntime(),
624
- envelope: hydrationRenderDataForApp ?? void 0,
625
- cspNonce: config.cspNonce
626
- }
627
- })) throw new Error("[Askr] Hydration mismatch detected. Server HTML does not match expected server-render output.");
628
- }
629
- const hydrateOptions = config.hydrate;
630
- if (hydrateOptions) {
631
- if (hydrateOptions.deferUntilIdle || hydrateOptions.deferBelowFold) {
632
- if (hydrationRenderDataForApp) startHydrationRenderPhase(hydrationRenderDataForApp);
633
- try {
634
- await applySelectiveHydration(rootElement, hydrationResolved, path, config.cleanupStrict, hydrateOptions, appRouteSource, {
635
- mountOrUpdate: mountHydratedRoot,
636
- registerAppNavigation,
637
- registerRootCleanupCallback,
638
- activateHydrationBoundary
639
- });
640
- } finally {
641
- if (hydrationRenderDataForApp) stopHydrationRenderPhase();
758
+ auth: routeAuth,
759
+ runtime: createAppRenderRuntime({
760
+ framework: hydrationRenderData?.framework,
761
+ route: hydrationRenderData?.route,
762
+ hasRoute: hydrationRenderData !== null,
763
+ routeRegistry: config.registry,
764
+ routeAuth
765
+ })
766
+ };
767
+ _setActiveRouteAuthOptions(routeAuth);
768
+ const { path, href: currentUrl, resolved } = await resolveInitialRoute(routeAuth, {
769
+ registry: config.registry,
770
+ load: false
771
+ });
772
+ setServerLocation(currentUrl);
773
+ if (isProductionEnvironment()) lockRouteRegistration();
774
+ if (!resolved) throw new Error(`hydrateSPA: no route found for current path (${path}).`);
775
+ if (resolved.kind === "redirect") throw new Error(`hydrateSPA: unresolved redirect for current path (${path}).`);
776
+ await reconcileInitialRouteMetadata(resolved);
777
+ const hydrationResolved = resolved.kind === "deny" ? {
778
+ handler: bindDeniedRouteHandler(resolved.status),
779
+ params: {}
780
+ } : {
781
+ handler: resolved.handler,
782
+ params: resolved.params
783
+ };
784
+ const mountHydratedRoot = (...args) => withIntrinsicHydrationAdoption(() => mountOrUpdate(args[0], args[1], {
785
+ ...args[2],
786
+ cspNonce: config.cspNonce
787
+ }));
788
+ if (shouldVerifyHydrationMarkup(config)) {
789
+ const { verifyHydrationSyncForUrl } = await import("./verify-hydration-Bo5_tpen.js");
790
+ if (!verifyHydrationSyncForUrl({
791
+ root: rootElement,
792
+ url: currentUrl,
793
+ registry: config.registry,
794
+ resolved: hydrationResolved,
795
+ options: {
796
+ data: hydrationRenderDataForApp?.resources,
797
+ dataRuntime: config.dataRuntime ?? getDefaultDataRuntime(),
798
+ envelope: hydrationRenderDataForApp ?? void 0,
799
+ cspNonce: config.cspNonce
800
+ }
801
+ })) throw new Error("[Askr] Hydration mismatch detected. Server HTML does not match expected server-render output.");
802
+ }
803
+ const hydrateOptions = config.hydrate;
804
+ if (hydrateOptions) {
805
+ if (hydrateOptions.deferUntilIdle || hydrateOptions.deferBelowFold) {
806
+ if (hydrationRenderDataForApp) startHydrationRenderPhase(hydrationRenderDataForApp);
807
+ try {
808
+ await applySelectiveHydration(rootElement, hydrationResolved, path, config.cleanupStrict, hydrateOptions, appRouteSource, {
809
+ mountOrUpdate: mountHydratedRoot,
810
+ registerAppNavigation,
811
+ registerRootCleanupCallback,
812
+ activateHydrationBoundary
813
+ }, interactionReplay);
814
+ } finally {
815
+ if (hydrationRenderDataForApp) stopHydrationRenderPhase();
816
+ }
817
+ interactionReplay.complete();
818
+ return;
642
819
  }
643
- return;
820
+ if (hydrateOptions.skipSelectors?.length) markSkippedElements(rootElement, hydrateOptions.skipSelectors);
644
821
  }
645
- if (hydrateOptions.skipSelectors?.length) markSkippedElements(rootElement, hydrateOptions.skipSelectors);
646
- }
647
- if (hydrationRenderDataForApp) startHydrationRenderPhase(hydrationRenderDataForApp);
648
- const listenerTransaction = beginHydrationListenerTransaction();
649
- beginHydrationDirectListenerMode();
650
- try {
651
- mountHydratedRoot(rootElement, resolved.kind === "deny" ? bindDeniedStatus(resolved.status) : bindResolvedRouteHandler(hydrationResolved), {
652
- cleanupStrict: config.cleanupStrict,
653
- appRuntime: appRouteSource.runtime
654
- });
655
- commitHydrationListenerTransaction(listenerTransaction);
822
+ if (hydrationRenderDataForApp) startHydrationRenderPhase(hydrationRenderDataForApp);
823
+ const listenerTransaction = beginHydrationListenerTransaction();
824
+ beginHydrationDirectListenerMode();
825
+ try {
826
+ mountHydratedRoot(rootElement, resolved.kind === "deny" ? bindDeniedStatus(resolved.status) : bindResolvedRouteHandler(hydrationResolved), {
827
+ cleanupStrict: config.cleanupStrict,
828
+ appRuntime: appRouteSource.runtime
829
+ });
830
+ commitHydrationListenerTransaction(listenerTransaction);
831
+ interactionReplay.complete();
832
+ } catch (error) {
833
+ discardHydrationListenerTransaction(listenerTransaction);
834
+ throw error;
835
+ } finally {
836
+ endHydrationDirectListenerMode();
837
+ if (hydrationRenderDataForApp) stopHydrationRenderPhase();
838
+ }
839
+ await registerAppNavigation(rootElement, path, { ...appRouteSource });
656
840
  } catch (error) {
657
- discardHydrationListenerTransaction(listenerTransaction);
841
+ interactionReplay.abort();
658
842
  throw error;
659
- } finally {
660
- endHydrationDirectListenerMode();
661
- if (hydrationRenderDataForApp) stopHydrationRenderPhase();
662
843
  }
663
- await registerAppNavigation(rootElement, path, { ...appRouteSource });
664
844
  }
665
845
  //#endregion
666
846
  export { cleanupApp as a, hydrateSPA as i, createIslands as n, hasApp as o, createSPA as r, createIsland as t };
@@ -73,6 +73,7 @@ declare function pressable({ disabled, onPress, isNativeButton }: PressableOptio
73
73
  * - Returns standard event handler props (onKeyDown, onPointerDownCapture)
74
74
  * - Composable via mergeProps with other foundations
75
75
  * - Caller provides the protected node reference for outside detection
76
+ * - Portaled descendants are registered explicitly as additional inside nodes
76
77
  * - Returned capture props must be attached to a surface that can observe both
77
78
  * the protected subtree and the outside interaction path (for example, an
78
79
  * overlay or wrapper around the protected node)
@@ -87,6 +88,7 @@ declare function pressable({ disabled, onPress, isNativeButton }: PressableOptio
87
88
  * USAGE:
88
89
  * const props = dismissable({
89
90
  * node: elementRef,
91
+ * additionalInsideNodes: [dropdownPortalRef],
90
92
  * disabled: false,
91
93
  * onDismiss: () => close()
92
94
  * });
@@ -107,6 +109,11 @@ interface DismissableOptions {
107
109
  * returned capture props to a surface that encloses this node.
108
110
  */
109
111
  node?: Node | null;
112
+ /**
113
+ * Additional roots that are logically inside the protected surface even
114
+ * when a portal places them outside `node` in the DOM tree.
115
+ */
116
+ additionalInsideNodes?: readonly (Node | null | undefined)[];
110
117
  /**
111
118
  * Whether dismiss is disabled
112
119
  */
@@ -117,7 +124,7 @@ interface DismissableOptions {
117
124
  onDismiss?: (trigger: 'escape' | 'outside') => void;
118
125
  }
119
126
  /** Produce keydown/outside-click props that invoke `onDismiss` on Escape or an outside click. */
120
- declare function dismissable({ node, disabled, onDismiss }: DismissableOptions): {
127
+ declare function dismissable({ node, additionalInsideNodes, disabled, onDismiss }: DismissableOptions): {
121
128
  onKeyDown: (e: KeyboardLikeEvent) => void;
122
129
  onPointerDownCapture: (e: PointerLikeEvent) => void;
123
130
  };
@@ -1,2 +1,2 @@
1
- import { a as focusable, i as hoverable, n as mergeInteractionProps, o as dismissable, r as rovingFocus, s as pressable, t as applyInteractionPolicy } from "../../interactions-B4XaFxt1.js";
1
+ import { a as focusable, i as hoverable, n as mergeInteractionProps, o as dismissable, r as rovingFocus, s as pressable, t as applyInteractionPolicy } from "../../interactions-B-7vkBgx.js";
2
2
  export { applyInteractionPolicy, dismissable, focusable, hoverable, mergeInteractionProps, pressable, rovingFocus };
package/dist/fx/index.js CHANGED
@@ -24,20 +24,20 @@ function debounce(fn, ms, options) {
24
24
  const { leading = false, trailing = true } = options || {};
25
25
  let lastArgs = null;
26
26
  let lastThis = null;
27
- let lastCallTime = 0;
27
+ let trailingPending = false;
28
28
  const debounced = function(...args) {
29
- const callTime = Date.now();
29
+ const windowActive = timeoutId !== null;
30
30
  lastArgs = args;
31
31
  lastThis = this;
32
32
  if (timeoutId !== null) clearTimeout(timeoutId);
33
- if (leading && callTime - lastCallTime >= ms) {
33
+ if (leading && !windowActive) {
34
34
  callable.apply(this, args);
35
- lastCallTime = callTime;
36
- }
37
- if (trailing) timeoutId = setTimeout(() => {
38
- callable.apply(lastThis, lastArgs);
35
+ trailingPending = false;
36
+ } else if (trailing) trailingPending = true;
37
+ timeoutId = setTimeout(() => {
38
+ if (trailingPending) callable.apply(lastThis, lastArgs);
39
39
  timeoutId = null;
40
- lastCallTime = Date.now();
40
+ trailingPending = false;
41
41
  }, ms);
42
42
  };
43
43
  debounced.cancel = () => {
@@ -45,6 +45,7 @@ function debounce(fn, ms, options) {
45
45
  clearTimeout(timeoutId);
46
46
  timeoutId = null;
47
47
  }
48
+ trailingPending = false;
48
49
  };
49
50
  return debounced;
50
51
  }
@@ -121,13 +122,13 @@ function once(fn) {
121
122
  const callable = fn;
122
123
  let called = false;
123
124
  let result;
124
- return ((...args) => {
125
+ return function(...args) {
125
126
  if (!called) {
126
127
  called = true;
127
- result = callable(...args);
128
+ result = callable.apply(this, args);
128
129
  }
129
130
  return result;
130
- });
131
+ };
131
132
  }
132
133
  /**
133
134
  * Defer — schedule on microtask queue
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { n as isSnapshotSource } from "./snapshot-source-BB42xgwb.js";
7
7
  import { c as registerSSRStyle } from "./render-context-B7ohlh6t.js";
8
8
  import { t as state } from "./state-DmkGM_LN.js";
9
9
  import { t as selector } from "./selector-CyK-sDwB.js";
10
- import { t as installRendererBridge } from "./renderer-tXX_xj86.js";
10
+ import { t as installRendererBridge } from "./renderer-Cg92QKOm.js";
11
11
  import { n as cspNonce, t as CspNonceScope } from "./csp-nonce-BVybpV1v.js";
12
12
  import { i as For, n as Match, r as Show, t as Case } from "./control-C0klPV7Q.js";
13
13
  import { jsx, jsxs } from "./jsx-runtime.js";
@@ -49,7 +49,7 @@ function pressable({ disabled, onPress, isNativeButton = false }) {
49
49
  //#endregion
50
50
  //#region src/foundations/interactions/dismissable.ts
51
51
  /** Produce keydown/outside-click props that invoke `onDismiss` on Escape or an outside click. */
52
- function dismissable({ node, disabled, onDismiss }) {
52
+ function dismissable({ node, additionalInsideNodes, disabled, onDismiss }) {
53
53
  function handleKeyDown(e) {
54
54
  if (disabled) return;
55
55
  if (e.key === "Escape") {
@@ -63,7 +63,8 @@ function dismissable({ node, disabled, onDismiss }) {
63
63
  const target = e.target;
64
64
  if (!(target instanceof Node)) return;
65
65
  if (!node) return;
66
- if (!node.contains(target)) onDismiss?.("outside");
66
+ const insideAdditionalNode = additionalInsideNodes?.some((insideNode) => insideNode?.contains(target));
67
+ if (!node.contains(target) && !insideAdditionalNode) onDismiss?.("outside");
67
68
  }
68
69
  return {
69
70
  onKeyDown: handleKeyDown,
@@ -5918,6 +5918,9 @@ function materializeComponentResultNode(childInstance, result, parentNamespace)
5918
5918
  } finally {
5919
5919
  restoreDomCommitScope(previousInstance);
5920
5920
  }
5921
+ return materializeResolvedComponentResultNode(childInstance, result, dom);
5922
+ }
5923
+ function materializeResolvedComponentResultNode(childInstance, result, dom) {
5921
5924
  if (dom instanceof Element) {
5922
5925
  mountInstanceInline(childInstance, dom);
5923
5926
  return dom;
@@ -5961,6 +5964,20 @@ function materializeComponentResultNode(childInstance, result, parentNamespace)
5961
5964
  return materialized.fragment ?? host;
5962
5965
  }
5963
5966
  //#endregion
5967
+ //#region src/renderer/component-chain-depth.ts
5968
+ const MAX_COMPONENT_CHAIN_DEPTH = 1e5;
5969
+ const COMPONENT_CHAIN_ERROR_CONTEXT = 8;
5970
+ function assertComponentChainDepth(depth, instance) {
5971
+ if (depth < MAX_COMPONENT_CHAIN_DEPTH) return;
5972
+ const names = [];
5973
+ let current = instance;
5974
+ while (current && names.length < COMPONENT_CHAIN_ERROR_CONTEXT) {
5975
+ names.push(current.fn.name || "<anonymous>");
5976
+ current = current.parentInstance;
5977
+ }
5978
+ throw new Error(`[Askr] Component chain exceeded ${MAX_COMPONENT_CHAIN_DEPTH.toLocaleString("en-US")} wrappers. This usually means component output recurses without terminating. Recent chain: ${names.reverse().join(" -> ")}`);
5979
+ }
5980
+ //#endregion
5964
5981
  //#region src/renderer/component-host-nested-results.ts
5965
5982
  function getNestedComponentVNode(result) {
5966
5983
  if (_isDOMElement(result) && typeof result.type === "function") return result;
@@ -5972,8 +5989,10 @@ function getNestedComponentVNode(result) {
5972
5989
  function resolveNestedComponentResult(result, snapshot, parentInstance) {
5973
5990
  let currentResult = result;
5974
5991
  let activeSnapshot = snapshot;
5992
+ let diagnosticParent = parentInstance;
5975
5993
  let depth = 0;
5976
- while (_isDOMElement(currentResult) && typeof currentResult.type === "function" && depth < 16) {
5994
+ while (_isDOMElement(currentResult) && typeof currentResult.type === "function") {
5995
+ assertComponentChainDepth(depth, diagnosticParent);
5977
5996
  const nestedSnapshot = getVNodeContextFrame(currentResult) ?? activeSnapshot;
5978
5997
  const nestedInstance = createComponentInstance(nextComponentInstanceId(), currentResult.type, currentResult.props ?? {}, null);
5979
5998
  nestedInstance.isRoot = isRouteRootComponentVNode(currentResult);
@@ -5990,6 +6009,7 @@ function resolveNestedComponentResult(result, snapshot, parentInstance) {
5990
6009
  throw error;
5991
6010
  }
5992
6011
  cleanupComponent(nestedInstance);
6012
+ diagnosticParent = nestedInstance;
5993
6013
  activeSnapshot = nestedSnapshot ?? null;
5994
6014
  currentResult = nextResult;
5995
6015
  depth += 1;
@@ -6005,7 +6025,8 @@ function resolveHostNestedComponentResult(host, retainedInstance, result, snapsh
6005
6025
  const createdVNodeOwners = [];
6006
6026
  try {
6007
6027
  let nestedVNode = getNestedComponentVNode(currentResult);
6008
- while (nestedVNode && depth < 16) {
6028
+ while (nestedVNode) {
6029
+ assertComponentChainDepth(depth, activeParent);
6009
6030
  const nestedSnapshot = getVNodeContextFrame(nestedVNode) ?? activeSnapshot;
6010
6031
  let nestedInstance = findHostInstanceByType(host, nestedVNode.type, nestedVNode, activeParent, depth);
6011
6032
  const hadNestedInstance = !!nestedInstance;
@@ -6063,7 +6084,8 @@ function resolveWrapperHostResult(host, retainedInstance, result, snapshot, reta
6063
6084
  let activeParent = retainedInstance;
6064
6085
  let depth = 0;
6065
6086
  let nestedVNode = getNestedComponentVNode(currentResult);
6066
- while (nestedVNode && depth < 16) {
6087
+ while (nestedVNode) {
6088
+ assertComponentChainDepth(depth, activeParent);
6067
6089
  const nestedSnapshot = getVNodeContextFrame(nestedVNode) ?? activeSnapshot;
6068
6090
  const nestedInstance = findHostInstanceByType(host, nestedVNode.type, nestedVNode, activeParent, depth);
6069
6091
  if (!nestedInstance) break;
@@ -6090,6 +6112,81 @@ function resolveWrapperHostResult(host, retainedInstance, result, snapshot, reta
6090
6112
  };
6091
6113
  }
6092
6114
  //#endregion
6115
+ //#region src/renderer/component-host-fresh-chain.ts
6116
+ function getFreshNestedComponentVNode(result) {
6117
+ if (_isDOMElement(result) && typeof result.type === "function") return result;
6118
+ if (!Array.isArray(result) && !isFragmentVNode(result)) return null;
6119
+ const children = normalizeComponentChildren(result);
6120
+ const child = children.length === 1 ? children[0] : void 0;
6121
+ return _isDOMElement(child) && typeof child.type === "function" ? child : null;
6122
+ }
6123
+ function prepareFreshNestedComponentResultDom(result, dom) {
6124
+ if (!Array.isArray(result) && !isFragmentVNode(result)) return dom;
6125
+ const fragment = document.createDocumentFragment();
6126
+ fragment.appendChild(dom);
6127
+ return fragment;
6128
+ }
6129
+ function rollbackFreshNestedComponentResolution(resolution) {
6130
+ for (let index = resolution.vnodeOwners.length - 1; index >= 0; index -= 1) {
6131
+ const owner = resolution.vnodeOwners[index];
6132
+ restoreVNodeComponentInstance(owner.node, owner.previous);
6133
+ }
6134
+ cleanupProvisionalComponentInstances(resolution.createdInstances);
6135
+ }
6136
+ function resolveFreshNestedComponentResult(result, snapshot, parentInstance) {
6137
+ let currentResult = result;
6138
+ let activeSnapshot = snapshot;
6139
+ let activeParent = parentInstance;
6140
+ let depth = 0;
6141
+ const resolution = {
6142
+ entries: [],
6143
+ createdInstances: [],
6144
+ vnodeOwners: []
6145
+ };
6146
+ try {
6147
+ let nestedVNode = getFreshNestedComponentVNode(currentResult);
6148
+ while (nestedVNode) {
6149
+ assertComponentChainDepth(depth, activeParent);
6150
+ const nestedSnapshot = getVNodeContextFrame(nestedVNode) ?? activeSnapshot;
6151
+ const componentFn = nestedVNode.type;
6152
+ const previous = getVNodeComponentInstance(nestedVNode);
6153
+ let nestedInstance = previous && hasComponentOwnershipIdentity(previous, componentFn, nestedVNode, activeParent, 0) ? previous : void 0;
6154
+ if (!nestedInstance) {
6155
+ nestedInstance = createComponentInstance(nextComponentInstanceId(), componentFn, nestedVNode.props ?? {}, null);
6156
+ resolution.vnodeOwners.push({
6157
+ node: nestedVNode,
6158
+ previous
6159
+ });
6160
+ registerVNodeComponentInstanceRollback(nestedVNode, previous, nestedInstance);
6161
+ setVNodeComponentInstance(nestedVNode, nestedInstance);
6162
+ resolution.createdInstances.push(nestedInstance);
6163
+ } else captureInlineRenderSnapshot(nestedInstance);
6164
+ setComponentOwnershipIdentity(nestedInstance, nestedVNode, activeParent, 0);
6165
+ nestedInstance.isRoot = isRouteRootComponentVNode(nestedVNode);
6166
+ nestedInstance.parentInstance = activeParent;
6167
+ nestedInstance.portalScope = activeParent.portalScope ?? nestedInstance.portalScope;
6168
+ nestedInstance.cleanupStrict = activeParent.cleanupStrict;
6169
+ nestedInstance.props = (nestedVNode.props ?? {}) || {};
6170
+ if (nestedSnapshot) nestedInstance.ownerFrame = nestedSnapshot;
6171
+ const nextResult = withContext(nestedSnapshot ?? null, () => renderComponentInline(nestedInstance));
6172
+ if (isPromiseLike(nextResult)) throw new Error("Async components are not supported. Components must return synchronously.");
6173
+ activeParent = nestedInstance;
6174
+ activeSnapshot = nestedSnapshot ?? null;
6175
+ currentResult = markVNodeTreeWithContextFrame(nextResult, activeSnapshot);
6176
+ resolution.entries.push({
6177
+ instance: nestedInstance,
6178
+ result: currentResult
6179
+ });
6180
+ depth += 1;
6181
+ nestedVNode = getFreshNestedComponentVNode(currentResult);
6182
+ }
6183
+ return resolution;
6184
+ } catch (error) {
6185
+ rollbackFreshNestedComponentResolution(resolution);
6186
+ throw error;
6187
+ }
6188
+ }
6189
+ //#endregion
6093
6190
  //#region src/renderer/component-host-creation.ts
6094
6191
  function createComponentElement(node, type, props, parentNamespace) {
6095
6192
  const snapshot = getVNodeContextFrame(node) || getCurrentContextFrame();
@@ -6099,6 +6196,7 @@ function createComponentElement(node, type, props, parentNamespace) {
6099
6196
  const currentParent = getCurrentInstance();
6100
6197
  let childInstance = previousVNodeInstance && hasComponentOwnershipIdentity(previousVNodeInstance, componentFn, node, currentParent, 0) ? previousVNodeInstance : void 0;
6101
6198
  const hadChildInstance = !!childInstance;
6199
+ let nestedResolution = null;
6102
6200
  if (!childInstance) {
6103
6201
  childInstance = createComponentInstance(nextComponentInstanceId(), componentFn, props || {}, null);
6104
6202
  registerVNodeComponentInstanceRollback(node, previousVNodeInstance, childInstance);
@@ -6116,10 +6214,20 @@ function createComponentElement(node, type, props, parentNamespace) {
6116
6214
  const result = snapshot ? withContext(snapshot, () => renderComponentInline(childInstance)) : renderComponentInline(childInstance);
6117
6215
  if (isPromiseLike(result)) throw new Error("Async components are not supported. Components must return synchronously.");
6118
6216
  const scopedResult = markVNodeTreeWithContextFrame(result, snapshot ?? null);
6119
- const dom = snapshot ? withContext(snapshot, () => materializeComponentResultNode(childInstance, scopedResult, parentNamespace)) : materializeComponentResultNode(childInstance, scopedResult, parentNamespace);
6217
+ nestedResolution = resolveFreshNestedComponentResult(scopedResult, snapshot ?? null, childInstance);
6218
+ const deepestEntry = nestedResolution.entries[nestedResolution.entries.length - 1];
6219
+ let dom = snapshot ? withContext(snapshot, () => materializeComponentResultNode(deepestEntry?.instance ?? childInstance, deepestEntry?.result ?? scopedResult, parentNamespace)) : materializeComponentResultNode(deepestEntry?.instance ?? childInstance, deepestEntry?.result ?? scopedResult, parentNamespace);
6220
+ if (deepestEntry) {
6221
+ for (let index = nestedResolution.entries.length - 2; index >= 0; index -= 1) {
6222
+ const entry = nestedResolution.entries[index];
6223
+ dom = materializeResolvedComponentResultNode(entry.instance, entry.result, prepareFreshNestedComponentResultDom(entry.result, dom));
6224
+ }
6225
+ dom = materializeResolvedComponentResultNode(childInstance, scopedResult, prepareFreshNestedComponentResultDom(scopedResult, dom));
6226
+ }
6120
6227
  if (dom instanceof Element) materializeFreshKey(dom, node, props);
6121
6228
  return dom;
6122
6229
  } catch (error) {
6230
+ if (nestedResolution) rollbackFreshNestedComponentResolution(nestedResolution);
6123
6231
  if (!hadChildInstance) {
6124
6232
  restoreVNodeComponentInstance(node, previousVNodeInstance);
6125
6233
  cleanupProvisionalComponentInstance(childInstance);
@@ -7,7 +7,7 @@ import { S as RouteDataLoadError, a as resolveRouteRequest, b as lazy, f as defe
7
7
  import { n as onRouteChange, t as currentRoute } from "../activity-d4t3CdzK.js";
8
8
  import { a as serializeRouteMeta, c as getCurrentHref, d as parseTargetUrl, h as syncRegisteredRouteSnapshot, i as resolveRouteMeta, l as getRegisteredAppsSnapshot, m as syncAppRegistrationLocation, n as navigate, p as setCurrentRouteLocation, r as reconcileRouteMeta, s as saveScrollPosition, u as getWindowHref } from "../navigate-Cix_Q9ym.js";
9
9
  import { o as mergeProps } from "../compose-ref-1Fhs7mbF.js";
10
- import { t as applyInteractionPolicy } from "../interactions-B4XaFxt1.js";
10
+ import { t as applyInteractionPolicy } from "../interactions-B-7vkBgx.js";
11
11
  //#region src/router/route-query.ts
12
12
  function getRouteQueryHistoryMode(options) {
13
13
  if (options.history) return options.history;
@@ -3,7 +3,7 @@ import { u as recordReadableRead } from "../readable-BsdGWsDE.js";
3
3
  import { l as addInvalidationListener, t as createDataRuntime } from "../data-runtime-7t_Y0iv3.js";
4
4
  import { a as notifySource, i as normalizeAsyncDataError, r as createReadableSource } from "../data-CnQwUS76.js";
5
5
  import { r as computeRouteActivityMatches } from "../route-matching-Bo6UqG-5.js";
6
- import { a as cleanupApp, r as createSPA, t as createIsland } from "../boot-DE-JutXH.js";
6
+ import { a as cleanupApp, r as createSPA, t as createIsland } from "../boot-BjldNXqF.js";
7
7
  //#region src/testing/render.ts
8
8
  const renderStates = /* @__PURE__ */ new WeakMap();
9
9
  const eventConstructorNames = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/askr",
3
- "version": "0.0.99",
3
+ "version": "0.2.0",
4
4
  "description": "Actor-backed deterministic UI framework",
5
5
  "keywords": [
6
6
  "askr",
@@ -148,8 +148,8 @@
148
148
  "prepublishOnly": "npm run check"
149
149
  },
150
150
  "dependencies": {
151
- "@askrjs/auth": ">=0.0.8 <0.1.0",
152
- "@askrjs/schema": ">=0.0.5 <0.1.0"
151
+ "@askrjs/auth": ">=0.2.0 <0.3.0",
152
+ "@askrjs/schema": ">=0.2.0 <0.3.0"
153
153
  },
154
154
  "devDependencies": {
155
155
  "@types/node": "^26.2.0",