@jsenv/navi 0.29.434 → 0.29.435

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.
@@ -12938,15 +12938,12 @@ const useNavStateWithWarnings = (id, options) => {
12938
12938
  ownerRef.current = {};
12939
12939
  }
12940
12940
  const owner = ownerRef.current;
12941
- // Claimed during render so warnAboutOrphanGeneratedKeys sees every id of
12942
- // the commit, including the ones whose effect has not run yet. The conflict
12943
- // is judged in the effect: when a screen replaces another in one commit,
12944
- // preact renders the new children before unmounting the old ones, so at
12945
- // render time the id may still be held by a component about to go.
12946
- if (id !== undefined && !idUsageMap.has(id)) {
12947
- idUsageMap.set(id, { owner, stackTrace: new Error().stack });
12948
- }
12949
12941
 
12942
+ // Claimed in the effect, never during render: only the effect has a cleanup
12943
+ // to give the id back, and a render is not always followed by its effects.
12944
+ // A page parked by a Suspense boundary (every hook cleanup run), rendered
12945
+ // again and taken down before the paint, leaves a render-time claim holding
12946
+ // the id for nobody — and the next mount reads a conflict.
12950
12947
  useEffect(() => {
12951
12948
  if (id === undefined) {
12952
12949
  return undefined;
@@ -12960,12 +12957,9 @@ This can cause UI state conflicts and unexpected behavior.
12960
12957
  Consider using unique IDs for each component instance.`,
12961
12958
  );
12962
12959
  } else {
12963
- // Also set when absent: preact/compat's Suspense parks a subtree by
12964
- // running every hook cleanup in it, and the render resuming it carries
12965
- // the same id.
12966
12960
  idUsageMap.set(id, { owner, stackTrace: new Error().stack });
12967
12961
  }
12968
- warnAboutOrphanGeneratedKeys();
12962
+ scheduleOrphanGeneratedKeysCheck();
12969
12963
  return () => {
12970
12964
  if (idUsageMap.get(id)?.owner === owner) {
12971
12965
  idUsageMap.delete(id);
@@ -12981,6 +12975,20 @@ Consider using unique IDs for each component instance.`,
12981
12975
  // and the mount coming back to the entry generates another id: the state is
12982
12976
  // there, and nothing reads it. Reported when a component mounting on the entry
12983
12977
  // finds such a key, the moment someone expected the state back.
12978
+ //
12979
+ // Checked once every effect of the flush has claimed its id: a component
12980
+ // whose effect comes later in the same flush is not an orphan.
12981
+ let orphanGeneratedKeysCheckScheduled = false;
12982
+ const scheduleOrphanGeneratedKeysCheck = () => {
12983
+ if (orphanGeneratedKeysCheckScheduled) {
12984
+ return;
12985
+ }
12986
+ orphanGeneratedKeysCheckScheduled = true;
12987
+ queueMicrotask(() => {
12988
+ orphanGeneratedKeysCheckScheduled = false;
12989
+ warnAboutOrphanGeneratedKeys();
12990
+ });
12991
+ };
12984
12992
  const warnAboutOrphanGeneratedKeys = () => {
12985
12993
  const state = browserIntegration.getDocumentState();
12986
12994
  if (!state) {
@@ -54662,6 +54670,26 @@ const Expandable = props => {
54662
54670
  return stopRide;
54663
54671
  };
54664
54672
 
54673
+ // A silent change is a state the page was already in (see openEffect): it
54674
+ // lands at once. Not playing a reveal is not enough — the stylesheet's own
54675
+ // transition plays it whenever the previous state's style was resolved
54676
+ // before the render: an expandable mounted closed and measured in the same
54677
+ // commit (a List sizing its items) before its deferred mount-time open, or
54678
+ // one kept on screen across the page change.
54679
+ const renderWithoutMovement = (contentContainer, render) => {
54680
+ if (!animation || !contentContainer) {
54681
+ flushSyncRendering(render);
54682
+ return;
54683
+ }
54684
+ contentContainer.style.transitionProperty = "none";
54685
+ flushSyncRendering(render);
54686
+ // The new style must be resolved while transitions are off — re-enabled
54687
+ // in the same recalc, the change would transition after all.
54688
+ // eslint-disable-next-line no-unused-expressions
54689
+ getComputedStyle(contentContainer).transitionProperty;
54690
+ contentContainer.style.transitionProperty = "";
54691
+ };
54692
+
54665
54693
  // What opening LOOKS like here, and how to undo it — the one thing an
54666
54694
  // expandable owns that a popup does not (see open_controller.js). Reassigned
54667
54695
  // on every render so it always closes over the latest props.
@@ -54682,10 +54710,15 @@ const Expandable = props => {
54682
54710
  // reopening during a collapse — read while the DOM still says closed.
54683
54711
  const startRect = revealing ? contentContainer.getBoundingClientRect() : null;
54684
54712
  cancelSettleWatch();
54685
- flushSyncRendering(() => {
54713
+ const renderOpened = () => {
54686
54714
  setOpened(true);
54687
54715
  setSettled(!revealing);
54688
- });
54716
+ };
54717
+ if (silent) {
54718
+ renderWithoutMovement(contentContainer, renderOpened);
54719
+ } else {
54720
+ flushSyncRendering(renderOpened);
54721
+ }
54689
54722
  if (hasAction) {
54690
54723
  runUnwatched(() => effectiveAction.run());
54691
54724
  }
@@ -54724,16 +54757,22 @@ const Expandable = props => {
54724
54757
  cancelSettleWatch();
54725
54758
  // `silent`: closed by the navigation that puts another page on screen,
54726
54759
  // which is its movement (see page_change.js).
54727
- const collapsing = animation && !closeEvent.detail.silent && Boolean(contentContainerAtClose);
54760
+ const silentClose = Boolean(closeEvent.detail.silent);
54761
+ const collapsing = animation && !silentClose && Boolean(contentContainerAtClose);
54728
54762
  if (collapsing) {
54729
54763
  // Now, while the content is still fully laid out — the collapsing
54730
54764
  // track uncovers a content frozen at that size.
54731
54765
  freezeContentSize();
54732
54766
  }
54733
- flushSyncRendering(() => {
54767
+ const renderClosed = () => {
54734
54768
  setOpened(false);
54735
54769
  setSettled(!collapsing);
54736
- });
54770
+ };
54771
+ if (silentClose) {
54772
+ renderWithoutMovement(contentContainerAtClose, renderClosed);
54773
+ } else {
54774
+ flushSyncRendering(renderClosed);
54775
+ }
54737
54776
  if (restoreFocus) {
54738
54777
  restoreFocus(closeEvent);
54739
54778
  } else if (focusedBeforeClose && contentContainerAtClose && contentContainerAtClose.contains(focusedBeforeClose)) {
@@ -74651,6 +74690,45 @@ const LIST_STYLE_CSS_VARS = {
74651
74690
  backgroundColor: "--list-background-color"
74652
74691
  };
74653
74692
  const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
74693
+ // Two lists mounted at once under one `id` are one entry to the scroll memory:
74694
+ // each overwrites the other's position (see docs/scroll.md). Said the moment
74695
+ // the second one mounts, once per id — on a route transition, that is the page
74696
+ // arriving while the page being left is still mounted, hidden.
74697
+ const createSharedRememberedIdWarning = () => {
74698
+ const mountedListsById = new Map();
74699
+ const warnedIdSet = new Set();
74700
+ return (listId, rememberScroll, ref) => {
74701
+ useLayoutEffect(() => {
74702
+ if (!rememberScroll) {
74703
+ return undefined;
74704
+ }
74705
+ let mountedLists = mountedListsById.get(listId);
74706
+ if (!mountedLists) {
74707
+ mountedLists = new Set();
74708
+ mountedListsById.set(listId, mountedLists);
74709
+ }
74710
+ const mounted = {
74711
+ element: ref.current
74712
+ };
74713
+ if (mountedLists.size > 0 && !warnedIdSet.has(listId)) {
74714
+ warnedIdSet.add(listId);
74715
+ const [other] = mountedLists;
74716
+ console.warn(`<List id="${listId}"> mounted while another list with this id is mounted. The position a list comes back to is kept under its id, so the two overwrite each other's: the one arriving can open where the other one was, or come back at its top. Lists are mounted together on one page, under a popup, in a bar every page keeps, and during a route transition, whose page being left stays mounted until the movement ends. Give each list an id naming it in the app (a component drawing a list on several pages takes its id from the page), or set scrollResetOnNavigation on a list with no use for coming back. See docs/scroll.md.`, {
74717
+ list: mounted.element,
74718
+ otherList: other.element
74719
+ });
74720
+ }
74721
+ mountedLists.add(mounted);
74722
+ return () => {
74723
+ mountedLists.delete(mounted);
74724
+ if (mountedLists.size === 0) {
74725
+ mountedListsById.delete(listId);
74726
+ }
74727
+ };
74728
+ }, [listId, rememberScroll]);
74729
+ };
74730
+ };
74731
+ const useWarnSharedRememberedId = createSharedRememberedIdWarning() ;
74654
74732
  const useListScrollSync = ({
74655
74733
  ref,
74656
74734
  listItems,
@@ -75557,6 +75635,7 @@ const useListScrollSync = ({
75557
75635
  }
75558
75636
  };
75559
75637
  }, []);
75638
+ useWarnSharedRememberedId(listId, rememberScroll, ref);
75560
75639
 
75561
75640
  // A list that gets narrower rewraps every item it holds, so everything below
75562
75641
  // moves and the reader loses their place — the very thing scrolling a long