@object-ui/react 17.6.0 → 18.0.0
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/CHANGELOG.md +4263 -0
- package/README.md +383 -42
- package/dist/SchemaRenderer.d.ts +64 -7
- package/dist/SchemaRenderer.js +1326 -106
- package/dist/context/ActionContext.js +40 -1
- package/dist/context/AppShellContext.d.ts +27 -0
- package/dist/context/DiscussionContext.d.ts +27 -0
- package/dist/context/DiscussionContext.js +4 -0
- package/dist/context/DrillNavigationContext.d.ts +13 -0
- package/dist/context/InlineEditContext.d.ts +151 -1
- package/dist/context/InlineEditContext.js +36 -2
- package/dist/context/PrintPageContext.d.ts +60 -0
- package/dist/context/PrintPageContext.js +32 -0
- package/dist/context/RecordContext.d.ts +48 -5
- package/dist/context/RecordContext.js +10 -1
- package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
- package/dist/context/RelatedRecordActionsContext.js +4 -0
- package/dist/context/SchemaRendererContext.d.ts +61 -6
- package/dist/context/SchemaRendererContext.js +39 -6
- package/dist/context/ThemeContext.js +1 -1
- package/dist/context/actionRunnerTranslator.d.ts +27 -0
- package/dist/context/actionRunnerTranslator.js +37 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
- package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
- package/dist/element-data-source/ElementDataSourceGate.js +401 -19
- package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
- package/dist/element-data-source/useElementApiDataSource.js +198 -0
- package/dist/hooks/index.d.ts +3 -1
- package/dist/hooks/index.js +10 -1
- package/dist/hooks/useActionEngine.js +46 -3
- package/dist/hooks/useActionRunner.js +4 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
- package/dist/hooks/useActionTextLocalizer.js +70 -14
- package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
- package/dist/hooks/useConfigBagEvaluator.js +40 -0
- package/dist/hooks/useDatasetDimensionLabels.js +25 -10
- package/dist/hooks/useDensityMode.d.ts +8 -2
- package/dist/hooks/useDensityMode.js +8 -2
- package/dist/hooks/useDiscovery.d.ts +1 -10
- package/dist/hooks/useETagCache.js +25 -4
- package/dist/hooks/useElementDataSource.d.ts +15 -2
- package/dist/hooks/useElementDataSource.js +24 -7
- package/dist/hooks/useExpression.d.ts +52 -59
- package/dist/hooks/useExpression.js +58 -90
- package/dist/hooks/useFilterScope.d.ts +22 -2
- package/dist/hooks/useFilterScope.js +29 -4
- package/dist/hooks/useGlobalUndo.d.ts +10 -0
- package/dist/hooks/useGlobalUndo.js +112 -46
- package/dist/hooks/useNavigationOverlay.d.ts +76 -11
- package/dist/hooks/useNavigationOverlay.js +119 -9
- package/dist/hooks/useOffline.js +65 -11
- package/dist/hooks/usePageAssignment.d.ts +33 -7
- package/dist/hooks/usePageAssignment.js +85 -21
- package/dist/hooks/useRecordSearch.d.ts +34 -1
- package/dist/hooks/useRecordSearch.js +42 -7
- package/dist/hooks/useResolvedFilter.d.ts +46 -0
- package/dist/hooks/useResolvedFilter.js +130 -0
- package/dist/hooks/useSchemaPersistence.d.ts +7 -0
- package/dist/hooks/useSchemaPersistence.js +144 -5
- package/dist/hooks/useSettledSchema.d.ts +123 -0
- package/dist/hooks/useSettledSchema.js +186 -0
- package/dist/hooks/useViewData.d.ts +10 -3
- package/dist/hooks/useViewData.js +5 -14
- package/dist/index.d.ts +9 -1
- package/dist/index.js +48 -1
- package/dist/schema-input.d.ts +38 -8
- package/dist/schema-input.js +39 -7
- package/dist/utils/configBag.d.ts +68 -0
- package/dist/utils/configBag.js +70 -0
- package/dist/utils/error-message.d.ts +1 -1
- package/dist/utils/error-message.js +1 -1
- package/dist/utils/inlineAria.d.ts +61 -0
- package/dist/utils/inlineAria.js +70 -0
- package/dist/utils/localeMap.d.ts +27 -0
- package/dist/utils/localeMap.js +106 -0
- package/dist/utils/nonGridRowCeiling.d.ts +44 -0
- package/dist/utils/nonGridRowCeiling.js +78 -0
- package/dist/utils/paramsBag.d.ts +28 -0
- package/dist/utils/paramsBag.js +146 -0
- package/dist/utils/propsBagDiagnostic.d.ts +196 -0
- package/dist/utils/propsBagDiagnostic.js +422 -0
- package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
- package/dist/utils/refusedDataPropDiagnostic.js +94 -0
- package/dist/utils/unevaluatedExpression.d.ts +44 -1
- package/dist/utils/unevaluatedExpression.js +92 -11
- package/dist/utils/visibilityDiagnostic.d.ts +282 -21
- package/dist/utils/visibilityDiagnostic.js +321 -46
- package/package.json +10 -10
- package/dist/hooks/useClientNotifications.d.ts +0 -47
- package/dist/hooks/useClientNotifications.js +0 -162
- package/dist/spec-bridge/SpecBridge.d.ts +0 -28
- package/dist/spec-bridge/SpecBridge.js +0 -56
- package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
- package/dist/spec-bridge/bridges/form-view.js +0 -170
- package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
- package/dist/spec-bridge/bridges/list-view.js +0 -268
- package/dist/spec-bridge/index.d.ts +0 -11
- package/dist/spec-bridge/index.js +0 -10
- package/dist/spec-bridge/types.d.ts +0 -55
- package/dist/spec-bridge/types.js +0 -8
package/dist/hooks/useOffline.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
8
|
+
import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Online/offline external store (SSR-safe)
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -93,7 +93,31 @@ export function useOffline(config = {}) {
|
|
|
93
93
|
const [queue, setQueue] = useState(loadQueue);
|
|
94
94
|
const [syncState, setSyncState] = useState('idle');
|
|
95
95
|
const syncConfigRef = useRef(syncConfig);
|
|
96
|
-
|
|
96
|
+
// `sync` below is keyed on `[enabled, queue]`, so a config-only change keeps
|
|
97
|
+
// the SAME `sync` closure alive — and the auto-sync effect deliberately
|
|
98
|
+
// captures one and fires it 100ms later. The ref is how those retained
|
|
99
|
+
// closures still read the newest `sync` config; putting `syncConfig` in
|
|
100
|
+
// `sync`'s deps instead would rebuild `sync` (and the memoised result) on
|
|
101
|
+
// every render, because callers pass `sync: { ... }` as an inline literal.
|
|
102
|
+
// Only the write moves out of the render body: `useInsertionEffect` runs in
|
|
103
|
+
// the mutation phase, ahead of every layout effect and paint, so no caller
|
|
104
|
+
// that could legally reach `sync()` observes a different value than before.
|
|
105
|
+
useInsertionEffect(() => {
|
|
106
|
+
syncConfigRef.current = syncConfig;
|
|
107
|
+
});
|
|
108
|
+
// The queue reaches `sync` through the SAME commit-phase mirror the sync
|
|
109
|
+
// config uses (objectui#6818). It used to come from `sync`'s own closure
|
|
110
|
+
// while `batchSize` came from the ref above, so the two halves of one call
|
|
111
|
+
// disagreed about how current they were: a retained `sync` — and the
|
|
112
|
+
// auto-sync effect below deliberately retains one and fires it 100ms later —
|
|
113
|
+
// batched a queue snapshot from an older render against the newest
|
|
114
|
+
// `batchSize`. Mirroring the queue makes both halves the newest COMMITTED
|
|
115
|
+
// value, and it is what takes `queue` out of `sync`'s dependency list, so
|
|
116
|
+
// `sync` stops changing identity on every queued mutation.
|
|
117
|
+
const queueRef = useRef(queue);
|
|
118
|
+
useInsertionEffect(() => {
|
|
119
|
+
queueRef.current = queue;
|
|
120
|
+
});
|
|
97
121
|
// Persist queue to localStorage whenever it changes
|
|
98
122
|
useEffect(() => {
|
|
99
123
|
persistQueue(queue);
|
|
@@ -130,14 +154,16 @@ export function useOffline(config = {}) {
|
|
|
130
154
|
setQueue([]);
|
|
131
155
|
}, []);
|
|
132
156
|
const sync = useCallback(async () => {
|
|
133
|
-
|
|
157
|
+
// Newest committed queue, not this closure's snapshot — see `queueRef`.
|
|
158
|
+
const pending = queueRef.current;
|
|
159
|
+
if (!enabled || pending.length === 0)
|
|
134
160
|
return;
|
|
135
161
|
setSyncState('syncing');
|
|
136
162
|
try {
|
|
137
163
|
// In a real implementation, this would batch-send mutations to the server.
|
|
138
164
|
// For now, we simulate a successful sync by clearing the queue.
|
|
139
|
-
const batchSize = syncConfigRef.current?.batchSize ??
|
|
140
|
-
const batch =
|
|
165
|
+
const batchSize = syncConfigRef.current?.batchSize ?? pending.length;
|
|
166
|
+
const batch = pending.slice(0, batchSize);
|
|
141
167
|
// Simulate network round-trip
|
|
142
168
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
143
169
|
// Remove synced mutations from queue
|
|
@@ -147,18 +173,46 @@ export function useOffline(config = {}) {
|
|
|
147
173
|
catch {
|
|
148
174
|
setSyncState('error');
|
|
149
175
|
}
|
|
150
|
-
}, [enabled
|
|
151
|
-
// Auto-sync
|
|
176
|
+
}, [enabled]);
|
|
177
|
+
// Auto-sync while online and holding queued mutations (short stabilization
|
|
178
|
+
// delay).
|
|
179
|
+
//
|
|
180
|
+
// The dependency list used to be `[isOnline, enabled]` with
|
|
181
|
+
// `react-hooks/exhaustive-deps` suppressed, on the stated grounds that
|
|
182
|
+
// re-running "on every queue change" would restart the 100ms timer once per
|
|
183
|
+
// queued mutation. That reason is real and is preserved below — the
|
|
184
|
+
// dependency is the BOOLEAN `hasPendingMutations`, never `queue` and never
|
|
185
|
+
// `queue.length`, so queueing a second mutation while a timer is already
|
|
186
|
+
// armed does not re-run this effect and does not restart the timer.
|
|
187
|
+
//
|
|
188
|
+
// What the suppression also did, and never justified, was evaluate the
|
|
189
|
+
// emptiness guard against a queue snapshot from whenever `isOnline` or
|
|
190
|
+
// `enabled` last changed. `queueMutation` accepts entries whenever the hook
|
|
191
|
+
// is enabled — it has never been conditional on being offline — so anything
|
|
192
|
+
// queued while ALREADY online found this effect asleep and had no auto-sync
|
|
193
|
+
// path at all; only an explicit `sync()` drained it (objectui#6818). Keying
|
|
194
|
+
// on the boolean re-evaluates the guard exactly when it can change answer.
|
|
195
|
+
//
|
|
196
|
+
// `sync` is now stable across queue changes (it reads the queue through
|
|
197
|
+
// `queueRef`), so naming it here costs no extra timer restart and the array
|
|
198
|
+
// is genuinely exhaustive — the suppression is gone rather than reworded.
|
|
199
|
+
//
|
|
200
|
+
// Known remaining edge, deliberately not widened here: a `batchSize` smaller
|
|
201
|
+
// than the queue drains one batch and leaves `hasPendingMutations` true, so
|
|
202
|
+
// the remainder waits for the next transition rather than chaining a second
|
|
203
|
+
// batch. That is the pre-existing behaviour, not a regression. Whether one
|
|
204
|
+
// auto-sync should drain the whole queue batch-by-batch is a semantics
|
|
205
|
+
// question about `batchSize` rather than a bug in this guard, and it is
|
|
206
|
+
// filed separately as objectui#6857.
|
|
207
|
+
const hasPendingMutations = queue.length > 0;
|
|
152
208
|
useEffect(() => {
|
|
153
|
-
if (!enabled || !isOnline ||
|
|
209
|
+
if (!enabled || !isOnline || !hasPendingMutations)
|
|
154
210
|
return;
|
|
155
211
|
const timer = setTimeout(() => {
|
|
156
212
|
void sync();
|
|
157
213
|
}, 100);
|
|
158
214
|
return () => clearTimeout(timer);
|
|
159
|
-
|
|
160
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
-
}, [isOnline, enabled]);
|
|
215
|
+
}, [isOnline, enabled, hasPendingMutations, sync]);
|
|
162
216
|
return useMemo(() => ({
|
|
163
217
|
isOnline,
|
|
164
218
|
enabled,
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*
|
|
8
8
|
* `usePageAssignment` — resolve the record PageSchema that should be rendered
|
|
9
|
-
* for a given object. Walks the metadata cache exposed by `<MetadataProvider
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* for a given object. Walks the metadata cache exposed by `<MetadataProvider>`,
|
|
10
|
+
* keeps the PageSchemas whose `type === 'record'` and `object` matches the
|
|
11
|
+
* requested name, and returns the object's DEFAULT record page among them
|
|
12
|
+
* (`isDefault: true`), else the first of them.
|
|
12
13
|
*
|
|
13
14
|
* Returns a discriminated result by `PageSchema.kind`:
|
|
14
15
|
* - `kind === 'full'` (default): the schema fully describes the page;
|
|
@@ -18,10 +19,17 @@
|
|
|
18
19
|
* `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
|
|
19
20
|
* fall through to synthesized defaults.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Selection is by the declared `PageSchema.isDefault`, then by DECLARATION
|
|
23
|
+
* ORDER among pages that are not the default: the spec allows one default
|
|
24
|
+
* record page per object, so the flag needs no tie-break here, and with no
|
|
25
|
+
* default the first matching page wins. No match returns null, so callers can
|
|
26
|
+
* deterministically fall back to the auto-generated DetailView when no record
|
|
27
|
+
* Page is authored.
|
|
28
|
+
*
|
|
29
|
+
* Future work (deferred): recordType / profile / app / formFactor filtering.
|
|
30
|
+
* Any further author-driven ordering would have to be DECLARED on
|
|
31
|
+
* `PageSchema` in `@objectstack/spec` first — see the note on the selection
|
|
32
|
+
* itself below.
|
|
25
33
|
*/
|
|
26
34
|
export interface PageAssignmentOptions {
|
|
27
35
|
/** Salesforce-style record type filter (reserved for future use). */
|
|
@@ -59,6 +67,24 @@ export interface PageAssignmentResult {
|
|
|
59
67
|
/** Loader error, if any. */
|
|
60
68
|
error: Error | null;
|
|
61
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* True when `page` is a record page bound to `objectName`: its `type` is
|
|
72
|
+
* `'record'` and its `object` names that object.
|
|
73
|
+
*
|
|
74
|
+
* This is the ONE reading of "a record page of object O" in the console. The
|
|
75
|
+
* object-based lookup below filters its candidates with it, and a route that
|
|
76
|
+
* names a page explicitly (the console's named record page route,
|
|
77
|
+
* objectui#12715) asks it before rendering the named page for a record of O,
|
|
78
|
+
* so a name that is a page of another object, or not a record page at all, is
|
|
79
|
+
* refused rather than rendered. The `pageName` override of
|
|
80
|
+
* {@link usePageAssignment} does not ask it: that option returns the page with
|
|
81
|
+
* that name whatever it is, and a caller that renders a record on the page it
|
|
82
|
+
* gets back asks this first.
|
|
83
|
+
*
|
|
84
|
+
* `type` is the one discriminator, as the lookup's own comment explains
|
|
85
|
+
* (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isRecordPageOf(page: unknown, objectName: string | undefined | null): boolean;
|
|
62
88
|
/**
|
|
63
89
|
* Resolve the record PageSchema for the given object name. Returns `null`
|
|
64
90
|
* when no record Page is configured, signalling the caller to fall back
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*
|
|
8
8
|
* `usePageAssignment` — resolve the record PageSchema that should be rendered
|
|
9
|
-
* for a given object. Walks the metadata cache exposed by `<MetadataProvider
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* for a given object. Walks the metadata cache exposed by `<MetadataProvider>`,
|
|
10
|
+
* keeps the PageSchemas whose `type === 'record'` and `object` matches the
|
|
11
|
+
* requested name, and returns the object's DEFAULT record page among them
|
|
12
|
+
* (`isDefault: true`), else the first of them.
|
|
12
13
|
*
|
|
13
14
|
* Returns a discriminated result by `PageSchema.kind`:
|
|
14
15
|
* - `kind === 'full'` (default): the schema fully describes the page;
|
|
@@ -18,13 +19,44 @@
|
|
|
18
19
|
* `buildDefaultPageSchema(objectDef, { slots })` so omitted slots
|
|
19
20
|
* fall through to synthesized defaults.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Selection is by the declared `PageSchema.isDefault`, then by DECLARATION
|
|
23
|
+
* ORDER among pages that are not the default: the spec allows one default
|
|
24
|
+
* record page per object, so the flag needs no tie-break here, and with no
|
|
25
|
+
* default the first matching page wins. No match returns null, so callers can
|
|
26
|
+
* deterministically fall back to the auto-generated DetailView when no record
|
|
27
|
+
* Page is authored.
|
|
28
|
+
*
|
|
29
|
+
* Future work (deferred): recordType / profile / app / formFactor filtering.
|
|
30
|
+
* Any further author-driven ordering would have to be DECLARED on
|
|
31
|
+
* `PageSchema` in `@objectstack/spec` first — see the note on the selection
|
|
32
|
+
* itself below.
|
|
25
33
|
*/
|
|
26
34
|
import { useEffect, useMemo, useState } from 'react';
|
|
35
|
+
import { defaultRecordPageObject } from '@objectstack/spec/ui';
|
|
27
36
|
import { useMetadata } from '../context/AppShellContext.js';
|
|
37
|
+
/**
|
|
38
|
+
* True when `page` is a record page bound to `objectName`: its `type` is
|
|
39
|
+
* `'record'` and its `object` names that object.
|
|
40
|
+
*
|
|
41
|
+
* This is the ONE reading of "a record page of object O" in the console. The
|
|
42
|
+
* object-based lookup below filters its candidates with it, and a route that
|
|
43
|
+
* names a page explicitly (the console's named record page route,
|
|
44
|
+
* objectui#12715) asks it before rendering the named page for a record of O,
|
|
45
|
+
* so a name that is a page of another object, or not a record page at all, is
|
|
46
|
+
* refused rather than rendered. The `pageName` override of
|
|
47
|
+
* {@link usePageAssignment} does not ask it: that option returns the page with
|
|
48
|
+
* that name whatever it is, and a caller that renders a record on the page it
|
|
49
|
+
* gets back asks this first.
|
|
50
|
+
*
|
|
51
|
+
* `type` is the one discriminator, as the lookup's own comment explains
|
|
52
|
+
* (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read.
|
|
53
|
+
*/
|
|
54
|
+
export function isRecordPageOf(page, objectName) {
|
|
55
|
+
if (!page || typeof page !== 'object' || !objectName)
|
|
56
|
+
return false;
|
|
57
|
+
const p = page;
|
|
58
|
+
return p.type === 'record' && p.object === objectName;
|
|
59
|
+
}
|
|
28
60
|
function matchesAssignment(_page, _opts) {
|
|
29
61
|
// Placeholder — every page matches until rules-based assignment lands.
|
|
30
62
|
return true;
|
|
@@ -75,25 +107,57 @@ export function usePageAssignment(objectName, opts = {}) {
|
|
|
75
107
|
const candidates = pages.filter(p => {
|
|
76
108
|
if (!p)
|
|
77
109
|
return false;
|
|
78
|
-
//
|
|
79
|
-
// user-facing record-detail page
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
// `type` is the ONE discriminator: only `type: 'record'` is a
|
|
111
|
+
// user-facing record-detail page, and every other page type
|
|
112
|
+
// (`home` / `app` / `utility` / `list`) is skipped.
|
|
113
|
+
//
|
|
114
|
+
// `pageType` is deliberately NOT read (objectui#9674). `PageSchema` is
|
|
115
|
+
// a `strictObject` that refuses `pageType` by name — it is a declared
|
|
116
|
+
// ALIAS of `type`, and a page carrying it is a hard parse error that
|
|
117
|
+
// names `type` as the key to write — so no page that parses carries it,
|
|
118
|
+
// and reading it would teach the refused spelling onward. A consumer-side
|
|
119
|
+
// alias for a refused key is the fallback AGENTS.md #0.1 rules out. The
|
|
120
|
+
// designer-side `'record_detail'` is not expressible either: it left
|
|
121
|
+
// `PageTypeSchema` (framework#2265), so `type: 'record_detail'` is an
|
|
122
|
+
// invalid enum value.
|
|
123
|
+
if (!isRecordPageOf(p, objectName))
|
|
88
124
|
return false;
|
|
89
125
|
return matchesAssignment(p, opts);
|
|
90
126
|
});
|
|
91
127
|
if (!candidates.length)
|
|
92
128
|
return null;
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
129
|
+
// The object's DEFAULT record page decides; with none, declaration order.
|
|
130
|
+
//
|
|
131
|
+
// At most one `isDefault: true` record page may exist for an object in a
|
|
132
|
+
// deployment. That is the spec's rule on `PageSchema.isDefault` (maintainer
|
|
133
|
+
// ruling A on objectstack#23015, record 6134056653), and the platform
|
|
134
|
+
// refuses a second default, naming both pages, at every door that can add
|
|
135
|
+
// one: `defineStack` / `composeStacks` (so `os validate` and a source boot),
|
|
136
|
+
// package registration, and metadata save. So this hook honours the flag
|
|
137
|
+
// with NO tie-break of its own. It asks `defaultRecordPageObject`, the one
|
|
138
|
+
// reading of "the default record page of an object" each of those doors
|
|
139
|
+
// asks, so the page the platform judges to be the default is the page that
|
|
140
|
+
// opens. Replacing a platform record page stays an ADR-0005 overlay by name
|
|
141
|
+
// (the same page, not a second default) or host composition; neither a
|
|
142
|
+
// second default nor an earlier position in the served list overrides one.
|
|
143
|
+
//
|
|
144
|
+
// Boundary, stated rather than left implicit: at a cold boot every package
|
|
145
|
+
// registers before the environment's stored pages hydrate, so a stored
|
|
146
|
+
// default saved while no package held that object's default, and a package
|
|
147
|
+
// that adds one later, both reach the registry unrefused
|
|
148
|
+
// (objectstack#23213, closed not_planned). This hook invents no precedence
|
|
149
|
+
// for that pair (no layer, package or recency rung); served order stays the
|
|
150
|
+
// only order it has, so the first default served wins.
|
|
151
|
+
//
|
|
152
|
+
// Among pages that are not the default, declaration order is the last
|
|
153
|
+
// resort: the first match wins. This used to sort on a `priority` key read
|
|
154
|
+
// off the candidate. No author could ever set it: `PageSchema` is a
|
|
155
|
+
// `strictObject` and does not declare `priority`, so a page carrying one is
|
|
156
|
+
// a HARD PARSE ERROR, not a page that sorts first. Every candidate therefore
|
|
157
|
+
// read `0`, the comparator returned `0` for every pair, and a stable sort
|
|
158
|
+
// left declaration order untouched — the sort documented an affordance the
|
|
159
|
+
// schema refuses (objectui#7298).
|
|
160
|
+
return candidates.find(p => defaultRecordPageObject(p) === objectName) ?? candidates[0];
|
|
97
161
|
}, [meta.pages, objectName, opts.pageName, opts.recordType, opts.profile, opts.app, opts.formFactor]);
|
|
98
162
|
// Discriminate by `kind`: full pages populate `page`, slotted pages
|
|
99
163
|
// populate `slots`. Missing `kind` defaults to 'full' for backwards
|
|
@@ -23,7 +23,10 @@ export interface RecordSearchHit {
|
|
|
23
23
|
* objects. Stable across runs for the same (query, display) pair.
|
|
24
24
|
*/
|
|
25
25
|
score: number;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Raw record payload, for callers that want extra context. It is the row as
|
|
28
|
+
* served: `fieldReadPolicy` gates the `display` label, not this payload.
|
|
29
|
+
*/
|
|
27
30
|
raw: any;
|
|
28
31
|
}
|
|
29
32
|
export interface UseRecordSearchOptions {
|
|
@@ -60,8 +63,38 @@ export interface UseRecordSearchOptions {
|
|
|
60
63
|
* Optional display-name resolver. Defaults to the same `titleFormat`
|
|
61
64
|
* fallback chain used by app-shell. Pass `getRecordDisplayName` from
|
|
62
65
|
* `@object-ui/app-shell` to share that implementation exactly.
|
|
66
|
+
*
|
|
67
|
+
* Safe to pass inline: the resolver's identity never re-runs the search.
|
|
68
|
+
* Each run reads the resolver of the latest render, so a swapped resolver
|
|
69
|
+
* labels the hits of the next run; results already shown keep their labels.
|
|
63
70
|
*/
|
|
64
71
|
getDisplayName?: (objectDef: any, record: any) => string;
|
|
72
|
+
/**
|
|
73
|
+
* Optional field-level read policy: pass `usePermissions()` from
|
|
74
|
+
* `@object-ui/permissions` (objectui#10500). Once it is loaded, a hit is
|
|
75
|
+
* labelled from its record with the fields the policy denies on the hit's
|
|
76
|
+
* object removed, `id` and `_id` kept. The row is gated BEFORE
|
|
77
|
+
* `getDisplayName` reads it, so a caller-supplied resolver is gated too. A
|
|
78
|
+
* denied field then reads exactly as an absent one and the resolver falls
|
|
79
|
+
* through to its next rung, the label a backend that strips denied fields
|
|
80
|
+
* (ObjectStack's `FieldMasker`) already yields. The rule is
|
|
81
|
+
* `withoutDeniedFields` from `@object-ui/core` (objectui#10594), the one the
|
|
82
|
+
* record title (objectui#10434) and the lookup option label (objectui#10411)
|
|
83
|
+
* call too.
|
|
84
|
+
*
|
|
85
|
+
* Omitted, or not loaded yet, the row is labelled as served. A title the
|
|
86
|
+
* server computed (`searchAll`'s `hit.title`) is the server's answer and is
|
|
87
|
+
* not re-judged here.
|
|
88
|
+
*
|
|
89
|
+
* Safe to pass inline: only `isLoaded` decides whether a run happens. When
|
|
90
|
+
* it changes the search re-runs, so hits labelled before the policy loaded
|
|
91
|
+
* are relabelled from the gated row. Otherwise each run reads the policy of
|
|
92
|
+
* the latest render, as it does `getDisplayName`.
|
|
93
|
+
*/
|
|
94
|
+
fieldReadPolicy?: {
|
|
95
|
+
isLoaded: boolean;
|
|
96
|
+
checkField: (object: string, field: string, action: 'read') => boolean;
|
|
97
|
+
};
|
|
65
98
|
}
|
|
66
99
|
export interface UseRecordSearchResult {
|
|
67
100
|
results: RecordSearchHit[];
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
-
import { getRecordDisplayName as defaultDisplayName } from '@object-ui/core';
|
|
9
|
+
import { getRecordDisplayName as defaultDisplayName, withoutDeniedFields } from '@object-ui/core';
|
|
10
10
|
import { errorCodeIs } from '@object-ui/types';
|
|
11
11
|
// ADR-0079: the global-search default display-name resolver is now the ONE
|
|
12
12
|
// shared `getRecordDisplayName` from `@object-ui/core` (honors titleFormat →
|
|
@@ -74,7 +74,10 @@ function scoreHit(query, display, recordId) {
|
|
|
74
74
|
* `$filter` translation.
|
|
75
75
|
*/
|
|
76
76
|
export function useRecordSearch(opts) {
|
|
77
|
-
const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, } = opts;
|
|
77
|
+
const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, fieldReadPolicy, } = opts;
|
|
78
|
+
// The one member of the read policy that decides whether a run happens: a
|
|
79
|
+
// primitive, so an inline policy object never re-runs the search by identity.
|
|
80
|
+
const policyLoaded = fieldReadPolicy?.isLoaded === true;
|
|
78
81
|
const [results, setResults] = useState([]);
|
|
79
82
|
const [isSearching, setIsSearching] = useState(false);
|
|
80
83
|
const [error, setError] = useState(undefined);
|
|
@@ -111,11 +114,41 @@ export function useRecordSearch(opts) {
|
|
|
111
114
|
}
|
|
112
115
|
return pool;
|
|
113
116
|
}, [objects, objectNames]);
|
|
117
|
+
// The object NAME is the whole signature: it is the only field of an object
|
|
118
|
+
// definition this effect consumes (`dataSource.find(obj.name, …)` and the
|
|
119
|
+
// `objects` whitelist sent to `searchAll`); `label` / `icon` are read per hit
|
|
120
|
+
// at render time and never decide whether the fanout re-runs.
|
|
121
|
+
//
|
|
122
|
+
// An `o?.titleField ?? ''` half used to be appended to each entry. It could
|
|
123
|
+
// never vary: `@objectstack/spec`'s object schema is a `strictObject` that
|
|
124
|
+
// REJECTS `titleField` with `unrecognized_keys` (objectui#6531), so no legal
|
|
125
|
+
// object metadata carries it and the half was permanently `''` — a constant
|
|
126
|
+
// suffix in a cache key, which also read as evidence that some producer ships
|
|
127
|
+
// the key. Removed in objectui#6557; the display name comes from
|
|
128
|
+
// `getRecordDisplayName`, which does not consult it either.
|
|
114
129
|
const candidateSignature = useMemo(() => {
|
|
115
|
-
return candidates.map((o) =>
|
|
130
|
+
return candidates.map((o) => String(o?.name)).join('|');
|
|
116
131
|
}, [candidates]);
|
|
117
132
|
// Run ID for racing-request guarding. Stable across renders.
|
|
118
133
|
const runIdRef = useRef(0);
|
|
134
|
+
// `getDisplayName` is caller-supplied and read only INSIDE a run, to label
|
|
135
|
+
// hits — it never decides whether a run happens. So the search effect reads
|
|
136
|
+
// the latest resolver through this ref and does not key on its identity
|
|
137
|
+
// (AGENTS.md §5 #10, objectui#10044). Keyed on it, an inline resolver (a new
|
|
138
|
+
// function every render) re-ran the effect on each render, and the cleanup
|
|
139
|
+
// cleared the pending debounce timer: renders faster than `debounceMs` never
|
|
140
|
+
// searched, and sparser renders issued a second identical request. Declared
|
|
141
|
+
// before the search effect, so it is current before any timer is armed.
|
|
142
|
+
//
|
|
143
|
+
// `fieldReadPolicy` is read the same way (objectui#10500): the run reads the
|
|
144
|
+
// latest policy through a ref, and only its `isLoaded` primitive
|
|
145
|
+
// (`policyLoaded`) is a dependency of the search effect.
|
|
146
|
+
const getDisplayNameRef = useRef(getDisplayName);
|
|
147
|
+
const fieldReadPolicyRef = useRef(fieldReadPolicy);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
getDisplayNameRef.current = getDisplayName;
|
|
150
|
+
fieldReadPolicyRef.current = fieldReadPolicy;
|
|
151
|
+
});
|
|
119
152
|
useEffect(() => {
|
|
120
153
|
if (!enabled || !dataSource || candidates.length === 0) {
|
|
121
154
|
setResults([]);
|
|
@@ -142,7 +175,7 @@ export function useRecordSearch(opts) {
|
|
|
142
175
|
// per-object `find({ $search })` fanout below only runs each object's
|
|
143
176
|
// metadata-driven search (ADR-0061) and can miss records the global
|
|
144
177
|
// search index knows about — the command-palette symptom in framework
|
|
145
|
-
// #3371, where `/api/v1/search` returned an account/opportunity the
|
|
178
|
+
// objectstack-ai/objectstack#3371, where `/api/v1/search` returned an account/opportunity the
|
|
146
179
|
// palette never showed. When `searchAll` is absent (mock/test adapters,
|
|
147
180
|
// non-ObjectStack backends) we fall back to the fanout.
|
|
148
181
|
const searchAll = dataSource.searchAll;
|
|
@@ -184,7 +217,9 @@ export function useRecordSearch(opts) {
|
|
|
184
217
|
continue;
|
|
185
218
|
const objDef = byName.get(objectName) ?? { name: objectName };
|
|
186
219
|
const title = typeof h?.title === 'string' && h.title.trim() !== '' ? h.title.trim() : '';
|
|
187
|
-
const display = title ||
|
|
220
|
+
const display = title ||
|
|
221
|
+
getDisplayNameRef.current(objDef, withoutDeniedFields(h?.record ?? {}, fieldReadPolicyRef.current, objectName)) ||
|
|
222
|
+
`Record #${recordId}`;
|
|
188
223
|
const snippet = typeof h?.snippet === 'string' ? h.snippet.trim() : '';
|
|
189
224
|
hits.push({
|
|
190
225
|
objectName,
|
|
@@ -256,7 +291,7 @@ export function useRecordSearch(opts) {
|
|
|
256
291
|
const recordId = record?.id ?? record?._id;
|
|
257
292
|
if (recordId == null)
|
|
258
293
|
continue;
|
|
259
|
-
const display =
|
|
294
|
+
const display = getDisplayNameRef.current(obj, withoutDeniedFields(record, fieldReadPolicyRef.current, obj.name));
|
|
260
295
|
hits.push({
|
|
261
296
|
objectName: obj.name,
|
|
262
297
|
objectLabel: typeof obj.label === 'string' && obj.label.length > 0
|
|
@@ -290,7 +325,7 @@ export function useRecordSearch(opts) {
|
|
|
290
325
|
maxObjectsQueried,
|
|
291
326
|
minLength,
|
|
292
327
|
debounceMs,
|
|
293
|
-
|
|
328
|
+
policyLoaded,
|
|
294
329
|
]);
|
|
295
330
|
return { results, isSearching, error };
|
|
296
331
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import { type FilterTokenScope } from '@object-ui/core';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a data node's own authored `filter` ONCE, and hold the result.
|
|
11
|
+
*
|
|
12
|
+
* Every context token the spec defines for filters (`{current_user_id}`,
|
|
13
|
+
* `{current_org_id}`, `{record_id}` where a record is in view, and the date
|
|
14
|
+
* macros such as `{today}`) is expanded by `@object-ui/core`'s shared
|
|
15
|
+
* `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
|
|
16
|
+
* session scope the host provides plus the mounted record's id
|
|
17
|
+
* (objectui#7297). Pass `useFilterScope()` as `scope`:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const filterScope = useFilterScope();
|
|
21
|
+
* const filter = useResolvedFilter(schema.filter, filterScope);
|
|
22
|
+
* // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
|
|
26
|
+
* is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
|
|
27
|
+
* warning naming it).
|
|
28
|
+
*
|
|
29
|
+
* The result is HELD against its inputs, the shape `plugin-view`'s
|
|
30
|
+
* `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
|
|
31
|
+
* resolved copy minted on every render would refetch on every render; and a
|
|
32
|
+
* date macro such as `{now}` resolves to a new value at every call, so
|
|
33
|
+
* comparing OUTPUTS cannot stop that. The key is the authored filter, compared
|
|
34
|
+
* by structure (a host that rebuilds an equal filter inline must not
|
|
35
|
+
* re-query), plus each scope member read one by one (`recordId` among them,
|
|
36
|
+
* so moving to another record resolves again), never the scope object's
|
|
37
|
+
* identity (AGENTS.md #10). The held pair lives in state, so the
|
|
38
|
+
* value handed out is always the one React committed. A structurally different
|
|
39
|
+
* filter, or a changed scope member, resolves again and hands out a new value.
|
|
40
|
+
*
|
|
41
|
+
* A filter the resolver leaves unchanged (no token, or only tokens the scope
|
|
42
|
+
* cannot resolve) is handed out as the authored value itself, not as the
|
|
43
|
+
* resolver's equal copy, so a token-free filter reaches the query exactly as
|
|
44
|
+
* it did before any resolution.
|
|
45
|
+
*/
|
|
46
|
+
export declare function useResolvedFilter<T>(authored: T, scope: FilterTokenScope): T;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The ONE hold every data node uses to resolve its OWN authored `filter`
|
|
10
|
+
* (objectui#10666). PR objectui#10656 wrote it for `list-view`
|
|
11
|
+
* (`useResolvedAuthoredFilter`, local to `ListView.tsx`); PR objectui#10705
|
|
12
|
+
* moved it into a module of `plugin-list` for `object-gallery`; the sweep that
|
|
13
|
+
* closed objectui#10666 moved it here, beside `useFilterScope`, so the nine
|
|
14
|
+
* packages whose nodes send their own filter share one copy instead of growing
|
|
15
|
+
* nine. The hold, its key and its comparator moved unchanged; the one addition
|
|
16
|
+
* is that a filter the resolver leaves unchanged is handed out as authored (see
|
|
17
|
+
* `resolveHeldFilter`).
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import { resolveFilterPlaceholders } from '@object-ui/core';
|
|
21
|
+
function resolveHeldFilter(authored, scope) {
|
|
22
|
+
const resolved = resolveFilterPlaceholders(authored, scope);
|
|
23
|
+
return {
|
|
24
|
+
authored,
|
|
25
|
+
currentUserId: scope.currentUserId,
|
|
26
|
+
currentOrgId: scope.currentOrgId,
|
|
27
|
+
recordId: scope.recordId,
|
|
28
|
+
onUnresolved: scope.onUnresolved,
|
|
29
|
+
// `resolveFilterPlaceholders` copies every array and plain object it walks,
|
|
30
|
+
// so a filter with nothing to resolve comes back EQUAL but not the same
|
|
31
|
+
// reference. Such a filter is handed out as authored: a node whose filter
|
|
32
|
+
// carries no token sends the very value it was given, as it did before it
|
|
33
|
+
// resolved anything (the `object-kanban` / `object-calendar` / `object-tree`
|
|
34
|
+
// member pins read `$filter` by identity). Only a filter the resolver
|
|
35
|
+
// actually changed is handed out as the resolved copy.
|
|
36
|
+
resolved: isSameAuthoredFilter(resolved, authored) ? authored : resolved,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */
|
|
40
|
+
const AUTHORED_FILTER_MAX_DEPTH = 12;
|
|
41
|
+
function isPlainFilterObject(value) {
|
|
42
|
+
if (value === null || typeof value !== 'object')
|
|
43
|
+
return false;
|
|
44
|
+
const proto = Object.getPrototypeOf(value);
|
|
45
|
+
return proto === Object.prototype || proto === null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compare two authored filters by structure, never by serialising them.
|
|
49
|
+
*
|
|
50
|
+
* The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460):
|
|
51
|
+
* primitives by `Object.is`, a `Date` by its instant, arrays element by
|
|
52
|
+
* element and in order, plain objects by key set and value. Anything else is
|
|
53
|
+
* equal only as the same reference, and a structure deeper than the bound is
|
|
54
|
+
* "changed". Every uncertainty answers "changed", which re-resolves and
|
|
55
|
+
* re-queries; the comparison can drop a redundant query, never a needed one.
|
|
56
|
+
*/
|
|
57
|
+
function isSameAuthoredFilter(a, b, depth = 0) {
|
|
58
|
+
if (Object.is(a, b))
|
|
59
|
+
return true;
|
|
60
|
+
if (depth >= AUTHORED_FILTER_MAX_DEPTH)
|
|
61
|
+
return false;
|
|
62
|
+
if (a instanceof Date || b instanceof Date) {
|
|
63
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
66
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
67
|
+
return false;
|
|
68
|
+
return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1));
|
|
69
|
+
}
|
|
70
|
+
if (isPlainFilterObject(a) && isPlainFilterObject(b)) {
|
|
71
|
+
const keys = Object.keys(a);
|
|
72
|
+
if (keys.length !== Object.keys(b).length)
|
|
73
|
+
return false;
|
|
74
|
+
return keys.every((key) => (Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1)));
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a data node's own authored `filter` ONCE, and hold the result.
|
|
80
|
+
*
|
|
81
|
+
* Every context token the spec defines for filters (`{current_user_id}`,
|
|
82
|
+
* `{current_org_id}`, `{record_id}` where a record is in view, and the date
|
|
83
|
+
* macros such as `{today}`) is expanded by `@object-ui/core`'s shared
|
|
84
|
+
* `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
|
|
85
|
+
* session scope the host provides plus the mounted record's id
|
|
86
|
+
* (objectui#7297). Pass `useFilterScope()` as `scope`:
|
|
87
|
+
*
|
|
88
|
+
* ```tsx
|
|
89
|
+
* const filterScope = useFilterScope();
|
|
90
|
+
* const filter = useResolvedFilter(schema.filter, filterScope);
|
|
91
|
+
* // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
|
|
95
|
+
* is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
|
|
96
|
+
* warning naming it).
|
|
97
|
+
*
|
|
98
|
+
* The result is HELD against its inputs, the shape `plugin-view`'s
|
|
99
|
+
* `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
|
|
100
|
+
* resolved copy minted on every render would refetch on every render; and a
|
|
101
|
+
* date macro such as `{now}` resolves to a new value at every call, so
|
|
102
|
+
* comparing OUTPUTS cannot stop that. The key is the authored filter, compared
|
|
103
|
+
* by structure (a host that rebuilds an equal filter inline must not
|
|
104
|
+
* re-query), plus each scope member read one by one (`recordId` among them,
|
|
105
|
+
* so moving to another record resolves again), never the scope object's
|
|
106
|
+
* identity (AGENTS.md #10). The held pair lives in state, so the
|
|
107
|
+
* value handed out is always the one React committed. A structurally different
|
|
108
|
+
* filter, or a changed scope member, resolves again and hands out a new value.
|
|
109
|
+
*
|
|
110
|
+
* A filter the resolver leaves unchanged (no token, or only tokens the scope
|
|
111
|
+
* cannot resolve) is handed out as the authored value itself, not as the
|
|
112
|
+
* resolver's equal copy, so a token-free filter reaches the query exactly as
|
|
113
|
+
* it did before any resolution.
|
|
114
|
+
*/
|
|
115
|
+
export function useResolvedFilter(authored, scope) {
|
|
116
|
+
const [held, setHeld] = React.useState(() => resolveHeldFilter(authored, scope));
|
|
117
|
+
if (held.currentUserId !== scope.currentUserId
|
|
118
|
+
|| held.currentOrgId !== scope.currentOrgId
|
|
119
|
+
|| held.recordId !== scope.recordId
|
|
120
|
+
|| held.onUnresolved !== scope.onUnresolved
|
|
121
|
+
|| !isSameAuthoredFilter(held.authored, authored)) {
|
|
122
|
+
// React's documented "information from previous renders" shape: a set
|
|
123
|
+
// during render re-renders this component at once, before any child sees
|
|
124
|
+
// the discarded pass, and the re-render finds the inputs equal.
|
|
125
|
+
const next = resolveHeldFilter(authored, scope);
|
|
126
|
+
setHeld(next);
|
|
127
|
+
return next.resolved;
|
|
128
|
+
}
|
|
129
|
+
return held.resolved;
|
|
130
|
+
}
|