@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, prefixFirstAndIndentRemainingLines, 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";
@@ -11240,6 +11240,179 @@ const dropGeneratedIdKeys = (state) => {
11240
11240
  return stateWithoutGeneratedIdKeys;
11241
11241
  };
11242
11242
 
11243
+ const FRAME_TIMEOUT_MS = 100;
11244
+
11245
+ /**
11246
+ * Calls back once the browser has painted what is committed now.
11247
+ *
11248
+ * The callbacks of the next frame run before that frame paints, so the call is
11249
+ * made from a task queued from inside one — the same way preact schedules
11250
+ * useEffect. Unlike an effect, nothing runs this early: preact flushes a
11251
+ * component's pending effects as soon as that component renders again, and a
11252
+ * re-render before the paint is exactly what a caller waiting for the paint
11253
+ * has to survive. A frame not coming at all (a background tab) still answers,
11254
+ * late, through the timeout.
11255
+ *
11256
+ * @param {() => void} callback
11257
+ * @returns {() => void} cancel
11258
+ */
11259
+ const afterPaint = (callback) => {
11260
+ let called = false;
11261
+ let timeoutId;
11262
+ let frameId;
11263
+ const onFrame = () => {
11264
+ if (called) {
11265
+ return;
11266
+ }
11267
+ called = true;
11268
+ clearTimeout(timeoutId);
11269
+ cancelAnimationFrame(frameId);
11270
+ timeoutId = setTimeout(callback, 0);
11271
+ };
11272
+ timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
11273
+ frameId = requestAnimationFrame(onFrame);
11274
+ return () => {
11275
+ called = true;
11276
+ clearTimeout(timeoutId);
11277
+ cancelAnimationFrame(frameId);
11278
+ };
11279
+ };
11280
+
11281
+ /**
11282
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
11283
+ * letting Preact batch it into the next microtask. Layout effects of what gets
11284
+ * mounted run inside the call too, exactly as they would on any other commit.
11285
+ *
11286
+ * For the caller that has to read the DOM it just asked for — measuring an
11287
+ * element whose content it mounts in the same breath — and cannot wait a tick
11288
+ * to do it, because what comes after is a browser event still in flight
11289
+ * (preventDefault, focus placement) that no longer accepts being answered late.
11290
+ *
11291
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
11292
+ * render queue drains; swapping it for "right now" for the duration of the call
11293
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
11294
+ * case above: rendering synchronously in the middle of an event gives up the
11295
+ * batching that makes several state changes one commit.
11296
+ */
11297
+ const flushSyncRendering = (fn) => {
11298
+ const debounceRenderingPrevious = options.debounceRendering;
11299
+ options.debounceRendering = (drainRenderQueue) => {
11300
+ drainRenderQueue();
11301
+ };
11302
+ try {
11303
+ fn();
11304
+ } finally {
11305
+ options.debounceRendering = debounceRenderingPrevious;
11306
+ }
11307
+ };
11308
+
11309
+ /**
11310
+ * The content of a `<Box mount="after-paint">`: built right after the first
11311
+ * frame that shows the box, unless that frame would show it.
11312
+ *
11313
+ * The browser paints nothing until the render that builds a page has ended, and
11314
+ * what lies below the first screen weighs in that render as much as what is on
11315
+ * it. A box marked this way is left empty in that render, then built once the
11316
+ * frame is painted — the page-level twin of a list's render window (see
11317
+ * `renderBudget` in list.jsx), for a page made of sections.
11318
+ *
11319
+ * Which boxes lie below the screen is measured, not taken from the caller. The
11320
+ * caller cannot know: a taller phone, a profile with nothing in its first
11321
+ * section, and the box it marked is on screen. So each box looks where it
11322
+ * stands in the animation callbacks of its first frame — laid out, not yet
11323
+ * painted — and one the frame would show, or one above it, is built there:
11324
+ * content appearing on screen is a pop-in, and content growing ABOVE what is
11325
+ * read pushes it down. Marking too much costs a second render in that frame,
11326
+ * never a wrong picture.
11327
+ *
11328
+ * The document put back where it was read is the other frame that shows it.
11329
+ * That offset is written by navi (see scroll_restoration.js), and written into
11330
+ * a document still missing its bottom it is clamped: the page opens higher than
11331
+ * it was left, and the bottom arrives under a page that has already jumped. So
11332
+ * whoever writes an arrival's offset builds first the boxes that offset shows
11333
+ * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
11334
+ * right after it (the offset, a route travel measuring its window) reads the
11335
+ * layout.
11336
+ */
11337
+
11338
+ const deferredBoxes = new Set();
11339
+ const MountAfterPaint = ({
11340
+ boxRef,
11341
+ children
11342
+ }) => {
11343
+ const [built, setBuilt] = useState(false);
11344
+ useLayoutEffect(() => {
11345
+ let frame;
11346
+ let cancelAfterPaint;
11347
+ const stopWaiting = () => {
11348
+ deferredBoxes.delete(deferredBox);
11349
+ cancelAnimationFrame(frame);
11350
+ cancelAfterPaint();
11351
+ };
11352
+ // The first of the three that come — the offset of an arrival, the frame
11353
+ // finding the box on screen, the paint — builds it and cancels the others.
11354
+ const deferredBox = {
11355
+ getElement: () => boxRef.current,
11356
+ build: () => {
11357
+ stopWaiting();
11358
+ setBuilt(true);
11359
+ }
11360
+ };
11361
+ deferredBoxes.add(deferredBox);
11362
+ // Requested before afterPaint's own frame callback, so the box is looked at
11363
+ // before that one turns the paint into a build.
11364
+ frame = requestAnimationFrame(() => {
11365
+ const top = readTopInWindow(boxRef.current);
11366
+ if (top !== null && top < window.innerHeight) {
11367
+ deferredBox.build();
11368
+ }
11369
+ });
11370
+ cancelAfterPaint = afterPaint(deferredBox.build);
11371
+ return stopWaiting;
11372
+ }, []);
11373
+ return built ? children : null;
11374
+ };
11375
+
11376
+ /**
11377
+ * Builds, now, every box still waiting whose top is above `bottom` (in the
11378
+ * document's coordinates): what the document will show once scrolled so that
11379
+ * `bottom` is the bottom of the screen. Boxes built push those below them
11380
+ * further down, so the ones measured above `bottom` are all the ones that can
11381
+ * be shown there, and a few more at worst.
11382
+ *
11383
+ * Renders synchronously, so it must be called outside a Preact commit: a render
11384
+ * flushed from a layout effect runs inside the commit still walking the tree.
11385
+ */
11386
+ const buildDeferredBoxesAbove = bottom => {
11387
+ if (deferredBoxes.size === 0) {
11388
+ return;
11389
+ }
11390
+ const toBuild = [];
11391
+ for (const deferredBox of deferredBoxes) {
11392
+ const top = readTopInWindow(deferredBox.getElement());
11393
+ if (top !== null && top + window.scrollY < bottom) {
11394
+ toBuild.push(deferredBox);
11395
+ }
11396
+ }
11397
+ if (toBuild.length === 0) {
11398
+ return;
11399
+ }
11400
+ flushSyncRendering(() => {
11401
+ for (const deferredBox of toBuild) {
11402
+ deferredBox.build();
11403
+ }
11404
+ });
11405
+ };
11406
+
11407
+ // null for a box nothing draws (a closed popup, a hidden tab): no frame shows
11408
+ // it, wherever the document is.
11409
+ const readTopInWindow = element => {
11410
+ if (!element || element.getClientRects().length === 0) {
11411
+ return null;
11412
+ }
11413
+ return element.getBoundingClientRect().top;
11414
+ };
11415
+
11243
11416
  /**
11244
11417
  * A container has put its page on screen — or as much of it as it can.
11245
11418
  *
@@ -11295,7 +11468,9 @@ const [publishRouteRender, observeRouteRender] = createPubSub();
11295
11468
  * What is NOT covered, and cannot be from here: a page whose height depends on
11296
11469
  * something still loading. Its content is not there at the moment it is put
11297
11470
  * back, so a position beyond what has arrived is clamped as before. Only the
11298
- * page knows when it is whole.
11471
+ * page knows when it is whole. What navi itself leaves out of a first render
11472
+ * is the exception — a `<Box mount="after-paint">` — and is built before the
11473
+ * offset that shows it is written (see mount_after_paint.jsx).
11299
11474
  *
11300
11475
  * WHEN a page is arrived at is not decided here either. A document navigation
11301
11476
  * lands where its kind says (see via_history.js), and one scrollport can be
@@ -11444,14 +11619,18 @@ const installScrollRestoration = () => {
11444
11619
  // What a reload asks for, now that the browser has been told not to do it.
11445
11620
  // Once, and at the first render of a route: the position is only meaningful
11446
11621
  // once there is a page under it.
11622
+ // Written once the commit that rendered it has ended: said from inside it,
11623
+ // and what the offset shows may still have to be built (see scrollTo).
11447
11624
  const positionOnLoad = positionByUrl.get(window.location.href);
11448
11625
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11449
11626
  const stopListening = observeRouteRender(() => {
11450
11627
  stopListening();
11451
- if (documentHoldCount) {
11452
- return;
11453
- }
11454
- scrollTo(positionOnLoad);
11628
+ queueMicrotask(() => {
11629
+ if (documentHoldCount) {
11630
+ return;
11631
+ }
11632
+ scrollTo(positionOnLoad);
11633
+ });
11455
11634
  });
11456
11635
  }
11457
11636
  };
@@ -11558,14 +11737,25 @@ const forgetScrollersOnArrival = (url, { from } = {}) => {
11558
11737
  // last read. Where this one was read, and its top when it never was — leaving
11559
11738
  // the offset alone would seat the reader wherever the neighbour happened to
11560
11739
  // be, so here "nothing recorded" and "stay" are not the same thing.
11740
+ //
11741
+ // Called outside a Preact commit only, like scrollTo: a row of tabs measures
11742
+ // its window right after, so what the top shows is built first here rather
11743
+ // than in the frame (see mount_after_paint.jsx).
11561
11744
  const arriveAtScrollPosition = (url) => {
11562
11745
  if (restoreScrollPosition(url)) {
11563
11746
  return;
11564
11747
  }
11748
+ buildDeferredBoxesAbove(window.innerHeight);
11565
11749
  startAtTop(url);
11566
11750
  };
11567
11751
 
11752
+ // What the offset shows is built first: written into a document still missing
11753
+ // a deferred box, the offset is clamped (see mount_after_paint.jsx). That build
11754
+ // is a render of its own, so this is never called from inside a commit — a
11755
+ // caller that learns the page is there from a layout effect (observeRouteRender)
11756
+ // writes once that commit has ended.
11568
11757
  const scrollTo = ({ x, y }) => {
11758
+ buildDeferredBoxesAbove(y + window.innerHeight);
11569
11759
  window.scrollTo({ top: y, left: x, behavior: "instant" });
11570
11760
  };
11571
11761
 
@@ -30928,6 +31118,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30928
31118
  * footer?: boolean,
30929
31119
  * body?: boolean,
30930
31120
  * sticky?: boolean,
31121
+ * mount?: "always" | "after-paint",
30931
31122
  * children?: import("ignore:preact").ComponentChildren,
30932
31123
  * [key: string]: any,
30933
31124
  * }>}
@@ -30976,6 +31167,16 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
30976
31167
  * (see docs/interactions.md). A plain box has no wiring of its own: it does
30977
31168
  * nothing with `action` (that is a control's prop), so a click on it is
30978
31169
  * declared here like any other interaction: `interactions={{ click: fn }}`.
31170
+ * @param {"always"|"after-paint"} [mount="always"] When `children` are built.
31171
+ * `"after-paint"` leaves the box empty in the render that first mounts it and
31172
+ * builds them right after the first frame that shows the page, then keeps
31173
+ * them: for the sections of a page that lie below its first screen. navi
31174
+ * measures where the box stands before that frame, and one the frame would
31175
+ * show (or where the document is put back, on a return) is built before the
31176
+ * paint all the same. Until built, what is inside is not in the document:
31177
+ * no id there is found, no named control there is there to hear its own
31178
+ * `signal` (see docs/control_object.md). Only for content nothing reaches
31179
+ * before the first frame (see docs/scroll.md).
30979
31180
  */
30980
31181
  const Box = props => {
30981
31182
  const {
@@ -30983,6 +31184,7 @@ const Box = props => {
30983
31184
  children,
30984
31185
  separator,
30985
31186
  interactions,
31187
+ mount,
30986
31188
  ...computeProps
30987
31189
  } = props;
30988
31190
  const parentBoxFlow = useContext(BoxFlowContext);
@@ -31060,6 +31262,12 @@ const Box = props => {
31060
31262
  children: innerChildren
31061
31263
  });
31062
31264
  }
31265
+ if (mount === "after-paint") {
31266
+ innerChildren = jsx(MountAfterPaint, {
31267
+ boxRef: finalRef,
31268
+ children: innerChildren
31269
+ });
31270
+ }
31063
31271
  return jsx(TagName, {
31064
31272
  ref: finalRef,
31065
31273
  className: innerClassName,
@@ -34630,6 +34838,13 @@ const OLD_BAND_TOP_PROPERTY = "--navi-transition-old-band-top";
34630
34838
  const OLD_BAND_RIGHT_PROPERTY = "--navi-transition-old-band-right";
34631
34839
  const OLD_BAND_BOTTOM_PROPERTY = "--navi-transition-old-band-bottom";
34632
34840
  const OLD_BAND_LEFT_PROPERTY = "--navi-transition-old-band-left";
34841
+ const VIEWPORT_TOP_PROPERTY = "--navi-transition-viewport-top";
34842
+ const VIEWPORT_LEFT_PROPERTY = "--navi-transition-viewport-left";
34843
+ // Worn by the root once the group is held at the window by our numbers alone
34844
+ // (see placeTransitionWindow): the group rule of each movement is written on
34845
+ // it. Read by rules on pseudo-elements only, so wearing it restyles nothing
34846
+ // the document inherits.
34847
+ const WINDOW_PLACED_ATTRIBUTE = "data-navi-transition-window-placed";
34633
34848
  const WINDOW_PROPERTIES = [
34634
34849
  WINDOW_TOP_PROPERTY,
34635
34850
  WINDOW_LEFT_PROPERTY,
@@ -34643,6 +34858,8 @@ const WINDOW_PROPERTIES = [
34643
34858
  OLD_BAND_RIGHT_PROPERTY,
34644
34859
  OLD_BAND_BOTTOM_PROPERTY,
34645
34860
  OLD_BAND_LEFT_PROPERTY,
34861
+ VIEWPORT_TOP_PROPERTY,
34862
+ VIEWPORT_LEFT_PROPERTY,
34646
34863
  ];
34647
34864
 
34648
34865
  // Whose numbers are currently published. The window belongs to the movement
@@ -34650,6 +34867,8 @@ const WINDOW_PROPERTIES = [
34650
34867
  // after another has replaced it must not wipe numbers the new one is standing
34651
34868
  // on.
34652
34869
  let windowOwner = null;
34870
+ // The element the box is captured as, for as long as the window is held.
34871
+ let windowElement = null;
34653
34872
  // Stops re-reading the arriving state's corner, when a movement is on.
34654
34873
  let unfollowArrivingState = null;
34655
34874
 
@@ -34705,6 +34924,10 @@ const holdTransitionWindow = (
34705
34924
  const right =
34706
34925
  rectBefore.right > rectAfter.right ? rectBefore.right : rectAfter.right;
34707
34926
  windowOwner = owner;
34927
+ windowElement = element;
34928
+ // Pictures of a movement this one took over from are placed by the browser
34929
+ // until this one's own pictures exist.
34930
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
34708
34931
  setTransitionValue(WINDOW_TOP_PROPERTY, `${top}px`);
34709
34932
  setTransitionValue(WINDOW_LEFT_PROPERTY, `${left}px`);
34710
34933
  setTransitionValue(WINDOW_WIDTH_PROPERTY, `${right - left}px`);
@@ -34729,6 +34952,33 @@ const holdTransitionWindow = (
34729
34952
  });
34730
34953
  };
34731
34954
 
34955
+ /**
34956
+ * Called once the pictures exist — the view transition's `ready` — with the
34957
+ * name the box was captured under. Where the viewport's corner stands in the
34958
+ * space the pictures are drawn in is the browser's placement of the group
34959
+ * minus our reading of the same element, both taken in this one instant; the
34960
+ * group is held at the window with it from then on (see the top of the file).
34961
+ * A browser that does not hand the group's placement back leaves the group
34962
+ * where it places it, moved to the window by the arriving corner.
34963
+ */
34964
+ const placeTransitionWindow = (owner, groupName) => {
34965
+ if (owner !== windowOwner) {
34966
+ return;
34967
+ }
34968
+ const { transform } = getComputedStyle(
34969
+ document.documentElement,
34970
+ `::view-transition-group(${groupName})`,
34971
+ );
34972
+ if (!transform || transform === "none") {
34973
+ return;
34974
+ }
34975
+ const placement = new DOMMatrixReadOnly(transform);
34976
+ const rect = windowElement.getBoundingClientRect();
34977
+ setTransitionValue(VIEWPORT_TOP_PROPERTY, `${placement.m42 - rect.top}px`);
34978
+ setTransitionValue(VIEWPORT_LEFT_PROPERTY, `${placement.m41 - rect.left}px`);
34979
+ document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
34980
+ };
34981
+
34732
34982
  // The live layout takes the box back. A discontinuity by construction — the
34733
34983
  // window stands at the held height, the box is at its own — and an invisible
34734
34984
  // one: the page arriving is fully in place, and the strip below it that the
@@ -34738,20 +34988,22 @@ const releaseTransitionWindow = (owner) => {
34738
34988
  return;
34739
34989
  }
34740
34990
  windowOwner = null;
34991
+ windowElement = null;
34741
34992
  if (unfollowArrivingState) {
34742
34993
  unfollowArrivingState();
34743
34994
  }
34995
+ document.documentElement.removeAttribute(WINDOW_PLACED_ATTRIBUTE);
34744
34996
  for (const property of WINDOW_PROPERTIES) {
34745
34997
  removeTransitionValue(property);
34746
34998
  }
34747
34999
  };
34748
35000
 
34749
- // The arriving state's corner, re-read every frame — as often as the browser
34750
- // refreshes the group's placement from the same element (see the top of the
34751
- // file) — and with it the offset outside the box, which carries the window
34752
- // and the corner of the state being left along. Requested, never awaited: the
34753
- // first call runs inside the update callback, where a frame cannot come.
34754
- // Written only when it moved: every write restyles the pictures.
35001
+ // The arriving state's corner, re-read every frame — as often as anything can
35002
+ // move the page arriving (see the top of the file) — and with it the offset
35003
+ // outside the box, which carries the window and the corner of the state being
35004
+ // left along. Requested, never awaited: the first call runs inside the update
35005
+ // callback, where a frame cannot come. Written only when it moved: every write
35006
+ // restyles the pictures.
34755
35007
  const followArrivingState = (element, rectAtHold, heldAt) => {
34756
35008
  if (unfollowArrivingState) {
34757
35009
  unfollowArrivingState();
@@ -35256,6 +35508,13 @@ const css$12 = /* css */`@property --navi-route-transition-leave {
35256
35508
  animation-name: none;
35257
35509
  }
35258
35510
 
35511
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-transition) {
35512
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
35513
+ left: calc(var(--navi-transition-window-left) +
35514
+ var(--navi-transition-viewport-left));
35515
+ transform: none;
35516
+ }
35517
+
35259
35518
  &::view-transition-old(navi-route-transition) {
35260
35519
  top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
35261
35520
  left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
@@ -36303,6 +36562,8 @@ const beginTransition = ({
36303
36562
  // the only place the silent misconfigurations show. They are all about the
36304
36563
  // same thing — a movement playing on pictures that are not the pages.
36305
36564
  const viewTransitionReady = () => {
36565
+ // Read before anything below writes to the document.
36566
+ placeTransitionWindow(transition, AREA_NAME);
36306
36567
  startTransitionFurniture(transition, {
36307
36568
  duration: transition.duration
36308
36569
  });
@@ -36814,6 +37075,13 @@ const css$11 = /* css */`.navi_route_travel {
36814
37075
  animation-duration: var(--navi-route-travel-duration, .3s);
36815
37076
  animation-name: none;
36816
37077
  }
37078
+
37079
+ &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
37080
+ top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
37081
+ left: calc(var(--navi-transition-window-left) +
37082
+ var(--navi-transition-viewport-left));
37083
+ transform: none;
37084
+ }
36817
37085
  }
36818
37086
 
36819
37087
  :root[data-navi-route-travel-held] {
@@ -37192,6 +37460,10 @@ const RouteTravel = ({
37192
37460
  });
37193
37461
  });
37194
37462
  travel.viewTransition = viewTransition;
37463
+ // Subscribed first, so it reads before the scrub below writes anything.
37464
+ viewTransition.ready.then(() => {
37465
+ placeTransitionWindow(travel, TRAVEL_NAME);
37466
+ }, ignoreSkipped);
37195
37467
  if (scrub) {
37196
37468
  // Said only now: the release has to have something to let go of, and the
37197
37469
  // transition did not exist a line above.
@@ -37391,16 +37663,20 @@ const RouteTravel = ({
37391
37663
  // what makes the document tall enough to hold that offset again —
37392
37664
  // and the recording stays deaf until then, over the clamp the swap
37393
37665
  // back makes on the way. Its own deafness rather than the travel's:
37394
- // the travel ends here, and this outlives it by a render.
37666
+ // the travel ends here, and this outlives it by a render. Written
37667
+ // once the commit that says so has ended, as arriveAtScrollPosition
37668
+ // asks.
37395
37669
  const resumeScrollRecording = suspendScrollRecording();
37396
37670
  const stopWatchingRender = observeRouteRender(() => {
37397
37671
  stopWatchingRender();
37398
- const {
37399
- route,
37400
- params
37401
- } = travel.fromPage;
37402
- arriveAtScrollPosition(route.buildUrl(params));
37403
- resumeScrollRecording();
37672
+ queueMicrotask(() => {
37673
+ const {
37674
+ route,
37675
+ params
37676
+ } = travel.fromPage;
37677
+ arriveAtScrollPosition(route.buildUrl(params));
37678
+ resumeScrollRecording();
37679
+ });
37404
37680
  });
37405
37681
  }
37406
37682
  releaseRendering();
@@ -40184,34 +40460,6 @@ registerNaviCommand("--navi-unselect", (source, event) => {
40184
40460
  };
40185
40461
  });
40186
40462
 
40187
- /**
40188
- * Runs `fn` and commits whatever it re-renders before returning, instead of
40189
- * letting Preact batch it into the next microtask. Layout effects of what gets
40190
- * mounted run inside the call too, exactly as they would on any other commit.
40191
- *
40192
- * For the caller that has to read the DOM it just asked for — measuring an
40193
- * element whose content it mounts in the same breath — and cannot wait a tick
40194
- * to do it, because what comes after is a browser event still in flight
40195
- * (preventDefault, focus placement) that no longer accepts being answered late.
40196
- *
40197
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
40198
- * render queue drains; swapping it for "right now" for the duration of the call
40199
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
40200
- * case above: rendering synchronously in the middle of an event gives up the
40201
- * batching that makes several state changes one commit.
40202
- */
40203
- const flushSyncRendering = (fn) => {
40204
- const debounceRenderingPrevious = options.debounceRendering;
40205
- options.debounceRendering = (drainRenderQueue) => {
40206
- drainRenderQueue();
40207
- };
40208
- try {
40209
- fn();
40210
- } finally {
40211
- options.debounceRendering = debounceRenderingPrevious;
40212
- }
40213
- };
40214
-
40215
40463
  /**
40216
40464
  * When a popup builds what it holds, and when it throws it away.
40217
40465
  *
@@ -42534,6 +42782,65 @@ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
42534
42782
  }
42535
42783
  return kept;
42536
42784
  };
42785
+ // The same claim, for an object: a key whose control is not there cannot be
42786
+ // contradicted by it — a slide not built yet (SlideContainer mount="near"), a
42787
+ // row scrolled out, a page parked. A key of `kept` stays until a control of
42788
+ // that name, there, says otherwise; a value put ON the group replaces the
42789
+ // whole object, which is how a key is dropped on purpose.
42790
+ const keptAndNamedChildUIStates = (children, fallbackState, kept) => {
42791
+ const groupValues =
42792
+ kept !== null && typeof kept === "object" && !Array.isArray(kept)
42793
+ ? { ...kept }
42794
+ : {};
42795
+ return Object.assign(
42796
+ groupValues,
42797
+ readNamedChildUIStates(children, { warnNameless: true }),
42798
+ );
42799
+ };
42800
+ // What the named controls among `children` say, one key each. A nameless
42801
+ // GROUP is a grouping, not a value: it exists to hold its children together
42802
+ // (a WheelGroup sharing navigation, a fieldset-ish cluster) without claiming a
42803
+ // key of its own, so what its controls say is written as if they were here.
42804
+ // Naming it is what turns the same group into one key — see ControlGroup's
42805
+ // `name`. Only what its CONTROLS say: such a grouping is handed the whole
42806
+ // object around it (see distributeChildUIState below) and keeps what they do
42807
+ // not answer for, so merged whole it would carry its siblings' keys back up,
42808
+ // stale.
42809
+ const readNamedChildUIStates = (children, { warnNameless }) => {
42810
+ const values = {};
42811
+ for (const child of children) {
42812
+ const { name, emptyUIState } = child;
42813
+ // A control holding nothing writes its own empty, not a hole: the key is in
42814
+ // the object either way, and what is read from it keeps the shape the
42815
+ // reader was promised (see resolveEmptyUIState).
42816
+ const uiState =
42817
+ child.uiState === undefined && emptyUIState !== undefined
42818
+ ? emptyUIState
42819
+ : child.uiState;
42820
+ if (!name) {
42821
+ if (isNamelessGrouping(child, uiState)) {
42822
+ Object.assign(
42823
+ values,
42824
+ child.keepsUnansweredKeys
42825
+ ? readNamedChildUIStates(child.getChildControllers(), {
42826
+ warnNameless: false,
42827
+ })
42828
+ : uiState,
42829
+ );
42830
+ continue;
42831
+ }
42832
+ if (warnNameless) {
42833
+ console.warn(
42834
+ "A group child is missing a name property, its state won't be included in the group state",
42835
+ child,
42836
+ );
42837
+ }
42838
+ continue;
42839
+ }
42840
+ values[name] = uiState;
42841
+ }
42842
+ return values;
42843
+ };
42537
42844
 
42538
42845
  // Default aggregate/distribute implementations keyed by controlType or stateType.
42539
42846
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
@@ -42603,37 +42910,7 @@ const GROUP_DEFAULTS = {
42603
42910
  childControlFilter: (child) => {
42604
42911
  return child.controlType !== "button" && child.controlType !== "link";
42605
42912
  },
42606
- aggregateChildStates: (children) => {
42607
- const groupValues = {};
42608
- for (const child of children) {
42609
- const { name, emptyUIState } = child;
42610
- // A control holding nothing writes its own empty, not a hole: the key
42611
- // is in the object either way, and what is read from it keeps the shape
42612
- // the reader was promised (see resolveEmptyUIState).
42613
- const uiState =
42614
- child.uiState === undefined && emptyUIState !== undefined
42615
- ? emptyUIState
42616
- : child.uiState;
42617
- if (!name) {
42618
- // A nameless GROUP is a grouping, not a value: it exists to hold its
42619
- // children together (a WheelGroup sharing navigation, a fieldset-ish
42620
- // cluster) without claiming a key of its own, so what it holds is
42621
- // merged in as if its children had been written here. Naming it is
42622
- // what turns the same group into one key — see ControlGroup's `name`.
42623
- if (isNamelessGrouping(child, uiState)) {
42624
- Object.assign(groupValues, uiState);
42625
- continue;
42626
- }
42627
- console.warn(
42628
- "A group child is missing a name property, its state won't be included in the group state",
42629
- child,
42630
- );
42631
- continue;
42632
- }
42633
- groupValues[name] = uiState;
42634
- }
42635
- return groupValues;
42636
- },
42913
+ aggregateChildStates: keptAndNamedChildUIStates,
42637
42914
  distributeChildUIState: (newUIState, child) => {
42638
42915
  const childName = child.name;
42639
42916
  if (
@@ -42657,7 +42934,8 @@ const GROUP_DEFAULTS = {
42657
42934
  return childUIState;
42658
42935
  }
42659
42936
  // Merged in on the way up (see above), so on the way down it takes the
42660
- // whole object and picks out its own keys — the same value it produced.
42937
+ // whole object: its controls pick out their own keys, and it keeps the
42938
+ // rest for those of its controls that are not there yet.
42661
42939
  // Only for a child with no name of its own: the same condition the
42662
42940
  // aggregate applies before merging. A NAMED child holding an object (a
42663
42941
  // `type="object"` picker, say) answers for one key, and the object it is
@@ -43145,6 +43423,11 @@ const useUIGroupStateController = (
43145
43423
  // a picker filling its popup, a value prop) rather than worked out from
43146
43424
  // its children. What it protects is read in onChange.
43147
43425
  stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
43426
+ // Its value carries keys none of its controls answers for (see
43427
+ // keptAndNamedChildUIStates): a group merging it as a nameless
43428
+ // grouping reads its controls instead of its value.
43429
+ keepsUnansweredKeys:
43430
+ resolvedAggregateChildStates === keptAndNamedChildUIStates,
43148
43431
  uiStateSignal,
43149
43432
  wantRequesterButtonState,
43150
43433
  ref,
@@ -53746,7 +54029,8 @@ const useExpandableContext = partName => {
53746
54029
  * other three, intent on the UI part (pointer entering it, focus landing in
53747
54030
  * it) builds the content ahead of the click; `"while-opened"` content is
53748
54031
  * built by a press on it only, and thrown away if that press does not
53749
- * expand it.
54032
+ * expand it. Named controls in content not built are kept by the group
54033
+ * around them (docs/control_object.md, "A control that is not there").
53750
54034
  */
53751
54035
  const Expandable = props => {
53752
54036
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -60837,12 +61121,19 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
60837
61121
  * would bring alongside (neighbours on the map, along the axes
60838
61122
  * `travelByDrag` opens, the other end of a `loop` included) — and kept from
60839
61123
  * then on. For slides that are costly to build (a grid of a few hundred
60840
- * controls each): opening builds the slide shown and its neighbours rather
61124
+ * items each): opening builds the slide shown and its neighbours rather
60841
61125
  * than all of them, and a jump to a far slide builds that one and none of
60842
61126
  * those in between, which a travel never shows. The box is then as large as
60843
61127
  * the largest slide BUILT so far (see `sizing`), so it grows as a larger one
60844
61128
  * is reached: give it a size of its own when the slides differ.
60845
61129
  * `<Slide mount>` says it for one slide — a heavy one among light ones.
61130
+ * A slide not built has no controls, and a group around the container keeps
61131
+ * their keys for them: what it was handed reaches them once built. What is
61132
+ * shared between slides is written through that group (its `signal`, a
61133
+ * `--navi-update` aimed at it), never into one control's own `signal`, which
61134
+ * nobody reads while that control is not built; and nothing validates a
61135
+ * field before its slide is built. See docs/control_object.md, "A control
61136
+ * that is not there".
60846
61137
  */
60847
61138
  const SlideContainer = ({
60848
61139
  layout = "row",
@@ -70926,44 +71217,6 @@ document.addEventListener(
70926
71217
  { capture: true, passive: true },
70927
71218
  );
70928
71219
 
70929
- const FRAME_TIMEOUT_MS = 100;
70930
-
70931
- /**
70932
- * Calls back once the browser has painted what is committed now.
70933
- *
70934
- * The callbacks of the next frame run before that frame paints, so the call is
70935
- * made from a task queued from inside one — the same way preact schedules
70936
- * useEffect. Unlike an effect, nothing runs this early: preact flushes a
70937
- * component's pending effects as soon as that component renders again, and a
70938
- * re-render before the paint is exactly what a caller waiting for the paint
70939
- * has to survive. A frame not coming at all (a background tab) still answers,
70940
- * late, through the timeout.
70941
- *
70942
- * @param {() => void} callback
70943
- * @returns {() => void} cancel
70944
- */
70945
- const afterPaint = (callback) => {
70946
- let called = false;
70947
- let timeoutId;
70948
- let frameId;
70949
- const onFrame = () => {
70950
- if (called) {
70951
- return;
70952
- }
70953
- called = true;
70954
- clearTimeout(timeoutId);
70955
- cancelAnimationFrame(frameId);
70956
- timeoutId = setTimeout(callback, 0);
70957
- };
70958
- timeoutId = setTimeout(onFrame, FRAME_TIMEOUT_MS);
70959
- frameId = requestAnimationFrame(onFrame);
70960
- return () => {
70961
- called = true;
70962
- clearTimeout(timeoutId);
70963
- cancelAnimationFrame(frameId);
70964
- };
70965
- };
70966
-
70967
71220
  const LoadingDotsSvg = () => {
70968
71221
  return jsxs("svg", {
70969
71222
  viewBox: "0 0 200 200",