@jsenv/navi 0.29.43 → 0.29.45

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.
@@ -2,7 +2,7 @@
2
2
  * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
- import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
5
+ import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
6
  import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getElementSignature, findEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
7
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, batch, useSignal } from "@preact/signals";
@@ -17237,15 +17237,26 @@ const isSizeSpacingKey = (key) => {
17237
17237
  // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
17238
17238
  // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
17239
17239
  // they are what a popup meant to stay clear of the keyboard should use.
17240
- const VIEWPORT_UNIT_SIGNALS = {
17241
- vvw: visualViewportWidthSignal,
17242
- vvh: visualViewportHeightSignal,
17243
- vw: windowWidthSignal,
17244
- vh: windowHeightSignal,
17245
- dvw: windowWidthSignal,
17246
- dvh: windowHeightSignal,
17247
- };
17248
- const VIEWPORT_LENGTH_REGEX = /^(-?\d+(?:\.\d+)?)(vvw|vvh|dvw|dvh|vw|vh)$/;
17240
+ // "appw"/"apph" are the same thing narrowed to the app's own screen: identical
17241
+ // to vvw/vvh until the app declares --navi-app-max-width, and a share of that
17242
+ // width afterwards. A gap meant to read as "a small margin" must use these —
17243
+ // 3vvw on a 1500px window is a 45px gap around a 600px app.
17244
+ // Functions rather than the signals themselves: appw/apph are not a signal to
17245
+ // read but a value to compute (a signal, then a CSS var read back). Reading the
17246
+ // signal inside still registers the same dependency for a caller doing this
17247
+ // during a render.
17248
+ const VIEWPORT_UNIT_VALUES = {
17249
+ appw: getAppWidth,
17250
+ apph: getAppHeight,
17251
+ vvw: () => visualViewportWidthSignal.value,
17252
+ vvh: () => visualViewportHeightSignal.value,
17253
+ vw: () => windowWidthSignal.value,
17254
+ vh: () => windowHeightSignal.value,
17255
+ dvw: () => windowWidthSignal.value,
17256
+ dvh: () => windowHeightSignal.value,
17257
+ };
17258
+ const VIEWPORT_LENGTH_REGEX =
17259
+ /^(-?\d+(?:\.\d+)?)(appw|apph|vvw|vvh|dvw|dvh|vw|vh)$/;
17249
17260
  const resolveViewportLength = (size) => {
17250
17261
  if (typeof size !== "string") {
17251
17262
  return null;
@@ -17255,7 +17266,7 @@ const resolveViewportLength = (size) => {
17255
17266
  return null;
17256
17267
  }
17257
17268
  const [, amount, unit] = match;
17258
- return (parseFloat(amount) / 100) * VIEWPORT_UNIT_SIGNALS[unit].value;
17269
+ return (parseFloat(amount) / 100) * VIEWPORT_UNIT_VALUES[unit]();
17259
17270
  };
17260
17271
 
17261
17272
  // "3cqw"/"2cqh" — a share of the container the given element lives in, the way
@@ -21546,6 +21557,564 @@ document.body.addEventListener(
21546
21557
  { capture: true },
21547
21558
  );
21548
21559
 
21560
+ const documentStateSignal = signal(null);
21561
+ const useDocumentState = () => {
21562
+ return documentStateSignal.value;
21563
+ };
21564
+ const updateDocumentState = (value) => {
21565
+ documentStateSignal.value = value;
21566
+ };
21567
+
21568
+ /**
21569
+ * A navigation is ABOUT to be applied — said before its very first write.
21570
+ *
21571
+ * Everything else a router says arrives once the change is made: a route
21572
+ * announces that it matches, an action that it is running. That is too late for
21573
+ * anyone who needs the page as it stands BEFORE, and the browser's view
21574
+ * transitions are exactly that kind of reader — the picture they keep of the
21575
+ * page being left is taken at the next frame, and a render answering a signal
21576
+ * written a moment ago is already in the DOM by then (see route_travel.jsx).
21577
+ *
21578
+ * So this is the one moment where nothing has moved yet. It is published
21579
+ * synchronously, from the top of the navigation, and whoever listens runs
21580
+ * before the URL, the visited set, or any route has changed.
21581
+ *
21582
+ * The other end is published too, and for the same kind of reader: whoever
21583
+ * held something across the change and has nobody to hand it to gets a moment
21584
+ * to let go of it that does not depend on guessing how long the change takes.
21585
+ */
21586
+
21587
+
21588
+ const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
21589
+ const [publishAfterRouting, observeAfterRouting] = createPubSub();
21590
+
21591
+ const setupBrowserIntegrationViaHistory = ({
21592
+ applyActions,
21593
+ applyRouting,
21594
+ isRouting,
21595
+ }) => {
21596
+ const { history } = window;
21597
+
21598
+ let globalAbortController = new AbortController();
21599
+ const triggerGlobalAbort = (reason) => {
21600
+ globalAbortController.abort(reason);
21601
+ globalAbortController = new AbortController();
21602
+ };
21603
+
21604
+ const dispatchActions = (params) => {
21605
+ const { requestedResult } = applyActions({
21606
+ globalAbortSignal: globalAbortController.signal,
21607
+ abortSignal: new AbortController().signal,
21608
+ ...params,
21609
+ });
21610
+ return requestedResult;
21611
+ };
21612
+ setActionDispatcher(dispatchActions);
21613
+
21614
+ const getDocumentState = () => {
21615
+ return window.history.state ? { ...window.history.state } : null;
21616
+ };
21617
+
21618
+ const historyStartAtStart = getDocumentState();
21619
+ const visitedUrlSet = historyStartAtStart
21620
+ ? new Set(historyStartAtStart.jsenv_visited_urls || [])
21621
+ : new Set();
21622
+
21623
+ // Create a signal that tracks visited URLs for reactive updates
21624
+ // Using a counter instead of the Set directly for better performance
21625
+ // Links will check isVisited() when this signal changes
21626
+ const visitedUrlsSignal = signal(0);
21627
+
21628
+ const isVisited = (url) => {
21629
+ url = new URL(url, window.location.href).href;
21630
+ return visitedUrlSet.has(url);
21631
+ };
21632
+ const markUrlAsVisited = (url) => {
21633
+ if (visitedUrlSet.has(url)) {
21634
+ return;
21635
+ }
21636
+ visitedUrlSet.add(url);
21637
+ visitedUrlsSignal.value++;
21638
+ };
21639
+
21640
+ let abortController = null;
21641
+ const handleRoutingTask = (url, options) => {
21642
+ // Before anything is written: the visited set, the URL and every route are
21643
+ // about to change, and this is the last moment the page still stands as it
21644
+ // was. And after, whichever way the change went out — so that whoever took
21645
+ // something at the first announcement has a definite place to give it back.
21646
+ publishBeforeRouting({ url, ...options });
21647
+ try {
21648
+ return applyRoutingTask(url, options);
21649
+ } finally {
21650
+ publishAfterRouting({ url, ...options });
21651
+ }
21652
+ };
21653
+
21654
+ const applyRoutingTask = (url, options) => {
21655
+ const isSameUrl = url === window.location.href;
21656
+ const {
21657
+ reason,
21658
+ navigationType, // "load", "reload", "replace", "push", "traverse"
21659
+ state,
21660
+ } = options;
21661
+
21662
+ if (navigationType === "push" || navigationType === "replace") {
21663
+ markUrlAsVisited(url);
21664
+ // undefined → inherit current state (link click, neutral navigation)
21665
+ // null → explicit reset (no nav-state keys carried over)
21666
+ // {...} → explicit state from enter()/leave(), already built from currentState
21667
+ // When state is given it's responsability of the caller to ensure it inherits document state (or not, you want it 99% of the time)
21668
+ let effectiveState;
21669
+ const sharedState = {
21670
+ jsenv_visited_urls: Array.from(visitedUrlSet),
21671
+ };
21672
+ if (state === undefined) {
21673
+ effectiveState = {
21674
+ ...(getDocumentState() || {}),
21675
+ ...sharedState,
21676
+ };
21677
+ } else if (state === null) {
21678
+ effectiveState = sharedState;
21679
+ } else if (state) {
21680
+ effectiveState = {
21681
+ ...state,
21682
+ ...sharedState,
21683
+ };
21684
+ }
21685
+ if (navigationType === "push") {
21686
+ window.history.pushState(effectiveState, null, url);
21687
+ } else {
21688
+ window.history.replaceState(effectiveState, null, url);
21689
+ }
21690
+ updateDocumentUrl(url);
21691
+ updateDocumentState(effectiveState);
21692
+ } else {
21693
+ // traverse / reload: state comes from the history entry, no push/replace needed.
21694
+ markUrlAsVisited(url);
21695
+ updateDocumentUrl(url);
21696
+ updateDocumentState(state);
21697
+ }
21698
+
21699
+ // Skip route matching for state-only changes: push/replace to the same URL
21700
+ // (e.g. useNavState updating document state without changing the route).
21701
+ // Do NOT apply for "traverse" — window.location.href is already updated by
21702
+ // the browser before the popstate handler runs, so isSameUrl is always true
21703
+ // for back/forward navigation regardless of whether the URL actually changed.
21704
+ if (
21705
+ isSameUrl &&
21706
+ (navigationType === "push" || navigationType === "replace")
21707
+ ) {
21708
+ return undefined;
21709
+ }
21710
+
21711
+ if (abortController) {
21712
+ abortController.abort(`navigating to ${url}`);
21713
+ }
21714
+ abortController = new AbortController();
21715
+ const abortSignal = abortController.signal;
21716
+ const { allResult, requestedResult } = applyRouting(url, {
21717
+ globalAbortSignal: globalAbortController.signal,
21718
+ abortSignal,
21719
+ reason,
21720
+ navigationType,
21721
+ isVisited,
21722
+ state,
21723
+ });
21724
+ executeWithCleanup(
21725
+ () => allResult,
21726
+ () => {
21727
+ abortController = undefined;
21728
+ },
21729
+ );
21730
+ return requestedResult;
21731
+ };
21732
+
21733
+ // Browser event handlers
21734
+ window.addEventListener(
21735
+ "click",
21736
+ (e) => {
21737
+ if (e.button !== 0) {
21738
+ // Ignore non-left clicks
21739
+ return;
21740
+ }
21741
+ if (e.metaKey) {
21742
+ // Ignore clicks with meta key (e.g. open in new tab)
21743
+ return;
21744
+ }
21745
+ if (e.defaultPrevented) {
21746
+ return;
21747
+ }
21748
+ const linkElement = e.target.closest("a");
21749
+ if (!linkElement) {
21750
+ return;
21751
+ }
21752
+ if (linkElement.hasAttribute("data-readonly")) {
21753
+ return;
21754
+ }
21755
+ const href = linkElement.href;
21756
+ const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
21757
+ getHrefTargetInfo(href);
21758
+ if (isEmpty || !isSameOrigin) {
21759
+ // Let link to other origins be handled by the browser
21760
+ return;
21761
+ }
21762
+ if (isAnchor) {
21763
+ // Fragment navigation belongs to the browser: it owns the indicated
21764
+ // part of the document, and taking it over would cost `:target` and the
21765
+ // focus handling that come with it.
21766
+ if (isCurrent) {
21767
+ // Except this one, which the browser answers with a scroll and
21768
+ // nothing else: same pathname, same hash, so no event and no url
21769
+ // change reaches whoever is waiting on the designated element.
21770
+ rearmUrlTarget();
21771
+ }
21772
+ return;
21773
+ }
21774
+ // Nothing here declared a route, so there is nothing to route to: the
21775
+ // page is a plain document and a link in it is a plain link. Taking it
21776
+ // over anyway would push the url and then have nothing to show for it —
21777
+ // the address bar moves and the page does not (see applyRouting's own
21778
+ // "not called yet" branch, which is where that used to end up).
21779
+ if (!isRouting()) {
21780
+ return;
21781
+ }
21782
+ e.preventDefault();
21783
+ handleRoutingTask(href, {
21784
+ reason: `"click" on a[href="${href}"]`,
21785
+ navigationType: "push",
21786
+ });
21787
+ },
21788
+ { capture: true },
21789
+ );
21790
+
21791
+ window.addEventListener(
21792
+ "submit",
21793
+ () => {
21794
+ // Handle form submissions?
21795
+ // Not needed yet
21796
+ },
21797
+ { capture: true },
21798
+ );
21799
+
21800
+ window.addEventListener("popstate", (popstateEvent) => {
21801
+ const url = window.location.href;
21802
+ const state = popstateEvent.state;
21803
+ handleRoutingTask(url, {
21804
+ reason: `"popstate" event for ${url}`,
21805
+ navigationType: "traverse",
21806
+ state,
21807
+ });
21808
+ });
21809
+
21810
+ // A fragment navigation is left to the browser (see the click handler above):
21811
+ // it owns the indicated part of the document, and taking it over would cost
21812
+ // `:target` and the focus handling that come with it. The document url still
21813
+ // has to follow it — nothing else here would notice that it moved.
21814
+ window.addEventListener("hashchange", () => {
21815
+ updateDocumentUrl(window.location.href);
21816
+ });
21817
+
21818
+ const navTo = async (url, { replace, state } = {}) => {
21819
+ handleRoutingTask(url, {
21820
+ reason: `navTo called with "${url}"`,
21821
+ navigationType: replace ? "replace" : "push",
21822
+ state,
21823
+ });
21824
+ };
21825
+
21826
+ const stop = (reason = "stop called") => {
21827
+ triggerGlobalAbort(reason);
21828
+ };
21829
+
21830
+ const reload = () => {
21831
+ const url = window.location.href;
21832
+ const state = history.state;
21833
+ handleRoutingTask(url, {
21834
+ reason: "reload called",
21835
+ navigationType: "reload",
21836
+ state,
21837
+ });
21838
+ };
21839
+
21840
+ const navBack = () => {
21841
+ window.history.back();
21842
+ };
21843
+
21844
+ const navForward = () => {
21845
+ window.history.forward();
21846
+ };
21847
+
21848
+ const init = () => {
21849
+ const url = window.location.href;
21850
+ const state = history.state;
21851
+ handleRoutingTask(url, {
21852
+ reason: "routing initialization",
21853
+ navigationType: "load",
21854
+ state,
21855
+ });
21856
+ };
21857
+
21858
+ return {
21859
+ integration: "browser_history_api",
21860
+ init,
21861
+ navTo,
21862
+ stop,
21863
+ reload,
21864
+ navBack,
21865
+ navForward,
21866
+ getDocumentState,
21867
+ isVisited,
21868
+ visitedUrlsSignal,
21869
+ };
21870
+ };
21871
+
21872
+ let updateRoutes;
21873
+
21874
+ const applyActions = (params) => {
21875
+ const updateActionsResult = updateActions(params);
21876
+ const { allResult, runningActionSet } = updateActionsResult;
21877
+ const pendingTaskNameArray = [];
21878
+ for (const runningAction of runningActionSet) {
21879
+ pendingTaskNameArray.push(runningAction.name);
21880
+ }
21881
+ workingWhile(() => allResult, pendingTaskNameArray);
21882
+ return updateActionsResult;
21883
+ };
21884
+ const applyRouting = (
21885
+ url,
21886
+ {
21887
+ globalAbortSignal,
21888
+ abortSignal,
21889
+ // state
21890
+ navigationType,
21891
+ isVisited,
21892
+ reason,
21893
+ },
21894
+ ) => {
21895
+ if (!updateRoutes) {
21896
+ // .init() not called yet
21897
+ // likely because code does not uses routing at all
21898
+ return {};
21899
+ }
21900
+ const {
21901
+ loadSet,
21902
+ reloadSet,
21903
+ abortSignalMap,
21904
+ routeLoadRequestedMap,
21905
+ activeRouteSet,
21906
+ } = updateRoutes(url, {
21907
+ navigationType,
21908
+ isVisited,
21909
+ // state,
21910
+ });
21911
+ if (
21912
+ (!loadSet || loadSet.size === 0) &&
21913
+ (!reloadSet || reloadSet.size === 0)
21914
+ ) {
21915
+ return {
21916
+ allResult: undefined,
21917
+ requestedResult: undefined,
21918
+ activeRouteSet: new Set(),
21919
+ };
21920
+ }
21921
+ const updateActionsResult = updateActions({
21922
+ globalAbortSignal,
21923
+ abortSignal,
21924
+ runSet: loadSet,
21925
+ rerunSet: reloadSet,
21926
+ abortSignalMap,
21927
+ reason,
21928
+ isReplace: navigationType === "replace",
21929
+ });
21930
+ const { allResult, runningActionSet } = updateActionsResult;
21931
+ const pendingTaskNameArray = [];
21932
+ for (const [route, routeAction] of routeLoadRequestedMap) {
21933
+ if (runningActionSet.has(routeAction)) {
21934
+ pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
21935
+ }
21936
+ }
21937
+ routingWhile(() => allResult, pendingTaskNameArray);
21938
+ return { ...updateActionsResult, activeRouteSet };
21939
+ };
21940
+
21941
+ const browserIntegration = setupBrowserIntegrationViaHistory({
21942
+ applyActions,
21943
+ applyRouting,
21944
+ // Routes are declared by the consumer and registered through
21945
+ // setOnAllRouteReady below, so "does this document route at all?" is only
21946
+ // answerable once that has run — hence a function, read at click time rather
21947
+ // than a value read at setup time.
21948
+ isRouting: () => Boolean(updateRoutes),
21949
+ });
21950
+
21951
+ setOnAllRouteReady((v) => {
21952
+ updateRoutes = v;
21953
+ browserIntegration.init();
21954
+ });
21955
+ setRouteIntegration(browserIntegration);
21956
+
21957
+ const navIntegratedVia = browserIntegration.integration;
21958
+ const navTo = (target, options) => {
21959
+ const url = new URL(target, window.location.href).href;
21960
+ const currentUrl = documentUrlSignal.peek();
21961
+ if (url === currentUrl) {
21962
+ if (options?.state === undefined) {
21963
+ return null;
21964
+ }
21965
+ // State-only update on same URL: skip if state is identical to current.
21966
+ const currentState = browserIntegration.getDocumentState();
21967
+ if (compareTwoJsValues(options.state, currentState)) {
21968
+ return null;
21969
+ }
21970
+ }
21971
+ return browserIntegration.navTo(url, options);
21972
+ };
21973
+ const stopLoad = (reason = "stopLoad() called") => {
21974
+ const windowIsLoading = windowIsLoadingSignal.value;
21975
+ if (windowIsLoading) {
21976
+ window.stop();
21977
+ }
21978
+ const documentIsBusy = documentIsBusySignal.value;
21979
+ if (documentIsBusy) {
21980
+ browserIntegration.stop(reason);
21981
+ }
21982
+ };
21983
+ const reload = browserIntegration.reload;
21984
+ const navBack = browserIntegration.navBack;
21985
+ const navForward = browserIntegration.navForward;
21986
+ const isVisited = browserIntegration.isVisited;
21987
+ const visitedUrlsSignal = browserIntegration.visitedUrlsSignal;
21988
+ browserIntegration.handleActionTask;
21989
+
21990
+ // Preact's own useId() (see preact/hooks) returns "P<mask0>-<mask1>", where
21991
+ // the mask is derived from render order within the nearest root/async
21992
+ // boundary — stable across re-renders of the *same* mount, but not across a
21993
+ // reload (render order can differ) or even across two mounts on the same
21994
+ // page (two components hitting useId() in the same relative order get the
21995
+ // same string). Storing one of these under type: "push" bakes it into a
21996
+ // history entry: reload the page and the entry's key may now belong to a
21997
+ // completely different component (or none), silently auto-opening whatever
21998
+ // happens to render at that same position instead.
21999
+ const PREACT_GENERATED_ID_REGEX = /^P\d+-\d+/;
22000
+ const isLikelyPreactGeneratedId = (id) => PREACT_GENERATED_ID_REGEX.test(id);
22001
+
22002
+ const NO_OP = () => {};
22003
+ const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
22004
+ const useNavStateBasic = (
22005
+ id,
22006
+ { debug, type = "replace", onLeave, defaultValue } = {},
22007
+ ) => {
22008
+ // Hooks must be called unconditionally — before the !id early return.
22009
+ const state = documentStateSignal.value;
22010
+ // Key presence is the flag — the value may be anything, including undefined.
22011
+ const keyInState = Boolean(id && state && Object.hasOwn(state, id));
22012
+ const onLeaveRef = useRef(onLeave);
22013
+ onLeaveRef.current = onLeave;
22014
+ const prevKeyInStateRef = useRef(keyInState);
22015
+ // enteredRef tracks whether enter() was called without a matching leave() yet.
22016
+ // It lets the effect distinguish an external disappearance (back button → fire onLeave)
22017
+ // from a programmatic one (leave() already set it to false before the state updates).
22018
+ const enteredRef = useRef(false);
22019
+ useEffect(() => {
22020
+ const prevKeyInState = prevKeyInStateRef.current;
22021
+ prevKeyInStateRef.current = keyInState;
22022
+ if (prevKeyInState && !keyInState && enteredRef.current) {
22023
+ enteredRef.current = false;
22024
+ onLeaveRef.current?.();
22025
+ }
22026
+ }, [keyInState]);
22027
+
22028
+ if (!id) {
22029
+ return NO_ID_GIVEN;
22030
+ }
22031
+
22032
+ let effectiveType = type;
22033
+ if (type === "push" && isLikelyPreactGeneratedId(id)) {
22034
+ effectiveType = "replace";
22035
+ }
22036
+
22037
+ const currentValue = keyInState ? state[id] : defaultValue;
22038
+
22039
+ if (debug) {
22040
+ console.debug(`useNavState(${id}) current value is ${currentValue}`);
22041
+ }
22042
+
22043
+ // enter(value): navigate TO this state (push or replace depending on type).
22044
+ // Calling enter() without a value stores "on" — the mere presence of the key
22045
+ // in the document state is enough to match; the value just allows associating
22046
+ // extra data with the entry when needed.
22047
+ const enter = (value = "on") => {
22048
+ enteredRef.current = true;
22049
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22050
+ if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
22051
+ return;
22052
+ }
22053
+ currentStateCopy[id] = value;
22054
+ navTo(window.location.href, {
22055
+ replace: effectiveType !== "push",
22056
+ state: currentStateCopy,
22057
+ });
22058
+ };
22059
+
22060
+ // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
22061
+ // isBack: when true (cancel close in push mode), call history.back() to restore the
22062
+ // pre-open state — discards any in-progress edits.
22063
+ // When false (confirmed close), replace the pushed entry instead: preserves the
22064
+ // current URL state (e.g. a new picker value) while removing the popup key.
22065
+ const leave = ({ isBack } = {}) => {
22066
+ enteredRef.current = false;
22067
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22068
+ if (!Object.hasOwn(currentStateCopy, id)) {
22069
+ return;
22070
+ }
22071
+ if (effectiveType === "push" && isBack) {
22072
+ browserIntegration.navBack();
22073
+ } else {
22074
+ delete currentStateCopy[id];
22075
+ navTo(window.location.href, {
22076
+ replace: true,
22077
+ state: currentStateCopy,
22078
+ });
22079
+ }
22080
+ };
22081
+
22082
+ return [currentValue, enter, leave];
22083
+ };
22084
+
22085
+ /**
22086
+ * Stores a named value in the browser's document state and returns it reactively.
22087
+ * The component re-renders whenever the value changes (navigation, back/forward button).
22088
+ *
22089
+ * @param {string} id
22090
+ * Unique key used to store the value in document state. Must be stable across renders.
22091
+ *
22092
+ * @param {object} [options]
22093
+ * @param {"push"|"replace"} [options.type="replace"]
22094
+ * Controls how enter() adds the state to browser history.
22095
+ * - "push": creates a new history entry — pressing the back button removes it and calls onLeave.
22096
+ * - "replace": updates the current history entry — no extra history entry is created.
22097
+ * Silently downgraded to "replace" (with a dev-only console.warn) when `id`
22098
+ * looks auto-generated (e.g. preact's own useId()) — an unstable id baked
22099
+ * into a pushed history entry won't survive a reload correctly, and could
22100
+ * even collide with a different component's own auto-generated id. Pass a
22101
+ * stable, explicit id to actually get "push" behavior.
22102
+ * @param {() => void} [options.onLeave]
22103
+ * Called when the state key disappears **externally** — e.g. the user presses the browser
22104
+ * back button. Not called when leave() is invoked programmatically.
22105
+ * @param {*} [options.defaultValue]
22106
+ * Value returned when `id` is absent from document state. Defaults to `undefined`.
22107
+ *
22108
+ * @returns {[value, enter, leave]}
22109
+ * - `value`: current value from document state, or `defaultValue` when the key is absent.
22110
+ * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
22111
+ * Calling without an argument stores `"on"` — the presence of the key is enough to match;
22112
+ * the value allows associating extra data when needed.
22113
+ * - `leave()`: navigate AWAY FROM this state (removes `id` from document state,
22114
+ * or goes back in history when `type` is "push").
22115
+ */
22116
+ const useNavState = useNavStateBasic;
22117
+
21549
22118
  /**
21550
22119
  * @param {Element} element The element asking — the command's source, and the
21551
22120
  * anchor a popup opens on unless `anchor` says otherwise.
@@ -21953,15 +22522,21 @@ registerNaviCommand("--navi-send", (source, event) => {
21953
22522
  requester = firstButtonSubmitting;
21954
22523
  }
21955
22524
  }
21956
- // Read here rather than above: it depends on the requester, which is only
21957
- // known now — Enter in a field sends through the first submit button, and
21958
- // what follows the send is that button's answer.
21959
- const afterSend = resolveAfterSend(target, requester);
21960
22525
  // Nothing is committed when a constraint fails, so nothing is decided
21961
22526
  // and the popup must stay open — with the form still in front of the
21962
22527
  // user, showing what it is waiting for.
21963
22528
  let invalid = false;
22529
+ // What follows the send is read at the moment it runs, never before it:
22530
+ // it depends on the requester (Enter in a field sends through the first
22531
+ // submit button, and what follows is that button's answer), and on
22532
+ // anything the send itself decided — an action that learned where to go
22533
+ // from the response writes it on the form while it runs
22534
+ // (data-after-send), and this is what picks it up.
21964
22535
  const runAfterSend = () => {
22536
+ const afterSend = resolveAfterSend(target, requester);
22537
+ if (!afterSend) {
22538
+ return;
22539
+ }
21965
22540
  triggerNaviCommand(source, afterSend, event, { optional: true });
21966
22541
  };
21967
22542
  const {
@@ -21996,7 +22571,7 @@ registerNaviCommand("--navi-send", (source, event) => {
21996
22571
  requester,
21997
22572
  }),
21998
22573
  );
21999
- if (sent === false || invalid || !afterSend) {
22574
+ if (sent === false || invalid) {
22000
22575
  return sent;
22001
22576
  }
22002
22577
  if (isRunning) {
@@ -22188,6 +22763,30 @@ registerNaviCommand("--navi-back", (source, event) => {
22188
22763
  };
22189
22764
  });
22190
22765
 
22766
+ // Where a press takes the user. The destination is the command's argument
22767
+ // because it says WHAT the command does — "--navi-nav-to:/games/42" — which is how
22768
+ // it can also be what follows a form submission: the form has answered its
22769
+ // question, and the answer to "what now" is a page.
22770
+ //
22771
+ // A destination fixed at the call site, so it is for a page known before the
22772
+ // send — which is what a form needs, since it must also know where to go when
22773
+ // the press had nothing to send. A destination the response decides (a
22774
+ // creation, whose id comes back with it) is the action's own business: it
22775
+ // navigates itself.
22776
+ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
22777
+ if (!argument) {
22778
+ console.warn(
22779
+ `[navi] "--navi-nav-to" needs a destination: --navi-nav-to:/the/url (relative to the current page, or absolute).`,
22780
+ );
22781
+ return undefined;
22782
+ }
22783
+ const target = resolveExplicitTarget(source) || source;
22784
+ return {
22785
+ target,
22786
+ implementation: () => navTo(argument),
22787
+ };
22788
+ });
22789
+
22191
22790
  registerNaviCommand("--navi-toggle", (source, event, { anchor } = {}) => {
22192
22791
  const target =
22193
22792
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
@@ -23758,9 +24357,9 @@ const useUIGroupStateController = (
23758
24357
  const delegatedChildrenRef = useRef(new Map());
23759
24358
 
23760
24359
  const groupIsRenderingRef = useRef(false);
23761
- const pendingChangeRef = useRef(false);
24360
+ const pendingChangeRef = useRef(null);
23762
24361
  groupIsRenderingRef.current = true;
23763
- pendingChangeRef.current = false;
24362
+ pendingChangeRef.current = null;
23764
24363
 
23765
24364
  const isMonitoringChild = (childUIStateController) => {
23766
24365
  if (childUIStateController.isProxy) return false;
@@ -23792,7 +24391,18 @@ const useUIGroupStateController = (
23792
24391
  // signals/pubsub without needing external refs.
23793
24392
  const onChange = (e, { notifyExternal }) => {
23794
24393
  if (groupIsRenderingRef.current) {
23795
- pendingChangeRef.current = true;
24394
+ // Held until the layout effect below, WITH what it asked for: a child
24395
+ // whose bound signal was written from the outside changes during the
24396
+ // render that follows, and replaying that as a mount sync is what
24397
+ // makes a group silently drift — its own state comes up to date while
24398
+ // the form around it is never told anything moved. A real change
24399
+ // deferred alongside a mount sync stays a real change.
24400
+ const pendingChange = pendingChangeRef.current;
24401
+ pendingChangeRef.current = {
24402
+ e,
24403
+ notifyExternal:
24404
+ pendingChange?.notifyExternal === true ? true : notifyExternal,
24405
+ };
23796
24406
  return;
23797
24407
  }
23798
24408
  const aggChildState = resolvedAggregateChildStates(
@@ -24256,12 +24866,17 @@ const useUIGroupStateController = (
24256
24866
 
24257
24867
  useLayoutEffect(() => {
24258
24868
  groupIsRenderingRef.current = false;
24259
- if (pendingChangeRef.current) {
24260
- pendingChangeRef.current = false;
24261
- scope._onChange(
24262
- new CustomEvent(`${controlType}_batched_ui_state_update`),
24263
- { notifyExternal: "silent" },
24869
+ const pendingChange = pendingChangeRef.current;
24870
+ if (pendingChange) {
24871
+ pendingChangeRef.current = null;
24872
+ const batchedEvent = new CustomEvent(
24873
+ `${controlType}_batched_ui_state_update`,
24874
+ { detail: {} },
24264
24875
  );
24876
+ chainEvent(batchedEvent, pendingChange.e);
24877
+ scope._onChange(batchedEvent, {
24878
+ notifyExternal: pendingChange.notifyExternal,
24879
+ });
24265
24880
  }
24266
24881
  });
24267
24882
 
@@ -24322,20 +24937,6 @@ const useUIFacadeStateController = (props, realUIStateController) => {
24322
24937
  // not be treated as the picker's synced child.
24323
24938
  return false;
24324
24939
  }
24325
- if (props.type === "form" && childController.controlType !== "form") {
24326
- // Only a form: what a type="form" picker syncs with is the form in
24327
- // its popup, not any control that happens to be in there (an input
24328
- // sitting outside the form, a ControlGroup — which is a way of
24329
- // grouping controls INSIDE a form, not a thing a picker talks to).
24330
- return false;
24331
- }
24332
- if (
24333
- props.type === "array" &&
24334
- childController.controlType !== "checkbox_group"
24335
- ) {
24336
- // only selectable list expose array, ignore others
24337
- return false;
24338
- }
24339
24940
  return true;
24340
24941
  };
24341
24942
 
@@ -24355,7 +24956,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
24355
24956
  `[navi] a second control ("${childType}"${child.name ? ` name="${child.name}"` : ""}) registered in the ${describePicker(props)} popup. ` +
24356
24957
  `A picker talks to ONE control: the first one receives the picker's whole value and is the only one read back, ` +
24357
24958
  `so this one is neither filled nor collected. ` +
24358
- `A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="form".`,
24959
+ `A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="object".`,
24359
24960
  child,
24360
24961
  );
24361
24962
  } else {
@@ -28217,15 +28818,26 @@ const css$V = /* css */`
28217
28818
 
28218
28819
  Capping the *size* here rather than only offsetting the position is
28219
28820
  what makes a centered dialog follow the mobile virtual keyboard for
28220
- free: --navi-vvw/--navi-vvh track the visual viewport, so the browser
28221
- reflows the dialog itself as the keyboard opens. */
28222
- --x-dialog-container-spacing: 3vvw;
28223
-
28821
+ free: --navi-app-width/--navi-app-height track the visual viewport, so
28822
+ the browser reflows the dialog itself as the keyboard opens.
28823
+
28824
+ A share of the app's own screen, not of the window (hence
28825
+ --navi-app-width rather than 3vvw): the gap must read as a small
28826
+ margin around the dialog, and 3% of a 1500px window is a 45px gap
28827
+ around a 600px app. Identical to 3vvw until the app declares
28828
+ --navi-app-max-width. */
28829
+ --x-dialog-container-spacing: calc(0.03 * var(--navi-app-width));
28830
+
28831
+ /* --navi-app-width, not --navi-vvw: a top-layer dialog is calibrated on
28832
+ the app's own screen, which is the viewport unless the app declared a
28833
+ narrower one (see navi_css_vars.js). An app-width cap alone never
28834
+ costs the gap below — it is subtracted from whichever of the two ends
28835
+ up smaller. */
28224
28836
  --dialog-maxmax-width: calc(
28225
- var(--navi-vvw) - 2 * var(--x-dialog-container-spacing)
28837
+ var(--navi-app-width) - 2 * var(--x-dialog-container-spacing)
28226
28838
  );
28227
28839
  --dialog-maxmax-height: calc(
28228
- var(--navi-vvh) - 2 * var(--x-dialog-container-spacing)
28840
+ var(--navi-app-height) - 2 * var(--x-dialog-container-spacing)
28229
28841
  );
28230
28842
 
28231
28843
  --dialog-border-radius: var(--navi-popup-border-radius);
@@ -28584,13 +29196,15 @@ const css$V = /* css */`
28584
29196
  * touch device.
28585
29197
  * @param {boolean} [props.expandY] - Same, vertically
28586
29198
  * (`--dialog-maxmax-height`).
28587
- * @param {string|number} [props.marginWithContainer="3vvw"] - Minimum gap kept
29199
+ * @param {string|number} [props.marginWithContainer="3appw"] - Minimum gap kept
28588
29200
  * between the dialog and the edges of its container, whatever its
28589
29201
  * `positionArea`: it both caps the dialog's own size (via
28590
29202
  * `--x-dialog-container-spacing`, written from this prop) and offsets a docked
28591
29203
  * one from the edge it docks to. Accepts a spacing token ("s", "m"…), a
28592
- * number of pixels, or a viewport length — "vvw"/"vvh" being the visual
28593
- * viewport, which shrinks when the mobile keyboard opens. Pass 0 for a dialog
29204
+ * number of pixels, or a viewport length — "appw"/"apph" being the app's own
29205
+ * screen (the visual viewport, or the narrower one the app declared with
29206
+ * --navi-app-max-width) and "vvw"/"vvh" the visual viewport itself, which
29207
+ * shrinks when the mobile keyboard opens. Pass 0 for a dialog
28594
29208
  * meant to sit flush (a side panel).
28595
29209
  * @param {"close"|"cancel"|"capture"|"none"} [props.pointerInteractionOutsideEffect="close"]
28596
29210
  * - `"close"` closes the dialog on an outside click. `"capture"`/`"none"`
@@ -28891,11 +29505,12 @@ const useDialogProps = props => {
28891
29505
  const isDocked = dockedOnTouch && coarsePointerSignal.value;
28892
29506
  const positionArea = positionAreaProp ?? (isDocked ? DOCKED.positionArea : "center");
28893
29507
  const marginWithContainer = marginWithContainerProp ?? (isDocked ? DOCKED.marginWithContainer :
28894
- // A share of whatever holds the dialog: the viewport for a top-layer
28895
- // one — where vvw is exactly "3% of the container", the container being
28896
- // the viewport and the positioned ancestor for a local one, where
28897
- // reading 3% of the viewport gives an absurd gap inside a small box.
28898
- isModal ? "3vvw" : "3cqw");
29508
+ // A share of whatever holds the dialog: the app's own screen for a
29509
+ // top-layer one — where appw is exactly "3% of the container", the
29510
+ // container being that screen (the viewport, unless the app declared a
29511
+ // narrower one) and the positioned ancestor for a local one, where
29512
+ // reading 3% of the screen gives an absurd gap inside a small box.
29513
+ isModal ? "3appw" : "3cqw");
28899
29514
  // "expand || expandX", the shorthand semantics Popup used to apply before
28900
29515
  // handing them over — the docked default only applies when neither was said
28901
29516
  const expandXUnset = expand === undefined && expandXProp === undefined;
@@ -29132,7 +29747,7 @@ const useDialogProps = props => {
29132
29747
  // A value only CSS could evaluate (a spacing token resolving to a var(),
29133
29748
  // a percentage…) — the placement below needs a real number, and letting
29134
29749
  // it through would put the dialog at NaN.
29135
- console.warn(`Dialog: marginWithContainer="${marginWithContainer}" cannot be resolved to pixels. Use a number, a viewport length ("3vvw", "2vvh") or a container length ("3cqw", "2cqh").`);
29750
+ console.warn(`Dialog: marginWithContainer="${marginWithContainer}" cannot be resolved to pixels. Use a number, a viewport length ("3appw", "3vvw", "2vvh") or a container length ("3cqw", "2cqh").`);
29136
29751
  marginWithContainerInPixels = 0;
29137
29752
  }
29138
29753
  // The size caps read the same gap in CSS as the placement below applies
@@ -29559,8 +30174,10 @@ const css$U = /* css */`
29559
30174
  rather than a value so an outer component can bridge its own prop into
29560
30175
  --popover-max-height without having to restate 300px (see picker). */
29561
30176
  --popover-max-height-default: 300px;
29562
- --popover-maxmax-height: calc(0.95 * var(--navi-vvh));
29563
- --popover-maxmax-width: calc(0.95 * var(--navi-vvw));
30177
+ /* --navi-app-*, not --navi-vvw/vvh: the app's own screen, which is the
30178
+ viewport unless the app declared a narrower one (navi_css_vars.js). */
30179
+ --popover-maxmax-height: calc(0.95 * var(--navi-app-height));
30180
+ --popover-maxmax-width: calc(0.95 * var(--navi-app-width));
29564
30181
 
29565
30182
  --popover-box-shadow: var(--navi-popup-box-shadow);
29566
30183
  --popover-border-radius: var(--navi-popup-border-radius);
@@ -32346,6 +32963,12 @@ const defaultRerunOn = {
32346
32963
  GET: false,
32347
32964
  GET_MANY: ["POST"],
32348
32965
  };
32966
+ // What makes a range reader stale (rerunOn.GET_RANGE overrides it). DELETE is
32967
+ // in there, unlike for GET_MANY: an action holds ids and the store drops the
32968
+ // deleted one out of every list holding it, while a list reading by slices
32969
+ // holds places — the row that left takes the ones after it one rank up, and
32970
+ // only the collection knows who fills the last one.
32971
+ const defaultInvalidateRangeOn = ["POST", "DELETE"];
32349
32972
 
32350
32973
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
32351
32974
  const createResourceLifecycleManager = () => {
@@ -32366,6 +32989,7 @@ const createResourceLifecycleManager = () => {
32366
32989
  paramScope,
32367
32990
  uniqueKeys,
32368
32991
  restActionSet: new Set(),
32992
+ rangeReaderSet: new Set(),
32369
32993
  });
32370
32994
 
32371
32995
  // Register dependencies
@@ -32378,6 +33002,14 @@ const createResourceLifecycleManager = () => {
32378
33002
  }
32379
33003
  }
32380
33004
  };
33005
+ // Only the reader the resource exposes is registered; the ones `bindParams`
33006
+ // makes share its signal (see resource_range_reader.js).
33007
+ const registerRangeReader = (resourceScope, rangeReader) => {
33008
+ const config = registeredResources.get(resourceScope);
33009
+ if (config) {
33010
+ config.rangeReaderSet.add(rangeReader);
33011
+ }
33012
+ };
32381
33013
  const registerAction = (resourceScope, restAction) => {
32382
33014
  const config = registeredResources.get(resourceScope);
32383
33015
  if (config) {
@@ -32608,7 +33240,39 @@ const createResourceLifecycleManager = () => {
32608
33240
  };
32609
33241
  };
32610
33242
 
33243
+ // Which readers hold slices of a collection the mutation just changed: the
33244
+ // one of the resource itself, and the ones of the resources that said they
33245
+ // depend on it.
33246
+ const invalidateRangeReaders = (
33247
+ triggeringAction,
33248
+ triggeringActionContext,
33249
+ ) => {
33250
+ const triggerVerb = triggeringAction.meta.verb;
33251
+ const triggerResourceScope = triggeringActionContext.resourceScope;
33252
+ for (const [resourceScope, config] of registeredResources) {
33253
+ if (config.rangeReaderSet.size === 0) {
33254
+ continue;
33255
+ }
33256
+ const isSameResource = resourceScope === triggerResourceScope;
33257
+ const isDependent = Boolean(
33258
+ triggerResourceScope &&
33259
+ resourceDependencies.get(triggerResourceScope)?.has(resourceScope),
33260
+ );
33261
+ if (!isSameResource && !isDependent) {
33262
+ continue;
33263
+ }
33264
+ const invalidateOn = config.rerunOn.GET_RANGE ?? defaultInvalidateRangeOn;
33265
+ if (!shouldRerunAfter(invalidateOn, triggerVerb)) {
33266
+ continue;
33267
+ }
33268
+ for (const rangeReader of config.rangeReaderSet) {
33269
+ rangeReader.invalidate();
33270
+ }
33271
+ }
33272
+ };
33273
+
32611
33274
  const onActionComplete = (restActionWhoJustCompleted, restActionContext) => {
33275
+ invalidateRangeReaders(restActionWhoJustCompleted, restActionContext);
32612
33276
  const { actionsToRerun, actionsToReset, reasons } = findEffectOnActions(
32613
33277
  restActionWhoJustCompleted,
32614
33278
  restActionContext,
@@ -32627,6 +33291,7 @@ const createResourceLifecycleManager = () => {
32627
33291
  return {
32628
33292
  registerResource,
32629
33293
  registerAction,
33294
+ registerRangeReader,
32630
33295
  onActionComplete,
32631
33296
  // Registers: when `triggerResource` fires, rerun `dependentResource`'s actions.
32632
33297
  // Used by scopedMany to make the parent GET rerun when a child mutation completes.
@@ -32704,13 +33369,20 @@ const getParamScope = (params) => {
32704
33369
  *
32705
33370
  * The reader is a function, so a list feeds on it the way it feeds on any other
32706
33371
  * source: `itemsAction={GAME.GET_RANGE.bindParams({ radar })}`.
33372
+ *
33373
+ * Keeping nothing does not mean hearing nothing: a mutation that decides who
33374
+ * belongs to the collection (a POST, a DELETE, whatever `rerunOn.GET_RANGE`
33375
+ * says) bumps `invalidationSignal`, and whoever reads slices through it goes
33376
+ * and asks again — the counterpart, for a reader, of what a rerun is for an
33377
+ * action. Every reader made by `bindParams` shares the signal of the one it
33378
+ * comes from: the params say which slices are read, not which collection.
32707
33379
  */
32708
33380
 
32709
33381
 
32710
33382
  const createRangeReader = (
32711
33383
  actionName,
32712
33384
  callback,
32713
- { store, params: boundParams },
33385
+ { store, params: boundParams, invalidationSignal = signal(0) },
32714
33386
  ) => {
32715
33387
  const readRange = async (range = {}) => {
32716
33388
  const { signal, ...rangeParams } = range;
@@ -32739,10 +33411,17 @@ const createRangeReader = (
32739
33411
  };
32740
33412
  Object.defineProperty(readRange, "name", { value: actionName });
32741
33413
  readRange.isRangeReader = true;
33414
+ // Bumped when the collection this reads has moved: the slices anyone holds
33415
+ // stand for a composition that is gone.
33416
+ readRange.invalidationSignal = invalidationSignal;
33417
+ readRange.invalidate = () => {
33418
+ invalidationSignal.value = invalidationSignal.peek() + 1;
33419
+ };
32742
33420
  readRange.bindParams = (paramsToBind) => {
32743
33421
  return createRangeReader(actionName, callback, {
32744
33422
  store,
32745
33423
  params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
33424
+ invalidationSignal,
32746
33425
  });
32747
33426
  };
32748
33427
  return readRange;
@@ -32809,9 +33488,11 @@ const debug$2 = (args) => {
32809
33488
  * - GET_MANY / POST_MANY / … → an array of item objects
32810
33489
  * - GET_RANGE → `{ items, start, count }`, one slice of the collection
32811
33490
  *
32812
- * `GET_RANGE` is a reader rather than an action: it keeps no value and takes no place in
32813
- * the rerun graph, so a `<List.Items>` can feed on it slice by slice
32814
- * (`itemsAction={USER.GET_RANGE.bindParams({ team })}`).
33491
+ * `GET_RANGE` is a reader rather than an action: it keeps no value and has nothing to
33492
+ * rerun, so a `<List.Items>` can feed on it slice by slice
33493
+ * (`itemsAction={USER.GET_RANGE.bindParams({ team })}`). A mutation listed in
33494
+ * `rerunOn.GET_RANGE` (`["POST", "DELETE"]` by default) tells it the collection moved,
33495
+ * and whoever holds slices reads them again.
32815
33496
  *
32816
33497
  * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
32817
33498
  * relationship method, never as an `op`/`type` discriminator dispatched inside one
@@ -33991,13 +34672,18 @@ ${originalActionName} source location: ${locationInfo}`,
33991
34672
  continue;
33992
34673
  }
33993
34674
  if (restCallbackKey === "GET_RANGE") {
33994
- // A range is read, never kept: no action, no place in the rerun graph
34675
+ // A range is read, never kept: no action, nothing to rerun only a
34676
+ // signal saying the slices anyone holds are out of date
33995
34677
  // (see resource_range_reader.js).
33996
34678
  stateFacade.GET_RANGE = createRangeReader(
33997
34679
  `${name}.GET_RANGE`,
33998
34680
  restCallback,
33999
34681
  { store, params },
34000
34682
  );
34683
+ resourceLifecycleManager.registerRangeReader(
34684
+ stateFacade,
34685
+ stateFacade.GET_RANGE,
34686
+ );
34001
34687
  continue;
34002
34688
  }
34003
34689
  const isMany = restCallbackKey.endsWith("_MANY");
@@ -35970,701 +36656,143 @@ const UITransition = ({
35970
36656
  alignX,
35971
36657
  alignY,
35972
36658
  ...props
35973
- }) => {
35974
- const contentIdRef = useRef(contentId);
35975
- const updateContentId = () => {
35976
- const uiTransition = uiTransitionRef.current;
35977
- if (!uiTransition) {
35978
- return;
35979
- }
35980
- const value = contentIdRef.current;
35981
- uiTransition.updateContentId(value);
35982
- };
35983
- const uiTransitionContentIdContextValue = useMemo(() => {
35984
- const set = new Set();
35985
- const onSetChange = () => {
35986
- const value = Array.from(set).join("|");
35987
- contentIdRef.current = value;
35988
- updateContentId();
35989
- };
35990
- const update = (part, newPart) => {
35991
- if (!set.has(part)) {
35992
- if (set.size === 0) {
35993
- console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id set is empty`);
35994
- return;
35995
- }
35996
- console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id not found in set, only got [${Array.from(set).join(", ")}]`);
35997
- return;
35998
- }
35999
- set.delete(part);
36000
- set.add(newPart);
36001
- onSetChange();
36002
- };
36003
- const add = part => {
36004
- if (!part) {
36005
- return;
36006
- }
36007
- if (set.has(part)) {
36008
- return;
36009
- }
36010
- set.add(part);
36011
- onSetChange();
36012
- };
36013
- const remove = part => {
36014
- if (!part) {
36015
- return;
36016
- }
36017
- if (!set.has(part)) {
36018
- return;
36019
- }
36020
- set.delete(part);
36021
- onSetChange();
36022
- };
36023
- return {
36024
- add,
36025
- update,
36026
- remove
36027
- };
36028
- }, []);
36029
- const ref = useRef();
36030
- const uiTransitionRefDefault = useRef();
36031
- uiTransitionRef = uiTransitionRef || uiTransitionRefDefault;
36032
- useLayoutEffect(() => {
36033
- const uiTransition = createUITransitionController(ref.current, {
36034
- alignX,
36035
- alignY
36036
- });
36037
- uiTransitionRef.current = uiTransition;
36038
- return () => {
36039
- uiTransition.cleanup();
36040
- };
36041
- }, [disabled, alignX, alignY]);
36042
- return jsxs("div", {
36043
- ref: ref,
36044
- ...props,
36045
- className: "ui_transition",
36046
- "data-disabled": disabled ? "" : undefined,
36047
- "data-transition-type": type,
36048
- "data-transition-duration": duration,
36049
- "data-debug-detection": debugDetection ? "" : undefined,
36050
- "data-debug-size": debugSize ? "" : undefined,
36051
- "data-debug-content": debugContent ? "" : undefined,
36052
- children: [jsxs("div", {
36053
- className: "ui_transition_active_group",
36054
- children: [jsx("div", {
36055
- className: "ui_transition_target_slot",
36056
- "data-content-id": contentIdRef.current ? contentIdRef.current : undefined,
36057
- children: jsx(UITransitionContentIdContext.Provider, {
36058
- value: uiTransitionContentIdContextValue,
36059
- children: children
36060
- })
36061
- }), jsx("div", {
36062
- className: "ui_transition_outgoing_slot",
36063
- inert: true
36064
- })]
36065
- }), jsxs("div", {
36066
- className: "ui_transition_previous_group",
36067
- inert: true,
36068
- children: [jsx("div", {
36069
- className: "ui_transition_previous_target_slot"
36070
- }), jsx("div", {
36071
- className: "ui_transition_previous_outgoing_slot"
36072
- })]
36073
- })]
36074
- });
36075
- };
36076
-
36077
- /**
36078
- * The goal of this hook is to allow a component to set a "content key"
36079
- * Meaning all content within the component is identified by that key
36080
- *
36081
- * When the key changes, UITransition will be able to detect that and consider the content
36082
- * as changed even if the component is still the same
36083
- *
36084
- * This is used by <Route> to set the content key to the route path
36085
- * When the route becomes inactive it will call useUITransitionContentId(undefined)
36086
- * And if a sibling route becones active it will call useUITransitionContentId with its own path
36087
- *
36088
- */
36089
- const useUITransitionContentId = value => {
36090
- const contentId = useContext(UITransitionContentIdContext);
36091
- const valueRef = useRef();
36092
- if (contentId !== undefined && valueRef.current !== value) {
36093
- const previousValue = valueRef.current;
36094
- valueRef.current = value;
36095
- if (previousValue === undefined) {
36096
- contentId.add(value);
36097
- } else {
36098
- contentId.update(previousValue, value);
36099
- }
36100
- }
36101
- useLayoutEffect(() => {
36102
- if (contentId === undefined) {
36103
- return null;
36104
- }
36105
- return () => {
36106
- contentId.remove(valueRef.current);
36107
- };
36108
- }, []);
36109
- };
36110
-
36111
- const documentStateSignal = signal(null);
36112
- const useDocumentState = () => {
36113
- return documentStateSignal.value;
36114
- };
36115
- const updateDocumentState = (value) => {
36116
- documentStateSignal.value = value;
36117
- };
36118
-
36119
- /**
36120
- * A navigation is ABOUT to be applied — said before its very first write.
36121
- *
36122
- * Everything else a router says arrives once the change is made: a route
36123
- * announces that it matches, an action that it is running. That is too late for
36124
- * anyone who needs the page as it stands BEFORE, and the browser's view
36125
- * transitions are exactly that kind of reader — the picture they keep of the
36126
- * page being left is taken at the next frame, and a render answering a signal
36127
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
36128
- *
36129
- * So this is the one moment where nothing has moved yet. It is published
36130
- * synchronously, from the top of the navigation, and whoever listens runs
36131
- * before the URL, the visited set, or any route has changed.
36132
- *
36133
- * The other end is published too, and for the same kind of reader: whoever
36134
- * held something across the change and has nobody to hand it to gets a moment
36135
- * to let go of it that does not depend on guessing how long the change takes.
36136
- */
36137
-
36138
-
36139
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
36140
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
36141
-
36142
- const setupBrowserIntegrationViaHistory = ({
36143
- applyActions,
36144
- applyRouting,
36145
- isRouting,
36146
- }) => {
36147
- const { history } = window;
36148
-
36149
- let globalAbortController = new AbortController();
36150
- const triggerGlobalAbort = (reason) => {
36151
- globalAbortController.abort(reason);
36152
- globalAbortController = new AbortController();
36153
- };
36154
-
36155
- const dispatchActions = (params) => {
36156
- const { requestedResult } = applyActions({
36157
- globalAbortSignal: globalAbortController.signal,
36158
- abortSignal: new AbortController().signal,
36159
- ...params,
36160
- });
36161
- return requestedResult;
36162
- };
36163
- setActionDispatcher(dispatchActions);
36164
-
36165
- const getDocumentState = () => {
36166
- return window.history.state ? { ...window.history.state } : null;
36167
- };
36168
-
36169
- const historyStartAtStart = getDocumentState();
36170
- const visitedUrlSet = historyStartAtStart
36171
- ? new Set(historyStartAtStart.jsenv_visited_urls || [])
36172
- : new Set();
36173
-
36174
- // Create a signal that tracks visited URLs for reactive updates
36175
- // Using a counter instead of the Set directly for better performance
36176
- // Links will check isVisited() when this signal changes
36177
- const visitedUrlsSignal = signal(0);
36178
-
36179
- const isVisited = (url) => {
36180
- url = new URL(url, window.location.href).href;
36181
- return visitedUrlSet.has(url);
36182
- };
36183
- const markUrlAsVisited = (url) => {
36184
- if (visitedUrlSet.has(url)) {
36185
- return;
36186
- }
36187
- visitedUrlSet.add(url);
36188
- visitedUrlsSignal.value++;
36189
- };
36190
-
36191
- let abortController = null;
36192
- const handleRoutingTask = (url, options) => {
36193
- // Before anything is written: the visited set, the URL and every route are
36194
- // about to change, and this is the last moment the page still stands as it
36195
- // was. And after, whichever way the change went out — so that whoever took
36196
- // something at the first announcement has a definite place to give it back.
36197
- publishBeforeRouting({ url, ...options });
36198
- try {
36199
- return applyRoutingTask(url, options);
36200
- } finally {
36201
- publishAfterRouting({ url, ...options });
36202
- }
36203
- };
36204
-
36205
- const applyRoutingTask = (url, options) => {
36206
- const isSameUrl = url === window.location.href;
36207
- const {
36208
- reason,
36209
- navigationType, // "load", "reload", "replace", "push", "traverse"
36210
- state,
36211
- } = options;
36212
-
36213
- if (navigationType === "push" || navigationType === "replace") {
36214
- markUrlAsVisited(url);
36215
- // undefined → inherit current state (link click, neutral navigation)
36216
- // null → explicit reset (no nav-state keys carried over)
36217
- // {...} → explicit state from enter()/leave(), already built from currentState
36218
- // When state is given it's responsability of the caller to ensure it inherits document state (or not, you want it 99% of the time)
36219
- let effectiveState;
36220
- const sharedState = {
36221
- jsenv_visited_urls: Array.from(visitedUrlSet),
36222
- };
36223
- if (state === undefined) {
36224
- effectiveState = {
36225
- ...(getDocumentState() || {}),
36226
- ...sharedState,
36227
- };
36228
- } else if (state === null) {
36229
- effectiveState = sharedState;
36230
- } else if (state) {
36231
- effectiveState = {
36232
- ...state,
36233
- ...sharedState,
36234
- };
36235
- }
36236
- if (navigationType === "push") {
36237
- window.history.pushState(effectiveState, null, url);
36238
- } else {
36239
- window.history.replaceState(effectiveState, null, url);
36240
- }
36241
- updateDocumentUrl(url);
36242
- updateDocumentState(effectiveState);
36243
- } else {
36244
- // traverse / reload: state comes from the history entry, no push/replace needed.
36245
- markUrlAsVisited(url);
36246
- updateDocumentUrl(url);
36247
- updateDocumentState(state);
36248
- }
36249
-
36250
- // Skip route matching for state-only changes: push/replace to the same URL
36251
- // (e.g. useNavState updating document state without changing the route).
36252
- // Do NOT apply for "traverse" — window.location.href is already updated by
36253
- // the browser before the popstate handler runs, so isSameUrl is always true
36254
- // for back/forward navigation regardless of whether the URL actually changed.
36255
- if (
36256
- isSameUrl &&
36257
- (navigationType === "push" || navigationType === "replace")
36258
- ) {
36259
- return undefined;
36260
- }
36261
-
36262
- if (abortController) {
36263
- abortController.abort(`navigating to ${url}`);
36264
- }
36265
- abortController = new AbortController();
36266
- const abortSignal = abortController.signal;
36267
- const { allResult, requestedResult } = applyRouting(url, {
36268
- globalAbortSignal: globalAbortController.signal,
36269
- abortSignal,
36270
- reason,
36271
- navigationType,
36272
- isVisited,
36273
- state,
36274
- });
36275
- executeWithCleanup(
36276
- () => allResult,
36277
- () => {
36278
- abortController = undefined;
36279
- },
36280
- );
36281
- return requestedResult;
36659
+ }) => {
36660
+ const contentIdRef = useRef(contentId);
36661
+ const updateContentId = () => {
36662
+ const uiTransition = uiTransitionRef.current;
36663
+ if (!uiTransition) {
36664
+ return;
36665
+ }
36666
+ const value = contentIdRef.current;
36667
+ uiTransition.updateContentId(value);
36282
36668
  };
36283
-
36284
- // Browser event handlers
36285
- window.addEventListener(
36286
- "click",
36287
- (e) => {
36288
- if (e.button !== 0) {
36289
- // Ignore non-left clicks
36290
- return;
36291
- }
36292
- if (e.metaKey) {
36293
- // Ignore clicks with meta key (e.g. open in new tab)
36294
- return;
36295
- }
36296
- if (e.defaultPrevented) {
36297
- return;
36298
- }
36299
- const linkElement = e.target.closest("a");
36300
- if (!linkElement) {
36669
+ const uiTransitionContentIdContextValue = useMemo(() => {
36670
+ const set = new Set();
36671
+ const onSetChange = () => {
36672
+ const value = Array.from(set).join("|");
36673
+ contentIdRef.current = value;
36674
+ updateContentId();
36675
+ };
36676
+ const update = (part, newPart) => {
36677
+ if (!set.has(part)) {
36678
+ if (set.size === 0) {
36679
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id set is empty`);
36680
+ return;
36681
+ }
36682
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id not found in set, only got [${Array.from(set).join(", ")}]`);
36301
36683
  return;
36302
36684
  }
36303
- if (linkElement.hasAttribute("data-readonly")) {
36685
+ set.delete(part);
36686
+ set.add(newPart);
36687
+ onSetChange();
36688
+ };
36689
+ const add = part => {
36690
+ if (!part) {
36304
36691
  return;
36305
36692
  }
36306
- const href = linkElement.href;
36307
- const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
36308
- getHrefTargetInfo(href);
36309
- if (isEmpty || !isSameOrigin) {
36310
- // Let link to other origins be handled by the browser
36693
+ if (set.has(part)) {
36311
36694
  return;
36312
36695
  }
36313
- if (isAnchor) {
36314
- // Fragment navigation belongs to the browser: it owns the indicated
36315
- // part of the document, and taking it over would cost `:target` and the
36316
- // focus handling that come with it.
36317
- if (isCurrent) {
36318
- // Except this one, which the browser answers with a scroll and
36319
- // nothing else: same pathname, same hash, so no event and no url
36320
- // change reaches whoever is waiting on the designated element.
36321
- rearmUrlTarget();
36322
- }
36696
+ set.add(part);
36697
+ onSetChange();
36698
+ };
36699
+ const remove = part => {
36700
+ if (!part) {
36323
36701
  return;
36324
36702
  }
36325
- // Nothing here declared a route, so there is nothing to route to: the
36326
- // page is a plain document and a link in it is a plain link. Taking it
36327
- // over anyway would push the url and then have nothing to show for it —
36328
- // the address bar moves and the page does not (see applyRouting's own
36329
- // "not called yet" branch, which is where that used to end up).
36330
- if (!isRouting()) {
36703
+ if (!set.has(part)) {
36331
36704
  return;
36332
36705
  }
36333
- e.preventDefault();
36334
- handleRoutingTask(href, {
36335
- reason: `"click" on a[href="${href}"]`,
36336
- navigationType: "push",
36337
- });
36338
- },
36339
- { capture: true },
36340
- );
36341
-
36342
- window.addEventListener(
36343
- "submit",
36344
- () => {
36345
- // Handle form submissions?
36346
- // Not needed yet
36347
- },
36348
- { capture: true },
36349
- );
36350
-
36351
- window.addEventListener("popstate", (popstateEvent) => {
36352
- const url = window.location.href;
36353
- const state = popstateEvent.state;
36354
- handleRoutingTask(url, {
36355
- reason: `"popstate" event for ${url}`,
36356
- navigationType: "traverse",
36357
- state,
36358
- });
36359
- });
36360
-
36361
- // A fragment navigation is left to the browser (see the click handler above):
36362
- // it owns the indicated part of the document, and taking it over would cost
36363
- // `:target` and the focus handling that come with it. The document url still
36364
- // has to follow it — nothing else here would notice that it moved.
36365
- window.addEventListener("hashchange", () => {
36366
- updateDocumentUrl(window.location.href);
36367
- });
36368
-
36369
- const navTo = async (url, { replace, state } = {}) => {
36370
- handleRoutingTask(url, {
36371
- reason: `navTo called with "${url}"`,
36372
- navigationType: replace ? "replace" : "push",
36373
- state,
36374
- });
36375
- };
36376
-
36377
- const stop = (reason = "stop called") => {
36378
- triggerGlobalAbort(reason);
36379
- };
36380
-
36381
- const reload = () => {
36382
- const url = window.location.href;
36383
- const state = history.state;
36384
- handleRoutingTask(url, {
36385
- reason: "reload called",
36386
- navigationType: "reload",
36387
- state,
36388
- });
36389
- };
36390
-
36391
- const navBack = () => {
36392
- window.history.back();
36393
- };
36394
-
36395
- const navForward = () => {
36396
- window.history.forward();
36397
- };
36398
-
36399
- const init = () => {
36400
- const url = window.location.href;
36401
- const state = history.state;
36402
- handleRoutingTask(url, {
36403
- reason: "routing initialization",
36404
- navigationType: "load",
36405
- state,
36406
- });
36407
- };
36408
-
36409
- return {
36410
- integration: "browser_history_api",
36411
- init,
36412
- navTo,
36413
- stop,
36414
- reload,
36415
- navBack,
36416
- navForward,
36417
- getDocumentState,
36418
- isVisited,
36419
- visitedUrlsSignal,
36420
- };
36421
- };
36422
-
36423
- let updateRoutes;
36424
-
36425
- const applyActions = (params) => {
36426
- const updateActionsResult = updateActions(params);
36427
- const { allResult, runningActionSet } = updateActionsResult;
36428
- const pendingTaskNameArray = [];
36429
- for (const runningAction of runningActionSet) {
36430
- pendingTaskNameArray.push(runningAction.name);
36431
- }
36432
- workingWhile(() => allResult, pendingTaskNameArray);
36433
- return updateActionsResult;
36434
- };
36435
- const applyRouting = (
36436
- url,
36437
- {
36438
- globalAbortSignal,
36439
- abortSignal,
36440
- // state
36441
- navigationType,
36442
- isVisited,
36443
- reason,
36444
- },
36445
- ) => {
36446
- if (!updateRoutes) {
36447
- // .init() not called yet
36448
- // likely because code does not uses routing at all
36449
- return {};
36450
- }
36451
- const {
36452
- loadSet,
36453
- reloadSet,
36454
- abortSignalMap,
36455
- routeLoadRequestedMap,
36456
- activeRouteSet,
36457
- } = updateRoutes(url, {
36458
- navigationType,
36459
- isVisited,
36460
- // state,
36461
- });
36462
- if (
36463
- (!loadSet || loadSet.size === 0) &&
36464
- (!reloadSet || reloadSet.size === 0)
36465
- ) {
36706
+ set.delete(part);
36707
+ onSetChange();
36708
+ };
36466
36709
  return {
36467
- allResult: undefined,
36468
- requestedResult: undefined,
36469
- activeRouteSet: new Set(),
36710
+ add,
36711
+ update,
36712
+ remove
36470
36713
  };
36471
- }
36472
- const updateActionsResult = updateActions({
36473
- globalAbortSignal,
36474
- abortSignal,
36475
- runSet: loadSet,
36476
- rerunSet: reloadSet,
36477
- abortSignalMap,
36478
- reason,
36479
- isReplace: navigationType === "replace",
36480
- });
36481
- const { allResult, runningActionSet } = updateActionsResult;
36482
- const pendingTaskNameArray = [];
36483
- for (const [route, routeAction] of routeLoadRequestedMap) {
36484
- if (runningActionSet.has(routeAction)) {
36485
- pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
36486
- }
36487
- }
36488
- routingWhile(() => allResult, pendingTaskNameArray);
36489
- return { ...updateActionsResult, activeRouteSet };
36490
- };
36491
-
36492
- const browserIntegration = setupBrowserIntegrationViaHistory({
36493
- applyActions,
36494
- applyRouting,
36495
- // Routes are declared by the consumer and registered through
36496
- // setOnAllRouteReady below, so "does this document route at all?" is only
36497
- // answerable once that has run — hence a function, read at click time rather
36498
- // than a value read at setup time.
36499
- isRouting: () => Boolean(updateRoutes),
36500
- });
36501
-
36502
- setOnAllRouteReady((v) => {
36503
- updateRoutes = v;
36504
- browserIntegration.init();
36505
- });
36506
- setRouteIntegration(browserIntegration);
36507
-
36508
- const navIntegratedVia = browserIntegration.integration;
36509
- const navTo = (target, options) => {
36510
- const url = new URL(target, window.location.href).href;
36511
- const currentUrl = documentUrlSignal.peek();
36512
- if (url === currentUrl) {
36513
- if (options?.state === undefined) {
36514
- return null;
36515
- }
36516
- // State-only update on same URL: skip if state is identical to current.
36517
- const currentState = browserIntegration.getDocumentState();
36518
- if (compareTwoJsValues(options.state, currentState)) {
36519
- return null;
36520
- }
36521
- }
36522
- return browserIntegration.navTo(url, options);
36523
- };
36524
- const stopLoad = (reason = "stopLoad() called") => {
36525
- const windowIsLoading = windowIsLoadingSignal.value;
36526
- if (windowIsLoading) {
36527
- window.stop();
36528
- }
36529
- const documentIsBusy = documentIsBusySignal.value;
36530
- if (documentIsBusy) {
36531
- browserIntegration.stop(reason);
36532
- }
36533
- };
36534
- const reload = browserIntegration.reload;
36535
- const navBack = browserIntegration.navBack;
36536
- const navForward = browserIntegration.navForward;
36537
- const isVisited = browserIntegration.isVisited;
36538
- const visitedUrlsSignal = browserIntegration.visitedUrlsSignal;
36539
- browserIntegration.handleActionTask;
36540
-
36541
- // Preact's own useId() (see preact/hooks) returns "P<mask0>-<mask1>", where
36542
- // the mask is derived from render order within the nearest root/async
36543
- // boundary — stable across re-renders of the *same* mount, but not across a
36544
- // reload (render order can differ) or even across two mounts on the same
36545
- // page (two components hitting useId() in the same relative order get the
36546
- // same string). Storing one of these under type: "push" bakes it into a
36547
- // history entry: reload the page and the entry's key may now belong to a
36548
- // completely different component (or none), silently auto-opening whatever
36549
- // happens to render at that same position instead.
36550
- const PREACT_GENERATED_ID_REGEX = /^P\d+-\d+/;
36551
- const isLikelyPreactGeneratedId = (id) => PREACT_GENERATED_ID_REGEX.test(id);
36552
-
36553
- const NO_OP = () => {};
36554
- const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
36555
- const useNavStateBasic = (
36556
- id,
36557
- { debug, type = "replace", onLeave, defaultValue } = {},
36558
- ) => {
36559
- // Hooks must be called unconditionally — before the !id early return.
36560
- const state = documentStateSignal.value;
36561
- // Key presence is the flag — the value may be anything, including undefined.
36562
- const keyInState = Boolean(id && state && Object.hasOwn(state, id));
36563
- const onLeaveRef = useRef(onLeave);
36564
- onLeaveRef.current = onLeave;
36565
- const prevKeyInStateRef = useRef(keyInState);
36566
- // enteredRef tracks whether enter() was called without a matching leave() yet.
36567
- // It lets the effect distinguish an external disappearance (back button → fire onLeave)
36568
- // from a programmatic one (leave() already set it to false before the state updates).
36569
- const enteredRef = useRef(false);
36570
- useEffect(() => {
36571
- const prevKeyInState = prevKeyInStateRef.current;
36572
- prevKeyInStateRef.current = keyInState;
36573
- if (prevKeyInState && !keyInState && enteredRef.current) {
36574
- enteredRef.current = false;
36575
- onLeaveRef.current?.();
36576
- }
36577
- }, [keyInState]);
36578
-
36579
- if (!id) {
36580
- return NO_ID_GIVEN;
36581
- }
36582
-
36583
- let effectiveType = type;
36584
- if (type === "push" && isLikelyPreactGeneratedId(id)) {
36585
- effectiveType = "replace";
36586
- }
36587
-
36588
- const currentValue = keyInState ? state[id] : defaultValue;
36589
-
36590
- if (debug) {
36591
- console.debug(`useNavState(${id}) current value is ${currentValue}`);
36592
- }
36593
-
36594
- // enter(value): navigate TO this state (push or replace depending on type).
36595
- // Calling enter() without a value stores "on" — the mere presence of the key
36596
- // in the document state is enough to match; the value just allows associating
36597
- // extra data with the entry when needed.
36598
- const enter = (value = "on") => {
36599
- enteredRef.current = true;
36600
- const currentStateCopy = browserIntegration.getDocumentState() || {};
36601
- if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
36602
- return;
36603
- }
36604
- currentStateCopy[id] = value;
36605
- navTo(window.location.href, {
36606
- replace: effectiveType !== "push",
36607
- state: currentStateCopy,
36714
+ }, []);
36715
+ const ref = useRef();
36716
+ const uiTransitionRefDefault = useRef();
36717
+ uiTransitionRef = uiTransitionRef || uiTransitionRefDefault;
36718
+ useLayoutEffect(() => {
36719
+ const uiTransition = createUITransitionController(ref.current, {
36720
+ alignX,
36721
+ alignY
36608
36722
  });
36609
- };
36610
-
36611
- // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
36612
- // isBack: when true (cancel close in push mode), call history.back() to restore the
36613
- // pre-open state discards any in-progress edits.
36614
- // When false (confirmed close), replace the pushed entry instead: preserves the
36615
- // current URL state (e.g. a new picker value) while removing the popup key.
36616
- const leave = ({ isBack } = {}) => {
36617
- enteredRef.current = false;
36618
- const currentStateCopy = browserIntegration.getDocumentState() || {};
36619
- if (!Object.hasOwn(currentStateCopy, id)) {
36620
- return;
36621
- }
36622
- if (effectiveType === "push" && isBack) {
36623
- browserIntegration.navBack();
36624
- } else {
36625
- delete currentStateCopy[id];
36626
- navTo(window.location.href, {
36627
- replace: true,
36628
- state: currentStateCopy,
36629
- });
36630
- }
36631
- };
36632
-
36633
- return [currentValue, enter, leave];
36723
+ uiTransitionRef.current = uiTransition;
36724
+ return () => {
36725
+ uiTransition.cleanup();
36726
+ };
36727
+ }, [disabled, alignX, alignY]);
36728
+ return jsxs("div", {
36729
+ ref: ref,
36730
+ ...props,
36731
+ className: "ui_transition",
36732
+ "data-disabled": disabled ? "" : undefined,
36733
+ "data-transition-type": type,
36734
+ "data-transition-duration": duration,
36735
+ "data-debug-detection": debugDetection ? "" : undefined,
36736
+ "data-debug-size": debugSize ? "" : undefined,
36737
+ "data-debug-content": debugContent ? "" : undefined,
36738
+ children: [jsxs("div", {
36739
+ className: "ui_transition_active_group",
36740
+ children: [jsx("div", {
36741
+ className: "ui_transition_target_slot",
36742
+ "data-content-id": contentIdRef.current ? contentIdRef.current : undefined,
36743
+ children: jsx(UITransitionContentIdContext.Provider, {
36744
+ value: uiTransitionContentIdContextValue,
36745
+ children: children
36746
+ })
36747
+ }), jsx("div", {
36748
+ className: "ui_transition_outgoing_slot",
36749
+ inert: true
36750
+ })]
36751
+ }), jsxs("div", {
36752
+ className: "ui_transition_previous_group",
36753
+ inert: true,
36754
+ children: [jsx("div", {
36755
+ className: "ui_transition_previous_target_slot"
36756
+ }), jsx("div", {
36757
+ className: "ui_transition_previous_outgoing_slot"
36758
+ })]
36759
+ })]
36760
+ });
36634
36761
  };
36635
36762
 
36636
36763
  /**
36637
- * Stores a named value in the browser's document state and returns it reactively.
36638
- * The component re-renders whenever the value changes (navigation, back/forward button).
36764
+ * The goal of this hook is to allow a component to set a "content key"
36765
+ * Meaning all content within the component is identified by that key
36639
36766
  *
36640
- * @param {string} id
36641
- * Unique key used to store the value in document state. Must be stable across renders.
36767
+ * When the key changes, UITransition will be able to detect that and consider the content
36768
+ * as changed even if the component is still the same
36642
36769
  *
36643
- * @param {object} [options]
36644
- * @param {"push"|"replace"} [options.type="replace"]
36645
- * Controls how enter() adds the state to browser history.
36646
- * - "push": creates a new history entry — pressing the back button removes it and calls onLeave.
36647
- * - "replace": updates the current history entry — no extra history entry is created.
36648
- * Silently downgraded to "replace" (with a dev-only console.warn) when `id`
36649
- * looks auto-generated (e.g. preact's own useId()) — an unstable id baked
36650
- * into a pushed history entry won't survive a reload correctly, and could
36651
- * even collide with a different component's own auto-generated id. Pass a
36652
- * stable, explicit id to actually get "push" behavior.
36653
- * @param {() => void} [options.onLeave]
36654
- * Called when the state key disappears **externally** — e.g. the user presses the browser
36655
- * back button. Not called when leave() is invoked programmatically.
36656
- * @param {*} [options.defaultValue]
36657
- * Value returned when `id` is absent from document state. Defaults to `undefined`.
36770
+ * This is used by <Route> to set the content key to the route path
36771
+ * When the route becomes inactive it will call useUITransitionContentId(undefined)
36772
+ * And if a sibling route becones active it will call useUITransitionContentId with its own path
36658
36773
  *
36659
- * @returns {[value, enter, leave]}
36660
- * - `value`: current value from document state, or `defaultValue` when the key is absent.
36661
- * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
36662
- * Calling without an argument stores `"on"` — the presence of the key is enough to match;
36663
- * the value allows associating extra data when needed.
36664
- * - `leave()`: navigate AWAY FROM this state (removes `id` from document state,
36665
- * or goes back in history when `type` is "push").
36666
36774
  */
36667
- const useNavState = useNavStateBasic;
36775
+ const useUITransitionContentId = value => {
36776
+ const contentId = useContext(UITransitionContentIdContext);
36777
+ const valueRef = useRef();
36778
+ if (contentId !== undefined && valueRef.current !== value) {
36779
+ const previousValue = valueRef.current;
36780
+ valueRef.current = value;
36781
+ if (previousValue === undefined) {
36782
+ contentId.add(value);
36783
+ } else {
36784
+ contentId.update(previousValue, value);
36785
+ }
36786
+ }
36787
+ useLayoutEffect(() => {
36788
+ if (contentId === undefined) {
36789
+ return null;
36790
+ }
36791
+ return () => {
36792
+ contentId.remove(valueRef.current);
36793
+ };
36794
+ }, []);
36795
+ };
36668
36796
 
36669
36797
  const NEVER_SET = {};
36670
36798
  const useUrlSearchParam = (paramName, defaultValue) => {
@@ -47795,21 +47923,51 @@ const withoutEmptyFields = uiState => {
47795
47923
  // register themselves in their own effects, which run first — this is the
47796
47924
  // earliest moment the form knows what it holds. Everything after this baseline
47797
47925
  // is a real send moving it forward (see useFormGroup's own onnavi_action_end).
47926
+ //
47927
+ // Taken a second time at the end of the tick, because "the earliest moment" is
47928
+ // not always late enough: a field that re-renders on its own schedule rather
47929
+ // than with the form — a row whose value is computed from signals, sitting
47930
+ // behind a memo — brings its value in a render of its own, which lands after
47931
+ // these effects. A form measured before it would open already changed, and
47932
+ // would never take the reference again. Both takes are the same arrival, so the
47933
+ // second one costs a render only when it moves something.
47798
47934
  const useHeldUIStateAsSent = (uiStateController, pristineKey) => {
47799
47935
  // The render that brought a new pristineKey read `changed` against the
47800
47936
  // previous baseline, and nothing else is going to move: the button would stay
47801
47937
  // lit on a form that holds exactly what it was just given. So ask for the one
47802
- // render that reads the new baseline — the first one has nobody to tell,
47803
- // every field it is waiting for re-renders the form as it registers.
47938
+ // render that reads the new baseline — the first take on mount has nobody to
47939
+ // tell, every field it is waiting for re-renders the form as it registers.
47804
47940
  const [, rereadBaseline] = useState(0);
47805
47941
  const isFirstRef = useRef(true);
47806
47942
  useLayoutEffect(() => {
47807
- uiStateController.sentUIState = readHeldUIState(uiStateController);
47808
- if (isFirstRef.current) {
47809
- isFirstRef.current = false;
47810
- return;
47811
- }
47812
- rereadBaseline(count => count + 1);
47943
+ const takeBaseline = () => {
47944
+ const baselineBefore = uiStateController.sentUIState;
47945
+ const baseline = readHeldUIState(uiStateController);
47946
+ uiStateController.sentUIState = baseline;
47947
+ return !compareTwoJsValues(baselineBefore, baseline);
47948
+ };
47949
+ const moved = takeBaseline();
47950
+ const isFirst = isFirstRef.current;
47951
+ isFirstRef.current = false;
47952
+ if (moved && !isFirst) {
47953
+ rereadBaseline(count => count + 1);
47954
+ }
47955
+ // A microtask, not a timeout: everything that belongs to this arrival —
47956
+ // the renders preact still has queued, the state they push into the form —
47957
+ // happens before the tick ends, and nothing a person does can land in
47958
+ // between.
47959
+ let abandoned = false;
47960
+ queueMicrotask(() => {
47961
+ if (abandoned) {
47962
+ return;
47963
+ }
47964
+ if (takeBaseline()) {
47965
+ rereadBaseline(count => count + 1);
47966
+ }
47967
+ });
47968
+ return () => {
47969
+ abandoned = true;
47970
+ };
47813
47971
  }, [uiStateController, pristineKey]);
47814
47972
  };
47815
47973
  const useUnregisteredControlWarning = ref => {
@@ -54720,6 +54878,7 @@ const ListUI = props => {
54720
54878
  expand: expand,
54721
54879
  "navi-nothing-to-display": nothingToDisplay ? "" : undefined,
54722
54880
  "navi-loading": loading ? "" : undefined,
54881
+ "navi-refreshing": virtual.refreshingSignal.value ? "" : undefined,
54723
54882
  "navi-error": error ? "" : undefined,
54724
54883
  styleCSSVars: LIST_STYLE_CSS_VARS,
54725
54884
  pseudoClasses: LIST_PSEUDO_CLASSES,
@@ -57044,6 +57203,10 @@ const createListVirtual = () => {
57044
57203
  // registers, nothing is drawn), and yet they are what it may have been
57045
57204
  // waiting for — the row it was told to open on, for one.
57046
57205
  const pagesSignal = signal(0);
57206
+ // How many runs are re-reading rows they already show. The list wears it as
57207
+ // an attribute: what is drawn is from before, and the app may want to say so
57208
+ // without taking anything away.
57209
+ const refreshingSignal = signal(0);
57047
57210
  const placeByOwner = new Map();
57048
57211
  const locatorByOwner = new Map();
57049
57212
  let passId = 0;
@@ -57051,6 +57214,7 @@ const createListVirtual = () => {
57051
57214
  const virtual = {
57052
57215
  totalSignal,
57053
57216
  pagesSignal,
57217
+ refreshingSignal,
57054
57218
  // What a run needs to know about the list it lives in: how many rows the
57055
57219
  // list is willing to draw at once, which end it opens on, and how much
57056
57220
  // room one row is given — a row whose content has not arrived must take
@@ -57153,7 +57317,7 @@ const VISIBILITY_HIDDEN_STYLE = {
57153
57317
  * inside `<List.Group>`s; each takes its place in declaration order.
57154
57318
  *
57155
57319
  * @type {import("ignore:preact").FunctionComponent<{
57156
- * renderItem: (item: any, index: number) => import("ignore:preact").ComponentChildren,
57320
+ * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
57157
57321
  * itemsAction: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
57158
57322
  * count?: number,
57159
57323
  * groupBy?: (item: any, index: number) => any,
@@ -57163,6 +57327,10 @@ const VISIBILITY_HIDDEN_STYLE = {
57163
57327
  * renderSkeleton?: false | ((index: number) => import("ignore:preact").ComponentChildren),
57164
57328
  * renderError?: (failure: {error: any, retry: () => void, start: number, end: number}) => import("ignore:preact").ComponentChildren,
57165
57329
  * }>}
57330
+ * @param {(item: any, index: number, state: {refreshing: boolean}) => any} props.renderItem
57331
+ * What one row is, given the item and where it sits. `state.refreshing` says
57332
+ * the rows drawn are the ones from before while the run reads the collection
57333
+ * again — the list carries `navi-refreshing` for the same reason.
57166
57334
  * @param {(item: any, index: number) => any} [props.groupBy]
57167
57335
  * What tells rows that belong together apart from the others — the day of a
57168
57336
  * message, the month of a game. Consecutive rows sharing it are wrapped in a
@@ -57369,6 +57537,9 @@ const ListItems = ({
57369
57537
  virtualItemSize: virtualItemSize
57370
57538
  }, "navi-list-filler-before"));
57371
57539
  }
57540
+ const renderItemState = {
57541
+ refreshing: store.refreshing
57542
+ };
57372
57543
  let rowIndex = windowFrom;
57373
57544
  while (rowIndex < windowTo) {
57374
57545
  if (rowIndex >= failureFrom && rowIndex <= failureTo) {
@@ -57396,7 +57567,7 @@ const ListItems = ({
57396
57567
  const key = item === undefined ? `${ownerId}_skeleton_${rowIndex}` : idOf(item, rowIndex);
57397
57568
  let rowVnode;
57398
57569
  if (item !== undefined) {
57399
- rowVnode = renderItem(item, rowIndex);
57570
+ rowVnode = renderItem(item, rowIndex, renderItemState);
57400
57571
  } else if (renderRowSkeleton === false) {
57401
57572
  // The row must still take its room: without it the rows below would
57402
57573
  // climb up and slide back down as the answer arrives.
@@ -57497,6 +57668,22 @@ const useItemStore = ({
57497
57668
  }
57498
57669
  const pages = pagesRef.current;
57499
57670
  const [, setPageVersion] = useState(0);
57671
+ // The rows held are out of date and the run has not asked for the new ones
57672
+ // yet. They stay on screen until the answer comes: what is drawn is from
57673
+ // before, which is not the same thing as nothing to draw.
57674
+ const staleRef = useRef(false);
57675
+ const [refreshing, setRefreshing] = useState(false);
57676
+ // A source that says when what it reads has moved (a resource range reader
57677
+ // does: see rerunOn.GET_RANGE) is heard here — a write deciding who belongs
57678
+ // to the collection is exactly what a run cannot deduce from the rows it
57679
+ // holds. A source that says nothing is read once and stays as it is.
57680
+ const invalidationSignal = typeof itemsAction === "function" ? itemsAction.invalidationSignal : null;
57681
+ const invalidation = invalidationSignal ? invalidationSignal.value : 0;
57682
+ const invalidationRef = useRef(invalidation);
57683
+ if (invalidationRef.current !== invalidation) {
57684
+ invalidationRef.current = invalidation;
57685
+ staleRef.current = true;
57686
+ }
57500
57687
  // The one request in flight, with the means to call it off: a page asked for
57501
57688
  // a window the list has left is work the server and the browser are doing for
57502
57689
  // nothing.
@@ -57506,7 +57693,8 @@ const useItemStore = ({
57506
57693
  end: -1,
57507
57694
  held: -1,
57508
57695
  controller: null,
57509
- generation: 0
57696
+ generation: 0,
57697
+ revalidating: false
57510
57698
  });
57511
57699
  // The rows asked for that never came. Kept as a range so the list can say
57512
57700
  // where the hole is, and cleared by a retry — which is what makes the same
@@ -57517,9 +57705,24 @@ const useItemStore = ({
57517
57705
  // It stands for a windowful of them: a list that is about to be filled looks
57518
57706
  // like rows on their way, not like an empty list.
57519
57707
  const rowCount = pages.count ?? count ?? virtual.renderBudget;
57708
+ // A run that never received anything has nothing to keep on screen: asking
57709
+ // again is its first ask, not a refresh.
57710
+ if (staleRef.current && pages.count === undefined) {
57711
+ staleRef.current = false;
57712
+ }
57713
+ useLayoutEffect(() => {
57714
+ if (!refreshing) {
57715
+ return null;
57716
+ }
57717
+ virtual.refreshingSignal.value = virtual.refreshingSignal.peek() + 1;
57718
+ return () => {
57719
+ virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
57720
+ };
57721
+ }, [refreshing]);
57520
57722
  const store = {
57521
57723
  rowCount,
57522
57724
  failure,
57725
+ refreshing,
57523
57726
  // JS memory is cheap next to the DOM, but a long enough scroll accumulates
57524
57727
  // everything it ever went through. Rows far from what is on screen are
57525
57728
  // dropped and simply asked for again if the user goes back — the same
@@ -57560,7 +57763,25 @@ const useItemStore = ({
57560
57763
  let start = missingStart;
57561
57764
  let end = missingEnd;
57562
57765
  let around;
57563
- if (pages.count === undefined) {
57766
+ // Rows that are all there but out of date: the ask is the window itself,
57767
+ // anchored on the row at its top — a source paginating by cursor gets a
57768
+ // row to count from, and the reading position is what must survive.
57769
+ const revalidating = staleRef.current;
57770
+ if (revalidating) {
57771
+ start = windowFrom;
57772
+ end = windowTo - 1;
57773
+ if (end < start) {
57774
+ // Nothing of this run is on screen (the window frames another one, or
57775
+ // the list is scrolled past it): its own first rows are what it will
57776
+ // draw next.
57777
+ start = 0;
57778
+ end = budget - 1;
57779
+ }
57780
+ const firstHeld = pages.byIndex.get(windowFrom);
57781
+ if (firstHeld && firstHeld.id !== undefined) {
57782
+ around = firstHeld.id;
57783
+ }
57784
+ } else if (pages.count === undefined) {
57564
57785
  const scrolled = virtual.scrolled;
57565
57786
  if (scrolled === "end") {
57566
57787
  // Counting back from the end, the way an HTTP range does: a list
@@ -57591,6 +57812,14 @@ const useItemStore = ({
57591
57812
  return;
57592
57813
  }
57593
57814
  const request = requestRef.current;
57815
+ if (revalidating && request.busy) {
57816
+ if (request.revalidating) {
57817
+ return;
57818
+ }
57819
+ // A page for a window that is about to be replaced wholesale.
57820
+ request.controller?.abort();
57821
+ request.busy = false;
57822
+ }
57594
57823
  if (request.busy) {
57595
57824
  // Still worth waiting for as long as what it went to fetch is still
57596
57825
  // what the list would draw. Once it is not, it is called off — and
@@ -57609,8 +57838,9 @@ const useItemStore = ({
57609
57838
  }
57610
57839
  const held = pages.byIndex.size;
57611
57840
  // Asking again for a range that was already asked for, having received
57612
- // nothing since, can only produce the same answer.
57613
- if (request.start === start && request.end === end && request.held === held) {
57841
+ // nothing since, can only produce the same answer. A revalidation is
57842
+ // exactly the case where it produces another one.
57843
+ if (!revalidating && request.start === start && request.end === end && request.held === held) {
57614
57844
  return;
57615
57845
  }
57616
57846
  request.start = start;
@@ -57625,17 +57855,33 @@ const useItemStore = ({
57625
57855
  end,
57626
57856
  around,
57627
57857
  limit: end - start + 1,
57628
- before: cursor.before,
57629
- after: cursor.after,
57858
+ // A cursor names a row of the collection as it was; a revalidation
57859
+ // is asked precisely because that is what changed.
57860
+ before: revalidating ? undefined : cursor.before,
57861
+ after: revalidating ? undefined : cursor.after,
57630
57862
  count: pages.count,
57631
57863
  signal: controller.signal
57632
57864
  };
57633
57865
  request.busy = true;
57866
+ request.revalidating = revalidating;
57867
+ if (revalidating) {
57868
+ setRefreshing(true);
57869
+ }
57634
57870
  const done = page => {
57635
57871
  const current = generation === request.generation;
57636
57872
  if (current) {
57637
57873
  request.busy = false;
57874
+ request.revalidating = false;
57638
57875
  setFailure(null);
57876
+ if (revalidating) {
57877
+ staleRef.current = false;
57878
+ setRefreshing(false);
57879
+ }
57880
+ }
57881
+ if (revalidating && !current) {
57882
+ // Rows of a composition already superseded by a newer ask: keeping
57883
+ // them would mix two states of the collection.
57884
+ return;
57639
57885
  }
57640
57886
  if (!page) {
57641
57887
  return;
@@ -57646,6 +57892,12 @@ const useItemStore = ({
57646
57892
  // Before the rows land: what is on screen has to stay where it is,
57647
57893
  // and the DOM still shows the state to hold onto.
57648
57894
  virtual.captureAnchor();
57895
+ if (revalidating) {
57896
+ // The rows held stood for a composition that has moved on; the
57897
+ // ones outside the window are forgotten and asked for again if the
57898
+ // user goes back to them.
57899
+ pages.byIndex = new Map();
57900
+ }
57649
57901
  let i = 0;
57650
57902
  while (i < pageItems.length) {
57651
57903
  pages.byIndex.set(pageStart + i, pageItems[i]);
@@ -57661,6 +57913,14 @@ const useItemStore = ({
57661
57913
  return;
57662
57914
  }
57663
57915
  request.busy = false;
57916
+ request.revalidating = false;
57917
+ if (revalidating) {
57918
+ // The rows from before stay: a revalidation that failed has
57919
+ // nothing better to put in their place.
57920
+ staleRef.current = false;
57921
+ setRefreshing(false);
57922
+ return;
57923
+ }
57664
57924
  setFailure({
57665
57925
  start,
57666
57926
  end,
@@ -58265,8 +58525,8 @@ const PickerTypeResolver = props => {
58265
58525
  ...props
58266
58526
  });
58267
58527
  }
58268
- if (props.type === "form") {
58269
- return jsx(PickerForm, {
58528
+ if (props.type === "object") {
58529
+ return jsx(PickerObject, {
58270
58530
  ...props
58271
58531
  });
58272
58532
  }
@@ -58282,19 +58542,19 @@ const PickerText = props => {
58282
58542
  });
58283
58543
  };
58284
58544
 
58285
- // The popup holds a group of named controls — a `<Form>`, or a `<ControlGroup>`
58286
- // when the group is only a shape and has no submit — and this picker's value is
58287
- // whatever that group aggregates. The popup itself holds nothing: it is a
58288
- // surface (see dialog.jsx), so there is nothing to tell it about the shape.
58289
- const PickerForm = props => {
58545
+ // The popup holds a group of named controls — a `<ControlGroup>`, or a `<Form>`
58546
+ // when that group is a question with a send of its own — and this picker's
58547
+ // value is the object that group aggregates. The popup itself holds nothing: it
58548
+ // is a surface (see dialog.jsx), so there is nothing to tell it about the shape.
58549
+ const PickerObject = props => {
58290
58550
  const Next = useNextResolver();
58291
58551
  return jsx(Next, {
58292
- ui: jsx(PickerFormUI, {}),
58552
+ ui: jsx(PickerObjectUI, {}),
58293
58553
  ...props,
58294
58554
  type: "navi_js"
58295
58555
  });
58296
58556
  };
58297
- const PickerFormUI = () => {
58557
+ const PickerObjectUI = () => {
58298
58558
  const {
58299
58559
  value,
58300
58560
  placeholder
@@ -59304,7 +59564,7 @@ Picker.UI.Week = PickerWeekUI;
59304
59564
  Picker.UI.Datetime = PickerDatetimeUI;
59305
59565
  Picker.UI.File = PickerFileUI;
59306
59566
  Picker.UI.Color = PickerColorUI;
59307
- Picker.UI.Form = PickerFormUI;
59567
+ Picker.UI.Object = PickerObjectUI;
59308
59568
  Picker.UI.Multiple = PickerArrayUI;
59309
59569
  Picker.UI.PencilSvg = PencilSvg;
59310
59570
  Picker.UI.ChevronDownSvg = ChevronDownSvg$1;