@arrai-innovations/reactive-helpers 23.0.0 → 23.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arrai-innovations/reactive-helpers",
3
- "version": "23.0.0",
3
+ "version": "23.0.2",
4
4
  "description": "VueJS 3 utility composition functions to help manipulate objects and lists.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@arrai-innovations/commitlint-config": "^3.0.0",
46
46
  "@arrai-innovations/diction-md": "^1.0.0",
47
- "@arrai-innovations/vitepress-theme": "1.0.1",
47
+ "@arrai-innovations/vitepress-theme": "^1.0.2",
48
48
  "@commitlint/cli": "^21.2.1",
49
49
  "@eslint/js": "9.39.4",
50
50
  "@godaddy/dmd": "^1.0.4",
@@ -89,11 +89,16 @@
89
89
  },
90
90
  "scripts": {
91
91
  "benchmark": "vitest bench --run",
92
+ "benchmark:ci": "pnpm benchmark:list:ci && pnpm benchmark:stream:ci && pnpm benchmark:layers:ci && pnpm benchmark:observed:ci",
92
93
  "benchmark:list": "vitest bench --run tests/benchmarks/listPush.bench.js",
93
94
  "benchmark:list:ci": "vitest bench --run tests/benchmarks/listPush.bench.js --outputJson benchmark-results/list-push.json",
94
95
  "benchmark:check": "node check_benchmark.js",
95
96
  "benchmark:layers": "vitest bench --run tests/benchmarks/listLayers.bench.js",
97
+ "benchmark:layers:ci": "vitest bench --run tests/benchmarks/listLayers.bench.js --outputJson benchmark-results/list-layers.json",
98
+ "benchmark:observed": "vitest bench --run tests/benchmarks/listObserved.bench.js",
99
+ "benchmark:observed:ci": "vitest bench --run tests/benchmarks/listObserved.bench.js --outputJson benchmark-results/list-observed.json",
96
100
  "benchmark:stream": "vitest bench --run tests/benchmarks/listStream.bench.js",
101
+ "benchmark:stream:ci": "vitest bench --run tests/benchmarks/listStream.bench.js --outputJson benchmark-results/list-stream.json",
97
102
  "test": "vitest",
98
103
  "eslint": "eslint --fix index.js config/**/*.js tests/**/*.js use/**/*.js utils/**/*.js",
99
104
  "prettier": "prettier --write .",
@@ -46,7 +46,7 @@ export namespace populatedRules {
46
46
  export { orderByRules_1 as orderByRules };
47
47
  export namespace relatedObjectsRules_1 {
48
48
  namespace org {
49
- export let pkKey: string;
49
+ export let fkKey: string;
50
50
  export { relatedOrganizations as objects };
51
51
  }
52
52
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ /** How many entries each generated lookup collection holds. */
2
+ export const lookupSize: 13;
3
+ export function makeLookup(index: number): import("../../use/listInstance.js").ObjectsByPk;
4
+ /** Lookup collection for the array-valued rule, which resolves a list of foreign keys per record. */
5
+ export const tagLookup: import("../../use/listInstance.js").ObjectsByPk;
6
+ /** The order the array-valued rule sorts its resolved tags into. */
7
+ export const tagOrder: string[];
8
+ export function makeRelatedRules(ruleCount: number, includeArrayRule?: boolean): import("../../use/listRelated.js").ListRelatedRules;
9
+ export function makeCalculatedRules(ruleCount: number, relatedRuleCount: number): {
10
+ [rule: string]: (object: object, related: object, calculated: object) => any;
11
+ };
12
+ export function makeReviewRows(count: number, start?: number, fkCount?: number): object[];
13
+ export function makeOrderByRules(sortOn: "none" | "plain" | "related" | "calculated"): import("../../use/listSort.js").OrderByRule[];
14
+ export function makeReviewList({ relatedRuleCount, calculatedRuleCount, sortOn, includeArrayRule, filter, sortThrottleWait, }?: ReviewListOptions): ReturnType<typeof useList>;
15
+ export function composeReviewStack({ relatedRuleCount, calculatedRuleCount, sortOn, includeArrayRule, filter, sortThrottleWait, }?: ReviewListOptions): {
16
+ states: {
17
+ [layer: string]: object;
18
+ };
19
+ push: (objects: object[]) => void;
20
+ stop: () => void;
21
+ };
22
+ export namespace readChannels {
23
+ let collection: {
24
+ [channel: string]: (state: object) => void;
25
+ };
26
+ let record: {
27
+ [channel: string]: (state: object, pk: string) => void;
28
+ };
29
+ }
30
+ export function attachRenderObservers(state: object, { collectionChannels, recordChannels }?: RenderObserverOptions): {
31
+ sync: () => void;
32
+ stop: () => void;
33
+ counts: {
34
+ [channel: string]: ChannelCounts;
35
+ };
36
+ totals: () => {
37
+ effects: number;
38
+ runs: number;
39
+ triggers: number;
40
+ };
41
+ reset: () => void;
42
+ };
43
+ /**
44
+ * The shape of the review list to build.
45
+ */
46
+ export type ReviewListOptions = {
47
+ /**
48
+ * How many related rules the list carries.
49
+ */
50
+ relatedRuleCount?: number;
51
+ /**
52
+ * How many calculated rules the list carries.
53
+ */
54
+ calculatedRuleCount?: number;
55
+ /**
56
+ * Which value the sort orders on.
57
+ */
58
+ sortOn?: "none" | "plain" | "related" | "calculated";
59
+ /**
60
+ * Whether the related rules include an array-valued rule.
61
+ */
62
+ includeArrayRule?: boolean;
63
+ /**
64
+ * Whether an allowed filter is active.
65
+ */
66
+ filter?: boolean;
67
+ /**
68
+ * The sort throttle, in milliseconds.
69
+ */
70
+ sortThrottleWait?: number;
71
+ };
72
+ /**
73
+ * Which channels observe the list.
74
+ */
75
+ export type RenderObserverOptions = {
76
+ /**
77
+ * Names from `readChannels.collection` to attach once.
78
+ */
79
+ collectionChannels?: string[];
80
+ /**
81
+ * Names from `readChannels.record` to attach per record.
82
+ */
83
+ recordChannels?: string[];
84
+ };
85
+ /**
86
+ * What one channel's effects have done.
87
+ */
88
+ export type ChannelCounts = {
89
+ /**
90
+ * How many effects the channel has attached.
91
+ */
92
+ effects: number;
93
+ /**
94
+ * How many times those effects have evaluated.
95
+ */
96
+ runs: number;
97
+ /**
98
+ * How many times those effects have been notified.
99
+ */
100
+ triggers: number;
101
+ };
102
+ import { useList } from "../../use/list.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -64,6 +64,7 @@
64
64
  * @typedef {object} ListCalculatedProperties - The properties for the list computed composition function.
65
65
  * @property {ListCalculatedState} state - The state for the list calculated property.
66
66
  * @property {ListCalculatedParentState} parentState - The parent state object.
67
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
67
68
  * @property {() => void} stop - Stops composition's effects and cleans up resources.
68
69
  */
69
70
  /**
@@ -221,6 +222,10 @@ export type ListCalculatedProperties = {
221
222
  * The parent state object.
222
223
  */
223
224
  parentState: ListCalculatedParentState;
225
+ /**
226
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
227
+ */
228
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
224
229
  /**
225
230
  * Stops composition's effects and cleans up resources.
226
231
  */
@@ -51,6 +51,7 @@
51
51
  * @typedef {object} ListFilterProperties - The properties of a list filter, including its state and associated Vue composition API utilities.
52
52
  * @property {ListFilterState} state - The reactive state managing the filter logic and results.
53
53
  * @property {ListFilterParentState} parentState - The state of the list being filtered.
54
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
54
55
  * @property {() => void} stop - A function to stop the effect scope and clean up resources.
55
56
  */
56
57
  /**
@@ -166,6 +167,10 @@ export type ListFilterProperties = {
166
167
  * The state of the list being filtered.
167
168
  */
168
169
  parentState: ListFilterParentState;
170
+ /**
171
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
172
+ */
173
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
169
174
  /**
170
175
  * A function to stop the effect scope and clean up resources.
171
176
  */
@@ -54,7 +54,7 @@
54
54
  * @property {object} params - Arguments passed to the server for listing operations.
55
55
  * @property {ObjectsMap} objectsMap - The map of objects stored by their pks.
56
56
  * @property {ObjectsByPk} objects - The list objects stored by their pks.
57
- * @property {number} objectsVersion - Increments when the set of object keys changes.
57
+ * @property {number} objectsVersion - Increments when this layer's set of object keys changes. Each layer that narrows membership publishes its own, so the value belongs to the state reporting it and is not comparable with another layer's. Watch it rather than reading it, and prefer `watchMembershipChanged`, which carries the same signal without exposing how it is counted.
58
58
  * @property {ListOrder} order - The order of objects in the list.
59
59
  * @property {ObjectsInOrder} objectsInOrder - The objects in the order specified by the list.
60
60
  * @property {import('vue').ShallowReactive<PaginateInfo>} paginateInfo - Pagination information for the list.
@@ -98,6 +98,7 @@
98
98
  * @property {(args: {action: string, pks?: import('../config/commonCrud.js').Pk[]} & import('../config/listCrud.js').AdditionalListArgs) => import('../utils/cancellablePromise.js').MaybeCancellablePromise<object|string|boolean|null>} executeAction - Initiates an action on all objects in the list, returning the response, or null if the action failed. The promise carries a `cancel` method when the handler's promise did.
99
99
  * @property {(info: PaginateInfo) => void} setPaginateInfo - The method to update pagination information.
100
100
  * @property {(total: ColumnTotals) => void} setColumnTotals - The method to update column totals.
101
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
101
102
  */
102
103
  /**
103
104
  * @typedef {ListInstanceMyFunctions & Pick<import('./loadingError.js').LoadingErrorStatus, "clearError">} ListInstanceFunctions - The methods contributed by the list instance, including its CRUD operations plus clearError.
@@ -321,7 +322,7 @@ export type ListInstanceRawMyState = {
321
322
  */
322
323
  objects: ObjectsByPk;
323
324
  /**
324
- * Increments when the set of object keys changes.
325
+ * Increments when this layer's set of object keys changes. Each layer that narrows membership publishes its own, so the value belongs to the state reporting it and is not comparable with another layer's. Watch it rather than reading it, and prefer `watchMembershipChanged`, which carries the same signal without exposing how it is counted.
325
326
  */
326
327
  objectsVersion: number;
327
328
  /**
@@ -436,6 +437,10 @@ export type ListInstanceMyFunctions = {
436
437
  * The method to update column totals.
437
438
  */
438
439
  setColumnTotals: (total: ColumnTotals) => void;
440
+ /**
441
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
442
+ */
443
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
439
444
  };
440
445
  /**
441
446
  * The methods contributed by the list instance, including its CRUD operations plus clearError.
@@ -59,6 +59,7 @@
59
59
  * @typedef {object} ListRelatedProperties - The properties for the list related composition function.
60
60
  * @property {ListRelatedState} state - The state for the list related property.
61
61
  * @property {ListRelatedParentState} parentState - The parent state object.
62
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
62
63
  * @property {() => void} stop - Stops all effects of the list related property.
63
64
  */
64
65
  /**
@@ -289,6 +290,10 @@ export type ListRelatedProperties = {
289
290
  * The parent state object.
290
291
  */
291
292
  parentState: ListRelatedParentState;
293
+ /**
294
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
295
+ */
296
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
292
297
  /**
293
298
  * Stops all effects of the list related property.
294
299
  */
@@ -67,6 +67,7 @@
67
67
  * @typedef {object} ListSearchProperties - The properties on a list search instance.
68
68
  * @property {ListSearchState} state - The state.
69
69
  * @property {import('./search.js').SearchInstance} textSearchIndex - The text search index.
70
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
70
71
  * @property {() => void} stop - Stops the effect scope and cleans up resources.
71
72
  */
72
73
  /**
@@ -253,6 +254,10 @@ export type ListSearchProperties = {
253
254
  * The text search index.
254
255
  */
255
256
  textSearchIndex: import("./search.js").SearchInstance;
257
+ /**
258
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
259
+ */
260
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
256
261
  /**
257
262
  * Stops the effect scope and cleans up resources.
258
263
  */
@@ -55,6 +55,7 @@ export function setListSortDefaultOptions({ sortThrottleWait }: {
55
55
  * @typedef {object} ListSortProperties - The properties available on a list sort instance.
56
56
  * @property {ListSortState} state - The reactive state for the list sort.
57
57
  * @property {ListSortParentState} parentState - The parent state.
58
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
58
59
  * @property {() => void} stop - A function to stop the effect scope and clean up resources.
59
60
  */
60
61
  /**
@@ -183,6 +184,10 @@ export type ListSortProperties = {
183
184
  * The parent state.
184
185
  */
185
186
  parentState: ListSortParentState;
187
+ /**
188
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
189
+ */
190
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
186
191
  /**
187
192
  * A function to stop the effect scope and clean up resources.
188
193
  */
@@ -33,6 +33,7 @@
33
33
  * @property {import('./listInstance.js').ListInstance} listInstance - The list instance used by the subscription.
34
34
  * @property {import('./cancellableIntent.js').CancellableIntent} listIntent - The `CancellableIntent` instance managing if the list should be (re)fetched.
35
35
  * @property {import('./cancellableIntent.js').CancellableIntent} subscribeIntent - The `CancellableIntent` instance managing if the subscription should be (un)subscribed.
36
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
36
37
  */
37
38
  /**
38
39
  * @typedef {ListSubscriptionFunctions & ListSubscriptionProperties} ListSubscription - An instance of a list subscription, returned by `useListSubscription`.
@@ -184,6 +185,10 @@ export type ListSubscriptionProperties = {
184
185
  * The `CancellableIntent` instance managing if the subscription should be (un)subscribed.
185
186
  */
186
187
  subscribeIntent: import("./cancellableIntent.js").CancellableIntent;
188
+ /**
189
+ * Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
190
+ */
191
+ watchMembershipChanged: import("../utils/watches.js").WatchMembershipChanged;
187
192
  };
188
193
  /**
189
194
  * An instance of a list subscription, returned by `useListSubscription`.
@@ -1,3 +1,33 @@
1
+ /**
2
+ * @module utils/watches.js
3
+ * @description
4
+ *
5
+ * A collection of utility classes and functions for managing Vue.js watchers.
6
+ *
7
+ */
8
+ /**
9
+ * @callback WatchMembershipChanged - Calls back when a list layer's set of object keys changes.
10
+ * Registers in the effect scope active where it is called, not in the layer's own scope, so the
11
+ * returned handle and the surrounding scope own the watcher. Stopping the layer does not dispose it,
12
+ * because the layer never owned it; it only silences it, since a stopped layer publishes nothing
13
+ * further. Stop it through the returned handle or through the enclosing scope.
14
+ * The callback takes no arguments: it reports that membership moved, not what it moved to. Read the
15
+ * layer's collection views for that.
16
+ * @param {() => void} callback - Called after the layer's set of object keys changes.
17
+ * @param {import('vue').WatchOptions} [options] - Passed through to Vue's `watch`, so `immediate`,
18
+ * `flush`, and `once` all behave as they do there.
19
+ * @returns {import('vue').WatchHandle} - Stops the watcher.
20
+ */
21
+ /**
22
+ * Builds a layer's membership watcher over the counter that layer publishes.
23
+ *
24
+ * @internal
25
+ * @param {{objectsVersion: number}} state - The layer's own state.
26
+ * @returns {WatchMembershipChanged} - The watcher registration function for that layer.
27
+ */
28
+ export function makeMembershipWatcher(state: {
29
+ objectsVersion: number;
30
+ }): WatchMembershipChanged;
1
31
  /**
2
32
  * Helper function to get the resulting promise from an AwaitTimeout instance.
3
33
  *
@@ -23,13 +53,6 @@ export function doAwaitNot({ obj, prop, ref, couldAlreadyBeFalse, timeout }: {
23
53
  couldAlreadyBeFalse?: boolean;
24
54
  timeout?: number;
25
55
  }): Promise<any>;
26
- /**
27
- * @module utils/watches.js
28
- * @description
29
- *
30
- * A collection of utility classes and functions for managing Vue.js watchers.
31
- *
32
- */
33
56
  /**
34
57
  * Provides a mechanism for immediately starting and potentially stopping a Vue.js watcher
35
58
  * during its first invocation. This is useful when the need arises to terminate the watch
@@ -152,3 +175,13 @@ export class AwaitNot {
152
175
  */
153
176
  stop(): void;
154
177
  }
178
+ /**
179
+ * Calls back when a list layer's set of object keys changes.
180
+ * Registers in the effect scope active where it is called, not in the layer's own scope, so the
181
+ * returned handle and the surrounding scope own the watcher. Stopping the layer does not dispose it,
182
+ * because the layer never owned it; it only silences it, since a stopped layer publishes nothing
183
+ * further. Stop it through the returned handle or through the enclosing scope.
184
+ * The callback takes no arguments: it reports that membership moved, not what it moved to. Read the
185
+ * layer's collection views for that.
186
+ */
187
+ export type WatchMembershipChanged = (callback: () => void, options?: import("vue").WatchOptions) => import("vue").WatchHandle;
@@ -1,4 +1,5 @@
1
1
  import { keyDiff } from "../utils/keyDiff.js";
2
+ import { makeMembershipWatcher } from "../utils/watches.js";
2
3
  import { loadingCombine } from "../utils/loadingCombine.js";
3
4
  import { proxyRunning } from "../utils/proxyRunning.js";
4
5
  import isEmpty from "lodash-es/isEmpty.js";
@@ -78,6 +79,7 @@ import { warnWrongSideRuleOptions } from "../utils/relatedCalculatedHelpers.js";
78
79
  * @typedef {object} ListCalculatedProperties - The properties for the list computed composition function.
79
80
  * @property {ListCalculatedState} state - The state for the list calculated property.
80
81
  * @property {ListCalculatedParentState} parentState - The parent state object.
82
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
81
83
  * @property {() => void} stop - Stops composition's effects and cleans up resources.
82
84
  */
83
85
 
@@ -181,6 +183,14 @@ export function useListCalculated(options) {
181
183
  running: computed(() => loadingCombine(state.calculatedRunning, parentRunning.value)),
182
184
  });
183
185
  const calculatedObjectsEffectScopes = {};
186
+ // Records whose rule bag has not been reconciled against the rules yet. A record arriving with an
187
+ // empty bag needs every rule applied. A record already carrying the full rule set needs nothing,
188
+ // and rediscovering that by diffing its bag cost one keyDiff per record in the collection, per
189
+ // page, along with a pair of refs and an effect scope activation for each.
190
+ const pendingObjectKeys = new Set();
191
+ // The rule keys the last reconciliation ran against, so a run can tell which of its two causes woke
192
+ // it. Only a rule change touches records that were already reconciled.
193
+ let previousRuleKeys = [];
184
194
 
185
195
  function parentStateObjectsWatch() {
186
196
  const { addedKeys, removedKeys } = keyDiff(
@@ -188,6 +198,7 @@ export function useListCalculated(options) {
188
198
  Object.keys(state.calculatedObjects)
189
199
  );
190
200
  for (const removedKey of removedKeys) {
201
+ pendingObjectKeys.delete(removedKey);
191
202
  delete state.calculatedObjects[removedKey];
192
203
  if (calculatedObjectsEffectScopes[removedKey]) {
193
204
  calculatedObjectsEffectScopes[removedKey].objectScope.stop();
@@ -196,6 +207,7 @@ export function useListCalculated(options) {
196
207
  }
197
208
  for (const addedKey of addedKeys) {
198
209
  state.calculatedObjects[addedKey] = {};
210
+ pendingObjectKeys.add(addedKey);
199
211
  }
200
212
  nextTick(() => {
201
213
  state.calculatedObjectsParentStateObjectsWatchRunning = false;
@@ -204,7 +216,18 @@ export function useListCalculated(options) {
204
216
 
205
217
  function calculatedObjectsWatch() {
206
218
  const calculatedObjectsRulesIsEmpty = !state.calculatedObjectsRules || isEmpty(state.calculatedObjectsRules);
207
- for (const objectKey of Object.keys(state.calculatedObjects)) {
219
+ const ruleKeys = Object.keys(state.calculatedObjectsRules || {});
220
+ const { addedKeys: addedRules, removedKeys: removedRules } = keyDiff(ruleKeys, previousRuleKeys, {
221
+ sameKeys: false,
222
+ });
223
+ previousRuleKeys = ruleKeys;
224
+ // A rule change alters the bag of every record, so it is the only cause that has to walk the
225
+ // collection. A page arriving leaves every record already present holding the rule set it
226
+ // already held, so only the arrivals have anything to reconcile.
227
+ const rulesChanged = addedRules.size > 0 || removedRules.size > 0;
228
+ const objectKeys = rulesChanged ? Object.keys(state.calculatedObjects) : [...pendingObjectKeys];
229
+ pendingObjectKeys.clear();
230
+ for (const objectKey of objectKeys) {
208
231
  if (!state.calculatedObjects[objectKey]) {
209
232
  state.calculatedObjects[objectKey] = {};
210
233
  }
@@ -292,6 +315,7 @@ export function useListCalculated(options) {
292
315
  return {
293
316
  state,
294
317
  parentState,
318
+ watchMembershipChanged: makeMembershipWatcher(state),
295
319
  stop: () => {
296
320
  es.stop();
297
321
  for (const key of Object.keys(calculatedObjectsEffectScopes)) {
package/use/listFilter.js CHANGED
@@ -1,5 +1,18 @@
1
1
  import { keyDiff } from "../utils/keyDiff.js";
2
- import { computed, effectScope, isRef, reactive, shallowReadonly, toRef, toRefs, unref, watch } from "vue";
2
+ import { makeMembershipWatcher } from "../utils/watches.js";
3
+ import {
4
+ computed,
5
+ effectScope,
6
+ isRef,
7
+ reactive,
8
+ ref,
9
+ shallowReactive,
10
+ shallowReadonly,
11
+ toRef,
12
+ toRefs,
13
+ unref,
14
+ watch,
15
+ } from "vue";
3
16
 
4
17
  /**
5
18
  * Provides reactive filtering functionality for lists within a Vue application. This composable
@@ -63,6 +76,7 @@ import { computed, effectScope, isRef, reactive, shallowReadonly, toRef, toRefs,
63
76
  * @typedef {object} ListFilterProperties - The properties of a list filter, including its state and associated Vue composition API utilities.
64
77
  * @property {ListFilterState} state - The reactive state managing the filter logic and results.
65
78
  * @property {ListFilterParentState} parentState - The state of the list being filtered.
79
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
66
80
  * @property {() => void} stop - A function to stop the effect scope and clean up resources.
67
81
  */
68
82
 
@@ -125,10 +139,12 @@ export function useListFilter({ parentState, allowedFilter, excludedFilter }) {
125
139
  const getAllowed = () => (isRef(allowedFilter) ? allowedFilter.value : allowedFilter);
126
140
  const getExcluded = () => (isRef(excludedFilter) ? excludedFilter.value : excludedFilter);
127
141
 
128
- const includeMap = reactive({});
142
+ // Track entry changes without proxying entries or unwrapping their computed refs.
143
+ /** @type {Map<import('../config/commonCrud.js').Pk, {scope: import('vue').EffectScope, include: import('vue').ComputedRef<boolean>}>} */
144
+ const includeMap = shallowReactive(new Map());
129
145
 
130
146
  function ensureIncludeComputed(pk) {
131
- if (!includeMap[pk]) {
147
+ if (!includeMap.get(pk)) {
132
148
  const child = es.run(() => effectScope());
133
149
  const include = child.run(() => {
134
150
  const objRef = toRef(parentState.objects, pk);
@@ -155,52 +171,148 @@ export function useListFilter({ parentState, allowedFilter, excludedFilter }) {
155
171
  });
156
172
  });
157
173
 
158
- includeMap[pk] = { scope: child, include };
174
+ includeMap.set(pk, { scope: child, include });
159
175
  }
160
- return includeMap[pk].include;
176
+ return includeMap.get(pk).include;
161
177
  }
162
178
 
163
179
  function disposeIncludeComputed(pk) {
164
- const entry = includeMap[pk];
180
+ const entry = includeMap.get(pk);
165
181
  if (entry) {
166
182
  entry.scope.stop();
167
- delete includeMap[pk];
183
+ includeMap.delete(pk);
168
184
  }
169
185
  }
170
186
 
187
+ const isIncluded = (/** @type {import('../config/commonCrud.js').Pk} */ pk) => {
188
+ const entry = includeMap.get(pk);
189
+ return entry ? entry.include.value : false;
190
+ };
191
+
192
+ // Cache enumeration separately from per-key reads.
193
+ /** @type {import('vue').ComputedRef<import('./listInstance.js').ObjectsByPk>} */
194
+ const includedObjects = computed(() => {
195
+ /** @type {{[pk: import('../config/commonCrud.js').Pk]: import('./objectInstance.js').ExistingCrudObject}} */
196
+ const out = {};
197
+ for (const [pk, o] of Object.entries(parentState.objects)) {
198
+ if (isIncluded(pk)) {
199
+ out[pk] = o;
200
+ }
201
+ }
202
+ return out;
203
+ });
204
+
205
+ // This layer's membership is its own: a rule change or a record edit moves it without the parent's
206
+ // key set moving, so the parent's objectsVersion does not describe this collection. Own one here
207
+ // and keep the property's contract true for every layer downstream, which is what lets them watch
208
+ // it instead of enumerating this layer once each.
209
+ const objectsVersion = ref(0);
210
+ /** @type {Set<import('../config/commonCrud.js').Pk>} */
211
+ const includedKeys = new Set();
212
+
171
213
  es.run(() => {
172
214
  watch(
173
215
  () => parentState.objectsVersion,
174
216
  () => {
175
217
  const newVal = Object.keys(parentState.objects);
176
- const { addedKeys, removedKeys } = keyDiff(newVal, Object.keys(includeMap));
218
+ const { addedKeys, removedKeys } = keyDiff(newVal, [...includeMap.keys()]);
219
+ // Only an arriving or departing record can move membership structurally, so the version
220
+ // is settled from those keys rather than from a fresh pass over the collection. This
221
+ // runs synchronously, and a full pass here would scale the synchronous half of a page
222
+ // with the collection behind it.
223
+ let moved = false;
177
224
  for (const pk of removedKeys) {
178
225
  disposeIncludeComputed(pk);
226
+ moved = includedKeys.delete(pk) || moved;
179
227
  }
180
228
  for (const pk of addedKeys) {
181
229
  ensureIncludeComputed(pk);
230
+ if (isIncluded(pk)) {
231
+ includedKeys.add(pk);
232
+ moved = true;
233
+ }
234
+ }
235
+ if (moved) {
236
+ objectsVersion.value++;
182
237
  }
183
238
  },
184
239
  { immediate: true, flush: "sync" }
185
240
  );
241
+
242
+ // A rule change or a record edit flips inclusion without moving any key set, so neither reaches
243
+ // the watcher above. Deferred on purpose: includedObjects invalidates once per record written,
244
+ // and a synchronous watcher here would rebuild the whole map once per record in a page.
245
+ watch(includedObjects, (included) => {
246
+ let moved = false;
247
+ for (const pk of Object.keys(included)) {
248
+ if (!includedKeys.has(pk)) {
249
+ includedKeys.add(pk);
250
+ moved = true;
251
+ }
252
+ }
253
+ if (includedKeys.size !== Object.keys(included).length) {
254
+ for (const pk of [...includedKeys]) {
255
+ if (!(pk in included)) {
256
+ includedKeys.delete(pk);
257
+ moved = true;
258
+ }
259
+ }
260
+ }
261
+ if (moved) {
262
+ objectsVersion.value++;
263
+ }
264
+ });
186
265
  });
187
266
 
188
- /** @type {import('vue').ComputedRef<import('./listInstance.js').ObjectsByPk>} */
189
- const objects = computed(() => {
190
- // built mutably here, then handed out read-only below
191
- /** @type {{[pk: import('../config/commonCrud.js').Pk]: import('./objectInstance.js').ExistingCrudObject}} */
192
- const out = {};
193
- for (const [pk, o] of Object.entries(parentState.objects)) {
194
- const inc = includeMap[pk]?.include;
195
- if (inc) out[pk] = o;
196
- }
197
- // the computed rebuilds this object on every run, so a write into it would be discarded
198
- // silently on the next read. Report it instead.
199
- return shallowReadonly(out);
267
+ // Resolve individual keys without tracking the full collection.
268
+ // Implement readonly behaviour here to avoid an extra proxy layer.
269
+ /** @type {import('./listInstance.js').ObjectsByPk} */
270
+ const objects = new Proxy(/** @type {any} */ ({}), {
271
+ get(target, prop) {
272
+ if (typeof prop === "symbol") {
273
+ return Reflect.get(target, prop);
274
+ }
275
+ if (prop === "__v_isReadonly") {
276
+ return true;
277
+ }
278
+ return isIncluded(prop) ? parentState.objects[prop] : undefined;
279
+ },
280
+ set(target, prop) {
281
+ console.warn(`useListFilter: set operation on key "${String(prop)}" failed: objects is read-only.`);
282
+ return true;
283
+ },
284
+ deleteProperty(target, prop) {
285
+ console.warn(`useListFilter: delete operation on key "${String(prop)}" failed: objects is read-only.`);
286
+ return true;
287
+ },
288
+ has(target, prop) {
289
+ if (typeof prop === "symbol") {
290
+ return Reflect.has(target, prop);
291
+ }
292
+ return isIncluded(prop) && prop in parentState.objects;
293
+ },
294
+ ownKeys() {
295
+ return Reflect.ownKeys(includedObjects.value);
296
+ },
297
+ getOwnPropertyDescriptor(target, prop) {
298
+ if (typeof prop === "symbol") {
299
+ return Reflect.getOwnPropertyDescriptor(target, prop);
300
+ }
301
+ // Use the cached collection because descriptor lookups occur during enumeration.
302
+ const object = includedObjects.value[prop];
303
+ if (object === undefined) {
304
+ // Keep descriptors consistent with ownKeys().
305
+ return undefined;
306
+ }
307
+ return { configurable: true, enumerable: true, value: object, writable: true };
308
+ },
309
+ getPrototypeOf() {
310
+ return Object.prototype;
311
+ },
200
312
  });
201
313
 
202
314
  /** @type {import('./listInstance.js').ListOrder} */
203
- const order = computed(() => shallowReadonly(parentState.order.filter((pk) => includeMap[pk]?.include)));
315
+ const order = computed(() => shallowReadonly(parentState.order.filter(isIncluded)));
204
316
 
205
317
  /** @type {import('./listInstance.js').ObjectsInOrder} */
206
318
  const objectsInOrder = computed(() => shallowReadonly(order.value.map((pk) => parentState.objects[pk])));
@@ -211,6 +323,7 @@ export function useListFilter({ parentState, allowedFilter, excludedFilter }) {
211
323
  allowedFilter,
212
324
  excludedFilter,
213
325
 
326
+ objectsVersion,
214
327
  objects,
215
328
  order,
216
329
  objectsInOrder,
@@ -223,11 +336,10 @@ export function useListFilter({ parentState, allowedFilter, excludedFilter }) {
223
336
  return {
224
337
  state,
225
338
  parentState,
339
+ watchMembershipChanged: makeMembershipWatcher(state),
226
340
  stop: () => {
227
341
  es.stop();
228
- for (const pk of Object.keys(includeMap)) {
229
- delete includeMap[pk];
230
- }
342
+ includeMap.clear();
231
343
  },
232
344
  };
233
345
  }
@@ -2,6 +2,7 @@ import { defaultListCrud, getListCrud } from "../config/listCrud.js";
2
2
  import { assignReactiveObject } from "../utils/assignReactiveObject.js";
3
3
  import { getFakePk } from "../utils/getFakePk.js";
4
4
  import { normalizePk } from "../utils/refIfReactive.js";
5
+ import { makeMembershipWatcher } from "../utils/watches.js";
5
6
  import { useLoadingError } from "./loadingError.js";
6
7
  import inspect from "browser-util-inspect";
7
8
  import { computed, effectScope, isReactive, reactive, readonly, ref, shallowReactive, shallowReadonly } from "vue";
@@ -97,7 +98,7 @@ export class ListInstanceError extends Error {
97
98
  * @property {object} params - Arguments passed to the server for listing operations.
98
99
  * @property {ObjectsMap} objectsMap - The map of objects stored by their pks.
99
100
  * @property {ObjectsByPk} objects - The list objects stored by their pks.
100
- * @property {number} objectsVersion - Increments when the set of object keys changes.
101
+ * @property {number} objectsVersion - Increments when this layer's set of object keys changes. Each layer that narrows membership publishes its own, so the value belongs to the state reporting it and is not comparable with another layer's. Watch it rather than reading it, and prefer `watchMembershipChanged`, which carries the same signal without exposing how it is counted.
101
102
  * @property {ListOrder} order - The order of objects in the list.
102
103
  * @property {ObjectsInOrder} objectsInOrder - The objects in the order specified by the list.
103
104
  * @property {import('vue').ShallowReactive<PaginateInfo>} paginateInfo - Pagination information for the list.
@@ -149,6 +150,7 @@ export class ListInstanceError extends Error {
149
150
  * @property {(args: {action: string, pks?: import('../config/commonCrud.js').Pk[]} & import('../config/listCrud.js').AdditionalListArgs) => import('../utils/cancellablePromise.js').MaybeCancellablePromise<object|string|boolean|null>} executeAction - Initiates an action on all objects in the list, returning the response, or null if the action failed. The promise carries a `cancel` method when the handler's promise did.
150
151
  * @property {(info: PaginateInfo) => void} setPaginateInfo - The method to update pagination information.
151
152
  * @property {(total: ColumnTotals) => void} setColumnTotals - The method to update column totals.
153
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
152
154
  */
153
155
 
154
156
  /**
@@ -449,6 +451,7 @@ export function useListInstance({ props, handlers = {} }) {
449
451
  /** @type {ListInstance} */
450
452
  const self = {
451
453
  state,
454
+ watchMembershipChanged: makeMembershipWatcher(state),
452
455
  setPaginateInfo: (info) => {
453
456
  assignReactiveObject(state.paginateInfo, info || {});
454
457
  },
@@ -1,4 +1,5 @@
1
1
  import { keyDiff } from "../utils/keyDiff.js";
2
+ import { makeMembershipWatcher } from "../utils/watches.js";
2
3
  import { loadingCombine } from "../utils/loadingCombine.js";
3
4
  import { normalizePk } from "../utils/refIfReactive.js";
4
5
  import { proxyRunning } from "../utils/proxyRunning.js";
@@ -109,6 +110,7 @@ export class ListRelatedError extends Error {
109
110
  * @typedef {object} ListRelatedProperties - The properties for the list related composition function.
110
111
  * @property {ListRelatedState} state - The state for the list related property.
111
112
  * @property {ListRelatedParentState} parentState - The parent state object.
113
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
112
114
  * @property {() => void} stop - Stops all effects of the list related property.
113
115
  */
114
116
 
@@ -238,6 +240,14 @@ export function useListRelated(options) {
238
240
  }
239
241
  );
240
242
  const relatedObjectsEffectScopes = {};
243
+ // Records whose rule bag has not been reconciled against the rules yet. A record arriving with an
244
+ // empty bag needs every rule applied. A record already carrying the full rule set needs nothing,
245
+ // and rediscovering that by diffing its bag cost one keyDiff per record in the collection, per
246
+ // page, which is what made an identical page cost more the further into a stream it arrived.
247
+ const pendingObjectKeys = new Set();
248
+ // The rule keys the last reconciliation ran against, so a run can tell which of its two causes woke
249
+ // it. Only a rule change touches records that were already reconciled.
250
+ let previousRuleKeys = [];
241
251
 
242
252
  function parentStateObjectsWatch() {
243
253
  const { addedKeys: addedIds, removedKeys: removedIds } = keyDiff(
@@ -245,6 +255,7 @@ export function useListRelated(options) {
245
255
  Object.keys(state.relatedObjects)
246
256
  );
247
257
  for (const removedId of removedIds) {
258
+ pendingObjectKeys.delete(removedId);
248
259
  delete state.relatedObjects[removedId];
249
260
  delete state.objAndKeyForPkAndRule[removedId];
250
261
  delete state.fkForPkAndRule[removedId];
@@ -257,6 +268,7 @@ export function useListRelated(options) {
257
268
  state.relatedObjects[addedId] = {};
258
269
  state.objAndKeyForPkAndRule[addedId] = {};
259
270
  state.fkForPkAndRule[addedId] = {};
271
+ pendingObjectKeys.add(addedId);
260
272
  }
261
273
  nextTick(() => {
262
274
  state.relatedObjectsParentStateObjectsWatchRunning = false;
@@ -326,7 +338,18 @@ export function useListRelated(options) {
326
338
 
327
339
  function relatedObjectsWatch() {
328
340
  const relatedObjectsRulesIsEmpty = !state.relatedObjectsRules || isEmpty(state.relatedObjectsRules);
329
- for (const objectKey of Object.keys(state.relatedObjects)) {
341
+ const ruleKeys = Object.keys(state.relatedObjectsRules || {});
342
+ const { addedKeys: addedRules, removedKeys: removedRules } = keyDiff(ruleKeys, previousRuleKeys, {
343
+ sameKeys: false,
344
+ });
345
+ previousRuleKeys = ruleKeys;
346
+ // A rule change alters the bag of every record, so it is the only cause that has to walk the
347
+ // collection. A page arriving leaves every record already present holding the rule set it
348
+ // already held, so only the arrivals have anything to reconcile.
349
+ const rulesChanged = addedRules.size > 0 || removedRules.size > 0;
350
+ const objectKeys = rulesChanged ? Object.keys(state.relatedObjects) : [...pendingObjectKeys];
351
+ pendingObjectKeys.clear();
352
+ for (const objectKey of objectKeys) {
330
353
  let removedRuleKeys, addedRuleKeys;
331
354
  if (!relatedObjectsRulesIsEmpty) {
332
355
  ({ removedKeys: removedRuleKeys, addedKeys: addedRuleKeys } = keyDiff(
@@ -401,6 +424,7 @@ export function useListRelated(options) {
401
424
  return {
402
425
  state,
403
426
  parentState,
427
+ watchMembershipChanged: makeMembershipWatcher(state),
404
428
  stop: () => {
405
429
  es.stop();
406
430
  for (const objectKey of Object.keys(relatedObjectsEffectScopes)) {
package/use/listSearch.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { keyDiff } from "../utils/keyDiff.js";
2
+ import { makeMembershipWatcher } from "../utils/watches.js";
2
3
  import { proxyRunning } from "../utils/proxyRunning.js";
3
4
  import { getObjectRelatedCalculatedByKey } from "../utils/relatedCalculatedHelpers.js";
4
5
  import { useSearch } from "./search.js";
@@ -87,6 +88,7 @@ import { refIfReactive } from "../utils/refIfReactive.js";
87
88
  * @typedef {object} ListSearchProperties - The properties on a list search instance.
88
89
  * @property {ListSearchState} state - The state.
89
90
  * @property {import('./search.js').SearchInstance} textSearchIndex - The text search index.
91
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
90
92
  * @property {() => void} stop - Stops the effect scope and cleans up resources.
91
93
  */
92
94
 
@@ -186,16 +188,41 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
186
188
  const _objects = reactive({});
187
189
  /** @type {import('vue').Ref<import('../config/commonCrud.js').Pk[]>} */
188
190
  const _order = ref([]);
191
+ const textSearchRules = refIfReactive(props, "textSearchRules", []);
192
+ const textSearchValue = refIfReactive(props, "textSearchValue", "");
193
+ // With no rules or no query the layer selects nothing, so it hands the parent's views on unchanged
194
+ // rather than copying the collection into `_objects` once per page.
195
+ const passthrough = computed(() => !textSearchRules.value?.length || !textSearchValue.value?.length);
196
+ // Constant collection for a pass-through that shows nothing, read-only like every other view here.
197
+ const noObjects = shallowReadonly({});
198
+ // This layer's membership is its own: a query or rule change moves it without the parent's key set
199
+ // moving, so the parent's objectsVersion does not describe this collection. Own one here and keep
200
+ // the property's contract true for the sort layer downstream, which is what lets it watch the
201
+ // version instead of enumerating this layer. Synced by syncObjectsVersion below.
202
+ const objectsVersion = ref(0);
203
+ const searchedObjects = shallowReadonly(_objects);
189
204
  /** @type {ListSearchState} */
190
205
  // @ts-ignore
191
206
  const state = reactive(
192
207
  /** @type {ListSearchRawState} */ {
193
208
  .../** @type {ListSearchParentStateToRefs} */ toRefs(parentState),
194
- objects: shallowReadonly(_objects),
195
- objectsInOrder: computed(() => shallowReadonly(internalState.objectsInOrderRefs.map((ref) => unref(ref)))),
209
+ objectsVersion,
210
+ objects: computed(() => {
211
+ if (!passthrough.value) {
212
+ return searchedObjects;
213
+ }
214
+ return showAllWhenEmpty ? parentState.objects : noObjects;
215
+ }),
216
+ objectsInOrder: computed(() => {
217
+ if (passthrough.value) {
218
+ // no private collection to hold refs into, so resolve against the parent per key
219
+ return shallowReadonly(_order.value.map((pk) => parentState.objects[pk]));
220
+ }
221
+ return shallowReadonly(internalState.objectsInOrderRefs.map((ref) => unref(ref)));
222
+ }),
196
223
  order: computed(() => shallowReadonly(_order.value)),
197
- textSearchRules: refIfReactive(props, "textSearchRules", []),
198
- textSearchValue: refIfReactive(props, "textSearchValue", ""),
224
+ textSearchRules,
225
+ textSearchValue,
199
226
  objectIndexes: {},
200
227
  customDocumentOptions: refIfReactive(props, "customDocumentOptions", {}),
201
228
  customSearchOptions: refIfReactive(props, "customSearchOptions", {}),
@@ -237,6 +264,38 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
237
264
  // @ts-ignore
238
265
  textSearchIndex.state.search = toRef(state, "textSearchValue");
239
266
 
267
+ /** @type {import('../config/commonCrud.js').Pk[]} */
268
+ let previousSearchedKeys = [];
269
+ /** @type {boolean} */
270
+ let previousPassthrough;
271
+ /** @type {number} */
272
+ let previousParentVersion;
273
+ const syncObjectsVersion = () => {
274
+ const parentVersion = parentState.objectsVersion;
275
+ const flipped = previousPassthrough !== passthrough.value;
276
+ if (passthrough.value) {
277
+ // The private collection is released while passing through, so the key set is either the
278
+ // parent's or empty. Neither needs enumerating: a shown pass-through moves exactly when
279
+ // the parent moves, and a hidden one never moves at all.
280
+ const moved = flipped || (showAllWhenEmpty && previousParentVersion !== parentVersion);
281
+ previousSearchedKeys = [];
282
+ previousPassthrough = true;
283
+ previousParentVersion = parentVersion;
284
+ if (moved) {
285
+ objectsVersion.value++;
286
+ }
287
+ return;
288
+ }
289
+ const searchedKeys = Object.keys(_objects);
290
+ const { addedKeys, removedKeys } = keyDiff(searchedKeys, previousSearchedKeys, { sameKeys: false });
291
+ previousSearchedKeys = searchedKeys;
292
+ previousPassthrough = false;
293
+ previousParentVersion = parentVersion;
294
+ if (flipped || addedKeys.size || removedKeys.size) {
295
+ objectsVersion.value++;
296
+ }
297
+ };
298
+
240
299
  const objectEffectScopes = {};
241
300
  const objectComputeds = {};
242
301
 
@@ -244,8 +303,9 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
244
303
  const previousObjectIndexes = {};
245
304
 
246
305
  const doPassthrough = (cleanComputed = false) => {
247
- // pass through the objects if there are no rules.
248
- assignReactiveObject(_objects, showAllWhenEmpty ? parentState.objects : {});
306
+ // the state resolves against the parent while passing through, so the private collection is
307
+ // released rather than rebuilt.
308
+ assignReactiveObject(_objects, {});
249
309
  if (!cleanComputed) {
250
310
  return;
251
311
  }
@@ -361,7 +421,7 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
361
421
  };
362
422
 
363
423
  const updateObjectsForResults = () => {
364
- if (!state.textSearchRules?.length || !state.textSearchValue?.length) {
424
+ if (passthrough.value) {
365
425
  doPassthrough();
366
426
  return;
367
427
  }
@@ -373,9 +433,21 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
373
433
  .map(([pk]) => [pk, toRef(parentState.objects, pk)])
374
434
  )
375
435
  );
436
+ // Published once the writes are done. Watching the collection instead would re-enumerate it on
437
+ // every key assignReactiveObject writes.
438
+ syncObjectsVersion();
376
439
  };
377
440
 
378
441
  const updateOrder = () => {
442
+ if (passthrough.value) {
443
+ // Every parent key is a member, so the order needs no filtering against a private
444
+ // collection and objectsInOrder needs no refs into one. `_order` is still written here
445
+ // rather than read from the parent live: the write is what coalesces a page's worth of
446
+ // parent order changes into one notification for everything downstream.
447
+ _order.value = showAllWhenEmpty ? [...parentState.order] : [];
448
+ assignReactiveObject(internalState.objectsInOrderRefs, []);
449
+ return;
450
+ }
379
451
  _order.value = parentState.order.filter((pk) => !!_objects[pk]);
380
452
  assignReactiveObject(
381
453
  internalState.objectsInOrderRefs,
@@ -396,10 +468,20 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
396
468
  };
397
469
 
398
470
  es.run(() => {
399
- watch([() => Object.keys(parentState.objects), toRef(state.textSearchRules)], makeComputeds, {
471
+ // The parent now owns a version that describes its own key set, so this watches the version
472
+ // rather than enumerating the parent's collection on every invalidation of anything it tracks.
473
+ watch([() => parentState.objectsVersion, toRef(state.textSearchRules)], makeComputeds, {
400
474
  immediate: true,
401
475
  });
402
476
 
477
+ // Publish this layer's own key set changes. A parent structural change reaches this collection
478
+ // only while passing through; the query-driven case is published by updateObjectsForResults
479
+ // once it has finished writing, so nothing here enumerates the private collection per write.
480
+ watch([passthrough, () => parentState.objectsVersion], syncObjectsVersion, {
481
+ immediate: true,
482
+ flush: "sync",
483
+ });
484
+
403
485
  watch(
404
486
  toRef(state, "objectIndexes"),
405
487
  () => {
@@ -423,15 +505,21 @@ export function useListSearch({ parentState, props, throttle = 500, showAllWhenE
423
505
  }
424
506
  );
425
507
 
426
- watch([() => Object.keys(_objects), toRef(parentState, "order")], updateOrder, {
427
- immediate: true,
428
- deep: true,
429
- });
508
+ // While passing through, the private collection is empty and enumerating it buys nothing.
509
+ watch(
510
+ [passthrough, () => (passthrough.value ? null : Object.keys(_objects)), toRef(parentState, "order")],
511
+ updateOrder,
512
+ {
513
+ immediate: true,
514
+ deep: true,
515
+ }
516
+ );
430
517
  });
431
518
  textSearchIndex.events.addEventListener("newIndex", indexWasCleared);
432
519
  return {
433
520
  state,
434
521
  textSearchIndex,
522
+ watchMembershipChanged: makeMembershipWatcher(state),
435
523
  stop: () => {
436
524
  textSearchIndex.events.removeEventListener("newIndex", indexWasCleared);
437
525
  es.stop();
package/use/listSort.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { keyDiff } from "../utils/keyDiff.js";
2
+ import { makeMembershipWatcher } from "../utils/watches.js";
2
3
  import { loadingCombine } from "../utils/loadingCombine.js";
3
4
  import { proxyRunning } from "../utils/proxyRunning.js";
4
5
  import get from "lodash-es/get.js";
@@ -10,6 +11,7 @@ import {
10
11
  onScopeDispose,
11
12
  reactive,
12
13
  ref,
14
+ shallowReactive,
13
15
  shallowReadonly,
14
16
  toRef,
15
17
  toRefs,
@@ -98,6 +100,7 @@ export function setListSortDefaultOptions({ sortThrottleWait }) {
98
100
  * @typedef {object} ListSortProperties - The properties available on a list sort instance.
99
101
  * @property {ListSortState} state - The reactive state for the list sort.
100
102
  * @property {ListSortParentState} parentState - The parent state.
103
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
101
104
  * @property {() => void} stop - A function to stop the effect scope and clean up resources.
102
105
  */
103
106
 
@@ -189,11 +192,14 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
189
192
  ),
190
193
  });
191
194
 
192
- const criteriaMap = reactive({});
195
+ // Track entry changes without proxying entries or unwrapping their computed refs.
196
+ /** @type {Map<import('../config/commonCrud.js').Pk, {scope: import('vue').EffectScope, crit: import('vue').ComputedRef<any[]>}>} */
197
+ const criteriaMap = shallowReactive(new Map());
193
198
 
194
199
  function ensureCriteria(pk) {
195
- if (criteriaMap[pk]) {
196
- return criteriaMap[pk];
200
+ const existing = criteriaMap.get(pk);
201
+ if (existing) {
202
+ return existing;
197
203
  }
198
204
  const scope = es.run(() => effectScope());
199
205
  const crit = scope.run(() =>
@@ -223,15 +229,15 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
223
229
  );
224
230
  })
225
231
  );
226
- criteriaMap[pk] = { scope, crit };
232
+ criteriaMap.set(pk, { scope, crit });
227
233
  return crit;
228
234
  }
229
235
 
230
236
  function syncCriteria(newKeys) {
231
- const { addedKeys, removedKeys } = keyDiff(newKeys, Object.keys(criteriaMap));
237
+ const { addedKeys, removedKeys } = keyDiff(newKeys, [...criteriaMap.keys()]);
232
238
  for (const pk of removedKeys) {
233
- criteriaMap[pk].scope.stop();
234
- delete criteriaMap[pk];
239
+ criteriaMap.get(pk).scope.stop();
240
+ criteriaMap.delete(pk);
235
241
  }
236
242
  for (const pk of addedKeys) {
237
243
  ensureCriteria(pk);
@@ -239,6 +245,10 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
239
245
  }
240
246
 
241
247
  es.run(() => {
248
+ // One structural pass per change. This used to carry a second, deferred watcher on the parent's
249
+ // keys, because the filter and search layers moved their own key set without moving the
250
+ // version they forwarded. Both own an accurate version now, so the sync watcher sees every
251
+ // cause and criteria exist before anything reads them.
242
252
  watch(
243
253
  () => parentState.objectsVersion,
244
254
  () => {
@@ -246,15 +256,14 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
246
256
  },
247
257
  { immediate: true, flush: "sync" }
248
258
  );
249
- watch(() => Object.keys(parentState.objects), syncCriteria);
250
259
  });
251
260
 
252
261
  const rawOrder = computed(() => {
253
262
  const arr = [...unref(toRef(parentState, "order"))];
254
263
  const rulesArr = internalState.orderByRules?.filter(identity) || [];
255
264
  return arr.sort((a, b) => {
256
- const aCrit = criteriaMap[a]?.crit ?? [];
257
- const bCrit = criteriaMap[b]?.crit ?? [];
265
+ const aCrit = criteriaMap.get(a)?.crit.value ?? [];
266
+ const bCrit = criteriaMap.get(b)?.crit.value ?? [];
258
267
  for (let i = 0; i < rulesArr.length; i++) {
259
268
  const rule = rulesArr[i];
260
269
  let x = aCrit[i],
@@ -286,17 +295,67 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
286
295
  });
287
296
  });
288
297
 
289
- const objects = computed(() => {
290
- // built mutably here, then handed out read-only below
298
+ // Track membership without reading the record's criteria.
299
+ const hasCriteria = (/** @type {import('../config/commonCrud.js').Pk} */ pk) => criteriaMap.has(pk);
300
+
301
+ // Cache enumeration separately from per-key reads.
302
+ /** @type {import('vue').ComputedRef<import('./listInstance.js').ObjectsByPk>} */
303
+ const criteriaObjects = computed(() => {
291
304
  /** @type {{[pk: import('../config/commonCrud.js').Pk]: import('./objectInstance.js').ExistingCrudObject}} */
292
305
  const out = {};
293
306
  for (const [pk, o] of Object.entries(parentState.objects)) {
294
- const inc = criteriaMap[pk]?.crit;
295
- if (inc) out[pk] = o;
307
+ if (hasCriteria(pk)) {
308
+ out[pk] = o;
309
+ }
296
310
  }
297
- // the computed rebuilds this object on every run, so a write into it would be discarded
298
- // silently on the next read. Report it instead.
299
- return shallowReadonly(out);
311
+ return out;
312
+ });
313
+
314
+ // Resolve individual keys without tracking the full collection.
315
+ // Implement readonly behaviour here to avoid an extra proxy layer.
316
+ /** @type {import('./listInstance.js').ObjectsByPk} */
317
+ const objects = new Proxy(/** @type {any} */ ({}), {
318
+ get(target, prop) {
319
+ if (typeof prop === "symbol") {
320
+ return Reflect.get(target, prop);
321
+ }
322
+ if (prop === "__v_isReadonly") {
323
+ return true;
324
+ }
325
+ return hasCriteria(prop) ? parentState.objects[prop] : undefined;
326
+ },
327
+ set(target, prop) {
328
+ console.warn(`useListSort: set operation on key "${String(prop)}" failed: objects is read-only.`);
329
+ return true;
330
+ },
331
+ deleteProperty(target, prop) {
332
+ console.warn(`useListSort: delete operation on key "${String(prop)}" failed: objects is read-only.`);
333
+ return true;
334
+ },
335
+ has(target, prop) {
336
+ if (typeof prop === "symbol") {
337
+ return Reflect.has(target, prop);
338
+ }
339
+ return hasCriteria(prop) && prop in parentState.objects;
340
+ },
341
+ ownKeys() {
342
+ return Reflect.ownKeys(criteriaObjects.value);
343
+ },
344
+ getOwnPropertyDescriptor(target, prop) {
345
+ if (typeof prop === "symbol") {
346
+ return Reflect.getOwnPropertyDescriptor(target, prop);
347
+ }
348
+ // Use the cached collection because descriptor lookups occur during enumeration.
349
+ const object = criteriaObjects.value[prop];
350
+ if (object === undefined) {
351
+ // Keep descriptors consistent with ownKeys().
352
+ return undefined;
353
+ }
354
+ return { configurable: true, enumerable: true, value: object, writable: true };
355
+ },
356
+ getPrototypeOf() {
357
+ return Object.prototype;
358
+ },
300
359
  });
301
360
 
302
361
  const order = ref([]);
@@ -343,23 +402,25 @@ export function useListSort({ parentState, orderByRules, sortThrottleWait = defa
343
402
  // 6) objectsInOrder just follows that
344
403
  const objectsInOrder = computed(() => shallowReadonly(order.value.map((pk) => parentState.objects[pk])));
345
404
 
405
+ /** @type {ListSortState} */
406
+ const state = reactive({
407
+ ...toRefs(parentState),
408
+ orderByRules: toRef(internalState, "orderByRules"),
409
+ orderByDesc: toRef(internalState, "orderByDesc"),
410
+ objects,
411
+ // the ref stays private to writeOrder; the state exposes a read-only view of it
412
+ order: computed(() => shallowReadonly(order.value)),
413
+ objectsInOrder,
414
+ running,
415
+ });
416
+
346
417
  return {
347
- state: reactive({
348
- ...toRefs(parentState),
349
- orderByRules: toRef(internalState, "orderByRules"),
350
- orderByDesc: toRef(internalState, "orderByDesc"),
351
- objects,
352
- // the ref stays private to writeOrder; the state exposes a read-only view of it
353
- order: computed(() => shallowReadonly(order.value)),
354
- objectsInOrder,
355
- running,
356
- }),
418
+ state,
357
419
  parentState,
420
+ watchMembershipChanged: makeMembershipWatcher(state),
358
421
  stop: () => {
359
422
  es.stop();
360
- for (const key of Object.keys(criteriaMap)) {
361
- delete criteriaMap[key];
362
- }
423
+ criteriaMap.clear();
363
424
  },
364
425
  };
365
426
  }
@@ -2,6 +2,7 @@ import { useCancellableIntent } from "./cancellableIntent.js";
2
2
  import { useListInstance } from "./listInstance.js";
3
3
  import { useLoadingError } from "./loadingError.js";
4
4
  import { normalizePk } from "../utils/refIfReactive.js";
5
+ import { makeMembershipWatcher } from "../utils/watches.js";
5
6
  import inspect from "browser-util-inspect";
6
7
  import cloneDeep from "lodash-es/cloneDeep.js";
7
8
  import isEmpty from "lodash-es/isEmpty.js";
@@ -74,6 +75,7 @@ export class ListSubscriptionError extends Error {
74
75
  * @property {import('./listInstance.js').ListInstance} listInstance - The list instance used by the subscription.
75
76
  * @property {import('./cancellableIntent.js').CancellableIntent} listIntent - The `CancellableIntent` instance managing if the list should be (re)fetched.
76
77
  * @property {import('./cancellableIntent.js').CancellableIntent} subscribeIntent - The `CancellableIntent` instance managing if the subscription should be (un)subscribed.
78
+ * @property {import('../utils/watches.js').WatchMembershipChanged} watchMembershipChanged - Registers a callback for changes to the set of object keys this layer holds. The watcher belongs to the effect scope active where it is called, not to this layer, so stopping this layer silences it without disposing it.
77
79
  */
78
80
 
79
81
  /**
@@ -339,6 +341,7 @@ export function useListSubscription({ listInstance, props, handlers }) {
339
341
  listInstance,
340
342
  listIntent,
341
343
  subscribeIntent,
344
+ watchMembershipChanged: makeMembershipWatcher(state),
342
345
  clearError: proxyLoadingError.clearError,
343
346
  // Stops both intents, mirroring useObjectSubscription, so a caller that owns this
344
347
  // subscription's lifetime does not have to know its intent inventory. The wrapped
package/utils/watches.js CHANGED
@@ -8,6 +8,38 @@ import { watch, toRef } from "vue";
8
8
  *
9
9
  */
10
10
 
11
+ /**
12
+ * @callback WatchMembershipChanged - Calls back when a list layer's set of object keys changes.
13
+ * Registers in the effect scope active where it is called, not in the layer's own scope, so the
14
+ * returned handle and the surrounding scope own the watcher. Stopping the layer does not dispose it,
15
+ * because the layer never owned it; it only silences it, since a stopped layer publishes nothing
16
+ * further. Stop it through the returned handle or through the enclosing scope.
17
+ * The callback takes no arguments: it reports that membership moved, not what it moved to. Read the
18
+ * layer's collection views for that.
19
+ * @param {() => void} callback - Called after the layer's set of object keys changes.
20
+ * @param {import('vue').WatchOptions} [options] - Passed through to Vue's `watch`, so `immediate`,
21
+ * `flush`, and `once` all behave as they do there.
22
+ * @returns {import('vue').WatchHandle} - Stops the watcher.
23
+ */
24
+
25
+ /**
26
+ * Builds a layer's membership watcher over the counter that layer publishes.
27
+ *
28
+ * @internal
29
+ * @param {{objectsVersion: number}} state - The layer's own state.
30
+ * @returns {WatchMembershipChanged} - The watcher registration function for that layer.
31
+ */
32
+ export function makeMembershipWatcher(state) {
33
+ // The counter is an implementation detail of how membership changes are published, so it is not
34
+ // passed to the callback. That keeps how the signal is carried free to change.
35
+ return (callback, options) =>
36
+ watch(
37
+ () => state.objectsVersion,
38
+ () => callback(),
39
+ options
40
+ );
41
+ }
42
+
11
43
  /**
12
44
  * Provides a mechanism for immediately starting and potentially stopping a Vue.js watcher
13
45
  * during its first invocation. This is useful when the need arises to terminate the watch