@jsenv/navi 0.27.67 → 0.27.69

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.
@@ -22344,6 +22344,36 @@ const createControlRules = (
22344
22344
  };
22345
22345
  };
22346
22346
 
22347
+ /**
22348
+ * Returns a stable object that is mutated across renders.
22349
+ * Closures that capture the returned reference always read current values
22350
+ * because the same object reference is reused — no stale captures.
22351
+ *
22352
+ * - `init(scope)` — called **once** on mount. Receives the (initially empty)
22353
+ * stable scope object and returns properties to assign to it. Use this to
22354
+ * create anything that must live for the component's full lifetime: signals,
22355
+ * pubsub pairs, the controller object itself, etc.
22356
+ *
22357
+ * - `update(scope)` — called on every subsequent render. Receives the current
22358
+ * scope and returns properties to update. Use this to sync mutable values
22359
+ * (current props, parent controller, etc.) and run side-effectful logic like
22360
+ * checking whether a controlled `state` prop changed.
22361
+ */
22362
+ const useRenderScope = (init, update) => {
22363
+ const scopeRef = useRef();
22364
+ let scope = scopeRef.current;
22365
+ if (!scope) {
22366
+ scope = {};
22367
+ scopeRef.current = scope;
22368
+ const initScope = init(scope);
22369
+ Object.assign(scope, initScope);
22370
+ } else {
22371
+ const updateScope = update(scope);
22372
+ Object.assign(scope, updateScope);
22373
+ }
22374
+ return scope;
22375
+ };
22376
+
22347
22377
  /**
22348
22378
  * Minimal interface that any object placed in `ParentUIStateControllerContext` must satisfy.
22349
22379
  * Implemented by `useUIGroupStateController`, `useUIFacadeStateController`, and
@@ -22413,531 +22443,516 @@ const useUIStateController = (
22413
22443
  const debugUIState = useDebugUIState();
22414
22444
  const debugFocus = useDebugFocus();
22415
22445
 
22416
- const uiStateControllerRef = useRef();
22417
22446
  const parentUIStateController = useContext(ParentUIStateControllerContext);
22418
22447
  const formContext = useContext(FormContext);
22419
- const { id, uiAction } = props;
22420
- const isProxy = Boolean(props["navi-control-proxy-for"]);
22421
22448
  if (persists === undefined && formContext) {
22422
22449
  persists = true;
22423
22450
  }
22424
22451
  const controlType = controlInfo.controlType;
22425
22452
  const isRadio = controlType === "input" && props.type === "radio";
22426
- const [
22427
- notifyParentAboutChildMount,
22428
- notifyParentAboutChildUIAction,
22429
- notifyParentAboutChildUnmount,
22430
- ] = useParentControllerNotifiers(
22431
- parentUIStateController,
22432
- uiStateControllerRef,
22433
- controlType,
22434
- debugUIState,
22435
- );
22436
- useLayoutEffect(() => {
22437
- const controller = uiStateControllerRef.current;
22438
- const el = controller.ref.current;
22439
- if (el) {
22440
- el.__uiStateController__ = controller;
22441
- }
22442
- notifyParentAboutChildMount();
22443
- return () => {
22444
- if (el && el.__uiStateController__ === controller) {
22445
- delete el.__uiStateController__;
22446
- }
22447
- notifyParentAboutChildUnmount();
22448
- onUIStateControllerDestroyed(controller);
22449
- };
22450
- }, []);
22453
+ const isProxy = Boolean(props["navi-control-proxy-for"]);
22451
22454
 
22452
- const existingUIStateController = uiStateControllerRef.current;
22453
- if (existingUIStateController) {
22454
- existingUIStateController._checkForUpdates({
22455
- props,
22456
- controlInfo,
22457
- });
22458
- return existingUIStateController;
22459
- }
22460
- const { stateInitial } = controlInfo;
22461
- debugUIState(
22462
- `Creating "${controlType}" ui state controller - initial state:`,
22463
- JSON.stringify(stateInitial),
22464
- );
22465
- const [publishUIState, subscribeUIState] = createPubSub();
22466
- const ownUIStateSignal = signal(stateInitial);
22467
- const inherit =
22468
- controlType === "button" &&
22469
- !controlInfo.hasStateProp &&
22470
- parentUIStateController;
22471
- const uiStateSignal = inherit
22472
- ? computed(() => {
22473
- const parentUIState = parentUIStateController.uiStateSignal.value;
22474
- const ownUIState = ownUIStateSignal.value;
22475
- return ownUIState || parentUIState;
22476
- })
22477
- : ownUIStateSignal;
22455
+ const scope = useRenderScope(
22456
+ // ── init: runs once on mount ───────────────────────────────────────────
22457
+ // Creates the controller and all long-lived objects. Captures first-render
22458
+ // values for stable config (controlType, isRadio…); live values are read
22459
+ // through `s` which is always updated by `update` before any method call.
22460
+ (s) => {
22461
+ const parentUiStateSignalHolder = signal(
22462
+ parentUIStateController?.uiStateSignal ?? null,
22463
+ );
22464
+ const { stateInitial } = controlInfo;
22465
+ debugUIState(
22466
+ `Creating "${controlType}" ui state controller - initial state:`,
22467
+ JSON.stringify(stateInitial),
22468
+ );
22469
+ const [publishUIState, subscribeUIState] = createPubSub();
22470
+ const ownUIStateSignal = signal(stateInitial);
22471
+ const inherit =
22472
+ controlType === "button" &&
22473
+ !controlInfo.hasStateProp &&
22474
+ parentUIStateController;
22475
+ const uiStateSignal = inherit
22476
+ ? computed(() => {
22477
+ const parentSig = parentUiStateSignalHolder.value;
22478
+ const parentUIState = parentSig?.value;
22479
+ const ownUIState = ownUIStateSignal.value;
22480
+ return ownUIState || parentUIState;
22481
+ })
22482
+ : ownUIStateSignal;
22478
22483
 
22479
- const uiStateController = {
22480
- _checkForUpdates: ({ props, controlInfo }) => {
22481
- // Raw Preact props from the current render. These are the component's input props,
22482
- // not the resolved/curated host props. useInteractiveProps overwrites
22483
- // uiStateController.controlHostProps with the resolved subset on every render.
22484
- uiStateController.props = props;
22485
- // Re-sync to this render's ref object. It's normally stable, but it can
22486
- // legitimately change identity (e.g. switching from an internal fallback
22487
- // ref to a forwarded one, or across an interrupted/resumed render such as
22488
- // a Suspense boundary resolving) — if we kept the original ref forever,
22489
- // `ref.current` would be stuck at whatever it was at creation time, even
22490
- // after the controller has moved on to a different, live DOM node.
22491
- uiStateController.ref = props.ref;
22492
- uiStateController.id = props.id; // never suppoed to changed, not supported for now
22493
- uiStateController.name = props.name;
22484
+ const controller = {
22485
+ controlType,
22486
+ parentUIStateController,
22487
+ parentUiStateSignalHolder,
22488
+ isProxy,
22489
+ allowNameless,
22494
22490
 
22495
- const { value, hasStateProp, state } = controlInfo;
22496
- uiStateController.value = value;
22497
- if (hasStateProp) {
22498
- uiStateController.hasStateProp = true;
22499
- const currentState = uiStateController.state;
22500
- if (compareTwoJsValues(state, currentState)) ; else {
22501
- uiStateController.state = state;
22502
- uiStateController.setUIState(
22503
- state,
22504
- new CustomEvent("state_prop_change"),
22491
+ props,
22492
+ ref: props.ref,
22493
+ id: props.id,
22494
+ name: props.name,
22495
+
22496
+ state: stateInitial,
22497
+ uiState: stateInitial,
22498
+ uiStateSignal,
22499
+ value: controlInfo.value,
22500
+
22501
+ facadeChild: null,
22502
+ getManagedControls: () => {
22503
+ if (controller.facadeChild) {
22504
+ const child = controller.facadeChild;
22505
+ const childManaged = child.getManagedControls();
22506
+ if (childManaged.length > 0) return childManaged;
22507
+ return [child];
22508
+ }
22509
+ return [];
22510
+ },
22511
+ onUIAction: (e, { skipCommand } = {}) => {
22512
+ if (controlType === "button" && controller.controlHostProps.name) {
22513
+ const buttonName = controller.controlHostProps.name;
22514
+ const parentController = controller.parentUIStateController;
22515
+ if (parentController && parentController.wantRequesterButtonState) {
22516
+ const currentState = parentController.uiState;
22517
+ const mergedState = {
22518
+ ...currentState,
22519
+ [buttonName]: controller.uiState,
22520
+ };
22521
+ parentController.syncInternalState(mergedState);
22522
+ debugUIState(
22523
+ `merging button state into parent control group:`,
22524
+ mergedState,
22525
+ );
22526
+ }
22527
+ }
22528
+ // Trigger uiAction/command side effects without changing UI state.
22529
+ const currentUIState = controller.uiState;
22530
+ s.uiActionInternal?.(currentUIState, e);
22531
+ if (s.uiAction) {
22532
+ debugUIState(`calling uiAction for ${controlType}`, currentUIState);
22533
+ s.uiAction(currentUIState, e);
22534
+ }
22535
+ if (skipCommand) ; else {
22536
+ const command = controller.controlHostProps.command;
22537
+ if (command) {
22538
+ const element = controller.ref.current;
22539
+ if (element) {
22540
+ debugUIState(
22541
+ `triggering command "${command}" for "${controlType}"`,
22542
+ );
22543
+ triggerNaviCommand(element, command, e);
22544
+ }
22545
+ }
22546
+ }
22547
+ },
22548
+ setUIState: (newUIState, e) => {
22549
+ const guardResult = controller.rules.guard.checkUIState(
22550
+ newUIState,
22551
+ e,
22505
22552
  );
22506
- }
22507
- } else if (uiStateController.hasStateProp) {
22508
- uiStateController.hasStateProp = false;
22509
- uiStateController.state = stateInitial;
22510
- }
22511
- },
22512
-
22513
- controlType,
22514
- parentUIStateController,
22515
- isProxy,
22516
- allowNameless,
22517
-
22518
- props,
22519
- ref: props.ref,
22520
- id: props.id,
22521
- name: props.name,
22522
-
22523
- state: stateInitial,
22524
- uiState: stateInitial,
22525
- uiStateSignal,
22526
- value: controlInfo.value,
22527
-
22528
- facadeChild: null,
22529
- getManagedControls: () => {
22530
- if (uiStateController.facadeChild) {
22531
- const child = uiStateController.facadeChild;
22532
- const childManaged = child.getManagedControls();
22533
- if (childManaged.length > 0) {
22534
- return childManaged;
22535
- }
22536
- return [child];
22537
- }
22538
- return [];
22539
- },
22540
- onUIAction: (e, { skipCommand } = {}) => {
22541
- if (controlType === "button" && uiStateController.controlHostProps.name) {
22542
- const buttonName = uiStateController.controlHostProps.name;
22543
- const parentController = uiStateController.parentUIStateController;
22544
- if (parentController && parentController.wantRequesterButtonState) {
22545
- const currentState = parentController.uiState;
22546
- const mergedState = {
22547
- ...currentState,
22548
- [buttonName]: uiStateController.uiState,
22549
- };
22550
- parentController.syncInternalState(mergedState);
22551
- debugUIState(
22552
- `merging button state into parent control group:`,
22553
- mergedState,
22553
+ if (guardResult) {
22554
+ if (Object.hasOwn(guardResult, "fixedValue")) {
22555
+ newUIState = guardResult.fixedValue;
22556
+ // fall through — continue with truncated value (callout already shown by guard)
22557
+ } else {
22558
+ return false;
22559
+ }
22560
+ }
22561
+ const controllerSig = getElementSignature(
22562
+ e.currentTarget || controller.ref.current,
22554
22563
  );
22555
- }
22556
- }
22557
- // Trigger uiAction/command side effects without changing UI state.
22558
- const currentUIState = uiStateController.uiState;
22559
- uiActionInternal?.(currentUIState, e);
22560
- if (uiAction) {
22561
- debugUIState(`calling uiAction for ${controlType}`, currentUIState);
22562
- uiAction(currentUIState, e);
22563
- }
22564
- if (skipCommand) ; else {
22565
- const command = uiStateController.controlHostProps.command;
22566
- if (command) {
22567
- const element = uiStateController.ref.current;
22568
- if (element) {
22564
+ const currentUIState = controller.uiState;
22565
+ const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
22566
+ if (stateIsTheSame) {
22567
+ if (controlType === "button" || controlType === "link") {
22568
+ if (!isInternalEvent(e)) {
22569
+ controller.onUIAction(e);
22570
+ }
22571
+ return true;
22572
+ }
22569
22573
  debugUIState(
22570
- `triggering command "${command}" for "${controlType}"`,
22574
+ e,
22575
+ `${controllerSig}.setUIState(${JSON.stringify(newUIState)}, "${e.type}") -> state unchanged, no update needed`,
22571
22576
  );
22572
- triggerNaviCommand(element, command, e);
22577
+ if (
22578
+ controlType === "input" &&
22579
+ controller.controlHostProps.type === "radio" &&
22580
+ !isInternalEvent(e)
22581
+ ) {
22582
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22583
+ stateChanged: false,
22584
+ });
22585
+ }
22586
+ if (e.currentTarget === null) {
22587
+ // A stale/reused event (currentTarget is null) means this is a debounced
22588
+ // callback firing the original input event after a timeout. The state hasn't
22589
+ // changed and this is not a live user gesture — skip uiAction and command.
22590
+ return false;
22591
+ }
22592
+ if (e.type === "state_prop_change") {
22593
+ // state_prop_change with the same uiState means the state prop was updated
22594
+ // to match what the user already has in the UI (e.g. action completed and
22595
+ // synced state back). No real user gesture — skip uiAction and command.
22596
+ return false;
22597
+ }
22598
+ if (e.type === "change") {
22599
+ // "change" fires after "input" for native inputs (date, color, etc.).
22600
+ // The "input" event already updated the state and fired uiAction.
22601
+ // When state is unchanged here it means "input" already ran — skip to
22602
+ // avoid a duplicate uiAction on the same user gesture.
22603
+ return false;
22604
+ }
22605
+ controller.onUIAction(e);
22606
+ return false;
22573
22607
  }
22574
- }
22575
- }
22576
- },
22577
- setUIState: (newUIState, e) => {
22578
- const guardResult = uiStateController.rules.guard.checkUIState(
22579
- newUIState,
22580
- e,
22581
- );
22582
- if (guardResult) {
22583
- if (Object.hasOwn(guardResult, "fixedValue")) {
22584
- newUIState = guardResult.fixedValue;
22585
- // fall through continue with truncated value (callout already shown by guard)
22586
- } else {
22587
- return false;
22588
- }
22589
- }
22590
- const controllerSig = getElementSignature(
22591
- e.currentTarget || uiStateController.ref.current,
22592
- );
22593
- // if (persists) {
22594
- // setNavState(prop);
22595
- // }
22596
- const currentUIState = uiStateController.uiState;
22597
- const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
22598
- if (stateIsTheSame) {
22599
- if (controlType === "button" || controlType === "link") {
22600
- if (!isInternalEvent(e)) {
22601
- uiStateController.onUIAction(e);
22608
+ // set immediatly (don't wait for preact re-render) so ui is in the right state for:
22609
+ // - side effect
22610
+ // - any "input" event that might be dispatched below
22611
+ syncDomState(newUIState, e);
22612
+ controller.uiState = newUIState;
22613
+ ownUIStateSignal.value = newUIState;
22614
+ const controlProxyFor =
22615
+ controller.controlHostProps["navi-control-proxy-for"];
22616
+ // Radio group: when a radio becomes checked, uncheck all siblings.
22617
+ // We only update their UIState — no parent notification, no synthetic
22618
+ // input event (the browser never fires input on the unchecked radios,
22619
+ // and we don't want to trigger their action flow with a stale DOM value).
22620
+ // Uses the in-memory registry instead of DOM queries so this works even
22621
+ // when sibling items are virtualized (not in the DOM).
22622
+ // Form scoping is preserved by comparing parentUIStateController references.
22623
+ if (isRadio && newUIState && controller.name && !controlProxyFor) {
22624
+ const siblings = getRadioSiblings(controller);
22625
+ if (siblings) {
22626
+ const siblingUncheckEvent = new CustomEvent(
22627
+ "radio_sibling_uncheck",
22628
+ { detail: {} },
22629
+ );
22630
+ chainEvent(siblingUncheckEvent, e);
22631
+ for (const siblingController of siblings) {
22632
+ if (siblingController === controller) continue;
22633
+ if (
22634
+ siblingController.parentUIStateController !==
22635
+ s.parentUIStateController
22636
+ ) {
22637
+ continue;
22638
+ }
22639
+ siblingController.setUIState(undefined, siblingUncheckEvent);
22640
+ }
22641
+ }
22602
22642
  }
22603
- return true;
22604
- }
22605
- debugUIState(
22606
- e,
22607
- `${controllerSig}.setUIState(${JSON.stringify(newUIState)}, "${e.type}") -> state unchanged, no update needed`,
22608
- );
22609
- if (
22610
- controlType === "input" &&
22611
- uiStateController.controlHostProps.type === "radio" &&
22612
- !isInternalEvent(e)
22613
- ) {
22614
- notifyParentAboutChildUIAction(e, { stateChanged: false });
22615
- }
22616
- // A stale/reused event (currentTarget is null) means this is a debounced
22617
- // callback firing the original input event after a timeout. The state hasn't
22618
- // changed and this is not a live user gesture — skip uiAction and command.
22619
- if (e.currentTarget === null) {
22620
- return false;
22621
- }
22622
- // state_prop_change with the same uiState means the state prop was updated
22623
- // to match what the user already has in the UI (e.g. action completed and
22624
- // synced state back). No real user gesture — skip uiAction and command.
22625
- if (e.type === "state_prop_change") {
22626
- return false;
22627
- }
22628
- // "change" fires after "input" for native inputs (date, color, etc.).
22629
- // The "input" event already updated the state and fired uiAction.
22630
- // When state is unchanged here it means "input" already ran — skip to
22631
- // avoid a duplicate uiAction on the same user gesture.
22632
- if (e.type === "change") {
22633
- return false;
22634
- }
22635
- uiStateController.onUIAction(e);
22636
- return false;
22637
- }
22638
- // set immediatly (don't wait for preact re-render) so ui is in the right state for:
22639
- // - side effect
22640
- // - any "input" event that might be dispatched below
22641
- syncDomState(newUIState, e);
22642
- uiStateController.uiState = newUIState;
22643
- ownUIStateSignal.value = newUIState;
22644
- // Radio group: when a radio becomes checked, uncheck all siblings.
22645
- // We only update their UIState — no parent notification, no synthetic
22646
- // input event (the browser never fires input on the unchecked radios,
22647
- // and we don't want to trigger their action flow with a stale DOM value).
22648
- // Uses the in-memory registry instead of DOM queries so this works even
22649
- // when sibling items are virtualized (not in the DOM).
22650
- // Form scoping is preserved by comparing parentUIStateController references.
22651
- const controlProxyFor =
22652
- uiStateController.controlHostProps["navi-control-proxy-for"];
22653
- if (isRadio && newUIState && uiStateController.name && !controlProxyFor) {
22654
- const siblings = getRadioSiblings(uiStateController);
22655
- if (siblings) {
22656
- const siblingUncheckEvent = new CustomEvent("radio_sibling_uncheck", {
22657
- detail: {},
22658
- });
22659
- chainEvent(siblingUncheckEvent, e);
22660
- for (const siblingController of siblings) {
22661
- if (siblingController === uiStateController) {
22662
- continue;
22643
+ debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
22644
+ publishUIState(newUIState, e);
22645
+ const el = controller.ref.current;
22646
+ // Always notify the element that its UI state changed.
22647
+ // Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
22648
+ // useUIState subscribes for reactive updates). Separate from navi_set_ui_state
22649
+ // which is the command; navi_ui_state_change is the notification.
22650
+ if (el) {
22651
+ dispatchInternalCustomEvent(el, "navi_ui_state_change", {
22652
+ event: e,
22653
+ value: newUIState,
22654
+ });
22655
+ }
22656
+ if (!controlProxyFor) {
22657
+ // When this controller is a real input that has a visible proxy
22658
+ // (linked via `navi-control-proxy-for`), mirror the new state to the
22659
+ // proxy DOM synchronously. Otherwise the proxy would only catch up
22660
+ // later through a React re-render — visible as e.g. two radios
22661
+ // appearing checked at once between the real input update and the
22662
+ // next render (radio_sibling_uncheck case).
22663
+ const proxyController = findProxyController(s.id);
22664
+ if (proxyController) {
22665
+ // Find any mounted controller that declared itself as a proxy for this one.
22666
+ // Communicates directly to the proxy controller — no DOM query needed.
22667
+ const mirrorEvent = new CustomEvent("proxy_mirror_state", {
22668
+ detail: {},
22669
+ });
22670
+ chainEvent(mirrorEvent, e);
22671
+ proxyController.setUIState(newUIState, mirrorEvent);
22672
+ }
22673
+ }
22674
+ if (isInternalEvent(e)) {
22675
+ if (e.type === "facade_child_mount_sync") {
22676
+ const wasEmptyString =
22677
+ currentUIState === "" && newUIState === undefined;
22678
+ const wasUndefinedNowEmpty =
22679
+ currentUIState === undefined && newUIState === "";
22680
+ if (wasEmptyString) {
22681
+ console.warn(
22682
+ `[navi] Picker mount sync changed state from "" to undefined. ` +
22683
+ `This will call uiAction on mount, which is likely unintended. ` +
22684
+ `Initialise the signal with undefined instead of "" to avoid this.`,
22685
+ );
22686
+ } else if (wasUndefinedNowEmpty) {
22687
+ console.warn(
22688
+ `[navi] Picker mount sync changed state from undefined to "". ` +
22689
+ `This will call uiAction on mount, which is likely unintended. ` +
22690
+ `The child component is emitting "" for an empty value — it should emit undefined instead.`,
22691
+ );
22692
+ }
22693
+ }
22694
+ // initial_state_push is pure initialization (equivalent to defaultValue on the
22695
+ // child itself): skip uiAction entirely so no side effects fire on mount.
22696
+ if (e.type !== "initial_state_push") {
22697
+ // Still fire uiAction so external listeners (e.g. signals) stay in
22698
+ // sync, but do NOT fire the command and do NOT notify the parent —
22699
+ // both would cause an infinite loop when a parent cascades state
22700
+ // down to its children (child command would re-trigger the cascade).
22701
+ controller.onUIAction(e, { skipCommand: true });
22702
+ }
22703
+ if (e.type === "facade_propagate_up") {
22704
+ // Exception: when the facade propagates a child state change up to the
22705
+ // real picker input, also notify the parent group (e.g. Form) so it
22706
+ // keeps its cached aggregated state in sync and fires its own uiAction.
22707
+ // This is consistent with how a direct Input inside a Form behaves:
22708
+ // the Form's uiAction fires on every value change.
22709
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22710
+ stateChanged: true,
22711
+ });
22663
22712
  }
22664
22713
  if (
22665
- siblingController.parentUIStateController !==
22666
- parentUIStateController
22714
+ e.type === "state_prop_change" &&
22715
+ s.parentUIStateController &&
22716
+ !s.parentUIStateController.hasStateProp
22667
22717
  ) {
22668
- continue;
22718
+ // Exception: state_prop_change can only fire on a control with its own
22719
+ // controlled state/value prop (see hasStateProp above) — groups never
22720
+ // cascade state down into such children (they're explicitly skipped,
22721
+ // see shouldPropagateStateToChild/hasStateProp checks), so this change
22722
+ // can never be an echo of the parent's own cascade. The loop risk this
22723
+ // suppression exists for only applies when the parent itself just pushed
22724
+ // this value down, which requires the parent to be controlled (have its
22725
+ // own state/value prop). When the parent is "stateless" (uncontrolled),
22726
+ // notifying it is always safe and necessary — otherwise its aggregated
22727
+ // state silently drifts out of sync with this child.
22728
+ s.parentUIStateController.onChildUIAction(controller, e, {
22729
+ stateChanged: true,
22730
+ });
22669
22731
  }
22670
- siblingController.setUIState(undefined, siblingUncheckEvent);
22671
- }
22672
- }
22673
- }
22674
- debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
22675
- publishUIState(newUIState, e);
22676
- const el = uiStateController.ref.current;
22677
- // Always notify the element that its UI state changed.
22678
- // Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
22679
- // useUIState subscribes for reactive updates). Separate from navi_set_ui_state
22680
- // which is the command; navi_ui_state_change is the notification.
22681
- if (el) {
22682
- dispatchInternalCustomEvent(el, "navi_ui_state_change", {
22683
- event: e,
22684
- value: newUIState,
22685
- });
22686
- }
22687
- // When this controller is a real input that has a visible proxy
22688
- // (linked via `navi-control-proxy-for`), mirror the new state to the
22689
- // proxy DOM synchronously. Otherwise the proxy would only catch up
22690
- // later through a React re-render — visible as e.g. two radios
22691
- // appearing checked at once between the real input update and the
22692
- // next render (radio_sibling_uncheck case).
22693
- if (!controlProxyFor) {
22694
- // Find any mounted controller that declared itself as a proxy for this one.
22695
- // Communicates directly to the proxy controller — no DOM query needed.
22696
- const proxyController = findProxyController(id);
22697
- if (proxyController) {
22698
- const mirrorEvent = new CustomEvent("proxy_mirror_state", {
22699
- detail: {},
22700
- });
22701
- chainEvent(mirrorEvent, e);
22702
- proxyController.setUIState(newUIState, mirrorEvent);
22703
- }
22704
- }
22705
- if (isInternalEvent(e)) {
22706
- if (e.type === "facade_child_mount_sync") {
22707
- // Warn when the picker's initial signal value is "" but the list
22708
- // resolved to undefined (no selection). These are semantically
22709
- // equivalent but technically different, so the mount-sync registers
22710
- // as a state change and fires uiAction unexpectedly.
22711
- // Fix: initialise the signal with undefined instead of "".
22712
- const wasEmptyString =
22713
- currentUIState === "" && newUIState === undefined;
22714
- const wasUndefinedNowEmpty =
22715
- currentUIState === undefined && newUIState === "";
22716
- if (wasEmptyString) {
22717
- console.warn(
22718
- `[navi] Picker mount sync changed state from "" to undefined. ` +
22719
- `This will call uiAction on mount, which is likely unintended. ` +
22720
- `Initialise the signal with undefined instead of "" to avoid this.`,
22721
- );
22722
- } else if (wasUndefinedNowEmpty) {
22723
- console.warn(
22724
- `[navi] Picker mount sync changed state from undefined to "". ` +
22725
- `This will call uiAction on mount, which is likely unintended. ` +
22726
- `The child component is emitting "" for an empty value — it should emit undefined instead.`,
22727
- );
22732
+ return true;
22728
22733
  }
22729
- }
22730
- // initial_state_push is pure initialization (equivalent to defaultValue on the
22731
- // child itself): skip uiAction entirely so no side effects fire on mount.
22732
- if (e.type !== "initial_state_push") {
22733
- // Still fire uiAction so external listeners (e.g. signals) stay in
22734
- // sync, but do NOT fire the command and do NOT notify the parent —
22735
- // both would cause an infinite loop when a parent cascades state
22736
- // down to its children (child command would re-trigger the cascade).
22737
- uiStateController.onUIAction(e, {
22738
- skipCommand: true,
22734
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22735
+ stateChanged: true,
22739
22736
  });
22740
- }
22741
- // Exception: when the facade propagates a child state change up to the
22742
- // real picker input, also notify the parent group (e.g. Form) so it
22743
- // keeps its cached aggregated state in sync and fires its own uiAction.
22744
- // This is consistent with how a direct Input inside a Form behaves:
22745
- // the Form's uiAction fires on every value change.
22746
- if (e.type === "facade_propagate_up") {
22747
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22748
- }
22749
- // Exception: state_prop_change can only fire on a control with its own
22750
- // controlled state/value prop (see hasStateProp above) — groups never
22751
- // cascade state down into such children (they're explicitly skipped,
22752
- // see shouldPropagateStateToChild/hasStateProp checks), so this change
22753
- // can never be an echo of the parent's own cascade. The loop risk this
22754
- // suppression exists for only applies when the parent itself just pushed
22755
- // this value down, which requires the parent to be controlled (have its
22756
- // own state/value prop). When the parent is "stateless" (uncontrolled),
22757
- // notifying it is always safe and necessary — otherwise its aggregated
22758
- // state silently drifts out of sync with this child.
22759
- if (
22760
- e.type === "state_prop_change" &&
22761
- parentUIStateController &&
22762
- !parentUIStateController.hasStateProp
22763
- ) {
22764
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22765
- }
22766
- return true;
22767
- }
22768
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22769
- if (controlProxyFor) {
22770
- // Proxy: forward the state change to the real input.
22771
- // Use a dedicated internal event so that when the real input's setUIState
22772
- // sees stateIsTheSame=true (already updated by the real input's own flow),
22773
- // it does NOT fire notifyParentAboutChildUIAction(stateChanged=false) back
22774
- // to the group — which would trigger the group action with a stale value.
22775
- const targetController = getUIStateControllerById(controlProxyFor);
22776
- if (targetController) {
22777
- debugUIState(
22778
- e,
22779
- `forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.ref.current)}`,
22780
- );
22781
- const forwardEvent = new CustomEvent("proxy_forward_set_ui_state", {
22782
- detail: {},
22783
- });
22784
- chainEvent(forwardEvent, e);
22785
- targetController.setUIState(newUIState, forwardEvent);
22786
- }
22787
- }
22788
- let syntheticInputFired = false;
22789
- if (el) {
22790
- // Dispatch a synthetic "input" event so external listeners see the new
22791
- // value. Skip when an input event on this element already exists in the chain.
22792
- const existingInputEvent = findEvent(e, (eInChain) => {
22793
- return eInChain.type === "input" && eInChain.target === el;
22794
- });
22795
- if (!existingInputEvent) {
22796
- if (el.tagName === "INPUT") {
22797
- if (el.type === "radio" || el.type === "checkbox") {
22798
- debugUIState(
22799
- e,
22800
- "dispatching synthetic input event without data for checkbox/radio",
22801
- );
22802
- el.dispatchEvent(new Event("input", { bubbles: true }));
22803
- syntheticInputFired = true;
22804
- } else {
22737
+ if (controlProxyFor) {
22738
+ // Proxy: forward the state change to the real input.
22739
+ // Use a dedicated internal event so that when the real input's setUIState
22740
+ // sees stateIsTheSame=true (already updated by the real input's own flow),
22741
+ // it does NOT fire notifyParentAboutChildUIAction(stateChanged=false) back
22742
+ // to the group which would trigger the group action with a stale value.
22743
+ const targetController = getUIStateControllerById(controlProxyFor);
22744
+ if (targetController) {
22805
22745
  debugUIState(
22806
22746
  e,
22807
- `dispatching synthetic input event with data "${newUIState}" for input`,
22747
+ `forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.ref.current)}`,
22808
22748
  );
22809
- el.dispatchEvent(
22810
- new InputEvent("input", {
22811
- bubbles: true,
22812
- cancelable: true,
22813
- inputType: "insertText",
22814
- data: newUIState,
22815
- }),
22749
+ const forwardEvent = new CustomEvent(
22750
+ "proxy_forward_set_ui_state",
22751
+ { detail: {} },
22816
22752
  );
22817
- syntheticInputFired = true;
22753
+ chainEvent(forwardEvent, e);
22754
+ targetController.setUIState(newUIState, forwardEvent);
22818
22755
  }
22819
22756
  }
22820
- // TODO: select, textarea
22821
- }
22822
- }
22823
- // When a synthetic "input" event was dispatched, the stateIsTheSame path
22824
- // already called onUIAction via the input event handler — skip here to
22825
- // avoid a duplicate uiAction on the same user gesture.
22826
- if (!syntheticInputFired) {
22827
- uiStateController.onUIAction(e);
22828
- }
22829
- // Sync validity after state change: re-check constraints against the new value.
22830
- // Internal events (programmatic) → silent check only.
22831
- // User events → full sync (may open/close callout).
22832
- if (isInternalEvent(e)) {
22833
- uiStateController.rules.validation.checkValidity({ event: e });
22834
- } else {
22835
- uiStateController.rules.validation.syncValidity(e);
22836
- }
22837
- return true;
22838
- },
22839
- clearUIState: (e) => {
22840
- // Radio and checkbox "unchecked" state is `undefined`, not `""`.
22841
- // Passing `""` would set checked=true because `"" !== undefined`.
22842
- const isCheckable =
22843
- controlType === "input" &&
22844
- (props.type === "radio" || props.type === "checkbox");
22845
- uiStateController.setUIState(isCheckable ? undefined : "", e);
22846
- },
22847
- resetUIState: (e) => {
22848
- uiStateController.setUIState(uiStateController.state, e);
22849
- },
22850
- onActionEnd: async (e) => {
22851
- debugUIState(`"${controlType}" actionEnd called`);
22852
- // wait for preact to re-render to update readonly as action end side effects are runned
22853
- // await new Promise((r) => requestAnimationFrame(r));
22854
- uiStateController.rules.validation.syncValidity(e);
22855
- },
22856
- onActionError: (e) => {
22857
- debugUIState(`"${controlType}" actionError called`);
22858
- uiStateController.rules.validation.syncValidity(e, { report: true });
22859
- },
22860
- subscribe: subscribeUIState,
22861
- // Leaf controls don't aggregate children, but they act as a transparent
22862
- // pass-through so that controls nested inside them (e.g. an Input inside
22863
- // a List.Item) can bubble up registration to the nearest group ancestor.
22864
- registerChild: (childUIStateController, options) => {
22865
- if (parentUIStateController) {
22866
- parentUIStateController.registerChild(childUIStateController, options);
22867
- }
22868
- },
22869
- unregisterChild: (childUIStateController) => {
22870
- if (parentUIStateController) {
22871
- parentUIStateController.unregisterChild(childUIStateController);
22872
- }
22757
+ // Dispatch a synthetic "input" event so external listeners see the new
22758
+ // value. Skip when an input event on this element already exists in the chain.
22759
+ let syntheticInputFired = false;
22760
+ if (el) {
22761
+ const existingInputEvent = findEvent(e, (eInChain) => {
22762
+ return eInChain.type === "input" && eInChain.target === el;
22763
+ });
22764
+ if (!existingInputEvent) {
22765
+ if (el.tagName === "INPUT") {
22766
+ if (el.type === "radio" || el.type === "checkbox") {
22767
+ debugUIState(
22768
+ e,
22769
+ "dispatching synthetic input event without data for checkbox/radio",
22770
+ );
22771
+ el.dispatchEvent(new Event("input", { bubbles: true }));
22772
+ syntheticInputFired = true;
22773
+ } else {
22774
+ debugUIState(
22775
+ e,
22776
+ `dispatching synthetic input event with data "${newUIState}" for input`,
22777
+ );
22778
+ el.dispatchEvent(
22779
+ new InputEvent("input", {
22780
+ bubbles: true,
22781
+ cancelable: true,
22782
+ inputType: "insertText",
22783
+ data: newUIState,
22784
+ }),
22785
+ );
22786
+ syntheticInputFired = true;
22787
+ }
22788
+ }
22789
+ // TODO: select, textarea
22790
+ }
22791
+ }
22792
+ if (!syntheticInputFired) {
22793
+ // When a synthetic "input" event was dispatched, the stateIsTheSame path
22794
+ // already called onUIAction via the input event handler — skip here to
22795
+ // avoid a duplicate uiAction on the same user gesture.
22796
+ controller.onUIAction(e);
22797
+ }
22798
+ // Sync validity after state change: re-check constraints against the new value.
22799
+ // Internal events (programmatic) silent check only.
22800
+ // User events full sync (may open/close callout).
22801
+ if (isInternalEvent(e)) {
22802
+ controller.rules.validation.checkValidity({ event: e });
22803
+ } else {
22804
+ controller.rules.validation.syncValidity(e);
22805
+ }
22806
+ return true;
22807
+ },
22808
+ clearUIState: (e) => {
22809
+ // Radio and checkbox "unchecked" state is `undefined`, not `""`.
22810
+ // Passing `""` would set checked=true because `"" !== undefined`.
22811
+ const isCheckable =
22812
+ controlType === "input" &&
22813
+ (props.type === "radio" || props.type === "checkbox");
22814
+ controller.setUIState(isCheckable ? undefined : "", e);
22815
+ },
22816
+ resetUIState: (e) => {
22817
+ controller.setUIState(controller.state, e);
22818
+ },
22819
+ onActionEnd: async (e) => {
22820
+ debugUIState(`"${controlType}" actionEnd called`);
22821
+ // wait for preact to re-render to update readonly as action end side effects are runned
22822
+ // await new Promise((r) => requestAnimationFrame(r));
22823
+ controller.rules.validation.syncValidity(e);
22824
+ },
22825
+ onActionError: (e) => {
22826
+ debugUIState(`"${controlType}" actionError called`);
22827
+ controller.rules.validation.syncValidity(e, { report: true });
22828
+ },
22829
+ subscribe: subscribeUIState,
22830
+ // Leaf controls act as a transparent pass-through so that controls
22831
+ // nested inside them (e.g. an Input inside a List.Item) can bubble
22832
+ // up registration to the nearest group ancestor.
22833
+ registerChild: (childUIStateController, options) => {
22834
+ s.parentUIStateController?.registerChild(
22835
+ childUIStateController,
22836
+ options,
22837
+ );
22838
+ },
22839
+ unregisterChild: (childUIStateController) => {
22840
+ s.parentUIStateController?.unregisterChild(childUIStateController);
22841
+ },
22842
+ onChildUIAction: (childUIStateController, e, options) => {
22843
+ s.parentUIStateController?.onChildUIAction(
22844
+ childUIStateController,
22845
+ e,
22846
+ options,
22847
+ );
22848
+ },
22849
+ };
22850
+ const rules = createControlRules(controller, {
22851
+ debugPopup,
22852
+ debugInteraction,
22853
+ debugUIState,
22854
+ debugFocus,
22855
+ });
22856
+ controller.rules = rules;
22857
+
22858
+ // Include all values that controller methods read from the scope so they
22859
+ // are available immediately — even if no re-render happens before the
22860
+ // first user interaction (update only runs on re-renders, not on mount).
22861
+ return {
22862
+ controller,
22863
+ ref: props.ref,
22864
+ id: props.id,
22865
+ name: props.name,
22866
+ props,
22867
+ controlInfo,
22868
+ uiAction: props.uiAction,
22869
+ uiActionInternal,
22870
+ parentUIStateController,
22871
+ parentUiStateSignalHolder,
22872
+ };
22873
22873
  },
22874
- onChildUIAction: (childUIStateController, e, options) => {
22875
- if (parentUIStateController) {
22876
- parentUIStateController.onChildUIAction(
22877
- childUIStateController,
22878
- e,
22879
- options,
22880
- );
22874
+ // ── update: runs every render after the first ─────────────────────────
22875
+ // Syncs public-facing fields and handles controlled state prop changes.
22876
+ (s) => {
22877
+ const { controller } = s;
22878
+ // Raw Preact props from the current render. These are the component's input props,
22879
+ // not the resolved/curated host props. useInteractiveProps overwrites
22880
+ // uiStateController.controlHostProps with the resolved subset on every render.
22881
+ controller.props = props;
22882
+ // Re-sync to this render's ref object. It's normally stable, but it can
22883
+ // legitimately change identity (e.g. switching from an internal fallback
22884
+ // ref to a forwarded one, or across an interrupted/resumed render such as
22885
+ // a Suspense boundary resolving) — if we kept the original ref forever,
22886
+ // `ref.current` would be stuck at whatever it was at creation time, even
22887
+ // after the controller has moved on to a different, live DOM node.
22888
+ controller.ref = props.ref;
22889
+ controller.id = props.id; // never supposed to change, not supported for now
22890
+ controller.name = props.name;
22891
+ controller.parentUIStateController = parentUIStateController;
22892
+ const { value, hasStateProp, state, stateInitial } = controlInfo;
22893
+ controller.value = value;
22894
+ if (hasStateProp) {
22895
+ controller.hasStateProp = true;
22896
+ const currentState = controller.state;
22897
+ if (!compareTwoJsValues(state, currentState)) {
22898
+ controller.state = state;
22899
+ controller.setUIState(state, new CustomEvent("state_prop_change"));
22900
+ }
22901
+ } else if (controller.hasStateProp) {
22902
+ controller.hasStateProp = false;
22903
+ controller.state = stateInitial;
22881
22904
  }
22905
+ return {
22906
+ ref: props.ref,
22907
+ id: props.id,
22908
+ name: props.name,
22909
+ props,
22910
+ controlInfo,
22911
+ uiAction: props.uiAction,
22912
+ uiActionInternal,
22913
+ parentUIStateController,
22914
+ };
22882
22915
  },
22883
- };
22884
- uiStateControllerRef.current = uiStateController;
22885
- const rules = createControlRules(uiStateController, {
22886
- debugPopup,
22887
- debugInteraction,
22888
- debugUIState,
22889
- debugFocus,
22890
- });
22891
- uiStateController.rules = rules;
22892
- return uiStateController;
22893
- };
22894
- const NO_PARENT = [() => {}, () => {}, () => {}];
22895
- const useParentControllerNotifiers = (
22896
- parentUIStateController,
22897
- uiStateControllerRef,
22898
- controlType,
22899
- debugUIState,
22900
- ) => {
22901
- return useMemo(() => {
22902
- if (!parentUIStateController) {
22903
- return NO_PARENT;
22904
- }
22916
+ );
22917
+ scope.parentUiStateSignalHolder.value =
22918
+ parentUIStateController?.uiStateSignal ?? null;
22905
22919
 
22906
- const parentControlType = parentUIStateController.controlType;
22907
- const notifyParentAboutChildMount = () => {
22908
- const uiStateController = uiStateControllerRef.current;
22909
- debugUIState(`"${controlType}" registering into "${parentControlType}"`);
22910
- parentUIStateController.registerChild(uiStateController);
22920
+ const { controller } = scope;
22921
+ const controllerRef = controller.ref;
22922
+ useLayoutEffect(() => {
22923
+ const el = controllerRef.current;
22924
+ if (el) {
22925
+ el.__uiStateController__ = controller;
22926
+ }
22927
+ // Re-register so the radio registry stays in sync when props.ref changes
22928
+ // identity (e.g. across a Suspense boundary). The render-phase call in
22929
+ // control_hooks.jsx handles the initial mount; this call handles re-runs.
22930
+ onUIStateControllerCreated(controller);
22931
+ return () => {
22932
+ if (el && el.__uiStateController__ === controller) {
22933
+ delete el.__uiStateController__;
22934
+ }
22935
+ onUIStateControllerDestroyed(controller);
22911
22936
  };
22937
+ }, [controllerRef]);
22912
22938
 
22913
- const notifyParentAboutChildUIAction = (
22914
- e,
22915
- { stateChanged, silent = false },
22916
- ) => {
22917
- const uiStateController = uiStateControllerRef.current;
22918
- debugUIState(
22919
- `"${controlType}" notifying "${parentControlType}" of child ui action (stateChanged: ${stateChanged})`,
22920
- );
22921
- parentUIStateController.onChildUIAction(uiStateController, e, {
22922
- stateChanged,
22923
- silent,
22924
- });
22925
- };
22939
+ const { parentUIStateController: parentController } = scope;
22940
+ useLayoutEffect(() => {
22941
+ if (!parentController) {
22942
+ return undefined;
22943
+ }
22926
22944
 
22927
- const notifyParentAboutChildUnmount = () => {
22928
- const uiStateController = uiStateControllerRef.current;
22945
+ debugUIState(`"${controlType}" registering into "${parent.controlType}"`);
22946
+ parentController.registerChild(controller);
22947
+ return () => {
22929
22948
  debugUIState(
22930
- `"${controlType}" unregistering from "${parentControlType}"`,
22949
+ `"${controlType}" unregistering from "${parent.controlType}"`,
22931
22950
  );
22932
- parentUIStateController.unregisterChild(uiStateController);
22951
+ parentController.unregisterChild(controller);
22933
22952
  };
22953
+ }, [parentController]);
22934
22954
 
22935
- return [
22936
- notifyParentAboutChildMount,
22937
- notifyParentAboutChildUIAction,
22938
- notifyParentAboutChildUnmount,
22939
- ];
22940
- }, []);
22955
+ return controller;
22941
22956
  };
22942
22957
 
22943
22958
  /**
@@ -23092,9 +23107,8 @@ const useUIGroupStateController = (
23092
23107
  const parentUIStateController = useContext(ParentUIStateControllerContext);
23093
23108
  const hasValueProp = Object.hasOwn(props, "value");
23094
23109
  const hasDefaultValueProp = Object.hasOwn(props, "defaultValue");
23095
- const { id, name, value, defaultValue, uiAction, command } = props;
23110
+ const { id, name, value, defaultValue, uiAction } = props;
23096
23111
  const ref = props.ref;
23097
- const uiActionRef = useRef(uiAction);
23098
23112
  const fallbackState =
23099
23113
  stateType === "array"
23100
23114
  ? EMPTY_ARRAY
@@ -23103,9 +23117,7 @@ const useUIGroupStateController = (
23103
23117
  : undefined;
23104
23118
  const childUIStateControllerArrayRef = useRef([]);
23105
23119
  const childUIStateControllerArray = childUIStateControllerArrayRef.current;
23106
- const controllerRef = useRef();
23107
- // Tracks children this controller rejected and delegated upward (bubble-up
23108
- // registration). Used to forward onChildUIAction and unregisterChild.
23120
+ // Tracks children rejected by the filter and delegated upward (bubble-up).
23109
23121
  const delegatedChildrenRef = useRef(new Map());
23110
23122
 
23111
23123
  const groupIsRenderingRef = useRef(false);
@@ -23113,103 +23125,8 @@ const useUIGroupStateController = (
23113
23125
  groupIsRenderingRef.current = true;
23114
23126
  pendingChangeRef.current = false;
23115
23127
 
23116
- const [
23117
- notifyParentAboutChildMount,
23118
- notifyParentAboutChildUIAction,
23119
- notifyParentAboutChildUnmount,
23120
- ] = useParentControllerNotifiers(
23121
- parentUIStateController,
23122
- controllerRef,
23123
- controlType,
23124
- debugUIGroup,
23125
- );
23126
- useLayoutEffect(() => {
23127
- const controller = controllerRef.current;
23128
- const el = ref.current;
23129
- if (el) {
23130
- el.__uiStateController__ = controller;
23131
- }
23132
- notifyParentAboutChildMount();
23133
- return () => {
23134
- notifyParentAboutChildUnmount();
23135
- onUIStateControllerDestroyed(controller);
23136
- };
23137
- }, []);
23138
-
23139
- const onChange = (e, { notifyExternal }) => {
23140
- if (groupIsRenderingRef.current) {
23141
- pendingChangeRef.current = true;
23142
- return;
23143
- }
23144
- const aggChildState = resolvedAggregateChildStates(
23145
- childUIStateControllerArray,
23146
- fallbackState,
23147
- );
23148
- const groupUIState =
23149
- aggChildState === undefined ? fallbackState : aggChildState;
23150
- debugUIGroup(
23151
- e,
23152
- `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
23153
- );
23154
- const groupUIStateController = controllerRef.current;
23155
- if (notifyExternal === true) {
23156
- applyState(groupUIState, e);
23157
- } else if (notifyExternal === "silent") {
23158
- // Silent mount/unmount sync: update state without triggering uiAction/command,
23159
- // but still notify parent (e.g. facade) so it can track the current child state.
23160
- groupUIStateController.syncInternalState(groupUIState, e);
23161
- notifyParentAboutChildUIAction(e, {
23162
- stateChanged: true,
23163
- silent: true,
23164
- });
23165
- } else {
23166
- groupUIStateController.syncInternalState(groupUIState, e);
23167
- }
23168
- };
23169
-
23170
- // Applies the aggregated state: updates signal, fires uiAction/command/navi_ui_state_change,
23171
- // and notifies the parent. Called both from onChange (after child UI action) and from
23172
- // setUIState (after cascading to children).
23173
- const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
23174
- const groupUIStateController = controllerRef.current;
23175
- const currentUIState = groupUIStateController.uiState;
23176
- groupUIStateController.uiState = newUIState;
23177
- uiStateSignal.value = newUIState;
23178
- debugUIGroup(
23179
- e,
23180
- `${controlType}.applyState(${JSON.stringify(newUIState)}, "${e.type}") -> updates from ${JSON.stringify(currentUIState)} to ${JSON.stringify(newUIState)}`,
23181
- );
23182
- publishUIState(newUIState);
23183
- // Notify the parent (facade) BEFORE firing the command so that when a
23184
- // command like --navi-send closes the picker, the picker input already
23185
- // holds the new value.
23186
- notifyParentAboutChildUIAction(e, { stateChanged: true });
23187
- groupUIStateController.onUIAction(e, {
23188
- skipCommand: internalBehavior,
23189
- });
23190
- const el = ref.current;
23191
- if (el) {
23192
- dispatchInternalCustomEvent(el, "navi_ui_state_change", {
23193
- event: e,
23194
- value: newUIState,
23195
- });
23196
- }
23197
- };
23198
-
23199
- useLayoutEffect(() => {
23200
- groupIsRenderingRef.current = false;
23201
- if (pendingChangeRef.current) {
23202
- pendingChangeRef.current = false;
23203
- onChange(new CustomEvent(`${controlType}_batched_ui_state_update`), {
23204
- notifyExternal: "silent",
23205
- });
23206
- }
23207
- });
23208
-
23209
23128
  const isMonitoringChild = (childUIStateController) => {
23210
- if (childUIStateController.isProxy) {
23211
- return false;
23212
- }
23129
+ if (childUIStateController.isProxy) return false;
23213
23130
  if (
23214
23131
  resolvedChildControlFilter &&
23215
23132
  !resolvedChildControlFilter(childUIStateController)
@@ -23219,354 +23136,411 @@ const useUIGroupStateController = (
23219
23136
  return true;
23220
23137
  };
23221
23138
  const shouldPropagateStateToChild = (childUIStateController) => {
23222
- if (!isMonitoringChild(childUIStateController)) {
23223
- return false;
23224
- }
23225
- if (childUIStateController.controlType === "button") {
23226
- return false;
23227
- }
23228
- if (childUIStateController.controlType === "link") {
23229
- return false;
23230
- }
23139
+ if (!isMonitoringChild(childUIStateController)) return false;
23140
+ if (childUIStateController.controlType === "button") return false;
23141
+ if (childUIStateController.controlType === "link") return false;
23231
23142
  return true;
23232
23143
  };
23233
23144
 
23234
- const existingController = controllerRef.current;
23235
- if (existingController) {
23236
- const prevValue = existingController.value;
23237
- const prevHasValueProp = existingController.hasValueProp;
23238
- existingController.props = props;
23239
- // Re-sync to this render's ref object — see the matching comment in
23240
- // useUIStateController._checkForUpdates for why this can't be captured
23241
- // once at creation time and left untouched.
23242
- existingController.ref = ref;
23243
- existingController.id = id;
23244
- existingController.name = name;
23245
- existingController.value = value;
23246
- existingController.defaultValue = defaultValue;
23247
- existingController.hasValueProp = hasValueProp;
23248
- existingController.hasDefaultValueProp = hasDefaultValueProp;
23249
- uiActionRef.current = uiAction;
23250
- // When the controlled value prop changes (or when becoming controlled for the
23251
- // first time), silently cascade to children that have no individual state prop.
23252
- if (
23253
- hasValueProp &&
23254
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
23255
- ) {
23256
- const propagateDownEvent = new CustomEvent(
23257
- "propagate_down_set_ui_state",
23258
- { detail: {} },
23145
+ const scope = useRenderScope(
23146
+ // ── init: runs once on mount ───────────────────────────────────────────
23147
+ (s) => {
23148
+ debugUIGroup(
23149
+ `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
23259
23150
  );
23260
- for (const childUIStateController of childUIStateControllerArray) {
23261
- if (!shouldPropagateStateToChild(childUIStateController)) {
23262
- continue;
23263
- }
23264
- if (childUIStateController.hasStateProp) {
23265
- continue;
23151
+ const [publishUIState, subscribeUIState] = createPubSub();
23152
+ const uiStateSignal = signal(fallbackState);
23153
+
23154
+ // onChange and applyState live inside init so they close over the stable
23155
+ // signals/pubsub without needing external refs.
23156
+ const onChange = (e, { notifyExternal }) => {
23157
+ if (groupIsRenderingRef.current) {
23158
+ pendingChangeRef.current = true;
23159
+ return;
23266
23160
  }
23267
- const childNewState = existingController.distributeChildUIState(
23268
- value,
23269
- childUIStateController,
23161
+ const aggChildState = resolvedAggregateChildStates(
23162
+ childUIStateControllerArray,
23163
+ fallbackState,
23270
23164
  );
23271
- if (childNewState === CANNOT_DERIVE) {
23272
- continue;
23273
- }
23274
- childUIStateController.setUIState(childNewState, propagateDownEvent);
23275
- }
23276
- existingController.syncInternalState(value);
23277
- }
23278
- return existingController;
23279
- }
23280
- debugUIGroup(
23281
- `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
23282
- );
23283
-
23284
- const [publishUIState, subscribeUIState] = createPubSub();
23285
- const uiStateSignal = signal(fallbackState);
23286
- const groupUIStateController = {
23287
- controlType,
23288
- id,
23289
- name,
23290
- value,
23291
- defaultValue,
23292
- hasValueProp,
23293
- hasDefaultValueProp,
23294
- props,
23295
- uiState: fallbackState,
23296
- uiStateSignal,
23297
- wantRequesterButtonState,
23298
- ref,
23299
- getPropFromState: (uiState) => uiState,
23300
- distributeChildUIState: resolvedDistributeChildUIState,
23301
- // Cascades newUIState to each monitored child via resolvedDistributeChildUIState,
23302
- // then re-aggregates and fires this group's own reactions.
23303
- setUIState: (newUIState, e) => {
23304
- if (
23305
- stateType === "object" &&
23306
- (newUIState === null || typeof newUIState !== "object")
23307
- ) {
23308
- console.warn(
23309
- `[${controlType}] setUIState received a non-object value: ${JSON.stringify(newUIState)} (expected an object). Ignoring.`,
23310
- newUIState,
23311
- );
23312
- return;
23313
- }
23314
- if (stateType === "array" && !Array.isArray(newUIState)) {
23315
- console.warn(
23316
- `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23317
- newUIState,
23165
+ const groupUIState =
23166
+ aggChildState === undefined ? fallbackState : aggChildState;
23167
+ debugUIGroup(
23168
+ e,
23169
+ `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
23318
23170
  );
23319
- return;
23320
- }
23321
- // initial_state_push propagates silently (no uiAction anywhere in the chain);
23322
- // regular updates use propagate_down_set_ui_state which fires uiAction on children.
23323
- const propagateEventType =
23324
- e.type === "initial_state_push"
23325
- ? "initial_state_push"
23326
- : "propagate_down_set_ui_state";
23327
- const propagateDownEvent = new CustomEvent(propagateEventType, {
23328
- detail: {},
23329
- });
23330
- chainEvent(propagateDownEvent, e);
23331
- for (const childUIStateController of childUIStateControllerArray) {
23332
- if (!shouldPropagateStateToChild(childUIStateController)) {
23333
- continue;
23171
+ const { controller } = s;
23172
+ if (notifyExternal === true) {
23173
+ applyState(groupUIState, e);
23174
+ } else if (notifyExternal === "silent") {
23175
+ controller.syncInternalState(groupUIState, e);
23176
+ s.parentUIStateController?.onChildUIAction(controller, e, {
23177
+ stateChanged: true,
23178
+ silent: true,
23179
+ });
23180
+ } else {
23181
+ controller.syncInternalState(groupUIState, e);
23334
23182
  }
23335
- const childNewState = resolvedDistributeChildUIState(
23336
- newUIState,
23337
- childUIStateController,
23183
+ };
23184
+
23185
+ const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
23186
+ const { controller } = s;
23187
+ const currentUIState = controller.uiState;
23188
+ controller.uiState = newUIState;
23189
+ uiStateSignal.value = newUIState;
23190
+ debugUIGroup(
23191
+ e,
23192
+ `${controlType}.applyState(${JSON.stringify(newUIState)}, "${e.type}") -> updates from ${JSON.stringify(currentUIState)} to ${JSON.stringify(newUIState)}`,
23338
23193
  );
23339
- if (childNewState === CANNOT_DERIVE) {
23340
- continue;
23341
- }
23342
- childUIStateController.setUIState(childNewState, propagateDownEvent);
23343
- }
23344
- // Re-aggregate from children and apply — do NOT call onChange to avoid a loop
23345
- // (onChange would call setUIState again, which would cascade again).
23346
- const aggChildState = resolvedAggregateChildStates(
23347
- childUIStateControllerArray,
23348
- fallbackState,
23349
- );
23350
- const groupUIState =
23351
- aggChildState === undefined ? fallbackState : aggChildState;
23352
- if (e.type === "initial_state_push") {
23353
- // Silent initialization: update state without firing uiAction or notifying parent.
23354
- groupUIStateController.syncInternalState(groupUIState);
23355
- return;
23356
- }
23357
- applyState(groupUIState, e, { internalBehavior: true });
23358
- },
23359
- // Called on mount/unmount/render-batch: updates state silently with no external reactions.
23360
- syncInternalState: (newUIState) => {
23361
- const currentUIState = groupUIStateController.uiState;
23362
- if (newUIState === currentUIState) {
23363
- return;
23364
- }
23365
- groupUIStateController.uiState = newUIState;
23366
- uiStateSignal.value = newUIState;
23367
- publishUIState(newUIState);
23368
- },
23369
- // Called when a child UI action does NOT change the aggregated value (e.g. radio re-clicked).
23370
- // Fires uiAction + command without touching state or the action pipeline.
23371
- onUIAction: (e, { skipCommand } = {}) => {
23372
- const currentUIState = groupUIStateController.uiState;
23373
- const uiAction = uiActionRef.current;
23374
- uiAction?.(currentUIState, e);
23375
- uiActionInternal?.(currentUIState, e);
23376
- if (skipCommand) ; else if (command) {
23377
- const el = ref.current;
23194
+ publishUIState(newUIState);
23195
+ s.parentUIStateController?.onChildUIAction(controller, e, {
23196
+ stateChanged: true,
23197
+ });
23198
+ controller.onUIAction(e, { skipCommand: internalBehavior });
23199
+ const el = controller.ref.current;
23378
23200
  if (el) {
23379
- triggerNaviCommand(el, command, e);
23201
+ dispatchInternalCustomEvent(el, "navi_ui_state_change", {
23202
+ event: e,
23203
+ value: newUIState,
23204
+ });
23380
23205
  }
23381
- }
23382
- },
23383
- registerChild: (
23384
- childUIStateController,
23385
- // { bubbled = false } = {}
23386
- ) => {
23387
- if (!isMonitoringChild(childUIStateController)) {
23388
- // Filter rejected this child.
23389
- if (!allowCapture && parentUIStateController) {
23390
- // Not a boundary — bubble the child up to the nearest ancestor.
23391
- delegatedChildrenRef.current.set(
23206
+ };
23207
+
23208
+ const controller = {
23209
+ controlType,
23210
+ id,
23211
+ name,
23212
+ value,
23213
+ defaultValue,
23214
+ hasValueProp,
23215
+ hasDefaultValueProp,
23216
+ props,
23217
+ uiState: fallbackState,
23218
+ uiStateSignal,
23219
+ wantRequesterButtonState,
23220
+ ref,
23221
+ getPropFromState: (uiState) => uiState,
23222
+ distributeChildUIState: resolvedDistributeChildUIState,
23223
+ setUIState: (newUIState, e) => {
23224
+ if (
23225
+ stateType === "object" &&
23226
+ (newUIState === null || typeof newUIState !== "object")
23227
+ ) {
23228
+ console.warn(
23229
+ `[${controlType}] setUIState received a non-object value: ${JSON.stringify(newUIState)} (expected an object). Ignoring.`,
23230
+ newUIState,
23231
+ );
23232
+ return;
23233
+ }
23234
+ if (stateType === "array" && !Array.isArray(newUIState)) {
23235
+ console.warn(
23236
+ `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23237
+ newUIState,
23238
+ );
23239
+ return;
23240
+ }
23241
+ const propagateEventType =
23242
+ e.type === "initial_state_push"
23243
+ ? "initial_state_push"
23244
+ : "propagate_down_set_ui_state";
23245
+ const propagateDownEvent = new CustomEvent(propagateEventType, {
23246
+ detail: {},
23247
+ });
23248
+ chainEvent(propagateDownEvent, e);
23249
+ for (const childUIStateController of childUIStateControllerArray) {
23250
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
23251
+ const childNewState = resolvedDistributeChildUIState(
23252
+ newUIState,
23253
+ childUIStateController,
23254
+ );
23255
+ if (childNewState === CANNOT_DERIVE) continue;
23256
+ childUIStateController.setUIState(
23257
+ childNewState,
23258
+ propagateDownEvent,
23259
+ );
23260
+ }
23261
+ const aggChildState = resolvedAggregateChildStates(
23262
+ childUIStateControllerArray,
23263
+ fallbackState,
23264
+ );
23265
+ const groupUIState =
23266
+ aggChildState === undefined ? fallbackState : aggChildState;
23267
+ if (e.type === "initial_state_push") {
23268
+ controller.syncInternalState(groupUIState);
23269
+ return;
23270
+ }
23271
+ applyState(groupUIState, e, { internalBehavior: true });
23272
+ },
23273
+ syncInternalState: (newUIState) => {
23274
+ const currentUIState = controller.uiState;
23275
+ if (newUIState === currentUIState) return;
23276
+ controller.uiState = newUIState;
23277
+ uiStateSignal.value = newUIState;
23278
+ publishUIState(newUIState);
23279
+ },
23280
+ onUIAction: (e, { skipCommand } = {}) => {
23281
+ const currentUIState = controller.uiState;
23282
+ s.uiAction?.(currentUIState, e);
23283
+ s.uiActionInternal?.(currentUIState, e);
23284
+ if (!skipCommand && controller.props.command) {
23285
+ const el = controller.ref.current;
23286
+ if (el) triggerNaviCommand(el, controller.props.command, e);
23287
+ }
23288
+ },
23289
+ registerChild: (childUIStateController) => {
23290
+ if (!isMonitoringChild(childUIStateController)) {
23291
+ const currentParent = s.parentUIStateController;
23292
+ if (!allowCapture && currentParent) {
23293
+ delegatedChildrenRef.current.set(
23294
+ childUIStateController,
23295
+ currentParent,
23296
+ );
23297
+ currentParent.registerChild(childUIStateController);
23298
+ }
23299
+ return;
23300
+ }
23301
+ const childControlType = childUIStateController.controlType;
23302
+ childUIStateControllerArray.push(childUIStateController);
23303
+ debugUIGroup(
23304
+ `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
23305
+ );
23306
+ if (!childUIStateController.hasStateProp) {
23307
+ const initialEvent = new CustomEvent("initial_state_push", {
23308
+ detail: {},
23309
+ });
23310
+ if (controller.hasValueProp) {
23311
+ const childNewState = resolvedDistributeChildUIState(
23312
+ controller.value,
23313
+ childUIStateController,
23314
+ );
23315
+ if (childNewState !== CANNOT_DERIVE) {
23316
+ childUIStateController.setUIState(childNewState, initialEvent);
23317
+ }
23318
+ } else if (controller.hasDefaultValueProp) {
23319
+ const childNewState = resolvedDistributeChildUIState(
23320
+ controller.defaultValue,
23321
+ childUIStateController,
23322
+ );
23323
+ if (childNewState !== CANNOT_DERIVE) {
23324
+ childUIStateController.setUIState(childNewState, initialEvent);
23325
+ }
23326
+ }
23327
+ }
23328
+ onChange(new CustomEvent(`${childControlType}_mount`), {
23329
+ notifyExternal: "silent",
23330
+ });
23331
+ },
23332
+ onChildUIAction: (
23333
+ childUIStateController,
23334
+ e,
23335
+ { stateChanged, silent },
23336
+ ) => {
23337
+ const delegatedTo = delegatedChildrenRef.current.get(
23392
23338
  childUIStateController,
23393
- parentUIStateController,
23394
23339
  );
23395
- parentUIStateController.registerChild(childUIStateController);
23396
- }
23397
- // allowCapture=true: hard boundary, stop bubbling and drop silently.
23398
- // No parent: end of chain, drop silently.
23399
- return;
23400
- }
23401
- const childControlType = childUIStateController.controlType;
23402
- childUIStateControllerArray.push(childUIStateController);
23403
- debugUIGroup(
23404
- `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
23405
- );
23406
- // Auto-derive child's initial state from the group's value/defaultValue
23407
- // when the child has no individually-controlled state prop.
23408
- // Use initial_state_push so uiAction does not fire during mount initialization.
23409
- if (!childUIStateController.hasStateProp) {
23410
- const initialEvent = new CustomEvent("initial_state_push", {
23411
- detail: {},
23412
- });
23413
- if (groupUIStateController.hasValueProp) {
23414
- // Controlled: always cascade current group value (even undefined = deselect all).
23415
- const childNewState = resolvedDistributeChildUIState(
23416
- groupUIStateController.value,
23340
+ if (delegatedTo) {
23341
+ delegatedTo.onChildUIAction(childUIStateController, e, {
23342
+ stateChanged,
23343
+ silent,
23344
+ });
23345
+ return;
23346
+ }
23347
+ if (!isMonitoringChild(childUIStateController)) return;
23348
+ const childControlType = childUIStateController.controlType;
23349
+ debugUIGroup(
23350
+ `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
23351
+ childUIStateController.uiState,
23352
+ )}`,
23353
+ );
23354
+ if (stateChanged) {
23355
+ onChange(e, { notifyExternal: silent ? "silent" : true });
23356
+ } else {
23357
+ controller.onUIAction(e);
23358
+ }
23359
+ },
23360
+ unregisterChild: (childUIStateController) => {
23361
+ const delegatedTo = delegatedChildrenRef.current.get(
23417
23362
  childUIStateController,
23418
23363
  );
23419
- if (childNewState !== CANNOT_DERIVE) {
23420
- childUIStateController.setUIState(childNewState, initialEvent);
23364
+ if (delegatedTo) {
23365
+ delegatedChildrenRef.current.delete(childUIStateController);
23366
+ delegatedTo.unregisterChild(childUIStateController);
23367
+ return;
23421
23368
  }
23422
- } else if (groupUIStateController.hasDefaultValueProp) {
23423
- // Uncontrolled: set initial state from defaultValue on mount.
23424
- const childNewState = resolvedDistributeChildUIState(
23425
- groupUIStateController.defaultValue,
23369
+ if (!isMonitoringChild(childUIStateController)) return;
23370
+ const childControlType = childUIStateController.controlType;
23371
+ const index = childUIStateControllerArray.indexOf(
23426
23372
  childUIStateController,
23427
23373
  );
23428
- if (childNewState !== CANNOT_DERIVE) {
23429
- childUIStateController.setUIState(childNewState, initialEvent);
23374
+ if (index === -1) {
23375
+ debugUIGroup(
23376
+ `${controlType}.unregisterChild("${childControlType}") -> not found`,
23377
+ );
23378
+ return;
23430
23379
  }
23431
- }
23432
- }
23433
- onChange(new CustomEvent(`${childControlType}_mount`), {
23434
- notifyExternal: "silent",
23435
- // childUIStateController,
23380
+ childUIStateControllerArray.splice(index, 1);
23381
+ debugUIGroup(
23382
+ `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
23383
+ );
23384
+ onChange(new CustomEvent(`${childControlType}_unmount`), {
23385
+ notifyExternal: "silent",
23386
+ });
23387
+ },
23388
+ resetUIState: (e) => {
23389
+ const ev = new CustomEvent("propagate_down_reset_ui_state", {
23390
+ detail: {},
23391
+ });
23392
+ chainEvent(ev, e);
23393
+ for (const c of childUIStateControllerArray) {
23394
+ if (shouldPropagateStateToChild(c)) c.resetUIState(ev);
23395
+ }
23396
+ onChange(e, { notifyExternal: true });
23397
+ },
23398
+ clearUIState: (e) => {
23399
+ const ev = new CustomEvent("propagate_down_clear_ui_state", {
23400
+ detail: {},
23401
+ });
23402
+ chainEvent(ev, e);
23403
+ for (const c of childUIStateControllerArray) {
23404
+ if (
23405
+ !isMonitoringChild(c) ||
23406
+ c.controlType === "button" ||
23407
+ c.controlType === "link"
23408
+ ) {
23409
+ continue;
23410
+ }
23411
+ c.clearUIState(ev);
23412
+ }
23413
+ onChange(e, { notifyExternal: true });
23414
+ },
23415
+ onActionEnd: (e) => {
23416
+ controller.rules.validation.syncValidity(e);
23417
+ },
23418
+ onActionError: (e) => {
23419
+ controller.rules.validation.syncValidity(e, { report: true });
23420
+ },
23421
+ findChildById: (searchId) => {
23422
+ for (const c of childUIStateControllerArray) {
23423
+ if (c.id === searchId) return c;
23424
+ }
23425
+ return null;
23426
+ },
23427
+ getChildControllers: () => childUIStateControllerArray,
23428
+ getManagedControls: () => {
23429
+ if (!cascadeValidationToChildren) return [];
23430
+ return childUIStateControllerArray.slice();
23431
+ },
23432
+ subscribe: subscribeUIState,
23433
+ };
23434
+ const rules = createControlRules(controller, {
23435
+ debugPopup,
23436
+ debugInteraction,
23437
+ debugUIState: debugUIGroup,
23438
+ debugFocus,
23436
23439
  });
23440
+ controller.rules = rules;
23441
+
23442
+ // Include all values read by controller methods so they are immediately
23443
+ // available, even if the user interacts before the first re-render.
23444
+ return {
23445
+ controller,
23446
+ _onChange: onChange,
23447
+ ref,
23448
+ parentUIStateController,
23449
+ uiAction,
23450
+ uiActionInternal,
23451
+ };
23437
23452
  },
23438
- onChildUIAction: (childUIStateController, e, { stateChanged, silent }) => {
23439
- const delegatedTo = delegatedChildrenRef.current.get(
23440
- childUIStateController,
23441
- );
23442
- if (delegatedTo) {
23443
- // Forward UI action for delegated children — we don't aggregate them,
23444
- // but their parent (who adopted them) needs to know about the change.
23445
- delegatedTo.onChildUIAction(childUIStateController, e, {
23446
- stateChanged,
23447
- silent,
23448
- });
23449
- return;
23450
- }
23451
- if (!isMonitoringChild(childUIStateController)) {
23452
- return;
23453
- }
23454
- const childControlType = childUIStateController.controlType;
23455
- debugUIGroup(
23456
- `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
23457
- childUIStateController.uiState,
23458
- )}`,
23459
- );
23460
- if (stateChanged) {
23461
- if (silent) {
23462
- // Silent update: keep the group's cached state in sync without firing
23463
- // uiAction, command, or action pipeline. Used when e.g. the picker facade
23464
- // propagates a child state change up through an internal event path.
23465
- onChange(e, { notifyExternal: "silent" });
23466
- } else {
23467
- // Value changed: re-aggregate and fire all reactions (uiAction, command, action pipeline).
23468
- onChange(e, { notifyExternal: true });
23453
+ // ── update: runs every render after the first ─────────────────────────
23454
+ (s) => {
23455
+ const { controller } = s;
23456
+ const prevValue = controller.value;
23457
+ const prevHasValueProp = controller.hasValueProp;
23458
+ controller.props = props;
23459
+ controller.ref = ref;
23460
+ controller.id = id;
23461
+ controller.name = name;
23462
+ controller.value = value;
23463
+ controller.defaultValue = defaultValue;
23464
+ controller.hasValueProp = hasValueProp;
23465
+ controller.hasDefaultValueProp = hasDefaultValueProp;
23466
+ if (
23467
+ hasValueProp &&
23468
+ (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
23469
+ ) {
23470
+ const propagateDownEvent = new CustomEvent(
23471
+ "propagate_down_set_ui_state",
23472
+ { detail: {} },
23473
+ );
23474
+ for (const childUIStateController of childUIStateControllerArray) {
23475
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
23476
+ if (childUIStateController.hasStateProp) continue;
23477
+ const childNewState = controller.distributeChildUIState(
23478
+ value,
23479
+ childUIStateController,
23480
+ );
23481
+ if (childNewState === CANNOT_DERIVE) continue;
23482
+ childUIStateController.setUIState(childNewState, propagateDownEvent);
23469
23483
  }
23470
- } else {
23471
- // Value unchanged (e.g. radio re-clicked): fire uiAction + command only.
23472
- groupUIStateController.onUIAction(e);
23484
+ controller.syncInternalState(value);
23473
23485
  }
23486
+
23487
+ return {
23488
+ ref,
23489
+ parentUIStateController,
23490
+ uiAction,
23491
+ uiActionInternal,
23492
+ id,
23493
+ name,
23494
+ value,
23495
+ defaultValue,
23496
+ hasValueProp,
23497
+ hasDefaultValueProp,
23498
+ props,
23499
+ };
23474
23500
  },
23475
- unregisterChild: (childUIStateController) => {
23476
- const delegatedTo = delegatedChildrenRef.current.get(
23477
- childUIStateController,
23478
- );
23479
- if (delegatedTo) {
23480
- delegatedChildrenRef.current.delete(childUIStateController);
23481
- delegatedTo.unregisterChild(childUIStateController);
23482
- return;
23483
- }
23484
- if (!isMonitoringChild(childUIStateController)) {
23485
- return;
23486
- }
23487
- const childControlType = childUIStateController.controlType;
23488
- const index = childUIStateControllerArray.indexOf(childUIStateController);
23489
- if (index === -1) {
23490
- debugUIGroup(
23491
- `${controlType}.unregisterChild("${childControlType}") -> not found`,
23492
- );
23493
- return;
23494
- }
23495
- childUIStateControllerArray.splice(index, 1);
23501
+ );
23502
+
23503
+ const { controller } = scope;
23504
+ useLayoutEffect(() => {
23505
+ const el = ref.current;
23506
+ if (el) {
23507
+ el.__uiStateController__ = controller;
23508
+ }
23509
+ return () => {
23510
+ onUIStateControllerDestroyed(controller);
23511
+ };
23512
+ }, []);
23513
+
23514
+ const { parentUIStateController: parentController } = scope;
23515
+ useLayoutEffect(() => {
23516
+ if (!parentController) {
23517
+ return undefined;
23518
+ }
23519
+
23520
+ debugUIGroup(
23521
+ `"${controlType}" registering into "${parentController.controlType}"`,
23522
+ );
23523
+ parentController.registerChild(controller);
23524
+ return () => {
23496
23525
  debugUIGroup(
23497
- `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
23498
- );
23499
- onChange(new CustomEvent(`${childControlType}_unmount`), {
23500
- notifyExternal: "silent",
23501
- // childUIStateController,
23502
- });
23503
- },
23504
- resetUIState: (e) => {
23505
- const propagateDownResetEvent = new CustomEvent(
23506
- "propagate_down_reset_ui_state",
23507
- { detail: {} },
23526
+ `"${controlType}" unregistering from "${parentController.controlType}"`,
23508
23527
  );
23509
- chainEvent(propagateDownResetEvent, e);
23510
- for (const childUIStateController of childUIStateControllerArray) {
23511
- if (!shouldPropagateStateToChild(childUIStateController)) {
23512
- continue;
23513
- }
23514
- childUIStateController.resetUIState(propagateDownResetEvent);
23515
- }
23516
- onChange(e, { notifyExternal: true });
23517
- },
23518
- clearUIState: (e) => {
23519
- const propagateDownClearEvent = new CustomEvent(
23520
- "propagate_down_clear_ui_state",
23521
- { detail: {} },
23528
+ parentController.unregisterChild(controller);
23529
+ };
23530
+ }, [parentController]);
23531
+
23532
+ useLayoutEffect(() => {
23533
+ groupIsRenderingRef.current = false;
23534
+ if (pendingChangeRef.current) {
23535
+ pendingChangeRef.current = false;
23536
+ scope._onChange(
23537
+ new CustomEvent(`${controlType}_batched_ui_state_update`),
23538
+ { notifyExternal: "silent" },
23522
23539
  );
23523
- chainEvent(propagateDownClearEvent, e);
23524
- for (const childUIStateController of childUIStateControllerArray) {
23525
- if (!isMonitoringChild(childUIStateController)) {
23526
- continue;
23527
- }
23528
- if (childUIStateController.controlType === "button") {
23529
- continue;
23530
- }
23531
- if (childUIStateController.controlType === "link") {
23532
- continue;
23533
- }
23534
- childUIStateController.clearUIState(propagateDownClearEvent);
23535
- }
23536
- onChange(e, { notifyExternal: true });
23537
- },
23538
- onActionEnd: (e) => {
23539
- groupUIStateController.rules.validation.syncValidity(e);
23540
- },
23541
- onActionError: (e) => {
23542
- groupUIStateController.rules.validation.syncValidity(e, { report: true });
23543
- },
23544
- findChildById: (id) => {
23545
- for (const childUIStateController of childUIStateControllerArray) {
23546
- if (childUIStateController.id === id) {
23547
- return childUIStateController;
23548
- }
23549
- }
23550
- return null;
23551
- },
23552
- getChildControllers: () => childUIStateControllerArray,
23553
- getManagedControls: () => {
23554
- if (!cascadeValidationToChildren) {
23555
- return [];
23556
- }
23557
- return childUIStateControllerArray.slice();
23558
- },
23559
- subscribe: subscribeUIState,
23560
- };
23561
- controllerRef.current = groupUIStateController;
23562
- const rules = createControlRules(groupUIStateController, {
23563
- debugPopup,
23564
- debugInteraction,
23565
- debugUIState: debugUIGroup,
23566
- debugFocus,
23540
+ }
23567
23541
  });
23568
- groupUIStateController.rules = rules;
23569
- return groupUIStateController;
23542
+
23543
+ return controller;
23570
23544
  };
23571
23545
  // Stable reference for an empty selection so the action always receives an
23572
23546
  // array (never undefined) and callers don't get a new reference each render.
@@ -23605,6 +23579,141 @@ const useUIFacadeStateController = (props, realUIStateController) => {
23605
23579
  const debugUIState = useDebugUIState();
23606
23580
  const debugFocus = useDebugFocus();
23607
23581
 
23582
+ // The facade controller's closures (registerChild/unregisterChild/onChildUIAction)
23583
+ // must not capture `realUIStateController` directly: that parameter can legitimately
23584
+ // point to a different controller instance on a later render (e.g. the picker's own
23585
+ // controller getting recreated). Instead, closures read `s.realUIStateController`
23586
+ // from the stable scope object, which is kept current by `update` on every render.
23587
+ const scope = useRenderScope(
23588
+ // ── init: runs once on mount ───────────────────────────────────────────
23589
+ (s) => {
23590
+ const canRegisterAsFacadeChild = (childController) => {
23591
+ if (childController.controlType === "button") return false;
23592
+ if (childController.controlType === "link") return false;
23593
+ if (childController.controlType === "facade") return false;
23594
+ if (childController.isProxy) return false;
23595
+ if (childController.props["navi-list"]) {
23596
+ // Controls with navi-list act as standalone list navigators and should
23597
+ // not be treated as the picker's synced child.
23598
+ return false;
23599
+ }
23600
+ if (
23601
+ props.type === "controlgroup" &&
23602
+ childController.controlType !== "control_group"
23603
+ ) {
23604
+ // ignore non control group registration (input outside the control group for instance)
23605
+ return false;
23606
+ }
23607
+ if (
23608
+ props.type === "array" &&
23609
+ childController.controlType !== "checkbox_group"
23610
+ ) {
23611
+ // only selectable list expose array, ignore others
23612
+ return false;
23613
+ }
23614
+ return true;
23615
+ };
23616
+
23617
+ const facadeUIStateController = {
23618
+ controlType: "facade",
23619
+ props,
23620
+ ref: realUIStateController.ref,
23621
+ uiStateSignal: realUIStateController.uiStateSignal,
23622
+ controlHostProps: realUIStateController.controlHostProps,
23623
+ registerChild: (child) => {
23624
+ if (!canRegisterAsFacadeChild(child)) {
23625
+ return;
23626
+ }
23627
+ const childType = child.controlType;
23628
+ if (firstChildControllerRef.current) {
23629
+ console.warn(
23630
+ `[useUIFacadeStateController] A second child ("${childType}"${child.name ? ` name="${child.name}"` : ""}) tried to register in the picker facade. ` +
23631
+ `The facade only syncs with the first child — wrap multiple controls in a single ControlGroup.`,
23632
+ child,
23633
+ );
23634
+ } else {
23635
+ debugUIState(
23636
+ `[useUIFacadeStateController] "${childType}"${child.name ? ` name="${child.name}"` : ""} registered as the first child in the picker facade.`,
23637
+ );
23638
+ firstChildControllerRef.current = child;
23639
+ s.realUIStateController.facadeChild = child;
23640
+ // If the picker already has a meaningful state (from value or defaultValue),
23641
+ // push it to the child on registration so it reflects the pre-set value
23642
+ // without firing uiAction (equivalent to defaultValue on the child itself).
23643
+ const initialState = s.realUIStateController.uiState;
23644
+ if (initialState !== undefined) {
23645
+ updatingRef.current = true;
23646
+ const initialEvent = new CustomEvent("initial_state_push", {
23647
+ detail: {},
23648
+ });
23649
+ child.setUIState(initialState, initialEvent);
23650
+ updatingRef.current = false;
23651
+ }
23652
+ }
23653
+ },
23654
+ unregisterChild: (child) => {
23655
+ if (firstChildControllerRef.current === child) {
23656
+ firstChildControllerRef.current = null;
23657
+ s.realUIStateController.facadeChild = null;
23658
+ }
23659
+ },
23660
+ getManagedControls: () => {
23661
+ const child = firstChildControllerRef.current;
23662
+ if (!child) {
23663
+ return [];
23664
+ }
23665
+ return child.getManagedControls();
23666
+ },
23667
+ onChildUIAction: (child, e, { stateChanged, silent = false }) => {
23668
+ if (!stateChanged) {
23669
+ return;
23670
+ }
23671
+ if (child !== firstChildControllerRef.current) {
23672
+ return;
23673
+ }
23674
+ updatingRef.current = true;
23675
+ // Use a different event type for silent (mount/unmount) syncs so that
23676
+ // the picker's setUIState does not fire navi_change or action pipelines.
23677
+ const eventType = silent
23678
+ ? "facade_child_mount_sync"
23679
+ : "facade_propagate_up";
23680
+ const propagateUpEvent = new CustomEvent(eventType, {
23681
+ detail: {},
23682
+ });
23683
+ chainEvent(propagateUpEvent, e);
23684
+ s.realUIStateController.setUIState(child.uiState, propagateUpEvent);
23685
+ updatingRef.current = false;
23686
+ },
23687
+ };
23688
+ const rules = createControlRules(facadeUIStateController, {
23689
+ debugPopup,
23690
+ debugInteraction,
23691
+ debugUIState,
23692
+ debugFocus,
23693
+ });
23694
+ facadeUIStateController.rules = rules;
23695
+
23696
+ // No initial checkValidity() here — the facade has no controlHostProps and no children
23697
+ // have registered yet, so any check would be a no-op. The real validity check happens
23698
+ // when child controllers trigger UI actions through the facade.
23699
+ return {
23700
+ controller: facadeUIStateController,
23701
+ realUIStateController,
23702
+ };
23703
+ },
23704
+ // ── update: runs every render after the first ─────────────────────────
23705
+ (s) => {
23706
+ s.controller.props = props;
23707
+ s.controller.ref = realUIStateController.ref;
23708
+ s.controller.uiStateSignal = realUIStateController.uiStateSignal;
23709
+ s.controller.controlHostProps = realUIStateController.controlHostProps;
23710
+
23711
+ return {
23712
+ realUIStateController,
23713
+ };
23714
+ },
23715
+ );
23716
+
23608
23717
  useLayoutEffect(() => {
23609
23718
  return realUIStateController.subscribe((newUIState, e) => {
23610
23719
  if (updatingRef.current) {
@@ -23623,132 +23732,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
23623
23732
  child.setUIState(newUIState, propagateDownEvent);
23624
23733
  updatingRef.current = false;
23625
23734
  });
23626
- }, []);
23627
-
23628
- const canRegisterAsFacadeChild = (childController) => {
23629
- if (childController.controlType === "button") {
23630
- return false;
23631
- }
23632
- if (childController.controlType === "link") {
23633
- return false;
23634
- }
23635
- if (childController.controlType === "facade") {
23636
- return false;
23637
- }
23638
- if (childController.isProxy) {
23639
- return false;
23640
- }
23641
- if (childController.props["navi-list"]) {
23642
- // Controls with navi-list act as standalone list navigators and should
23643
- // not be treated as the picker's synced child.
23644
- return false;
23645
- }
23646
- if (
23647
- props.type === "controlgroup" &&
23648
- childController.controlType !== "control_group"
23649
- ) {
23650
- // ignore non control group registration (input outside the control group for instance)
23651
- return false;
23652
- }
23653
- if (
23654
- props.type === "array" &&
23655
- childController.controlType !== "checkbox_group"
23656
- ) {
23657
- // only selectable list expose array, ignore others
23658
- return false;
23659
- }
23660
- return true;
23661
- };
23735
+ }, [realUIStateController]);
23662
23736
 
23663
- const controllerRef = useRef();
23664
- if (controllerRef.current) {
23665
- return controllerRef.current;
23666
- }
23667
-
23668
- const facadeUIStateController = {
23669
- controlType: "facade",
23670
- props,
23671
- ref: realUIStateController.ref,
23672
- uiStateSignal: realUIStateController.uiStateSignal,
23673
- registerChild: (child) => {
23674
- if (!canRegisterAsFacadeChild(child)) {
23675
- return;
23676
- }
23677
- const childType = child.controlType;
23678
- if (firstChildControllerRef.current) {
23679
- console.warn(
23680
- `[useUIFacadeStateController] A second child ("${childType}"${child.name ? ` name="${child.name}"` : ""}) tried to register in the picker facade. ` +
23681
- `The facade only syncs with the first child — wrap multiple controls in a single ControlGroup.`,
23682
- child,
23683
- );
23684
- } else {
23685
- debugUIState(
23686
- `[useUIFacadeStateController] "${childType}"${child.name ? ` name="${child.name}"` : ""} registered as the first child in the picker facade.`,
23687
- );
23688
- firstChildControllerRef.current = child;
23689
- realUIStateController.facadeChild = child;
23690
- // If the picker already has a meaningful state (from value or defaultValue),
23691
- // push it to the child on registration so it reflects the pre-set value
23692
- // without firing uiAction (equivalent to defaultValue on the child itself).
23693
- const initialState = realUIStateController.uiState;
23694
- if (initialState !== undefined) {
23695
- updatingRef.current = true;
23696
- const initialEvent = new CustomEvent("initial_state_push", {
23697
- detail: {},
23698
- });
23699
- child.setUIState(initialState, initialEvent);
23700
- updatingRef.current = false;
23701
- }
23702
- }
23703
- },
23704
- unregisterChild: (child) => {
23705
- if (firstChildControllerRef.current === child) {
23706
- firstChildControllerRef.current = null;
23707
- realUIStateController.facadeChild = null;
23708
- }
23709
- },
23710
- getManagedControls: () => {
23711
- const child = firstChildControllerRef.current;
23712
- if (!child) {
23713
- return [];
23714
- }
23715
- return child.getManagedControls();
23716
- },
23717
- onChildUIAction: (child, e, { stateChanged, silent = false }) => {
23718
- if (!stateChanged) {
23719
- return;
23720
- }
23721
- if (child !== firstChildControllerRef.current) {
23722
- return;
23723
- }
23724
- updatingRef.current = true;
23725
- // Use a different event type for silent (mount/unmount) syncs so that
23726
- // the picker's setUIState does not fire navi_change or action pipelines.
23727
- const eventType = silent
23728
- ? "facade_child_mount_sync"
23729
- : "facade_propagate_up";
23730
- const propagateUpEvent = new CustomEvent(eventType, {
23731
- detail: {},
23732
- });
23733
- chainEvent(propagateUpEvent, e);
23734
- realUIStateController.setUIState(child.uiState, propagateUpEvent);
23735
- updatingRef.current = false;
23736
- },
23737
- };
23738
- controllerRef.current = facadeUIStateController;
23739
- const rules = createControlRules(facadeUIStateController, {
23740
- debugPopup,
23741
- debugInteraction,
23742
- debugUIState,
23743
- debugFocus,
23744
- });
23745
- facadeUIStateController.rules = rules;
23746
- facadeUIStateController.controlHostProps =
23747
- realUIStateController.controlHostProps;
23748
- // No initial checkValidity() here — the facade has no controlHostProps and no children
23749
- // have registered yet, so any check would be a no-op. The real validity check happens
23750
- // when child controllers trigger UI actions through the facade.
23751
- return facadeUIStateController;
23737
+ return scope.controller;
23752
23738
  };
23753
23739
 
23754
23740
  /**
@@ -24410,7 +24396,7 @@ const useControlProps = (props, {
24410
24396
  debounce: actionDebounce,
24411
24397
  debugInteraction
24412
24398
  });
24413
- }, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction]);
24399
+ }, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction, ref]);
24414
24400
  const refComposed = useComposeElementRef(refCallback, ref);
24415
24401
  const onPaste = e => {
24416
24402
  props.onPaste?.(e);
@@ -33960,20 +33946,24 @@ const InputTextualFirstResolver = props => {
33960
33946
  });
33961
33947
  };
33962
33948
  const InputTextual = createComponentResolver([InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
33963
- const RealInput = props => {
33964
- const autoSelectReadOnlyProps = useAutoSelectReadOnly(props);
33949
+ const RealInput = ({
33950
+ maxLength,
33951
+ ...domProps
33952
+ }) => {
33953
+ const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
33965
33954
  return jsx(Box, {
33966
- ...props,
33955
+ ...domProps,
33967
33956
  as: "input",
33968
33957
  baseClassName: "navi_control_input",
33969
33958
  ...autoSelectReadOnlyProps,
33970
- // Never set native maxLength — our guard handles it. maxLength stays in
33971
- // inputControlHostProps so form validation constraints still read it.
33972
- maxLength: undefined
33973
- // But do expose it (needed by navi_input_full event)
33974
- ,
33975
-
33976
- "navi-max-length": props.maxLength
33959
+ // Never set native maxLength — our guard handles it. Omitting it entirely
33960
+ // avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
33961
+ // (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
33962
+ // which coerces to 0 (Number("") = 0), capping the input at 0 characters.
33963
+ // see https://github.com/preactjs/preact/issues/2677
33964
+ // The JS value stays accessible via the navi-max-length attribute and via
33965
+ // inputControlHostProps (which the validation system reads directly).
33966
+ "navi-max-length": maxLength
33977
33967
  });
33978
33968
  };
33979
33969
  const InputStyleCSSVars = {
@@ -36225,6 +36215,11 @@ const PickerCustom = props => {
36225
36215
  }
36226
36216
  const pickerEl = ref.current;
36227
36217
  const inputEl = getPickerInput(pickerEl);
36218
+ const valueAtClose = getUIStateFromElement(inputEl);
36219
+ if (compareTwoJsValues(valueAtClose, valueAtOpen)) {
36220
+ // Value unchanged — no action to run, but still allow the close.
36221
+ return;
36222
+ }
36228
36223
  dispatchRequestAction(inputEl, {
36229
36224
  event: requestCloseEvent,
36230
36225
  name: "picker request close",