@jsenv/navi 0.29.56 → 0.29.58

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.
@@ -6,7 +6,7 @@ import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visua
6
6
  export { coarsePointerSignal } from "./jsenv_navi_side_effects.js";
7
7
  import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getElementSignature, findEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
8
8
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
- import { signal, computed, effect, batch, useSignal } from "@preact/signals";
9
+ import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
10
10
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
11
11
  import { useContext, useLayoutEffect, useCallback, useRef, useState, useEffect, useMemo, useId, useErrorBoundary } from "preact/hooks";
12
12
  import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime";
@@ -33903,31 +33903,61 @@ const getParamScope = (params) => {
33903
33903
  * replays it, and takes a place in the rerun graph — two things a slice must
33904
33904
  * not do: the list already holds the slices it received and glues them back
33905
33905
  * together, and a mutation would otherwise send every slice ever loaded back to
33906
- * the network at once. So the reader keeps nothing. It runs the callback with
33907
- * the range the list asks for, writes what comes back into the store, and hands
33908
- * the rows back as store items — never a copy of the JSON, so the relations a
33909
- * row reads are the shared ones and a request sent from a row is read back on
33910
- * it. A row following its own fields through a write reads them from the store
33911
- * (`RESOURCE.useById(id)`): an update replaces the item object, and the one the
33912
- * list is holding is the one it was given.
33906
+ * the network at once. So the reader keeps no response. It runs the callback
33907
+ * with the range the list asks for, writes what comes back into the store, and
33908
+ * hands the rows back as store items — never a copy of the JSON, so the
33909
+ * relations a row reads are the shared ones and a request sent from a row is
33910
+ * read back on it. A row following its own fields through a write reads them
33911
+ * from the store (`RESOURCE.useById(id)`): an update replaces the item object,
33912
+ * and the one the list is holding is the one it was given.
33913
+ *
33914
+ * What the reader does keep is the collection's composition: which rank holds
33915
+ * which id, and how many ranks there are, per resolved bound params. The rows
33916
+ * themselves are in the store already; the composition is the one thing about a
33917
+ * paginated collection the store cannot model, and without it a list that left
33918
+ * the screen comes back to skeletons and a request for rows it had a second
33919
+ * before. Ids and a count, so nothing here can hold a stale copy of a row, and
33920
+ * a row dropped from the store simply stops resolving. A list that comes back
33921
+ * draws the composition it left and asks again for the window it draws — the
33922
+ * revalidation an invalidation goes through, from a fresh mount.
33913
33923
  *
33914
33924
  * The reader is a function, so a list feeds on it the way it feeds on any other
33915
33925
  * source: `itemsAction={GAME.GET_RANGE.bindParams({ radar })}`.
33916
33926
  *
33917
- * Keeping nothing does not mean hearing nothing: a mutation that decides who
33918
- * belongs to the collection (a POST, a DELETE, whatever `rerunOn.GET_RANGE`
33919
- * says) bumps `invalidationSignal`, and whoever reads slices through it goes
33920
- * and asks again — the counterpart, for a reader, of what a rerun is for an
33921
- * action. Every reader made by `bindParams` shares the signal of the one it
33922
- * comes from: the params say which slices are read, not which collection.
33927
+ * A mutation that decides who belongs to the collection (a POST, a DELETE,
33928
+ * whatever `rerunOn.GET_RANGE` says) bumps `invalidationSignal` and drops the
33929
+ * compositions: they stand for an order that is gone. Whoever reads slices
33930
+ * through the reader then goes and asks again — the counterpart, for a reader,
33931
+ * of what a rerun is for an action. Every reader made by `bindParams` shares
33932
+ * the signal and the compositions of the one it comes from: the params say
33933
+ * which slices are read, not which collection.
33923
33934
  */
33924
33935
 
33925
33936
 
33926
33937
  const createRangeReader = (
33927
33938
  actionName,
33928
33939
  callback,
33929
- { store, params: boundParams, invalidationSignal = signal(0) },
33940
+ {
33941
+ store,
33942
+ params: boundParams,
33943
+ invalidationSignal = signal(0),
33944
+ compositionSet = new Set(),
33945
+ },
33930
33946
  ) => {
33947
+ // Which composition this reader is about: the values its params hold, not its
33948
+ // own identity, so two `bindParams({ scope: "thread" })` made in two places
33949
+ // read and write the same one. There are as many compositions as there are
33950
+ // collections read through this reader — a handful, walked with the deep
33951
+ // comparison the rest of the codebase memoizes on.
33952
+ const currentParams = () => untracked(() => resolveParams(boundParams));
33953
+ const findComposition = (params) => {
33954
+ for (const composition of compositionSet) {
33955
+ if (compareTwoJsValues(composition.params, params)) {
33956
+ return composition;
33957
+ }
33958
+ }
33959
+ return null;
33960
+ };
33931
33961
  const readRange = async (range = {}) => {
33932
33962
  const { signal, ...rangeParams } = range;
33933
33963
  const paramsResolved = { ...resolveParams(boundParams), ...rangeParams };
@@ -33959,14 +33989,80 @@ const createRangeReader = (
33959
33989
  // stand for a composition that is gone.
33960
33990
  readRange.invalidationSignal = invalidationSignal;
33961
33991
  readRange.invalidate = () => {
33992
+ compositionSet.clear();
33962
33993
  invalidationSignal.value = invalidationSignal.peek() + 1;
33963
33994
  };
33995
+ // The rows of a composition, drawn from the store: a rank whose row is gone
33996
+ // from the store resolves to nothing and is asked for again.
33997
+ readRange.readComposition = () => {
33998
+ const composition = findComposition(currentParams());
33999
+ if (!composition) {
34000
+ return null;
34001
+ }
34002
+ const byIndex = new Map();
34003
+ untracked(() => {
34004
+ for (const [index, id] of composition.idByIndex) {
34005
+ const item = store.select(id);
34006
+ if (item) {
34007
+ byIndex.set(index, item);
34008
+ }
34009
+ }
34010
+ });
34011
+ return { byIndex, count: composition.count };
34012
+ };
34013
+ // `replace` is a revalidation: the ranks that are not in what just came back
34014
+ // stood for a composition that has moved on. Otherwise the ranks are merged,
34015
+ // so two lists reading the same collection through their own windows add up
34016
+ // to one composition instead of taking turns erasing each other.
34017
+ readRange.writeComposition = ({ byIndex, count, replace }) => {
34018
+ const params = currentParams();
34019
+ let composition = findComposition(params);
34020
+ if (!composition) {
34021
+ composition = { params, idByIndex: new Map(), count };
34022
+ compositionSet.add(composition);
34023
+ } else if (replace) {
34024
+ composition.idByIndex = new Map();
34025
+ }
34026
+ composition.count = count;
34027
+ for (const [index, item] of byIndex) {
34028
+ const id = item ? item[store.idKey] : undefined;
34029
+ if (id !== undefined) {
34030
+ composition.idByIndex.set(index, id);
34031
+ }
34032
+ }
34033
+ };
34034
+ // The same trade a list makes with the rows it holds, applied to what is kept
34035
+ // for the next mount. Two lists on one collection each trim by their own
34036
+ // window; the rows a list is drawing stay on its screen regardless — a rank
34037
+ // dropped here is one that gets asked for again after a remount.
34038
+ readRange.trimComposition = (keepFrom, keepTo, budget) => {
34039
+ const composition = findComposition(currentParams());
34040
+ if (!composition || !budget || composition.idByIndex.size <= budget) {
34041
+ return;
34042
+ }
34043
+ for (const index of composition.idByIndex.keys()) {
34044
+ if (index < keepFrom || index > keepTo) {
34045
+ composition.idByIndex.delete(index);
34046
+ }
34047
+ }
34048
+ };
34049
+ // Memoized for the reasons an action's bindParams is (see actions.js): params
34050
+ // and the reader they make have synchronized lifetimes, and params equal in
34051
+ // value give back the reader that already exists instead of a second one.
34052
+ const readerByParams = createJsValueWeakMap();
33964
34053
  readRange.bindParams = (paramsToBind) => {
33965
- return createRangeReader(actionName, callback, {
34054
+ const existing = readerByParams.get(paramsToBind);
34055
+ if (existing) {
34056
+ return existing;
34057
+ }
34058
+ const reader = createRangeReader(actionName, callback, {
33966
34059
  store,
33967
34060
  params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
33968
34061
  invalidationSignal,
34062
+ compositionSet,
33969
34063
  });
34064
+ readerByParams.set(paramsToBind, reader);
34065
+ return reader;
33970
34066
  };
33971
34067
  return readRange;
33972
34068
  };
@@ -58355,6 +58451,7 @@ const VISIBILITY_HIDDEN_STYLE = {
58355
58451
  * memoryBudget?: number,
58356
58452
  * renderSkeleton?: false | ((index: number) => import("ignore:preact").ComponentChildren),
58357
58453
  * renderError?: (failure: {error: any, retry: () => void, start: number, end: number}) => import("ignore:preact").ComponentChildren,
58454
+ * onRequestStateChange?: (state: {busy: boolean, refreshing: boolean, range: {start: number, end: number}|null}) => void,
58358
58455
  * }>}
58359
58456
  * @param {(item: any, index: number, state: {refreshing: boolean}) => any} props.renderItem
58360
58457
  * What one row is, given the item and where it sits. `state.refreshing` says
@@ -58392,6 +58489,14 @@ const VISIBILITY_HIDDEN_STYLE = {
58392
58489
  * a `retry` to call, and the `start`/`end` of the range that failed. Defaults
58393
58490
  * to an inline message with a retry button, drawn on the row the user is
58394
58491
  * looking at.
58492
+ * @param {(state: {busy: boolean, refreshing: boolean, range: {start: number, end: number}|null}) => void} [props.onRequestStateChange]
58493
+ * Called when the run starts or stops asking for rows — for the screen around
58494
+ * the list to say that it is looking (the rows themselves have skeletons and
58495
+ * `refreshing` already). `busy` covers every ask, first slice and holes
58496
+ * opened by scrolling included; `refreshing` is the subset where rows already
58497
+ * held are being read again; `range` is what is being asked for, `null` once
58498
+ * nothing is. A range called off and asked again right away stays one `busy`,
58499
+ * and a list unmounted while asking says `busy: false` on its way out.
58395
58500
  */
58396
58501
  const ListItems = ({
58397
58502
  renderItem,
@@ -58403,7 +58508,8 @@ const ListItems = ({
58403
58508
  renderGroupLabel,
58404
58509
  groupLabelProps,
58405
58510
  renderSkeleton,
58406
- renderError
58511
+ renderError,
58512
+ onRequestStateChange
58407
58513
  }) => {
58408
58514
  const ownerId = useId();
58409
58515
  const virtual = useContext(ListVirtualContext);
@@ -58412,7 +58518,8 @@ const ListItems = ({
58412
58518
  const store = useItemStore({
58413
58519
  count,
58414
58520
  itemsAction,
58415
- memoryBudget
58521
+ memoryBudget,
58522
+ onRequestStateChange
58416
58523
  });
58417
58524
  const renderRowSkeleton = renderSkeleton === undefined ? virtual.renderSkeleton : renderSkeleton;
58418
58525
  // A row on its way takes the room the list reserves for it: anything else
@@ -58686,6 +58793,12 @@ const ListItemsFailure = ({
58686
58793
  // hits the network again.
58687
58794
  const ITEM_STORE_MAX_DEFAULT = 1000;
58688
58795
  const ITEM_STORE_KEEP_AROUND = 250;
58796
+ const rangeIsSame = (a, b) => {
58797
+ if (!a || !b) {
58798
+ return a === b;
58799
+ }
58800
+ return a.start === b.start && a.end === b.end;
58801
+ };
58689
58802
 
58690
58803
  // The rows a run has, and how it gets more. Two shapes behind one reader: the
58691
58804
  // caller holds them (items/count/itemStart), or the run asked for them and
@@ -58695,21 +58808,37 @@ const ITEM_STORE_KEEP_AROUND = 250;
58695
58808
  const useItemStore = ({
58696
58809
  count,
58697
58810
  itemsAction,
58698
- memoryBudget
58811
+ memoryBudget,
58812
+ onRequestStateChange
58699
58813
  }) => {
58814
+ // What the source kept of the collection when the screen it was on went away
58815
+ // (a range reader keeps the composition: see resource_range_reader.js). The
58816
+ // rows are drawn from it right away and the window is asked for again — the
58817
+ // revalidation below, entered from a fresh mount rather than from a write.
58818
+ // A reader is an interface, not just a function that answers a range: a list
58819
+ // handed something else keeps drawing rows and quietly gives up everything
58820
+ // the reader holds for it (see resource_range_reader.js).
58821
+ useRef(false);
58700
58822
  const pagesRef = useRef(null);
58823
+ let restored = false;
58701
58824
  if (!pagesRef.current) {
58702
- pagesRef.current = {
58703
- byIndex: new Map(),
58704
- count: undefined
58705
- };
58825
+ const composition = typeof itemsAction === "function" && itemsAction.readComposition ? itemsAction.readComposition() : null;
58826
+ if (composition && composition.count !== undefined) {
58827
+ pagesRef.current = composition;
58828
+ restored = true;
58829
+ } else {
58830
+ pagesRef.current = {
58831
+ byIndex: new Map(),
58832
+ count: undefined
58833
+ };
58834
+ }
58706
58835
  }
58707
58836
  const pages = pagesRef.current;
58708
58837
  const [, setPageVersion] = useState(0);
58709
58838
  // The rows held are out of date and the run has not asked for the new ones
58710
58839
  // yet. They stay on screen until the answer comes: what is drawn is from
58711
58840
  // before, which is not the same thing as nothing to draw.
58712
- const staleRef = useRef(false);
58841
+ const staleRef = useRef(restored);
58713
58842
  const [refreshing, setRefreshing] = useState(false);
58714
58843
  // A source that says when what it reads has moved (a resource range reader
58715
58844
  // does: see rerunOn.GET_RANGE) is heard here — a write deciding who belongs
@@ -58757,6 +58886,51 @@ const useItemStore = ({
58757
58886
  virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
58758
58887
  };
58759
58888
  }, [refreshing]);
58889
+
58890
+ // What the run is doing, for the screen around the list to draw: the list has
58891
+ // its own skeletons, what is around it has to be told. Read from the request
58892
+ // itself rather than pushed from each place that touches it, so a range
58893
+ // called off and asked again right away stays one uninterrupted ask.
58894
+ const requestStateRef = useRef({
58895
+ busy: false,
58896
+ refreshing: false,
58897
+ range: null
58898
+ });
58899
+ const onRequestStateChangeRef = useRef(onRequestStateChange);
58900
+ onRequestStateChangeRef.current = onRequestStateChange;
58901
+ const publishRequestState = () => {
58902
+ const request = requestRef.current;
58903
+ const busy = request.busy;
58904
+ const state = {
58905
+ busy,
58906
+ refreshing: busy && request.revalidating,
58907
+ range: busy ? {
58908
+ start: request.start,
58909
+ end: request.end
58910
+ } : null
58911
+ };
58912
+ const previous = requestStateRef.current;
58913
+ if (previous.busy === state.busy && previous.refreshing === state.refreshing && rangeIsSame(previous.range, state.range)) {
58914
+ return;
58915
+ }
58916
+ requestStateRef.current = state;
58917
+ if (onRequestStateChangeRef.current) {
58918
+ onRequestStateChangeRef.current(state);
58919
+ }
58920
+ };
58921
+ // A list taken off the screen while it was asking leaves nothing ringing
58922
+ // behind it: whoever is drawing "looking for rows" has to stop.
58923
+ useLayoutEffect(() => {
58924
+ return () => {
58925
+ if (requestStateRef.current.busy && onRequestStateChangeRef.current) {
58926
+ onRequestStateChangeRef.current({
58927
+ busy: false,
58928
+ refreshing: false,
58929
+ range: null
58930
+ });
58931
+ }
58932
+ };
58933
+ }, []);
58760
58934
  const store = {
58761
58935
  rowCount,
58762
58936
  failure,
@@ -58767,16 +58941,21 @@ const useItemStore = ({
58767
58941
  // trade the render window makes, one order of magnitude further out.
58768
58942
  forget: (windowFrom, windowTo) => {
58769
58943
  const budget = memoryBudget === undefined ? ITEM_STORE_MAX_DEFAULT : memoryBudget;
58770
- if (!budget || pages.byIndex.size <= budget) {
58944
+ if (!budget) {
58771
58945
  return;
58772
58946
  }
58773
58947
  const keepFrom = windowFrom - ITEM_STORE_KEEP_AROUND;
58774
58948
  const keepTo = windowTo + ITEM_STORE_KEEP_AROUND;
58775
- for (const index of pages.byIndex.keys()) {
58776
- if (index < keepFrom || index > keepTo) {
58777
- pages.byIndex.delete(index);
58949
+ if (pages.byIndex.size > budget) {
58950
+ for (const index of pages.byIndex.keys()) {
58951
+ if (index < keepFrom || index > keepTo) {
58952
+ pages.byIndex.delete(index);
58953
+ }
58778
58954
  }
58779
58955
  }
58956
+ if (typeof itemsAction === "function" && itemsAction.trimComposition) {
58957
+ itemsAction.trimComposition(keepFrom, keepTo, budget);
58958
+ }
58780
58959
  },
58781
58960
  retry: () => {
58782
58961
  const request = requestRef.current;
@@ -58842,7 +59021,7 @@ const useItemStore = ({
58842
59021
  end = start + budget - 1;
58843
59022
  }
58844
59023
  }
58845
- useLayoutEffect(() => {
59024
+ const ask = () => {
58846
59025
  if (start === -1) {
58847
59026
  return;
58848
59027
  }
@@ -58915,6 +59094,7 @@ const useItemStore = ({
58915
59094
  staleRef.current = false;
58916
59095
  setRefreshing(false);
58917
59096
  }
59097
+ publishRequestState();
58918
59098
  }
58919
59099
  if (revalidating && !current) {
58920
59100
  // Rows of a composition already superseded by a newer ask: keeping
@@ -58942,6 +59122,15 @@ const useItemStore = ({
58942
59122
  i++;
58943
59123
  }
58944
59124
  pages.count = pageCount;
59125
+ // Which rank holds which id, kept by the source so a list drawing
59126
+ // this collection again finds it drawn (see readComposition above).
59127
+ if (itemsAction.writeComposition) {
59128
+ itemsAction.writeComposition({
59129
+ byIndex: pages.byIndex,
59130
+ count: pageCount,
59131
+ replace: revalidating
59132
+ });
59133
+ }
58945
59134
  virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
58946
59135
  setPageVersion(version => version + 1);
58947
59136
  };
@@ -58952,6 +59141,7 @@ const useItemStore = ({
58952
59141
  }
58953
59142
  request.busy = false;
58954
59143
  request.revalidating = false;
59144
+ publishRequestState();
58955
59145
  if (revalidating) {
58956
59146
  // The rows from before stay: a revalidation that failed has
58957
59147
  // nothing better to put in their place.
@@ -58980,6 +59170,10 @@ const useItemStore = ({
58980
59170
  } else {
58981
59171
  done(result);
58982
59172
  }
59173
+ };
59174
+ useLayoutEffect(() => {
59175
+ ask();
59176
+ publishRequestState();
58983
59177
  });
58984
59178
  }
58985
59179
  };