@object-ui/react 17.5.0 → 17.7.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +4100 -0
  2. package/README.md +303 -46
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.d.ts +38 -7
  6. package/dist/SchemaRenderer.js +1603 -60
  7. package/dist/context/ActionContext.js +40 -1
  8. package/dist/context/DiscussionContext.d.ts +12 -0
  9. package/dist/context/DiscussionContext.js +2 -0
  10. package/dist/context/DrillNavigationContext.d.ts +13 -0
  11. package/dist/context/HostNavigationContext.d.ts +82 -0
  12. package/dist/context/HostNavigationContext.js +65 -0
  13. package/dist/context/InlineEditContext.d.ts +81 -1
  14. package/dist/context/InlineEditContext.js +8 -2
  15. package/dist/context/RecordContext.d.ts +48 -5
  16. package/dist/context/RecordContext.js +10 -1
  17. package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
  18. package/dist/context/SchemaRendererContext.d.ts +54 -2
  19. package/dist/context/SchemaRendererContext.js +38 -5
  20. package/dist/context/ThemeContext.js +1 -1
  21. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  22. package/dist/context/actionRunnerTranslator.js +37 -0
  23. package/dist/context/index.d.ts +13 -12
  24. package/dist/context/index.js +13 -12
  25. package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
  26. package/dist/element-data-source/ElementDataSourceGate.js +407 -15
  27. package/dist/hooks/index.d.ts +37 -35
  28. package/dist/hooks/index.js +44 -35
  29. package/dist/hooks/useActionEngine.js +47 -4
  30. package/dist/hooks/useActionRunner.js +4 -0
  31. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  32. package/dist/hooks/useActionTextLocalizer.js +33 -8
  33. package/dist/hooks/useCapabilityGate.js +6 -5
  34. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  35. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  36. package/dist/hooks/useCrudShortcuts.js +1 -1
  37. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  38. package/dist/hooks/useDensityMode.d.ts +6 -2
  39. package/dist/hooks/useDensityMode.js +6 -2
  40. package/dist/hooks/useDiscovery.d.ts +1 -10
  41. package/dist/hooks/useDiscovery.js +1 -1
  42. package/dist/hooks/useETagCache.js +25 -4
  43. package/dist/hooks/useElementDataSource.d.ts +15 -2
  44. package/dist/hooks/useElementDataSource.js +29 -9
  45. package/dist/hooks/useExpression.d.ts +52 -38
  46. package/dist/hooks/useExpression.js +62 -42
  47. package/dist/hooks/useFilterScope.d.ts +22 -2
  48. package/dist/hooks/useFilterScope.js +29 -4
  49. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  50. package/dist/hooks/useGlobalUndo.js +112 -46
  51. package/dist/hooks/useNavigationOverlay.d.ts +77 -12
  52. package/dist/hooks/useNavigationOverlay.js +119 -9
  53. package/dist/hooks/useOffline.js +65 -11
  54. package/dist/hooks/usePageAssignment.d.ts +8 -5
  55. package/dist/hooks/usePageAssignment.js +38 -17
  56. package/dist/hooks/usePageTransition.js +1 -1
  57. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  58. package/dist/hooks/useRecordSearch.d.ts +34 -1
  59. package/dist/hooks/useRecordSearch.js +42 -7
  60. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  61. package/dist/hooks/useResolvedFilter.js +130 -0
  62. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  63. package/dist/hooks/useSchemaPersistence.js +144 -5
  64. package/dist/hooks/useSettledSchema.d.ts +123 -0
  65. package/dist/hooks/useSettledSchema.js +186 -0
  66. package/dist/hooks/useViewData.d.ts +7 -2
  67. package/dist/hooks/useViewData.js +1 -1
  68. package/dist/hooks/useViewTransition.js +1 -1
  69. package/dist/index.d.ts +18 -11
  70. package/dist/index.js +54 -10
  71. package/dist/schema-input.d.ts +55 -14
  72. package/dist/schema-input.js +55 -12
  73. package/dist/utils/configBag.d.ts +68 -0
  74. package/dist/utils/configBag.js +70 -0
  75. package/dist/utils/error-message.d.ts +59 -0
  76. package/dist/utils/error-message.js +116 -1
  77. package/dist/utils/inlineAria.d.ts +61 -0
  78. package/dist/utils/inlineAria.js +70 -0
  79. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  80. package/dist/utils/nonGridRowCeiling.js +78 -0
  81. package/dist/utils/paramsBag.d.ts +28 -0
  82. package/dist/utils/paramsBag.js +146 -0
  83. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  84. package/dist/utils/propsBagDiagnostic.js +421 -0
  85. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  86. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  87. package/dist/utils/unevaluatedExpression.d.ts +110 -0
  88. package/dist/utils/unevaluatedExpression.js +257 -0
  89. package/dist/utils/visibilityDiagnostic.d.ts +371 -0
  90. package/dist/utils/visibilityDiagnostic.js +551 -0
  91. package/package.json +10 -10
  92. package/dist/hooks/useClientNotifications.d.ts +0 -47
  93. package/dist/hooks/useClientNotifications.js +0 -162
  94. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  95. package/dist/spec-bridge/SpecBridge.js +0 -56
  96. package/dist/spec-bridge/bridges/form-view.d.ts +0 -82
  97. package/dist/spec-bridge/bridges/form-view.js +0 -126
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -241
  100. package/dist/spec-bridge/index.d.ts +0 -11
  101. package/dist/spec-bridge/index.js +0 -10
  102. package/dist/spec-bridge/types.d.ts +0 -55
  103. package/dist/spec-bridge/types.js +0 -8
@@ -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) => `${o?.name}:${o?.titleField ?? ''}`).join('|');
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 || getDisplayName(objDef, h?.record ?? {}) || `Record #${recordId}`;
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 = getDisplayName(obj, record);
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
- getDisplayName,
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
+ }
@@ -63,6 +63,13 @@ export declare function createLocalStorageAdapter(prefix?: string): SchemaPersis
63
63
  * Accepts a pluggable adapter for connecting to any backend.
64
64
  * Falls back to a localStorage adapter for development use.
65
65
  *
66
+ * `save()` refuses a schema carrying function-valued keys anywhere in it —
67
+ * every adapter ends in a `JSON.stringify`, which drops callables silently, so
68
+ * the save would report success and the reload would hand back a schema that
69
+ * lost them. The refusal sets `error` (naming the offending key paths and both
70
+ * escapes), returns `null`, leaves `lastSavedAt` untouched, and never reaches
71
+ * the adapter. Fully declarative schemas are unaffected.
72
+ *
66
73
  * @example
67
74
  * ```tsx
68
75
  * // With default localStorage adapter
@@ -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 { useState, useCallback, useRef, useMemo } from 'react';
8
+ import { useState, useCallback, useRef, useMemo, useInsertionEffect } from 'react';
9
9
  /**
10
10
  * Default localStorage adapter for schema persistence.
11
11
  * Useful for development and demos.
@@ -51,6 +51,96 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
51
51
  },
52
52
  };
53
53
  }
54
+ /**
55
+ * Cap on how many offending paths the refusal message spells out in full. A
56
+ * schema that puts callables on every row would otherwise produce an error
57
+ * string nobody can read; the count is always reported exactly.
58
+ */
59
+ const MAX_REPORTED_CALLABLE_PATHS = 20;
60
+ /**
61
+ * Collect the path of every function-valued property reachable in `value`.
62
+ *
63
+ * Walks own enumerable properties — the same set `JSON.stringify` serializes —
64
+ * so what this finds is exactly what `JSON.stringify` would fail to preserve.
65
+ * It fails in TWO different ways, and neither says a word. Both are visible in
66
+ * a single document:
67
+ *
68
+ * JSON.stringify({ columns: [{ name: 'c', cell: fn }, fn] })
69
+ * // => {"columns":[{"name":"c"},null]}
70
+ * // the object key `cell` is DROPPED; the array element is COERCED to null
71
+ *
72
+ * The array case is arguably the worse of the two. A dropped key at least
73
+ * disappears, so a reader of the stored document can see something is missing;
74
+ * a coerced `null` survives as a plausible-looking value and reads as real data
75
+ * on reload. That is why arrays are walked by index (`columns[2].cell`) rather
76
+ * than skipped — and it is the realistic shape besides, since handlers live on
77
+ * column/field entries, not only at the top level.
78
+ *
79
+ * A `seen` set keeps a cyclic schema from recursing forever (`JSON.stringify`
80
+ * throws on those; this guard runs first and must not hang before it can). It
81
+ * also means a subtree reachable by two paths is reported at the first one
82
+ * only — the refusal is still correct, the path list is just not exhaustive for
83
+ * shared references.
84
+ */
85
+ function collectCallablePaths(value, path, out, seen) {
86
+ if (typeof value === 'function') {
87
+ out.push(path || '(root)');
88
+ return;
89
+ }
90
+ if (value === null || typeof value !== 'object')
91
+ return;
92
+ if (seen.has(value))
93
+ return;
94
+ seen.add(value);
95
+ if (Array.isArray(value)) {
96
+ value.forEach((item, index) => collectCallablePaths(item, `${path}[${index}]`, out, seen));
97
+ return;
98
+ }
99
+ for (const [key, child] of Object.entries(value)) {
100
+ collectCallablePaths(child, path ? `${path}.${key}` : key, out, seen);
101
+ }
102
+ }
103
+ /**
104
+ * The paths of the function-valued keys anywhere in a schema, in walk order.
105
+ * Empty means the schema is fully declarative and serializes without loss.
106
+ *
107
+ * Two corners worth knowing, both measured, neither a reason to loosen this:
108
+ *
109
+ * - `Object.entries` INVOKES getters. A schema with a throwing getter therefore
110
+ * throws inside this guard rather than inside the adapter's `JSON.stringify`.
111
+ * Different call site, same outcome: `save()` refuses and surfaces the
112
+ * getter's own error.
113
+ * - A schema carrying its own `toJSON` method is refused, even though
114
+ * `JSON.stringify` would have serialized it THROUGH that method. That is a
115
+ * deliberate false positive: the method itself cannot survive the round trip,
116
+ * so what `load()` hands back would not be the object that was saved.
117
+ */
118
+ function findCallablePaths(schema) {
119
+ const out = [];
120
+ collectCallablePaths(schema, '', out, new Set());
121
+ return out;
122
+ }
123
+ /**
124
+ * The refusal an author sees instead of a save that quietly lost their handler.
125
+ *
126
+ * It names the exact offending paths and both escapes, because an error that
127
+ * only says "no" leaves the author guessing at a defect whose symptom would
128
+ * otherwise have surfaced at render or click time, in another component, with
129
+ * no link back to the save that caused it.
130
+ */
131
+ function callableRefusalMessage(id, paths) {
132
+ const shown = paths.slice(0, MAX_REPORTED_CALLABLE_PATHS);
133
+ const listed = shown.join(', ');
134
+ const elided = paths.length > shown.length ? ` (and ${paths.length - shown.length} more)` : '';
135
+ const plural = paths.length === 1 ? 'key is' : 'keys are';
136
+ return (`useSchemaPersistence: refusing to save schema "${id}" — ` +
137
+ `${paths.length} function-valued ${plural} not serializable: JSON.stringify ` +
138
+ `silently drops an object key, or coerces an array element to null. ` +
139
+ `Offending paths: ${listed}${elided}. ` +
140
+ 'Two ways forward: strip the callables before saving (persist only the serializable ' +
141
+ 'data and re-attach the handlers after load), or express the behaviour in the ' +
142
+ 'declarative form of these keys so it survives the round trip.');
143
+ }
54
144
  /**
55
145
  * Hook for persisting designer schemas (save/load/list/delete).
56
146
  * Implements schema persistence for @object-ui/plugin-designer.
@@ -58,6 +148,13 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
58
148
  * Accepts a pluggable adapter for connecting to any backend.
59
149
  * Falls back to a localStorage adapter for development use.
60
150
  *
151
+ * `save()` refuses a schema carrying function-valued keys anywhere in it —
152
+ * every adapter ends in a `JSON.stringify`, which drops callables silently, so
153
+ * the save would report success and the reload would hand back a schema that
154
+ * lost them. The refusal sets `error` (naming the offending key paths and both
155
+ * escapes), returns `null`, leaves `lastSavedAt` untouched, and never reaches
156
+ * the adapter. Fully declarative schemas are unaffected.
157
+ *
61
158
  * @example
62
159
  * ```tsx
63
160
  * // With default localStorage adapter
@@ -80,10 +177,45 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
80
177
  * ```
81
178
  */
82
179
  export function useSchemaPersistence(adapter) {
83
- const defaultAdapter = useRef(createLocalStorageAdapter());
84
- const adapterRef = useRef(adapter ?? defaultAdapter.current);
85
- // Keep the ref up to date if the adapter prop changes
86
- adapterRef.current = adapter ?? defaultAdapter.current;
180
+ // Built once per hook instance. `useRef(createLocalStorageAdapter())` ran the
181
+ // factory on EVERY render and discarded all but the first result; `useMemo`
182
+ // runs it once and — unlike a ref — needs no `.current` read during render.
183
+ // Re-creating it would be harmless either way: the adapter is a stateless
184
+ // facade over `localStorage`, so a fresh one behaves identically to the one
185
+ // it replaces, and nothing outside this hook ever sees its identity.
186
+ const defaultAdapter = useMemo(() => createLocalStorageAdapter(), []);
187
+ const resolvedAdapter = adapter ?? defaultAdapter;
188
+ // The four callbacks below are created once (`[]` deps) and read the adapter
189
+ // at CALL time, so the latest adapter must reach them without changing their
190
+ // identity. That is the whole job of this ref.
191
+ //
192
+ // The write lives in `useInsertionEffect` — not in the render body, where it
193
+ // used to be, and not in `useEffect`/`useLayoutEffect`:
194
+ //
195
+ // - Render body: a render React discards or replays (StrictMode, a
196
+ // Suspense retry, a concurrent interruption) still performed the write,
197
+ // so a save could be routed through an adapter from a render that never
198
+ // committed. That is what `react-hooks/refs` flags.
199
+ // - `useEffect`: runs after paint, so a call made from any layout effect in
200
+ // the same commit would still reach the PREVIOUS adapter.
201
+ // - `useLayoutEffect`: a child's layout effects run BEFORE its parent's, so
202
+ // a child calling `save()` from its own layout effect would still see the
203
+ // previous adapter.
204
+ //
205
+ // Insertion effects run in the mutation phase — before every layout effect in
206
+ // the tree, before paint, and before any event handler can fire — so every
207
+ // call site that may legally invoke `save`/`load`/`list`/`remove` observes
208
+ // exactly what the old render-body write gave it. The single window that did
209
+ // change is a read during the render phase itself, which no legal consumer
210
+ // has: these calls are side effects and are never allowed during render.
211
+ //
212
+ // `useSchemaPersistence.adapterTracking.test.tsx` pins both halves — that a
213
+ // changed `adapter` prop is tracked, and that it is already in place by the
214
+ // time a child's layout effect runs in that same commit.
215
+ const adapterRef = useRef(resolvedAdapter);
216
+ useInsertionEffect(() => {
217
+ adapterRef.current = resolvedAdapter;
218
+ }, [resolvedAdapter]);
87
219
  const [loading, setLoading] = useState(false);
88
220
  const [error, setError] = useState(null);
89
221
  const [isDirty, setIsDirty] = useState(false);
@@ -92,6 +224,13 @@ export function useSchemaPersistence(adapter) {
92
224
  setLoading(true);
93
225
  setError(null);
94
226
  try {
227
+ // Refuse BEFORE the adapter runs, so the guard covers a host-injected or
228
+ // REST adapter too — every one of them ends in a `JSON.stringify` that
229
+ // drops callables without a word (objectui#6658).
230
+ const callablePaths = findCallablePaths(schema);
231
+ if (callablePaths.length > 0) {
232
+ throw new Error(callableRefusalMessage(id, callablePaths));
233
+ }
95
234
  const result = await adapterRef.current.save(id, schema);
96
235
  setIsDirty(false);
97
236
  setLastSavedAt(new Date());
@@ -0,0 +1,123 @@
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 { DataSource } from '@object-ui/types';
9
+ /**
10
+ * The settled result of `{@link useSettledSchema}`: whether the object
11
+ * definition for the CURRENT key has finished resolving, and the definition
12
+ * itself once it has.
13
+ *
14
+ * `ready` is never a fact about "a fetch completed" in isolation — it is
15
+ * "a fetch completed FOR THE OBJECT THIS RENDER IS ASKING ABOUT". See the
16
+ * hook's own doc comment for why that distinction is the entire point.
17
+ */
18
+ export interface SettledSchema<TDef = unknown> {
19
+ /**
20
+ * `true` once the object definition for the key passed to this render has
21
+ * settled — successfully, with a thrown read, or because there was no
22
+ * source to read from. `false` while it is still in flight, and also
23
+ * `false` for exactly one render right after `key` changes, even if a
24
+ * PREVIOUS key's resolution is sitting in state (objectui#6481's defect,
25
+ * made unwritable — see the hook doc comment).
26
+ */
27
+ ready: boolean;
28
+ /**
29
+ * The resolved definition once `ready` is `true`, otherwise `null`.
30
+ * `null` while `ready` is `true` is a legitimate, DISTINCT outcome from
31
+ * "not ready yet" — it means the resolution settled with nothing (no
32
+ * `dataSource`, no `getObjectSchema`, no key, or a read that threw), not
33
+ * that the read never happened.
34
+ */
35
+ def: TDef | null;
36
+ }
37
+ /**
38
+ * Resolves an object's schema/definition and tracks whether that resolution
39
+ * has SETTLED for the object CURRENTLY being asked about — one piece of
40
+ * state a caller cannot observe half of.
41
+ *
42
+ * ## The shape, and why it is exactly this one
43
+ *
44
+ * This is the RESOLUTION half of the settled-schema gate four views
45
+ * (`ObjectKanban`, `ObjectView`, `ObjectCalendar`, and — pre-fix —
46
+ * `ObjectTree`) each hand-wrote (objectui#6271, #6419, #6453, #6014). Ruled
47
+ * objectui#6482 (maintainer, 2026-08-27, Option A): extract the resolution
48
+ * half as a shared, published hook; leave GATE PLACEMENT — deciding which
49
+ * effect branch actually waits on `ready` — to each component, because that
50
+ * part is genuinely component-private (`ObjectCalendar` gates only its
51
+ * `object`-provider branch and keys on the object the block RESOLVES rather
52
+ * than on `schema.objectName`, because an inline `value` data set issues no
53
+ * metadata read at all — a whole-effect gate would hold its query open on a
54
+ * resolution nothing was ever going to produce).
55
+ *
56
+ * `ready` is DERIVED at render time — `resolution !== null && resolution.key
57
+ * === key` — from a SINGLE piece of state, `{ key, def } | null`, rather than
58
+ * stored as a second, independent boolean. That is not a style choice; it is
59
+ * the fix for the defect this card exists to close. objectui#6481's
60
+ * `ObjectTree` carried the definition (`objectSchema`) and "has it settled"
61
+ * (`schemaSettled`) as two SEPARATE `useState`s. `schemaSettled` was a
62
+ * one-way latch — set `true` on first settle and never reset — so when the
63
+ * host swapped `objectName` mid-life, the fetch effect re-ran and started
64
+ * refetching the NEW object's schema, but `schemaSettled` stayed `true` from
65
+ * the OLD object's settle. The gated effect read `schemaSettled === true`
66
+ * and `objectSchema` still holding the OLD object's fields, and queried with
67
+ * the WRONG `$expand` — a stale key reading as ready.
68
+ *
69
+ * With one state value and a render-time key comparison, that failure mode
70
+ * is not merely fixed, it is UNREPRESENTABLE: the instant `key` changes,
71
+ * `resolution.key === key` is false in that very render (no effect needs to
72
+ * run first), so `ready` flips to `false` in the same commit the key
73
+ * changed — there is no window, and no second piece of state a caller could
74
+ * read out of sync with the first, because there is no second piece of
75
+ * state. A caller cannot spell "ready for the wrong object": `ready` and
76
+ * `def` are two views of one value, never independently settable.
77
+ *
78
+ * Every exit of the internal fetch effect settles the resolution — success,
79
+ * a thrown read, and "there is no source to read from" alike — because a
80
+ * caller's gated effect WAITS on `ready`. An exit that returned without
81
+ * settling would not merely skip the expansion; it would hold that gated
82
+ * query open forever.
83
+ *
84
+ * ## What this hook does NOT do
85
+ *
86
+ * It does not decide when to fetch beyond "when `key` or `dataSource`
87
+ * change", and it does not decide what a caller's OTHER effects should wait
88
+ * on. A caller that should not fetch at all for the current render (e.g.
89
+ * `ObjectCalendar`'s inline `value` provider, which issues no metadata read)
90
+ * passes `dataSource: undefined` for that render rather than a new "should
91
+ * fetch" flag — the hook already settles-with-`null` whenever `dataSource`
92
+ * is absent, which is the exact "no source to read from" outcome that case
93
+ * needs.
94
+ *
95
+ * @param key - The identity of the object THIS render is asking about — for a
96
+ * record-bound view, `resolveRecordSourceObjectName(schema, dataConfig) ?? ''`
97
+ * from `@object-ui/core`. ⛔ Do NOT re-spell that ladder inline here: six view
98
+ * plugins each carried their own copy and had drifted, which is the whole of
99
+ * what `b041b9c0c` collapsed. Choosing the key is still the caller's job — it is the
100
+ * component-private half of the original hand copies, not something this hook
101
+ * can infer — but the ladder behind it is published and shared.
102
+ * @param dataSource - The data source to read the definition from. Pass
103
+ * `undefined`/`null` for a render that should settle immediately with no
104
+ * definition (no source, or a provider that needs none) rather than adding
105
+ * a separate enable flag.
106
+ * @returns `{ ready, def }` — see {@link SettledSchema}.
107
+ *
108
+ * @example
109
+ * ```tsx
110
+ * const schemaKey = resolveRecordSourceObjectName(schema, dataConfig); // @object-ui/core
111
+ * const { ready: objectSchemaReady, def: objectSchema } =
112
+ * useSettledSchema(schemaKey ?? '', hasInlineData ? undefined : dataSource);
113
+ *
114
+ * useEffect(() => {
115
+ * // A PROVIDER test, not an object-name read — it asks "is there metadata to
116
+ * // wait for at all", which the shared reader deliberately does not answer.
117
+ * // Gate placement stays local (objectui#6482); the ladder does not.
118
+ * if (dataConfig?.provider === 'object' && !objectSchemaReady) return;
119
+ * // ...issue the record query, `buildExpandFields(objectSchema?.fields)`
120
+ * }, [objectSchemaReady, objectSchema, /* ... *\/]);
121
+ * ```
122
+ */
123
+ export declare function useSettledSchema<TDef = unknown>(key: string, dataSource: DataSource<any> | null | undefined): SettledSchema<TDef>;