@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +4263 -0
  2. package/README.md +383 -42
  3. package/dist/SchemaRenderer.d.ts +64 -7
  4. package/dist/SchemaRenderer.js +1326 -106
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/AppShellContext.d.ts +27 -0
  7. package/dist/context/DiscussionContext.d.ts +27 -0
  8. package/dist/context/DiscussionContext.js +4 -0
  9. package/dist/context/DrillNavigationContext.d.ts +13 -0
  10. package/dist/context/InlineEditContext.d.ts +151 -1
  11. package/dist/context/InlineEditContext.js +36 -2
  12. package/dist/context/PrintPageContext.d.ts +60 -0
  13. package/dist/context/PrintPageContext.js +32 -0
  14. package/dist/context/RecordContext.d.ts +48 -5
  15. package/dist/context/RecordContext.js +10 -1
  16. package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
  17. package/dist/context/RelatedRecordActionsContext.js +4 -0
  18. package/dist/context/SchemaRendererContext.d.ts +61 -6
  19. package/dist/context/SchemaRendererContext.js +39 -6
  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 +1 -0
  24. package/dist/context/index.js +1 -0
  25. package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
  26. package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
  27. package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
  28. package/dist/element-data-source/ElementDataSourceGate.js +401 -19
  29. package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
  30. package/dist/element-data-source/useElementApiDataSource.js +198 -0
  31. package/dist/hooks/index.d.ts +3 -1
  32. package/dist/hooks/index.js +10 -1
  33. package/dist/hooks/useActionEngine.js +46 -3
  34. package/dist/hooks/useActionRunner.js +4 -0
  35. package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
  36. package/dist/hooks/useActionTextLocalizer.js +70 -14
  37. package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
  38. package/dist/hooks/useConfigBagEvaluator.js +40 -0
  39. package/dist/hooks/useDatasetDimensionLabels.js +25 -10
  40. package/dist/hooks/useDensityMode.d.ts +8 -2
  41. package/dist/hooks/useDensityMode.js +8 -2
  42. package/dist/hooks/useDiscovery.d.ts +1 -10
  43. package/dist/hooks/useETagCache.js +25 -4
  44. package/dist/hooks/useElementDataSource.d.ts +15 -2
  45. package/dist/hooks/useElementDataSource.js +24 -7
  46. package/dist/hooks/useExpression.d.ts +52 -59
  47. package/dist/hooks/useExpression.js +58 -90
  48. package/dist/hooks/useFilterScope.d.ts +22 -2
  49. package/dist/hooks/useFilterScope.js +29 -4
  50. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  51. package/dist/hooks/useGlobalUndo.js +112 -46
  52. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  53. package/dist/hooks/useNavigationOverlay.js +119 -9
  54. package/dist/hooks/useOffline.js +65 -11
  55. package/dist/hooks/usePageAssignment.d.ts +33 -7
  56. package/dist/hooks/usePageAssignment.js +85 -21
  57. package/dist/hooks/useRecordSearch.d.ts +34 -1
  58. package/dist/hooks/useRecordSearch.js +42 -7
  59. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  60. package/dist/hooks/useResolvedFilter.js +130 -0
  61. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  62. package/dist/hooks/useSchemaPersistence.js +144 -5
  63. package/dist/hooks/useSettledSchema.d.ts +123 -0
  64. package/dist/hooks/useSettledSchema.js +186 -0
  65. package/dist/hooks/useViewData.d.ts +10 -3
  66. package/dist/hooks/useViewData.js +5 -14
  67. package/dist/index.d.ts +9 -1
  68. package/dist/index.js +48 -1
  69. package/dist/schema-input.d.ts +38 -8
  70. package/dist/schema-input.js +39 -7
  71. package/dist/utils/configBag.d.ts +68 -0
  72. package/dist/utils/configBag.js +70 -0
  73. package/dist/utils/error-message.d.ts +1 -1
  74. package/dist/utils/error-message.js +1 -1
  75. package/dist/utils/inlineAria.d.ts +61 -0
  76. package/dist/utils/inlineAria.js +70 -0
  77. package/dist/utils/localeMap.d.ts +27 -0
  78. package/dist/utils/localeMap.js +106 -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 +422 -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 +44 -1
  88. package/dist/utils/unevaluatedExpression.js +92 -11
  89. package/dist/utils/visibilityDiagnostic.d.ts +282 -21
  90. package/dist/utils/visibilityDiagnostic.js +321 -46
  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 -158
  97. package/dist/spec-bridge/bridges/form-view.js +0 -170
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -268
  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
@@ -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>;
@@ -0,0 +1,186 @@
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 { useEffect, useState } from 'react';
9
+ /**
10
+ * Is this refetch's answer the SAME answer, by value, as the one already
11
+ * published?
12
+ *
13
+ * Used for exactly one decision — whether a settle republishes or keeps the
14
+ * object consumers already hold — and its failure direction is chosen for
15
+ * that decision. A false NEGATIVE (two encodings of the same metadata that
16
+ * serialise differently, e.g. with the keys in another order) republishes,
17
+ * which is precisely what this hook did for every answer before
18
+ * objectui#10106: no consumer can be worse off than it already was. A false
19
+ * POSITIVE would withhold a genuinely new definition from consumers, so the
20
+ * comparison is deliberately a whole-payload one and never a sampled or
21
+ * shallow test.
22
+ *
23
+ * ⚠️ An answer JSON cannot express — a cycle, a `BigInt` — makes
24
+ * `JSON.stringify` throw; that is answered as "not equal" rather than allowed
25
+ * to escape into a state updater, which lands on the safe side above.
26
+ */
27
+ function isEqualPayload(a, b) {
28
+ if (Object.is(a, b))
29
+ return true;
30
+ try {
31
+ return JSON.stringify(a) === JSON.stringify(b);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
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 function useSettledSchema(key, dataSource) {
124
+ const [resolution, setResolution] = useState(null);
125
+ useEffect(() => {
126
+ let isMounted = true;
127
+ const settleKey = key;
128
+ /**
129
+ * Settle THIS effect run's resolution — and, when the answer is one
130
+ * consumers already hold, settle it as the object they already hold.
131
+ *
132
+ * This is the PRODUCER half of `AGENTS.md` §5 commandment #10: *"a
133
+ * provider that refetches may not republish an equal payload as a new
134
+ * object, and a consumer that needs stability keys on the data, not on a
135
+ * cache"*. This hook refetches whenever the adapter identity changes, and
136
+ * the consumers its own `@example` instructs do exactly what the other
137
+ * half of that sentence asks — they key their record fetch on `def`, the
138
+ * payload, never on a memo identity. So a fresh object for an unchanged
139
+ * answer is not a wasted allocation; it is a DUPLICATE RECORD QUERY at
140
+ * every one of them, carrying the same expand set as the one before it.
141
+ * Pinned with its control in
142
+ * `useSettledSchema.equalPayload-10106.test.tsx` (objectui#10106).
143
+ *
144
+ * ⛔ The cure may NOT be moved to the consumer — the same sentence names
145
+ * keying on a cache as the wrong one, and a consumer that stopped keying
146
+ * on `def` would also stop re-querying when the definition genuinely
147
+ * changes, which the control case in that pin holds open.
148
+ *
149
+ * Bailing out is only ever safe for the SAME key: `resolution` is one
150
+ * piece of state whose `key` decides `ready`, so a settle for a different
151
+ * key must always publish (see the hook doc comment above).
152
+ */
153
+ const settle = (settledDef) => {
154
+ setResolution((prev) => prev && prev.key === settleKey && isEqualPayload(prev.def, settledDef)
155
+ ? prev
156
+ : { key: settleKey, def: settledDef });
157
+ };
158
+ const resolve = async () => {
159
+ if (!dataSource || !settleKey || typeof dataSource.getObjectSchema !== 'function') {
160
+ // No source for a definition: settle with none, so anything gated on
161
+ // `ready` still runs (unexpanded — with no schema there is no expand
162
+ // set to derive).
163
+ if (isMounted)
164
+ settle(null);
165
+ return;
166
+ }
167
+ try {
168
+ const def = await dataSource.getObjectSchema(settleKey);
169
+ if (isMounted)
170
+ settle(def);
171
+ }
172
+ catch (err) {
173
+ console.error('[useSettledSchema] getObjectSchema failed for', settleKey, err);
174
+ if (isMounted)
175
+ settle(null);
176
+ }
177
+ };
178
+ resolve();
179
+ return () => {
180
+ isMounted = false;
181
+ };
182
+ }, [key, dataSource]);
183
+ const ready = resolution !== null && resolution.key === key;
184
+ const def = ready ? resolution.def : null;
185
+ return { ready, def };
186
+ }
@@ -37,8 +37,13 @@ export interface UseViewDataResult<T = any> {
37
37
  dataSource: DataSource<T> | null;
38
38
  /** Re-fetch data with current or new params */
39
39
  refresh: (newParams?: QueryParams) => Promise<void>;
40
- /** Fetch a single record by ID */
41
- fetchOne: (id: string | number) => Promise<T | null>;
40
+ /**
41
+ * Fetch a single record by ID. The id is a `string`, as every record door on
42
+ * `DataSource` declares it — this hook hands the value straight to
43
+ * `DataSource.findOne`, so the union it used to admit was a claim the
44
+ * protocol never made (objectui#9511).
45
+ */
46
+ fetchOne: (id: string) => Promise<T | null>;
42
47
  /** Whether more records are available */
43
48
  hasMore: boolean;
44
49
  }
@@ -50,8 +55,10 @@ export interface UseViewDataResult<T = any> {
50
55
  *
51
56
  * 1. Resolves the ViewData config to the right DataSource adapter
52
57
  * - `provider: 'object'` → context DataSource (ObjectStackAdapter)
53
- * - `provider: 'api'` → ApiDataSource (raw HTTP)
54
58
  * - `provider: 'value'` → ValueDataSource (in-memory)
59
+ * The view-level `provider: 'api'` is retired in `@objectstack/spec`
60
+ * (objectstack#23082) and has no arm in `resolveDataSource`: a block that
61
+ * still names it is an unknown provider there (objectui#12325).
55
62
  * 2. Fetches data automatically on mount
56
63
  * 3. Provides loading/error states and refresh capability
57
64
  *
@@ -22,8 +22,10 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
22
22
  *
23
23
  * 1. Resolves the ViewData config to the right DataSource adapter
24
24
  * - `provider: 'object'` → context DataSource (ObjectStackAdapter)
25
- * - `provider: 'api'` → ApiDataSource (raw HTTP)
26
25
  * - `provider: 'value'` → ValueDataSource (in-memory)
26
+ * The view-level `provider: 'api'` is retired in `@objectstack/spec`
27
+ * (objectstack#23082) and has no arm in `resolveDataSource`: a block that
28
+ * still names it is an unknown provider there (objectui#12325).
27
29
  * 2. Fetches data automatically on mount
28
30
  * 3. Provides loading/error states and refresh capability
29
31
  *
@@ -44,30 +46,19 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
44
46
  */
45
47
  export function useViewData(options) {
46
48
  const { viewData, resource = '', params, dataSource: explicitDataSource, adapterOptions, skip = false, } = options;
47
- // Get context DataSource + host-authenticated fetch
49
+ // Get context DataSource
48
50
  const context = useContext(SchemaRendererContext);
49
51
  const contextDataSource = context?.dataSource ?? null;
50
- const contextApiFetch = context?.apiFetch;
51
52
  // Resolve the DataSource — memoize to prevent re-creation on every render.
52
53
  // We key on the viewData provider + config identity.
53
54
  const resolvedDataSource = useMemo(() => {
54
55
  if (explicitDataSource)
55
56
  return explicitDataSource;
56
- // Default the 'api' provider's fetch to the host's authenticated fetch
57
- // (SchemaRendererContext.apiFetch) so custom endpoints carry the same
58
- // credentials as native requests; explicit adapterOptions still win.
59
- return resolveDataSource(viewData, contextDataSource, {
60
- fetch: contextApiFetch,
61
- ...adapterOptions,
62
- });
57
+ return resolveDataSource(viewData, contextDataSource, adapterOptions);
63
58
  // eslint-disable-next-line react-hooks/exhaustive-deps
64
59
  }, [
65
60
  explicitDataSource,
66
- contextApiFetch,
67
61
  viewData?.provider,
68
- // For 'api' provider, key on read/write URLs
69
- viewData?.provider === 'api' ? viewData.read?.url : undefined,
70
- viewData?.provider === 'api' ? viewData.write?.url : undefined,
71
62
  // For 'value' provider, key on items reference (or length as proxy)
72
63
  viewData?.provider === 'value' ? viewData.items?.length : undefined,
73
64
  contextDataSource,
package/dist/index.d.ts CHANGED
@@ -10,10 +10,18 @@ export * from './schema-input.js';
10
10
  export * from './hooks/index.js';
11
11
  export * from './context/index.js';
12
12
  export * from './LazyPluginLoader.js';
13
- export * from './spec-bridge/index.js';
14
13
  export * from './data-invalidation.js';
15
14
  export * from './element-data-source/ElementDataSourceGate.js';
15
+ export * from './element-data-source/useElementApiDataSource.js';
16
16
  export { resolveKeyedI18nLabel } from './utils/i18n.js';
17
+ export { resolveInlineAriaProps } from './utils/inlineAria.js';
18
+ export { isConfigBag } from './utils/configBag.js';
19
+ export { reportUnresolvableVisibilityPredicate, formatUnresolvableVisibilityMessage, UNRESOLVABLE_VISIBILITY_PREFIX, UNRESOLVABLE_ENABLEMENT_PREFIX, __resetVisibilityPredicateWarnings, } from './utils/visibilityDiagnostic.js';
20
+ export type { PredicateScopeTier } from './utils/visibilityDiagnostic.js';
21
+ export type { PredicateGateKind } from './utils/visibilityDiagnostic.js';
17
22
  export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js';
18
23
  export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js';
19
24
  export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type I18nConfig, type I18nProviderProps, type TranslationKeys, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from '@object-ui/i18n';
25
+ export { NON_GRID_ROW_CEILING, applyNonGridRowCeiling } from '@object-ui/core';
26
+ export type { NonGridCeilingResult } from '@object-ui/core';
27
+ export { NonGridRowCeilingNote } from './utils/nonGridRowCeiling.js';