@jsenv/navi 0.29.425 → 0.29.426

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.
@@ -7,7 +7,7 @@ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchIntern
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { options, isValidElement, createContext, render, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
- import { useErrorBoundary, useLayoutEffect, useContext, useMemo, useRef, useEffect, useCallback, useState, useReducer, useId } from "preact/hooks";
10
+ import { useErrorBoundary, useLayoutEffect, useContext, useState, useMemo, useRef, useEffect, useCallback, useReducer, useId } from "preact/hooks";
11
11
  import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
12
12
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
13
13
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
@@ -10812,6 +10812,179 @@ const dropGeneratedIdKeys = (state) => {
10812
10812
  return stateWithoutGeneratedIdKeys;
10813
10813
  };
10814
10814
 
10815
+ const FRAME_TIMEOUT_MS = 100;
10816
+
10817
+ /**
10818
+ * Calls back once the browser has painted what is committed now.
10819
+ *
10820
+ * The callbacks of the next frame run before that frame paints, so the call is
10821
+ * made from a task queued from inside one — the same way preact schedules
10822
+ * useEffect. Unlike an effect, nothing runs this early: preact flushes a
10823
+ * component's pending effects as soon as that component renders again, and a
10824
+ * re-render before the paint is exactly what a caller waiting for the paint
10825
+ * has to survive. A frame not coming at all (a background tab) still answers,
10826
+ * late, through the timeout.
10827
+ *
10828
+ * @param {() => void} callback
10829
+ * @returns {() => void} cancel
10830
+ */
10831
+ const afterPaint = (callback) => {
10832
+ let called = false;
10833
+ let timeoutId;
10834
+ let frameId;
10835
+ const onFrame = () => {
10836
+ if (called) {
10837
+ return;
10838
+ }
10839
+ called = true;
10840
+ clearTimeout(timeoutId);
10841
+ cancelAnimationFrame(frameId);
10842
+ timeoutId = setTimeout(callback, 0);
10843
+ };
10844
+ timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
10845
+ frameId = requestAnimationFrame(onFrame);
10846
+ return () => {
10847
+ called = true;
10848
+ clearTimeout(timeoutId);
10849
+ cancelAnimationFrame(frameId);
10850
+ };
10851
+ };
10852
+
10853
+ /**
10854
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
10855
+ * letting Preact batch it into the next microtask. Layout effects of what gets
10856
+ * mounted run inside the call too, exactly as they would on any other commit.
10857
+ *
10858
+ * For the caller that has to read the DOM it just asked for — measuring an
10859
+ * element whose content it mounts in the same breath — and cannot wait a tick
10860
+ * to do it, because what comes after is a browser event still in flight
10861
+ * (preventDefault, focus placement) that no longer accepts being answered late.
10862
+ *
10863
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
10864
+ * render queue drains; swapping it for "right now" for the duration of the call
10865
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
10866
+ * case above: rendering synchronously in the middle of an event gives up the
10867
+ * batching that makes several state changes one commit.
10868
+ */
10869
+ const flushSyncRendering = (fn) => {
10870
+ const debounceRenderingPrevious = options.debounceRendering;
10871
+ options.debounceRendering = (drainRenderQueue) => {
10872
+ drainRenderQueue();
10873
+ };
10874
+ try {
10875
+ fn();
10876
+ } finally {
10877
+ options.debounceRendering = debounceRenderingPrevious;
10878
+ }
10879
+ };
10880
+
10881
+ /**
10882
+ * The content of a `<Box mount="after-paint">`: built right after the first
10883
+ * frame that shows the box, unless that frame would show it.
10884
+ *
10885
+ * The browser paints nothing until the render that builds a page has ended, and
10886
+ * what lies below the first screen weighs in that render as much as what is on
10887
+ * it. A box marked this way is left empty in that render, then built once the
10888
+ * frame is painted — the page-level twin of a list's render window (see
10889
+ * `renderBudget` in list.jsx), for a page made of sections.
10890
+ *
10891
+ * Which boxes lie below the screen is measured, not taken from the caller. The
10892
+ * caller cannot know: a taller phone, a profile with nothing in its first
10893
+ * section, and the box it marked is on screen. So each box looks where it
10894
+ * stands in the animation callbacks of its first frame — laid out, not yet
10895
+ * painted — and one the frame would show, or one above it, is built there:
10896
+ * content appearing on screen is a pop-in, and content growing ABOVE what is
10897
+ * read pushes it down. Marking too much costs a second render in that frame,
10898
+ * never a wrong picture.
10899
+ *
10900
+ * The document put back where it was read is the other frame that shows it.
10901
+ * That offset is written by navi (see scroll_restoration.js), and written into
10902
+ * a document still missing its bottom it is clamped: the page opens higher than
10903
+ * it was left, and the bottom arrives under a page that has already jumped. So
10904
+ * whoever writes an arrival's offset builds first the boxes that offset shows
10905
+ * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
10906
+ * right after it (the offset, a route travel measuring its window) reads the
10907
+ * layout.
10908
+ */
10909
+
10910
+ const deferredBoxes = new Set();
10911
+ const MountAfterPaint = ({
10912
+ boxRef,
10913
+ children
10914
+ }) => {
10915
+ const [built, setBuilt] = useState(false);
10916
+ useLayoutEffect(() => {
10917
+ let frame;
10918
+ let cancelAfterPaint;
10919
+ const stopWaiting = () => {
10920
+ deferredBoxes.delete(deferredBox);
10921
+ cancelAnimationFrame(frame);
10922
+ cancelAfterPaint();
10923
+ };
10924
+ // The first of the three that come — the offset of an arrival, the frame
10925
+ // finding the box on screen, the paint — builds it and cancels the others.
10926
+ const deferredBox = {
10927
+ getElement: () => boxRef.current,
10928
+ build: () => {
10929
+ stopWaiting();
10930
+ setBuilt(true);
10931
+ }
10932
+ };
10933
+ deferredBoxes.add(deferredBox);
10934
+ // Requested before afterPaint's own frame callback, so the box is looked at
10935
+ // before that one turns the paint into a build.
10936
+ frame = requestAnimationFrame(() => {
10937
+ const top = readTopInWindow(boxRef.current);
10938
+ if (top !== null && top < window.innerHeight) {
10939
+ deferredBox.build();
10940
+ }
10941
+ });
10942
+ cancelAfterPaint = afterPaint(deferredBox.build);
10943
+ return stopWaiting;
10944
+ }, []);
10945
+ return built ? children : null;
10946
+ };
10947
+
10948
+ /**
10949
+ * Builds, now, every box still waiting whose top is above `bottom` (in the
10950
+ * document's coordinates): what the document will show once scrolled so that
10951
+ * `bottom` is the bottom of the screen. Boxes built push those below them
10952
+ * further down, so the ones measured above `bottom` are all the ones that can
10953
+ * be shown there, and a few more at worst.
10954
+ *
10955
+ * Renders synchronously, so it must be called outside a Preact commit: a render
10956
+ * flushed from a layout effect runs inside the commit still walking the tree.
10957
+ */
10958
+ const buildDeferredBoxesAbove = bottom => {
10959
+ if (deferredBoxes.size === 0) {
10960
+ return;
10961
+ }
10962
+ const toBuild = [];
10963
+ for (const deferredBox of deferredBoxes) {
10964
+ const top = readTopInWindow(deferredBox.getElement());
10965
+ if (top !== null && top + window.scrollY < bottom) {
10966
+ toBuild.push(deferredBox);
10967
+ }
10968
+ }
10969
+ if (toBuild.length === 0) {
10970
+ return;
10971
+ }
10972
+ flushSyncRendering(() => {
10973
+ for (const deferredBox of toBuild) {
10974
+ deferredBox.build();
10975
+ }
10976
+ });
10977
+ };
10978
+
10979
+ // null for a box nothing draws (a closed popup, a hidden tab): no frame shows
10980
+ // it, wherever the document is.
10981
+ const readTopInWindow = element => {
10982
+ if (!element || element.getClientRects().length === 0) {
10983
+ return null;
10984
+ }
10985
+ return element.getBoundingClientRect().top;
10986
+ };
10987
+
10815
10988
  /**
10816
10989
  * A container has put its page on screen — or as much of it as it can.
10817
10990
  *
@@ -10867,7 +11040,9 @@ const [publishRouteRender, observeRouteRender] = createPubSub();
10867
11040
  * What is NOT covered, and cannot be from here: a page whose height depends on
10868
11041
  * something still loading. Its content is not there at the moment it is put
10869
11042
  * back, so a position beyond what has arrived is clamped as before. Only the
10870
- * page knows when it is whole.
11043
+ * page knows when it is whole. What navi itself leaves out of a first render
11044
+ * is the exception — a `<Box mount="after-paint">` — and is built before the
11045
+ * offset that shows it is written (see mount_after_paint.jsx).
10871
11046
  *
10872
11047
  * WHEN a page is arrived at is not decided here either. A document navigation
10873
11048
  * lands where its kind says (see via_history.js), and one scrollport can be
@@ -11016,14 +11191,18 @@ const installScrollRestoration = () => {
11016
11191
  // What a reload asks for, now that the browser has been told not to do it.
11017
11192
  // Once, and at the first render of a route: the position is only meaningful
11018
11193
  // once there is a page under it.
11194
+ // Written once the commit that rendered it has ended: said from inside it,
11195
+ // and what the offset shows may still have to be built (see scrollTo).
11019
11196
  const positionOnLoad = positionByUrl.get(window.location.href);
11020
11197
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11021
11198
  const stopListening = observeRouteRender(() => {
11022
11199
  stopListening();
11023
- if (documentHoldCount) {
11024
- return;
11025
- }
11026
- scrollTo(positionOnLoad);
11200
+ queueMicrotask(() => {
11201
+ if (documentHoldCount) {
11202
+ return;
11203
+ }
11204
+ scrollTo(positionOnLoad);
11205
+ });
11027
11206
  });
11028
11207
  }
11029
11208
  };
@@ -11130,14 +11309,25 @@ const forgetScrollersOnArrival = (url, { from } = {}) => {
11130
11309
  // last read. Where this one was read, and its top when it never was — leaving
11131
11310
  // the offset alone would seat the reader wherever the neighbour happened to
11132
11311
  // be, so here "nothing recorded" and "stay" are not the same thing.
11312
+ //
11313
+ // Called outside a Preact commit only, like scrollTo: a row of tabs measures
11314
+ // its window right after, so what the top shows is built first here rather
11315
+ // than in the frame (see mount_after_paint.jsx).
11133
11316
  const arriveAtScrollPosition = (url) => {
11134
11317
  if (restoreScrollPosition(url)) {
11135
11318
  return;
11136
11319
  }
11320
+ buildDeferredBoxesAbove(window.innerHeight);
11137
11321
  startAtTop(url);
11138
11322
  };
11139
11323
 
11324
+ // What the offset shows is built first: written into a document still missing
11325
+ // a deferred box, the offset is clamped (see mount_after_paint.jsx). That build
11326
+ // is a render of its own, so this is never called from inside a commit — a
11327
+ // caller that learns the page is there from a layout effect (observeRouteRender)
11328
+ // writes once that commit has ended.
11140
11329
  const scrollTo = ({ x, y }) => {
11330
+ buildDeferredBoxesAbove(y + window.innerHeight);
11141
11331
  window.scrollTo({ top: y, left: x, behavior: "instant" });
11142
11332
  };
11143
11333
 
@@ -30032,6 +30222,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30032
30222
  * footer?: boolean,
30033
30223
  * body?: boolean,
30034
30224
  * sticky?: boolean,
30225
+ * mount?: "always" | "after-paint",
30035
30226
  * children?: import("ignore:preact").ComponentChildren,
30036
30227
  * [key: string]: any,
30037
30228
  * }>}
@@ -30080,6 +30271,16 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30080
30271
  * (see docs/interactions.md). A plain box has no wiring of its own: it does
30081
30272
  * nothing with `action` (that is a control's prop), so a click on it is
30082
30273
  * declared here like any other interaction: `interactions={{ click: fn }}`.
30274
+ * @param {"always"|"after-paint"} [mount="always"] When `children` are built.
30275
+ * `"after-paint"` leaves the box empty in the render that first mounts it and
30276
+ * builds them right after the first frame that shows the page, then keeps
30277
+ * them: for the sections of a page that lie below its first screen. navi
30278
+ * measures where the box stands before that frame, and one the frame would
30279
+ * show (or where the document is put back, on a return) is built before the
30280
+ * paint all the same. Until built, what is inside is not in the document:
30281
+ * no id there is found, no named control there is there to hear its own
30282
+ * `signal` (see docs/control_object.md). Only for content nothing reaches
30283
+ * before the first frame (see docs/scroll.md).
30083
30284
  */
30084
30285
  const Box = props => {
30085
30286
  const {
@@ -30087,6 +30288,7 @@ const Box = props => {
30087
30288
  children,
30088
30289
  separator,
30089
30290
  interactions,
30291
+ mount,
30090
30292
  ...computeProps
30091
30293
  } = props;
30092
30294
  const parentBoxFlow = useContext(BoxFlowContext);
@@ -30164,6 +30366,12 @@ const Box = props => {
30164
30366
  children: innerChildren
30165
30367
  });
30166
30368
  }
30369
+ if (mount === "after-paint") {
30370
+ innerChildren = jsx(MountAfterPaint, {
30371
+ boxRef: finalRef,
30372
+ children: innerChildren
30373
+ });
30374
+ }
30167
30375
  return jsx(TagName, {
30168
30376
  ref: finalRef,
30169
30377
  className: innerClassName,
@@ -33642,6 +33850,13 @@ const OLD_BAND_TOP_PROPERTY = "--navi-transition-old-band-top";
33642
33850
  const OLD_BAND_RIGHT_PROPERTY = "--navi-transition-old-band-right";
33643
33851
  const OLD_BAND_BOTTOM_PROPERTY = "--navi-transition-old-band-bottom";
33644
33852
  const OLD_BAND_LEFT_PROPERTY = "--navi-transition-old-band-left";
33853
+ const VIEWPORT_TOP_PROPERTY = "--navi-transition-viewport-top";
33854
+ const VIEWPORT_LEFT_PROPERTY = "--navi-transition-viewport-left";
33855
+ // Worn by the root once the group is held at the window by our numbers alone
33856
+ // (see placeTransitionWindow): the group rule of each movement is written on
33857
+ // it. Read by rules on pseudo-elements only, so wearing it restyles nothing
33858
+ // the document inherits.
33859
+ const WINDOW_PLACED_ATTRIBUTE = "data-navi-transition-window-placed";
33645
33860
  const WINDOW_PROPERTIES = [
33646
33861
  WINDOW_TOP_PROPERTY,
33647
33862
  WINDOW_LEFT_PROPERTY,
@@ -33655,6 +33870,8 @@ const WINDOW_PROPERTIES = [
33655
33870
  OLD_BAND_RIGHT_PROPERTY,
33656
33871
  OLD_BAND_BOTTOM_PROPERTY,
33657
33872
  OLD_BAND_LEFT_PROPERTY,
33873
+ VIEWPORT_TOP_PROPERTY,
33874
+ VIEWPORT_LEFT_PROPERTY,
33658
33875
  ];
33659
33876
 
33660
33877
  // Whose numbers are currently published. The window belongs to the movement
@@ -33662,6 +33879,8 @@ const WINDOW_PROPERTIES = [
33662
33879
  // after another has replaced it must not wipe numbers the new one is standing
33663
33880
  // on.
33664
33881
  let windowOwner = null;
33882
+ // The element the box is captured as, for as long as the window is held.
33883
+ let windowElement = null;
33665
33884
  // Stops re-reading the arriving state's corner, when a movement is on.
33666
33885
  let unfollowArrivingState = null;
33667
33886
 
@@ -33717,6 +33936,10 @@ const holdTransitionWindow = (
33717
33936
  const right =
33718
33937
  rectBefore.right > rectAfter.right ? rectBefore.right : rectAfter.right;
33719
33938
  windowOwner = owner;
33939
+ windowElement = element;
33940
+ // Pictures of a movement this one took over from are placed by the browser
33941
+ // until this one's own pictures exist.
33942
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
33720
33943
  setTransitionValue(WINDOW_TOP_PROPERTY, `${top}px`);
33721
33944
  setTransitionValue(WINDOW_LEFT_PROPERTY, `${left}px`);
33722
33945
  setTransitionValue(WINDOW_WIDTH_PROPERTY, `${right - left}px`);
@@ -33741,6 +33964,33 @@ const holdTransitionWindow = (
33741
33964
  });
33742
33965
  };
33743
33966
 
33967
+ /**
33968
+ * Called once the pictures exist — the view transition's `ready` — with the
33969
+ * name the box was captured under. Where the viewport's corner stands in the
33970
+ * space the pictures are drawn in is the browser's placement of the group
33971
+ * minus our reading of the same element, both taken in this one instant; the
33972
+ * group is held at the window with it from then on (see the top of the file).
33973
+ * A browser that does not hand the group's placement back leaves the group
33974
+ * where it places it, moved to the window by the arriving corner.
33975
+ */
33976
+ const placeTransitionWindow = (owner, groupName) => {
33977
+ if (owner !== windowOwner) {
33978
+ return;
33979
+ }
33980
+ const { transform } = getComputedStyle(
33981
+ document.documentElement,
33982
+ `::view-transition-group(${groupName})`,
33983
+ );
33984
+ if (!transform || transform === "none") {
33985
+ return;
33986
+ }
33987
+ const placement = new DOMMatrixReadOnly(transform);
33988
+ const rect = windowElement.getBoundingClientRect();
33989
+ setTransitionValue(VIEWPORT_TOP_PROPERTY, `${placement.m42 - rect.top}px`);
33990
+ setTransitionValue(VIEWPORT_LEFT_PROPERTY, `${placement.m41 - rect.left}px`);
33991
+ document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
33992
+ };
33993
+
33744
33994
  // The live layout takes the box back. A discontinuity by construction — the
33745
33995
  // window stands at the held height, the box is at its own — and an invisible
33746
33996
  // one: the page arriving is fully in place, and the strip below it that the
@@ -33750,20 +34000,22 @@ const releaseTransitionWindow = (owner) => {
33750
34000
  return;
33751
34001
  }
33752
34002
  windowOwner = null;
34003
+ windowElement = null;
33753
34004
  if (unfollowArrivingState) {
33754
34005
  unfollowArrivingState();
33755
34006
  }
34007
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
33756
34008
  for (const property of WINDOW_PROPERTIES) {
33757
34009
  removeTransitionValue(property);
33758
34010
  }
33759
34011
  };
33760
34012
 
33761
- // The arriving state's corner, re-read every frame — as often as the browser
33762
- // refreshes the group's placement from the same element (see the top of the
33763
- // file) — and with it the offset outside the box, which carries the window
33764
- // and the corner of the state being left along. Requested, never awaited: the
33765
- // first call runs inside the update callback, where a frame cannot come.
33766
- // Written only when it moved: every write restyles the pictures.
34013
+ // The arriving state's corner, re-read every frame — as often as anything can
34014
+ // move the page arriving (see the top of the file) — and with it the offset
34015
+ // outside the box, which carries the window and the corner of the state being
34016
+ // left along. Requested, never awaited: the first call runs inside the update
34017
+ // callback, where a frame cannot come. Written only when it moved: every write
34018
+ // restyles the pictures.
33767
34019
  const followArrivingState = (element, rectAtHold, heldAt) => {
33768
34020
  if (unfollowArrivingState) {
33769
34021
  unfollowArrivingState();
@@ -34268,6 +34520,13 @@ const css$12 = /* css */`@property --navi-route-transition-leave {
34268
34520
  animation-name: none;
34269
34521
  }
34270
34522
 
34523
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-transition) {
34524
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
34525
+ left: calc(var(--navi-transition-window-left) +
34526
+ var(--navi-transition-viewport-left));
34527
+ transform: none;
34528
+ }
34529
+
34271
34530
  &::view-transition-old(navi-route-transition) {
34272
34531
  top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
34273
34532
  left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
@@ -35315,6 +35574,8 @@ const beginTransition = ({
35315
35574
  // the only place the silent misconfigurations show. They are all about the
35316
35575
  // same thing — a movement playing on pictures that are not the pages.
35317
35576
  const viewTransitionReady = () => {
35577
+ // Read before anything below writes to the document.
35578
+ placeTransitionWindow(transition, AREA_NAME);
35318
35579
  startTransitionFurniture(transition, {
35319
35580
  duration: transition.duration
35320
35581
  });
@@ -35826,6 +36087,13 @@ const css$11 = /* css */`.navi_route_travel {
35826
36087
  animation-duration: var(--navi-route-travel-duration, .3s);
35827
36088
  animation-name: none;
35828
36089
  }
36090
+
36091
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
36092
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
36093
+ left: calc(var(--navi-transition-window-left) +
36094
+ var(--navi-transition-viewport-left));
36095
+ transform: none;
36096
+ }
35829
36097
  }
35830
36098
 
35831
36099
  :root[data-navi-route-travel-held] {
@@ -36204,6 +36472,10 @@ const RouteTravel = ({
36204
36472
  });
36205
36473
  });
36206
36474
  travel.viewTransition = viewTransition;
36475
+ // Subscribed first, so it reads before the scrub below writes anything.
36476
+ viewTransition.ready.then(() => {
36477
+ placeTransitionWindow(travel, TRAVEL_NAME);
36478
+ }, ignoreSkipped);
36207
36479
  if (scrub) {
36208
36480
  // Said only now: the release has to have something to let go of, and the
36209
36481
  // transition did not exist a line above.
@@ -36403,16 +36675,20 @@ const RouteTravel = ({
36403
36675
  // what makes the document tall enough to hold that offset again —
36404
36676
  // and the recording stays deaf until then, over the clamp the swap
36405
36677
  // back makes on the way. Its own deafness rather than the travel's:
36406
- // the travel ends here, and this outlives it by a render.
36678
+ // the travel ends here, and this outlives it by a render. Written
36679
+ // once the commit that says so has ended, as arriveAtScrollPosition
36680
+ // asks.
36407
36681
  const resumeScrollRecording = suspendScrollRecording();
36408
36682
  const stopWatchingRender = observeRouteRender(() => {
36409
36683
  stopWatchingRender();
36410
- const {
36411
- route,
36412
- params
36413
- } = travel.fromPage;
36414
- arriveAtScrollPosition(route.buildUrl(params));
36415
- resumeScrollRecording();
36684
+ queueMicrotask(() => {
36685
+ const {
36686
+ route,
36687
+ params
36688
+ } = travel.fromPage;
36689
+ arriveAtScrollPosition(route.buildUrl(params));
36690
+ resumeScrollRecording();
36691
+ });
36416
36692
  });
36417
36693
  }
36418
36694
  releaseRendering();
@@ -39145,34 +39421,6 @@ registerNaviCommand("--navi-unselect", (source, event) => {
39145
39421
  };
39146
39422
  });
39147
39423
 
39148
- /**
39149
- * Runs `fn` and commits whatever it re-renders before returning, instead of
39150
- * letting Preact batch it into the next microtask. Layout effects of what gets
39151
- * mounted run inside the call too, exactly as they would on any other commit.
39152
- *
39153
- * For the caller that has to read the DOM it just asked for — measuring an
39154
- * element whose content it mounts in the same breath — and cannot wait a tick
39155
- * to do it, because what comes after is a browser event still in flight
39156
- * (preventDefault, focus placement) that no longer accepts being answered late.
39157
- *
39158
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
39159
- * render queue drains; swapping it for "right now" for the duration of the call
39160
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
39161
- * case above: rendering synchronously in the middle of an event gives up the
39162
- * batching that makes several state changes one commit.
39163
- */
39164
- const flushSyncRendering = (fn) => {
39165
- const debounceRenderingPrevious = options.debounceRendering;
39166
- options.debounceRendering = (drainRenderQueue) => {
39167
- drainRenderQueue();
39168
- };
39169
- try {
39170
- fn();
39171
- } finally {
39172
- options.debounceRendering = debounceRenderingPrevious;
39173
- }
39174
- };
39175
-
39176
39424
  /**
39177
39425
  * When a popup builds what it holds, and when it throws it away.
39178
39426
  *
@@ -41471,6 +41719,65 @@ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
41471
41719
  }
41472
41720
  return kept;
41473
41721
  };
41722
+ // The same claim, for an object: a key whose control is not there cannot be
41723
+ // contradicted by it — a slide not built yet (SlideContainer mount="near"), a
41724
+ // row scrolled out, a page parked. A key of `kept` stays until a control of
41725
+ // that name, there, says otherwise; a value put ON the group replaces the
41726
+ // whole object, which is how a key is dropped on purpose.
41727
+ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
41728
+ const groupValues =
41729
+ kept !== null && typeof kept === "object" && !Array.isArray(kept)
41730
+ ? { ...kept }
41731
+ : {};
41732
+ return Object.assign(
41733
+ groupValues,
41734
+ readNamedChildUIStates(children, { warnNameless: true }),
41735
+ );
41736
+ };
41737
+ // What the named controls among `children` say, one key each. A nameless
41738
+ // GROUP is a grouping, not a value: it exists to hold its children together
41739
+ // (a WheelGroup sharing navigation, a fieldset-ish cluster) without claiming a
41740
+ // key of its own, so what its controls say is written as if they were here.
41741
+ // Naming it is what turns the same group into one key — see ControlGroup's
41742
+ // `name`. Only what its CONTROLS say: such a grouping is handed the whole
41743
+ // object around it (see distributeChildUIState below) and keeps what they do
41744
+ // not answer for, so merged whole it would carry its siblings' keys back up,
41745
+ // stale.
41746
+ const readNamedChildUIStates = (children, { warnNameless }) => {
41747
+ const values = {};
41748
+ for (const child of children) {
41749
+ const { name, emptyUIState } = child;
41750
+ // A control holding nothing writes its own empty, not a hole: the key is in
41751
+ // the object either way, and what is read from it keeps the shape the
41752
+ // reader was promised (see resolveEmptyUIState).
41753
+ const uiState =
41754
+ child.uiState === undefined && emptyUIState !== undefined
41755
+ ? emptyUIState
41756
+ : child.uiState;
41757
+ if (!name) {
41758
+ if (isNamelessGrouping(child, uiState)) {
41759
+ Object.assign(
41760
+ values,
41761
+ child.keepsUnansweredKeys
41762
+ ? readNamedChildUIStates(child.getChildControllers(), {
41763
+ warnNameless: false,
41764
+ })
41765
+ : uiState,
41766
+ );
41767
+ continue;
41768
+ }
41769
+ if (warnNameless) {
41770
+ console.warn(
41771
+ "A group child is missing a name property, its state won't be included in the group state",
41772
+ child,
41773
+ );
41774
+ }
41775
+ continue;
41776
+ }
41777
+ values[name] = uiState;
41778
+ }
41779
+ return values;
41780
+ };
41474
41781
 
41475
41782
  // Default aggregate/distribute implementations keyed by controlType or stateType.
41476
41783
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
@@ -41540,37 +41847,7 @@ const GROUP_DEFAULTS = {
41540
41847
  childControlFilter: (child) => {
41541
41848
  return child.controlType !== "button" && child.controlType !== "link";
41542
41849
  },
41543
- aggregateChildStates: (children) => {
41544
- const groupValues = {};
41545
- for (const child of children) {
41546
- const { name, emptyUIState } = child;
41547
- // A control holding nothing writes its own empty, not a hole: the key
41548
- // is in the object either way, and what is read from it keeps the shape
41549
- // the reader was promised (see resolveEmptyUIState).
41550
- const uiState =
41551
- child.uiState === undefined && emptyUIState !== undefined
41552
- ? emptyUIState
41553
- : child.uiState;
41554
- if (!name) {
41555
- // A nameless GROUP is a grouping, not a value: it exists to hold its
41556
- // children together (a WheelGroup sharing navigation, a fieldset-ish
41557
- // cluster) without claiming a key of its own, so what it holds is
41558
- // merged in as if its children had been written here. Naming it is
41559
- // what turns the same group into one key — see ControlGroup's `name`.
41560
- if (isNamelessGrouping(child, uiState)) {
41561
- Object.assign(groupValues, uiState);
41562
- continue;
41563
- }
41564
- console.warn(
41565
- "A group child is missing a name property, its state won't be included in the group state",
41566
- child,
41567
- );
41568
- continue;
41569
- }
41570
- groupValues[name] = uiState;
41571
- }
41572
- return groupValues;
41573
- },
41850
+ aggregateChildStates: keptAndNamedChildUIStates,
41574
41851
  distributeChildUIState: (newUIState, child) => {
41575
41852
  const childName = child.name;
41576
41853
  if (
@@ -41594,7 +41871,8 @@ const GROUP_DEFAULTS = {
41594
41871
  return childUIState;
41595
41872
  }
41596
41873
  // Merged in on the way up (see above), so on the way down it takes the
41597
- // whole object and picks out its own keys — the same value it produced.
41874
+ // whole object: its controls pick out their own keys, and it keeps the
41875
+ // rest for those of its controls that are not there yet.
41598
41876
  // Only for a child with no name of its own: the same condition the
41599
41877
  // aggregate applies before merging. A NAMED child holding an object (a
41600
41878
  // `type="object"` picker, say) answers for one key, and the object it is
@@ -42072,6 +42350,11 @@ const useUIGroupStateController = (
42072
42350
  // a picker filling its popup, a value prop) rather than worked out from
42073
42351
  // its children. What it protects is read in onChange.
42074
42352
  stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
42353
+ // Its value carries keys none of its controls answers for (see
42354
+ // keptAndNamedChildUIStates): a group merging it as a nameless
42355
+ // grouping reads its controls instead of its value.
42356
+ keepsUnansweredKeys:
42357
+ resolvedAggregateChildStates === keptAndNamedChildUIStates,
42075
42358
  uiStateSignal,
42076
42359
  wantRequesterButtonState,
42077
42360
  ref,
@@ -52551,7 +52834,8 @@ const useExpandableContext = partName => {
52551
52834
  * other three, intent on the UI part (pointer entering it, focus landing in
52552
52835
  * it) builds the content ahead of the click; `"while-opened"` content is
52553
52836
  * built by a press on it only, and thrown away if that press does not
52554
- * expand it.
52837
+ * expand it. Named controls in content not built are kept by the group
52838
+ * around them (docs/control_object.md, "A control that is not there").
52555
52839
  */
52556
52840
  const Expandable = props => {
52557
52841
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -59613,12 +59897,19 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
59613
59897
  * would bring alongside (neighbours on the map, along the axes
59614
59898
  * `travelByDrag` opens, the other end of a `loop` included) — and kept from
59615
59899
  * then on. For slides that are costly to build (a grid of a few hundred
59616
- * controls each): opening builds the slide shown and its neighbours rather
59900
+ * items each): opening builds the slide shown and its neighbours rather
59617
59901
  * than all of them, and a jump to a far slide builds that one and none of
59618
59902
  * those in between, which a travel never shows. The box is then as large as
59619
59903
  * the largest slide BUILT so far (see `sizing`), so it grows as a larger one
59620
59904
  * is reached: give it a size of its own when the slides differ.
59621
59905
  * `<Slide mount>` says it for one slide — a heavy one among light ones.
59906
+ * A slide not built has no controls, and a group around the container keeps
59907
+ * their keys for them: what it was handed reaches them once built. What is
59908
+ * shared between slides is written through that group (its `signal`, a
59909
+ * `--navi-update` aimed at it), never into one control's own `signal`, which
59910
+ * nobody reads while that control is not built; and nothing validates a
59911
+ * field before its slide is built. See docs/control_object.md, "A control
59912
+ * that is not there".
59622
59913
  */
59623
59914
  const SlideContainer = ({
59624
59915
  layout = "row",
@@ -69543,44 +69834,6 @@ document.addEventListener(
69543
69834
  { capture: true, passive: true },
69544
69835
  );
69545
69836
 
69546
- const FRAME_TIMEOUT_MS = 100;
69547
-
69548
- /**
69549
- * Calls back once the browser has painted what is committed now.
69550
- *
69551
- * The callbacks of the next frame run before that frame paints, so the call is
69552
- * made from a task queued from inside one — the same way preact schedules
69553
- * useEffect. Unlike an effect, nothing runs this early: preact flushes a
69554
- * component's pending effects as soon as that component renders again, and a
69555
- * re-render before the paint is exactly what a caller waiting for the paint
69556
- * has to survive. A frame not coming at all (a background tab) still answers,
69557
- * late, through the timeout.
69558
- *
69559
- * @param {() => void} callback
69560
- * @returns {() => void} cancel
69561
- */
69562
- const afterPaint = (callback) => {
69563
- let called = false;
69564
- let timeoutId;
69565
- let frameId;
69566
- const onFrame = () => {
69567
- if (called) {
69568
- return;
69569
- }
69570
- called = true;
69571
- clearTimeout(timeoutId);
69572
- cancelAnimationFrame(frameId);
69573
- timeoutId = setTimeout(callback, 0);
69574
- };
69575
- timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
69576
- frameId = requestAnimationFrame(onFrame);
69577
- return () => {
69578
- called = true;
69579
- clearTimeout(timeoutId);
69580
- cancelAnimationFrame(frameId);
69581
- };
69582
- };
69583
-
69584
69837
  const LoadingDotsSvg = () => {
69585
69838
  return jsxs("svg", {
69586
69839
  viewBox: "0 0 200 200",