@jsenv/navi 0.29.52 → 0.29.53

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.
@@ -13083,6 +13083,69 @@ const setActionPrivateProperties = (action, properties) => {
13083
13083
  actionPrivatePropertiesWeakMap.set(action, properties);
13084
13084
  };
13085
13085
 
13086
+ /**
13087
+ * Where an action error goes when nothing displays it.
13088
+ *
13089
+ * An action that fails writes the error into its `errorSignal` and stops there.
13090
+ * It cannot know whether a screen is going to show it: at the instant it fails,
13091
+ * the screen that will is often not even mounted — a route action runs before
13092
+ * its page renders, which is precisely the case a guess made at failure time
13093
+ * gets wrong. So nothing is guessed. The error is let go, and whoever displays
13094
+ * it SAYS so by marking it; what is still unmarked once the DOM has had its
13095
+ * chance was displayed by nobody, and only that is reported as unhandled.
13096
+ *
13097
+ * The mark is `__handled_by__`, the same one the jsenv supervisor reads to stay
13098
+ * out of the way of an error the app is already showing — one mark, one meaning:
13099
+ * "this is on screen somewhere".
13100
+ *
13101
+ * The whole picture, control errors and validation included: docs/error_handling.md
13102
+ */
13103
+
13104
+ const markErrorAsDisplayedBy = (error, by) => {
13105
+ if (error && typeof error === "object") {
13106
+ error.__handled_by__ = by;
13107
+ }
13108
+ };
13109
+
13110
+ const errorIsDisplayed = (error) => {
13111
+ return Boolean(error && error.__handled_by__);
13112
+ };
13113
+
13114
+ /**
13115
+ * Reported from a macrotask: every render that could display the error —
13116
+ * Preact's own queue, a Suspense boundary settling on the failure, the error
13117
+ * boundary above it — happens in microtasks, so by the time this runs the
13118
+ * answer is final. A screen that would display the error much later than that
13119
+ * (mounted by something slower than a render) is reported anyway; it is the
13120
+ * one case where this says "nobody" a bit too early, and the report is then a
13121
+ * duplicate of what the screen shows rather than a lie about it.
13122
+ *
13123
+ * Rethrown rather than logged: an error nobody shows is an unhandled error, and
13124
+ * the runtime already knows what to do with those (window "error" event, jsenv
13125
+ * overlay in dev). Same trick preact/debug uses for the same reason.
13126
+ */
13127
+ const errorReportedSet = new WeakSet();
13128
+ const reportErrorIfNobodyDisplaysIt = (error, { action } = {}) => {
13129
+ setTimeout(() => {
13130
+ if (errorIsDisplayed(error)) {
13131
+ return;
13132
+ }
13133
+ if (error && typeof error === "object") {
13134
+ // The same error can reach here from more than one direction (the run
13135
+ // that produced it, the routing promise carrying it): it is one error and
13136
+ // it is reported once.
13137
+ if (errorReportedSet.has(error)) {
13138
+ return;
13139
+ }
13140
+ errorReportedSet.add(error);
13141
+ }
13142
+ if (action && error && typeof error === "object" && !error.action) {
13143
+ error.action = action;
13144
+ }
13145
+ throw error;
13146
+ });
13147
+ };
13148
+
13086
13149
  const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
13087
13150
 
13088
13151
  let DEBUG$1 = false;
@@ -13916,7 +13979,6 @@ const createAction = (callback, rootOptions = {}) => {
13916
13979
  const ui = {
13917
13980
  renderLoaded: null,
13918
13981
  renderLoadedAsync,
13919
- hasRenderers: false, // Flag to track if action is bound to UI components
13920
13982
  };
13921
13983
  let sideEffectCleanup;
13922
13984
  let completeSideEffectCleanup;
@@ -14058,28 +14120,27 @@ const createAction = (callback, rootOptions = {}) => {
14058
14120
  return error;
14059
14121
  }
14060
14122
  if (DEBUG$1) {
14061
- console.log(
14062
- `"${action}": failed (error: ${error}, handled by ui: ${ui.hasRenderers})`,
14063
- );
14123
+ console.log(`"${action}": failed (error: ${error})`);
14064
14124
  }
14125
+ error.action = action;
14065
14126
  batch(() => {
14066
14127
  errorSignal.value = error;
14067
14128
  runningStateSignal.value = FAILED;
14068
14129
  onError?.(error, { event, action, args });
14069
14130
  });
14070
14131
 
14071
- if (ui.hasRenderers || onError) {
14072
- // When inside suspense this console.error is redundant with the error thrown by preact debug at
14073
- // https://github.com/preactjs/preact/blob/21dd6d04c1a9a43e5b60976bb5eb7d856253195b/debug/src/debug.js#L109
14074
- console.error(error);
14075
- // For UI-bound actions: error is properly handled by logging + UI display
14076
- // Return error instead of throwing to signal it's handled and prevent:
14077
- // - jsenv error overlay from appearing
14078
- // - error being treated as unhandled by runtime
14079
- return error;
14132
+ // The error is in errorSignal; from here it is the UI's, and running
14133
+ // the action is not the place to decide whether the UI wants it
14134
+ // that answer does not exist yet at this instant (see
14135
+ // action_error_report.js). So the run never throws: it settles with
14136
+ // the error as its value, and being displayed or not is constated
14137
+ // afterwards, in one place, by one rule.
14138
+ if (onError) {
14139
+ // Asking for the error IS taking it.
14140
+ markErrorAsDisplayedBy(error, "onError");
14080
14141
  }
14081
- error.action = action;
14082
- throw error;
14142
+ reportErrorIfNobodyDisplaysIt(error, { action });
14143
+ return error;
14083
14144
  };
14084
14145
 
14085
14146
  try {
@@ -14440,15 +14501,8 @@ const createActionProxyFromSignal = (
14440
14501
  performReset: proxyPrivateMethod("performReset"),
14441
14502
  ui: currentActionPrivateProperties.ui,
14442
14503
  };
14443
- onActionTargetChange((actionTarget, previousTarget) => {
14504
+ onActionTargetChange(() => {
14444
14505
  proxyPrivateProperties.ui = currentActionPrivateProperties.ui;
14445
- if (previousTarget && actionTarget) {
14446
- const previousPrivateProps = getActionPrivateProperties(previousTarget);
14447
- if (previousPrivateProps.ui.hasRenderers) {
14448
- const newPrivateProps = getActionPrivateProperties(actionTarget);
14449
- newPrivateProps.ui.hasRenderers = true;
14450
- }
14451
- }
14452
14506
  proxyPrivateProperties.childActionWeakSet =
14453
14507
  currentActionPrivateProperties.childActionWeakSet;
14454
14508
  });
@@ -21701,7 +21755,18 @@ const setupBrowserIntegrationViaHistory = ({
21701
21755
  // something at the first announcement has a definite place to give it back.
21702
21756
  publishBeforeRouting({ url, ...options });
21703
21757
  try {
21704
- return applyRoutingTask(url, options);
21758
+ const routingResult = applyRoutingTask(url, options);
21759
+ if (routingResult && typeof routingResult.then === "function") {
21760
+ // Every caller below drops this value — a click handler has nothing to
21761
+ // do with what the routing returns — so a rejection here would become
21762
+ // an anonymous unhandled one, pointing at the navigation rather than at
21763
+ // what failed. It goes to the single place that knows what to do with
21764
+ // an error nobody displays (see action_error_report.js).
21765
+ routingResult.catch((e) => {
21766
+ reportErrorIfNobodyDisplaysIt(e);
21767
+ });
21768
+ }
21769
+ return routingResult;
21705
21770
  } finally {
21706
21771
  publishAfterRouting({ url, ...options });
21707
21772
  }
@@ -21777,6 +21842,9 @@ const setupBrowserIntegrationViaHistory = ({
21777
21842
  isVisited,
21778
21843
  state,
21779
21844
  });
21845
+ if (navigationType === "push") {
21846
+ startAtTop(url);
21847
+ }
21780
21848
  executeWithCleanup(
21781
21849
  () => allResult,
21782
21850
  () => {
@@ -21925,6 +21993,36 @@ const setupBrowserIntegrationViaHistory = ({
21925
21993
  };
21926
21994
  };
21927
21995
 
21996
+ // A page one arrives at for the first time starts at its top. Only a document
21997
+ // navigation does that on its own: a pushState creates its entry with whatever
21998
+ // scroll happened to be there, so without this the new page opens at the offset
21999
+ // of the one before it — and worse, that borrowed offset is what the browser
22000
+ // then remembers FOR that entry, and hands back on the way forward.
22001
+ //
22002
+ // Push only. A traverse is the browser's business and it is already right: it
22003
+ // keeps a position per entry and restores it. A replace is not an arrival —
22004
+ // it is the same place, said differently (a tab row travelling, see
22005
+ // route_travel.jsx), and resetting there would throw the reader out of a page
22006
+ // they never left.
22007
+ //
22008
+ // After the routes have been told, and that ordering is the whole subtlety:
22009
+ // the routes changing is what sets a travel off, and a travel measures the box
22010
+ // it is leaving as it stands. Reset before that and the picture of the page
22011
+ // being left is taken at the top of a page the reader was not at the top of —
22012
+ // it is then watched jumping back to its first line before it even begins to
22013
+ // leave (see holdTravelGeometry in route_travel.jsx). After pushState too, so
22014
+ // the entry being left keeps the offset it is at.
22015
+ //
22016
+ // The document, because the document is the scrollport in the common case. An
22017
+ // app that scrolls an element of its own scrolls it itself.
22018
+ const startAtTop = (url) => {
22019
+ // A fragment names where to land, and the browser is the one that finds it.
22020
+ if (new URL(url, window.location.href).hash) {
22021
+ return;
22022
+ }
22023
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
22024
+ };
22025
+
21928
22026
  let updateRoutes;
21929
22027
 
21930
22028
  const applyActions = (params) => {
@@ -32227,18 +32325,6 @@ const ActionRenderer = ({
32227
32325
  } = useActionStatus(action);
32228
32326
  const UIRenderedPromise = useUIRenderedPromise(action);
32229
32327
  const [errorBoundary, resetErrorBoundary] = useErrorBoundary();
32230
-
32231
- // Mark this action as bound to UI components (has renderers)
32232
- // This tells the action system that errors should be caught and stored
32233
- // in the action's error state rather than bubbling up
32234
- useLayoutEffect(() => {
32235
- if (action) {
32236
- const {
32237
- ui
32238
- } = getActionPrivateProperties(action);
32239
- ui.hasRenderers = true;
32240
- }
32241
- }, [action]);
32242
32328
  useLayoutEffect(() => {
32243
32329
  resetErrorBoundary();
32244
32330
  }, [action, loading, idle, resetErrorBoundary]);
@@ -32266,6 +32352,8 @@ const ActionRenderer = ({
32266
32352
  return renderIdle(action);
32267
32353
  }
32268
32354
  if (errorBoundary) {
32355
+ // Displaying it is what makes it handled (see action_error_report.js)
32356
+ markErrorAsDisplayedBy(errorBoundary, "<ActionRenderer>");
32269
32357
  return renderError(errorBoundary, "ui_error", action);
32270
32358
  }
32271
32359
  if (aborted) {
@@ -32291,6 +32379,7 @@ const ActionRenderer = ({
32291
32379
  return renderLoading(action);
32292
32380
  }
32293
32381
  if (error) {
32382
+ markErrorAsDisplayedBy(error, "<ActionRenderer>");
32294
32383
  return renderError(error, "action_error", action);
32295
32384
  }
32296
32385
  return renderCompletedSafe(data, action);
@@ -35773,13 +35862,17 @@ const useActionAsyncData = (action, {
35773
35862
  }
35774
35863
  const actionError = action.errorSignal.peek();
35775
35864
  if (errorEffect === "use") {
35865
+ // Handed to the component, which is what displays it from here on
35866
+ // (see action_error_report.js)
35867
+ markErrorAsDisplayedBy(actionError, "useAsyncData({ error: true })");
35776
35868
  const dismissError = () => {
35777
35869
  dismissedActionWeakSet.add(action);
35778
35870
  setTick(n => n + 1);
35779
35871
  };
35780
35872
  return [undefined, false, actionError, dismissError];
35781
35873
  }
35782
- actionError.action = action;
35874
+ // Not marked: nothing is displayed yet — the boundary that catches this is
35875
+ // what says so, and only if it has something to show.
35783
35876
  throw actionError;
35784
35877
  }
35785
35878
 
@@ -35917,8 +36010,34 @@ const LoadingFallback = ({
35917
36010
  };
35918
36011
 
35919
36012
  // ─── ErrorBoundary ────────────────────────────────────────────────────────────
35920
- // Catches errors thrown by useAction. Subscribes to error.action so it
35921
- // auto-resets when the action runs again.
36013
+ /**
36014
+ * Displays what its subtree throws — an action failure delegated by
36015
+ * `useAsyncData`, or any render error under it.
36016
+ *
36017
+ * Two things it gets right that a hand-written boundary rarely does, both
36018
+ * explained in docs/error_handling.md:
36019
+ *
36020
+ * - It marks the error as displayed ONLY when it actually displays it.
36021
+ * `preact/debug` rethrows every error a boundary caught in a `setTimeout`, on
36022
+ * purpose (React devtools compatibility), so a handled error still reaches
36023
+ * window and the jsenv overlay covers the app unless `__handled_by__` is set.
36024
+ * Setting it before knowing whether anything is rendered turns a boundary into
36025
+ * a bug swallower: a TypeError in a component becomes a blank page AND a
36026
+ * silent one. Without a `fallback` there is nothing to display, so the error is
36027
+ * left alone and continues up.
36028
+ *
36029
+ * - It resets on navigation, not only on rerun. Rerunning the failed action is
36030
+ * one way out; going somewhere else is the common one. Without a reset on the
36031
+ * document URL, the error stays in place of every page after it, including the
36032
+ * ones that would render fine.
36033
+ *
36034
+ * @param {object} props
36035
+ * @param {Function|import("ignore:preact").VNode} [props.fallback] - what is displayed
36036
+ * instead of the children: an element, or a component receiving
36037
+ * `{ error, resetError }`. Without it the boundary is transparent.
36038
+ * @param {() => void} [props.onReset] - called when the fallback dismisses the
36039
+ * error via its `resetError`.
36040
+ */
35922
36041
  const ErrorBoundary = ({
35923
36042
  children,
35924
36043
  fallback,
@@ -35932,9 +36051,30 @@ const ErrorBoundary = ({
35932
36051
  cleanupRef.current?.();
35933
36052
  };
35934
36053
  }, []);
35935
- if (error) {
35936
- error.__handled_by__ = "<ErrorBoundary>"; // prevent jsenv from displaying it
35937
36054
 
36055
+ // The error belongs to the page that failed: leaving it means leaving it
36056
+ // behind.
36057
+ useEffect(() => {
36058
+ if (!error) {
36059
+ return undefined;
36060
+ }
36061
+ const documentUrlWhenCaught = documentUrlSignal.peek();
36062
+ return documentUrlSignal.subscribe(documentUrl => {
36063
+ // subscribe() calls back synchronously with the current value
36064
+ if (documentUrl === documentUrlWhenCaught) {
36065
+ return;
36066
+ }
36067
+ setDismissed(false);
36068
+ resetError();
36069
+ });
36070
+ }, [error]);
36071
+ if (error) {
36072
+ if (!fallback) {
36073
+ // Nothing to display means nothing handled: rethrow untouched so the
36074
+ // error reaches whoever can do something with it (an outer boundary, or
36075
+ // the dev overlay).
36076
+ throw error;
36077
+ }
35938
36078
  const action = error.action;
35939
36079
  if (action) {
35940
36080
  cleanupRef.current?.();
@@ -35964,9 +36104,7 @@ const ErrorBoundary = ({
35964
36104
  setDismissed(true);
35965
36105
  resetError();
35966
36106
  };
35967
- if (!fallback) {
35968
- return null;
35969
- }
36107
+ markErrorAsDisplayedBy(error, "<ErrorBoundary>"); // displayed here, so nothing else has to
35970
36108
  if (typeof fallback === "function") {
35971
36109
  return h(fallback, {
35972
36110
  error,
@@ -37368,10 +37506,24 @@ const debug$1 = (...args) => {
37368
37506
  }
37369
37507
  };
37370
37508
 
37371
- // <Route> dispatches based on props:
37372
- // - children RouteContainer (traverses children statically, renders active branch)
37373
- // - route RouteLeafRoute (rendered by parent container when URL matches)
37374
- // - fallback RouteActive (rendered by parent container when no sibling matches)
37509
+ /**
37510
+ * Dispatches on its props:
37511
+ * - children RouteContainer (traverses children statically, renders active branch)
37512
+ * - route RouteLeafRoute (rendered by parent container when URL matches)
37513
+ * - fallback → RouteActive (rendered by parent container when no sibling matches)
37514
+ *
37515
+ * @param {object} props
37516
+ * @param {object} [props.route] - the route this branch is for, from `route()`
37517
+ * @param {object} [props.routeParams] - selects a branch on a param of that route
37518
+ * @param {boolean} [props.fallback] - the branch taken when no sibling matches
37519
+ * @param {Function|import("ignore:preact").VNode} [props.element] - what the branch renders
37520
+ * @param {object} [props.elementProps] - props given to `element`
37521
+ *
37522
+ * A branch says what it renders, not what happens when it cannot: loading and
37523
+ * error states are delegated to `<Loading>` and `<ErrorBoundary>` ancestors,
37524
+ * which may be written between routes (a container reads through them, see
37525
+ * collectBranches).
37526
+ */
37375
37527
  const Route = props => {
37376
37528
  if (props.children) {
37377
37529
  return jsx(RouteContainer, {
@@ -37413,6 +37565,9 @@ const collectRoutePages = children => {
37413
37565
  return;
37414
37566
  }
37415
37567
  if (child.type !== Route) {
37568
+ // Something written between routes — <Loading>, <ErrorBoundary>, a box of
37569
+ // the app's own. The pages are inside it (see collectBranches).
37570
+ visit(child.props && child.props.children);
37416
37571
  return;
37417
37572
  }
37418
37573
  const {
@@ -37475,8 +37630,8 @@ const RouteContainer = ({
37475
37630
  return content;
37476
37631
  };
37477
37632
  // Walk JSX children vnodes (without rendering) to build a branch list and
37478
- // find the active one in the same pass.
37479
- // All children must be <Route> throws in dev otherwise.
37633
+ // find the active one in the same pass. Anything that is not a <Route> is read
37634
+ // through and kept around the branch it holds (see below).
37480
37635
  // Returns { matchingBranch, fallbackBranch, activeBranch }.
37481
37636
  const collectBranches = children => {
37482
37637
  let matchingBranch = null;
@@ -37492,7 +37647,31 @@ const collectBranches = children => {
37492
37647
  return;
37493
37648
  }
37494
37649
  if (child.type !== Route) {
37495
- throw new Error(`All <Route> children must be <Route> nodes, got: ${String(child.type?.name ?? child.type)}`);
37650
+ // Anything else is a wrapper around branches, and the two that matter are
37651
+ // navi's own: a page says what it renders and delegates what it cannot —
37652
+ // loading to <Loading>, failing to <ErrorBoundary> — so those are written
37653
+ // BETWEEN the container and its routes. Reading through them is what lets
37654
+ // a subtree of pages share one, instead of the router demanding that its
37655
+ // children be routes and pushing every boundary outside of it.
37656
+ //
37657
+ // The wrapper is kept around whatever it holds: the container renders the
37658
+ // active branch alone, so the branch has to carry the wrapper with it, or
37659
+ // being selected would mean losing what was written around it.
37660
+ const wrapperChildren = child.props && child.props.children;
37661
+ if (!wrapperChildren) {
37662
+ throw new Error(`A <Route> child must be a <Route>, or hold some: ${String(child.type?.name ?? child.type)} holds nothing.`);
37663
+ }
37664
+ const {
37665
+ matchingBranch: matchingInside,
37666
+ fallbackBranch: fallbackInside
37667
+ } = collectBranches(wrapperChildren);
37668
+ if (matchingInside && !matchingBranch) {
37669
+ matchingBranch = wrapBranch(matchingInside, child);
37670
+ }
37671
+ if (fallbackInside && !fallbackBranch) {
37672
+ fallbackBranch = wrapBranch(fallbackInside, child);
37673
+ }
37674
+ return;
37496
37675
  }
37497
37676
  const {
37498
37677
  children: nodeChildren,
@@ -37549,6 +37728,12 @@ const collectBranches = children => {
37549
37728
  activeBranch
37550
37729
  };
37551
37730
  };
37731
+ const wrapBranch = (branch, wrapper) => {
37732
+ return {
37733
+ ...branch,
37734
+ node: cloneElement(wrapper, null, branch.node)
37735
+ };
37736
+ };
37552
37737
  const RouteLeaf = props => {
37553
37738
  if (props.route) {
37554
37739
  return jsx(RouteLeafRoute, {
@@ -37644,6 +37829,19 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
37644
37829
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
37645
37830
  // The name the box wears while it travels, and only then (see nameForTravel).
37646
37831
  const TRAVEL_NAME = "navi-route-travel";
37832
+ // Where the two boxes of a travel stand in the window, published for the
37833
+ // length of it. Measurements only: what is DERIVED from them — where a picture
37834
+ // goes, what a bar covers — is derived in the CSS below, so the app's own
37835
+ // numbers (the room its fixed bars take) can take part in it. Only the
37836
+ // measuring needs JS, and only for the one moment both boxes exist (see
37837
+ // holdTravelGeometry).
37838
+ const TRAVEL_TOP_PROPERTY = "--navi-route-travel-top";
37839
+ const TRAVEL_LEFT_PROPERTY = "--navi-route-travel-left";
37840
+ const TRAVEL_WIDTH_PROPERTY = "--navi-route-travel-width";
37841
+ const TRAVEL_HEIGHT_PROPERTY = "--navi-route-travel-height";
37842
+ const TRAVEL_OLD_TOP_PROPERTY = "--navi-route-travel-old-top";
37843
+ const TRAVEL_OLD_LEFT_PROPERTY = "--navi-route-travel-old-left";
37844
+ const TRAVEL_GEOMETRY_PROPERTIES = [TRAVEL_TOP_PROPERTY, TRAVEL_LEFT_PROPERTY, TRAVEL_WIDTH_PROPERTY, TRAVEL_HEIGHT_PROPERTY, TRAVEL_OLD_TOP_PROPERTY, TRAVEL_OLD_LEFT_PROPERTY];
37647
37845
  const css$R = /* css */`
37648
37846
  /* The name that makes the page inside this box a picture of its own during a
37649
37847
  transition — rather than part of the one big picture the document takes, so
@@ -37716,6 +37914,23 @@ const css$R = /* css */`
37716
37914
  same page changing its mind. */
37717
37915
  mix-blend-mode: normal;
37718
37916
  }
37917
+ &::view-transition-old(navi-route-travel) {
37918
+ /* Where the page being left WAS on screen, which is not where the group
37919
+ stands: the group is at the arriving box (its position animation is
37920
+ dropped along with its height one, below), and the two boxes are at the
37921
+ same place in the layout without being at the same place in the window
37922
+ — one page is scrolled and the other is not, so the box being left
37923
+ starts higher up. Left at the group's own corner the page being left
37924
+ would be seen jumping back to its top before it even begins to leave.
37925
+ Offset here rather than by \`translate\`, which the movement itself uses,
37926
+ and at its own size rather than the group's so that nothing is cut off
37927
+ the far side of the shift (see holdTravelGeometry). */
37928
+ top: calc(var(${TRAVEL_OLD_TOP_PROPERTY}) - var(${TRAVEL_TOP_PROPERTY}));
37929
+ left: calc(
37930
+ var(${TRAVEL_OLD_LEFT_PROPERTY}) - var(${TRAVEL_LEFT_PROPERTY})
37931
+ );
37932
+ width: auto;
37933
+ }
37719
37934
  /* The pages are cut at the edge of the box they travel in. Said HERE and
37720
37935
  nowhere else: these pictures are drawn in the top layer, so no overflow
37721
37936
  on any element of the document — not the box's own, not a frame around
@@ -37727,7 +37942,7 @@ const css$R = /* css */`
37727
37942
  }
37728
37943
  &::view-transition-group(navi-route-travel) {
37729
37944
  /* The window the two pictures are seen through, held still for the whole
37730
- travel at the taller of the two boxes (see holdTravelHeight): the group
37945
+ travel at the taller of the two boxes (see holdTravelGeometry): the group
37731
37946
  is what CLIPS, and the browser animates its height from the box being
37732
37947
  left to the box arriving — so the window shrinks under the pictures and
37733
37948
  cuts the page leaving from the bottom, progressively. The box does end
@@ -37738,7 +37953,44 @@ const css$R = /* css */`
37738
37953
  winning against it with !important — which also drops its position
37739
37954
  animation, fine while a travel box stands in the same place from one
37740
37955
  route to the next. */
37741
- height: var(--navi-route-travel-height);
37956
+ height: var(${TRAVEL_HEIGHT_PROPERTY});
37957
+
37958
+ /* Cut at the safe area, on top of being cut at the box. The pictures are
37959
+ drawn in the top layer, so they cover a fixed bar as easily as anything
37960
+ else — and the box they travel in runs UNDER the bars by design: that
37961
+ is what a fixed bar is for, and what the room it gives back is for. A
37962
+ box scrolled by so much as a pixel therefore starts above the top bar
37963
+ and ends below the bottom one, and the travel would be watched painting
37964
+ over both for its whole length.
37965
+
37966
+ The band left free is the app's own safe area (see layout/safe_area.js)
37967
+ — every kind of furniture at once, not the bars alone, and read rather
37968
+ than asked for, so one that grows, shrinks or unmounts mid-travel is
37969
+ followed without anything being told. What the group cannot know is
37970
+ only where it itself stands, and that is the measured half. */
37971
+ --navi-route-travel-clip-top: max(
37972
+ 0px,
37973
+ var(--navi-safe-area-inset-top) - var(${TRAVEL_TOP_PROPERTY})
37974
+ );
37975
+ --navi-route-travel-clip-left: max(
37976
+ 0px,
37977
+ var(--navi-safe-area-inset-left) - var(${TRAVEL_LEFT_PROPERTY})
37978
+ );
37979
+ --navi-route-travel-clip-bottom: max(
37980
+ 0px,
37981
+ var(${TRAVEL_TOP_PROPERTY}) + var(${TRAVEL_HEIGHT_PROPERTY}) +
37982
+ var(--navi-safe-area-inset-bottom) - 100dvh
37983
+ );
37984
+ --navi-route-travel-clip-right: max(
37985
+ 0px,
37986
+ var(${TRAVEL_LEFT_PROPERTY}) + var(${TRAVEL_WIDTH_PROPERTY}) +
37987
+ var(--navi-safe-area-inset-right) - 100dvw
37988
+ );
37989
+ clip-path: inset(
37990
+ var(--navi-route-travel-clip-top) var(--navi-route-travel-clip-right)
37991
+ var(--navi-route-travel-clip-bottom)
37992
+ var(--navi-route-travel-clip-left)
37993
+ );
37742
37994
  animation-duration: var(--navi-route-travel-duration, 300ms);
37743
37995
  animation-name: none;
37744
37996
  }
@@ -37947,9 +38199,10 @@ const css$R = /* css */`
37947
38199
  * sections inside the box the whole application travels in — and the class they
37948
38200
  * share plus their axis are not enough to tell them apart from the outside.
37949
38201
  *
37950
- * The pages are cut at the edge of this box while they travel, which is written
37951
- * on the transition's own pseudo-elements no overflow of the document reaches
37952
- * pictures drawn in the top layer. It needs nothing of the browser beyond view
38202
+ * The pages are cut at the edge of this box while they travel, and at the app's
38203
+ * safe area the box runs under, which is written on the transition's own
38204
+ * pseudo-elements no overflow of the document reaches pictures drawn in the
38205
+ * top layer. It needs nothing of the browser beyond view
37953
38206
  * transitions themselves: a browser without them (Firefox) navigates without the
37954
38207
  * movement, and the gesture applies its change on release instead of dragging a
37955
38208
  * picture that does not exist.
@@ -38052,8 +38305,8 @@ const RouteTravel = ({
38052
38305
  }
38053
38306
  pageAskedForRef.current = page;
38054
38307
  // The box as it stands before anything moves: rendering is held, so this is
38055
- // still the page being left (see holdTravelHeight).
38056
- const heightBefore = elementRef.current.getBoundingClientRect().height;
38308
+ // still the page being left (see holdTravelGeometry).
38309
+ const rectBefore = elementRef.current.getBoundingClientRect();
38057
38310
  // The hold a navigation already took, if this travel is the answer to one:
38058
38311
  // taking another would be taking a hold on a page that is holding still.
38059
38312
  const releaseRendering = renderingHeldForRouting || holdRendering();
@@ -38068,6 +38321,11 @@ const RouteTravel = ({
38068
38321
  // screen and an error nobody asked for.
38069
38322
  const renderWait = armRouteRenderWait();
38070
38323
  const viewTransition = startViewTransition(async () => {
38324
+ // Whatever is awaited here must be able to resolve without the page being
38325
+ // rendered: the document is frozen for the whole of this callback, and a
38326
+ // frame never comes — waiting for one waits until the browser gives up on
38327
+ // the transition. And it stays frozen exactly this long, so this is also
38328
+ // the shortest thing there is to keep short.
38071
38329
  await whilePageRenders(page, async () => {
38072
38330
  releaseRendering();
38073
38331
  if (change) {
@@ -38076,7 +38334,7 @@ const RouteTravel = ({
38076
38334
  }, renderWait);
38077
38335
  // The page arriving is in the DOM and the transition has not started
38078
38336
  // playing: the one moment both boxes can be known.
38079
- holdTravelHeight(elementRef.current, heightBefore);
38337
+ holdTravelGeometry(elementRef.current, rectBefore);
38080
38338
  });
38081
38339
  travel.viewTransition = viewTransition;
38082
38340
  if (scrub) {
@@ -38405,7 +38663,7 @@ const RouteTravel = ({
38405
38663
  document.documentElement.removeAttribute(TRAVEL_AXIS_ATTRIBUTE);
38406
38664
  document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
38407
38665
  document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
38408
- releaseTravelHeight();
38666
+ releaseTravelGeometry();
38409
38667
  }
38410
38668
  };
38411
38669
 
@@ -38787,23 +39045,43 @@ const releaseHold = travel => {
38787
39045
  travelHoldingPictures = null;
38788
39046
  document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
38789
39047
  };
38790
- const TRAVEL_HEIGHT_PROPERTY = "--navi-route-travel-height";
38791
- // The height the group is held at for the whole travel: the taller of the two
38792
- // boxes, so neither picture is ever cut. It cannot be said in CSS neither box
38793
- // is knowable there — and it cannot be measured from one side alone: a page
38794
- // arriving shorter than the one it replaces would cut the one leaving, a page
38795
- // arriving taller would be cut itself.
38796
- const holdTravelHeight = (element, heightBefore) => {
38797
- const heightAfter = element.getBoundingClientRect().height;
38798
- const height = heightBefore > heightAfter ? heightBefore : heightAfter;
38799
- document.documentElement.style.setProperty(TRAVEL_HEIGHT_PROPERTY, `${height}px`);
39048
+
39049
+ // The two boxes of a travel, measured at the one moment both exist: the
39050
+ // arriving page is in the DOM and the transition has not started playing.
39051
+ //
39052
+ // The group stands at the ARRIVING box its own animation is dropped, so it
39053
+ // takes the geometry the browser declared for it and holds it for the whole
39054
+ // travel. That is why both rectangles have to be published: a group that does
39055
+ // not move says nothing about where the page being left was, and its rectangle
39056
+ // in the window is the only thing CSS cannot work out on its own.
39057
+ const holdTravelGeometry = (element, rectBefore) => {
39058
+ const rectAfter = element.getBoundingClientRect();
39059
+ // The height it is held at is the taller of the two boxes, so neither picture
39060
+ // is ever cut. It cannot be measured from one side alone: a page arriving
39061
+ // shorter than the one it replaces would cut the one leaving, a page arriving
39062
+ // taller would be cut itself.
39063
+ const height = rectBefore.height > rectAfter.height ? rectBefore.height : rectAfter.height;
39064
+ const {
39065
+ style
39066
+ } = document.documentElement;
39067
+ style.setProperty(TRAVEL_TOP_PROPERTY, `${rectAfter.top}px`);
39068
+ style.setProperty(TRAVEL_LEFT_PROPERTY, `${rectAfter.left}px`);
39069
+ style.setProperty(TRAVEL_WIDTH_PROPERTY, `${rectAfter.width}px`);
39070
+ style.setProperty(TRAVEL_HEIGHT_PROPERTY, `${height}px`);
39071
+ style.setProperty(TRAVEL_OLD_TOP_PROPERTY, `${rectBefore.top}px`);
39072
+ style.setProperty(TRAVEL_OLD_LEFT_PROPERTY, `${rectBefore.left}px`);
38800
39073
  };
38801
39074
  // The live layout takes the box back. A discontinuity by construction — the
38802
39075
  // group stands at the held height, the box is at the new one — and an invisible
38803
39076
  // one: the page arriving is fully in place, and the strip below it that the
38804
39077
  // group still covers shows the page leaving only while it is still on screen.
38805
- const releaseTravelHeight = () => {
38806
- document.documentElement.style.removeProperty(TRAVEL_HEIGHT_PROPERTY);
39078
+ const releaseTravelGeometry = () => {
39079
+ const {
39080
+ style
39081
+ } = document.documentElement;
39082
+ for (const property of TRAVEL_GEOMETRY_PROPERTIES) {
39083
+ style.removeProperty(property);
39084
+ }
38807
39085
  };
38808
39086
 
38809
39087
  // The browser does not take the picture of the page being left when a
@@ -43278,60 +43556,15 @@ const withPixelUnit = value => {
43278
43556
  };
43279
43557
 
43280
43558
  /**
43281
- * The room a fixed bar takes from the content, published so whatever scrolls
43282
- * under it can give that room back.
43283
- *
43284
- * There are TWO rooms to give back, and forgetting the second one is the
43285
- * classic bug:
43286
- *
43287
- * - **padding**, so the end of the content can be scrolled out from under the
43288
- * bar. Without it the last screenful stays covered, unreachable.
43289
- * - **scroll-padding**, so anything the browser scrolls TO lands in front of
43290
- * the bar rather than under it. An anchor link, `scrollIntoView()`, a focused
43291
- * field brought into view, restoring a scroll position — all of them align
43292
- * the target with the edge of the scrollport, which is behind the bar. The
43293
- * padding above does not help here: it moves the content, not the place the
43294
- * browser scrolls the target to.
43295
- *
43296
- * Published on <html> as CSS variables rather than applied to some element:
43297
- * which element scrolls is the app's business, and an app with more than one
43298
- * would have to fight a component that picked for it. The app either marks its
43299
- * scrolling area with `data-navi-fixed-bar-space` (the rules below) or reads
43300
- * the variables itself. `:root` gets the scroll-padding unconditionally,
43301
- * because the document is the scrollport in the common case and an anchor
43302
- * landing under a bar is never what anyone wants.
43303
- *
43304
- * The variables hold the measured size of the bars on that edge — see the
43305
- * comment where FixedBar sets them.
43559
+ * How much room the fixed bars take on each edge, published for the safe area
43560
+ * to add up (see layout/safe_area.js — it declares the four variables written
43561
+ * here, and what reads them reads the sum, never these).
43562
+ *
43563
+ * Measured rather than declared: a bar's size comes from a prop, a theme
43564
+ * variable, its own content or the device's notch, and only the used value
43565
+ * knows all four.
43306
43566
  */
43307
43567
 
43308
- const FIXED_BAR_SPACE_CSS = /* css */ `
43309
- :root {
43310
- --navi-fixed-bar-space-top: 0px;
43311
- --navi-fixed-bar-space-bottom: 0px;
43312
- --navi-fixed-bar-space-left: 0px;
43313
- --navi-fixed-bar-space-right: 0px;
43314
-
43315
- scroll-padding-top: var(--navi-fixed-bar-space-top);
43316
- scroll-padding-right: var(--navi-fixed-bar-space-right);
43317
- scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
43318
- scroll-padding-left: var(--navi-fixed-bar-space-left);
43319
- }
43320
-
43321
- /* Put this on whatever scrolls under the bars. */
43322
- [data-navi-fixed-bar-space] {
43323
- padding-top: var(--navi-fixed-bar-space-top);
43324
- padding-right: var(--navi-fixed-bar-space-right);
43325
- padding-bottom: var(--navi-fixed-bar-space-bottom);
43326
- padding-left: var(--navi-fixed-bar-space-left);
43327
-
43328
- scroll-padding-top: var(--navi-fixed-bar-space-top);
43329
- scroll-padding-right: var(--navi-fixed-bar-space-right);
43330
- scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
43331
- scroll-padding-left: var(--navi-fixed-bar-space-left);
43332
- }
43333
- `;
43334
-
43335
43568
  // Several bars can share an edge — during a page transition the outgoing and
43336
43569
  // the incoming one are both mounted. They are all pinned to that same edge, so
43337
43570
  // they overlap: the room to give back is the largest of them, not their sum,
@@ -43461,7 +43694,10 @@ installImportMetaCssBuild(import.meta);/**
43461
43694
  * nearest scrolling ancestor, and an app shell almost always has one (an
43462
43695
  * `overflow` somewhere) — the bar would then stick inside that box and
43463
43696
  * never to the window. Fixed, centered and bounded by `maxWidth`, it also
43464
- * stays lined up with the content on a wide screen.
43697
+ * stays lined up with the content on a wide screen. It is pinned to the
43698
+ * app's rectangle rather than to the glass (`--navi-app-inset-*`, see
43699
+ * layout/safe_area.js): an app that declares itself narrower than the window
43700
+ * keeps its bars against its own edges.
43465
43701
  * 2. **It gives its space back.** Being fixed it covers the content: without a
43466
43702
  * reserve the end of a long page stays under it, unreachable. It publishes
43467
43703
  * what it takes on <html> — see fixed_bar_space.js.
@@ -43493,8 +43729,6 @@ const css$L = /* css */`
43493
43729
  }
43494
43730
  }
43495
43731
 
43496
- ${FIXED_BAR_SPACE_CSS}
43497
-
43498
43732
  .navi_fixed_bar {
43499
43733
  position: fixed;
43500
43734
  z-index: var(--navi-z-index-bar);
@@ -43509,8 +43743,8 @@ const css$L = /* css */`
43509
43743
  whose padding ignored it would put its first item under it. */
43510
43744
  &[data-area="top"],
43511
43745
  &[data-area="bottom"] {
43512
- right: 0;
43513
- left: 0;
43746
+ right: var(--navi-app-inset-right);
43747
+ left: var(--navi-app-inset-left);
43514
43748
  /* No width of its own: pinned to both edges, the used width absorbs the
43515
43749
  padding instead of being inflated by it. max-width then narrows it and
43516
43750
  the auto margins re-center it. */
@@ -43524,8 +43758,8 @@ const css$L = /* css */`
43524
43758
  }
43525
43759
  &[data-area="left"],
43526
43760
  &[data-area="right"] {
43527
- top: 0;
43528
- bottom: 0;
43761
+ top: var(--navi-app-inset-top);
43762
+ bottom: var(--navi-app-inset-bottom);
43529
43763
  padding-top: calc(
43530
43764
  var(--navi-fixed-bar-padding) + env(safe-area-inset-top)
43531
43765
  );
@@ -43539,28 +43773,28 @@ const css$L = /* css */`
43539
43773
  added to the size: the background then runs under the notch while the
43540
43774
  content keeps the whole width/height asked for. */
43541
43775
  &[data-area="top"] {
43542
- top: 0;
43776
+ top: var(--navi-app-inset-top);
43543
43777
  height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-top));
43544
43778
  padding-top: env(safe-area-inset-top);
43545
43779
  box-shadow: 0 var(--navi-fixed-bar-border-width) 0
43546
43780
  var(--navi-fixed-bar-border-color);
43547
43781
  }
43548
43782
  &[data-area="bottom"] {
43549
- bottom: 0;
43783
+ bottom: var(--navi-app-inset-bottom);
43550
43784
  height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-bottom));
43551
43785
  padding-bottom: env(safe-area-inset-bottom);
43552
43786
  box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
43553
43787
  var(--navi-fixed-bar-border-color);
43554
43788
  }
43555
43789
  &[data-area="left"] {
43556
- left: 0;
43790
+ left: var(--navi-app-inset-left);
43557
43791
  width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-left));
43558
43792
  padding-left: env(safe-area-inset-left);
43559
43793
  box-shadow: var(--navi-fixed-bar-border-width) 0 0
43560
43794
  var(--navi-fixed-bar-border-color);
43561
43795
  }
43562
43796
  &[data-area="right"] {
43563
- right: 0;
43797
+ right: var(--navi-app-inset-right);
43564
43798
  width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-right));
43565
43799
  padding-right: env(safe-area-inset-right);
43566
43800
  box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
@@ -54760,12 +54994,13 @@ const css$v = /* css */`
54760
54994
  pointer-events: none;
54761
54995
  }
54762
54996
 
54763
- /* Scrolling with the page means sticking to the viewport, and a FixedBar
54764
- is in front of that viewport: without the offset a sticky label lands
54765
- behind the bar. The bar publishes the room it takes (see
54766
- fixed_bar_space.js) and it is 0px when there is no bar. */
54997
+ /* Scrolling with the page means sticking to the viewport, and whatever the
54998
+ app puts in front of that viewport a FixedBar, a band of its own — is
54999
+ in front of the label too: without the offset a sticky label lands behind
55000
+ it. The safe area is what that adds up to (see layout/safe_area.js) and
55001
+ it is 0px when nothing covers the top. */
54767
55002
  &[data-scroller="document"] {
54768
- --x-list-group-label-top: var(--navi-fixed-bar-space-top, 0px);
55003
+ --x-list-group-label-top: var(--navi-safe-area-inset-top);
54769
55004
  }
54770
55005
 
54771
55006
  &[data-expand-x] {