@object-ui/react 17.5.0 → 17.6.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 (48) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +9 -8
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.js +434 -22
  6. package/dist/context/HostNavigationContext.d.ts +82 -0
  7. package/dist/context/HostNavigationContext.js +65 -0
  8. package/dist/context/RelatedRecordActionsContext.d.ts +15 -0
  9. package/dist/context/index.d.ts +13 -12
  10. package/dist/context/index.js +13 -12
  11. package/dist/element-data-source/ElementDataSourceGate.d.ts +75 -2
  12. package/dist/element-data-source/ElementDataSourceGate.js +81 -2
  13. package/dist/hooks/index.d.ts +35 -35
  14. package/dist/hooks/index.js +35 -35
  15. package/dist/hooks/useActionEngine.js +1 -1
  16. package/dist/hooks/useCapabilityGate.js +6 -5
  17. package/dist/hooks/useClientNotifications.js +2 -2
  18. package/dist/hooks/useCrudShortcuts.js +1 -1
  19. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  20. package/dist/hooks/useDiscovery.js +1 -1
  21. package/dist/hooks/useElementDataSource.js +5 -2
  22. package/dist/hooks/useExpression.d.ts +24 -3
  23. package/dist/hooks/useExpression.js +56 -4
  24. package/dist/hooks/useNavigationOverlay.d.ts +1 -1
  25. package/dist/hooks/usePageAssignment.js +1 -1
  26. package/dist/hooks/usePageTransition.js +1 -1
  27. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  28. package/dist/hooks/useViewData.js +1 -1
  29. package/dist/hooks/useViewTransition.js +1 -1
  30. package/dist/index.d.ts +11 -11
  31. package/dist/index.js +10 -10
  32. package/dist/schema-input.d.ts +16 -5
  33. package/dist/schema-input.js +15 -4
  34. package/dist/spec-bridge/SpecBridge.d.ts +1 -1
  35. package/dist/spec-bridge/SpecBridge.js +2 -2
  36. package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
  37. package/dist/spec-bridge/bridges/form-view.js +51 -7
  38. package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
  39. package/dist/spec-bridge/bridges/list-view.js +37 -10
  40. package/dist/spec-bridge/index.d.ts +4 -4
  41. package/dist/spec-bridge/index.js +3 -3
  42. package/dist/utils/error-message.d.ts +59 -0
  43. package/dist/utils/error-message.js +116 -1
  44. package/dist/utils/unevaluatedExpression.d.ts +69 -0
  45. package/dist/utils/unevaluatedExpression.js +194 -0
  46. package/dist/utils/visibilityDiagnostic.d.ts +119 -0
  47. package/dist/utils/visibilityDiagnostic.js +284 -0
  48. package/package.json +7 -7
@@ -5,15 +5,48 @@
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
+ /**
9
+ * A spec `ListColumn` in, the same column out — in the spelling the spec
10
+ * declares (objectui#5068).
11
+ *
12
+ * This used to down-translate every column to the data-table adapter's
13
+ * `{ accessorKey, header }` before handing it to an `object-grid` node, and
14
+ * `ObjectGrid` translated it straight back through a tolerance branch that
15
+ * sniffed `columns[0]`. A round trip whose input was ALREADY canonical: the
16
+ * bridge's own parameter is `@objectstack/spec/ui`'s `ListColumn`, where
17
+ * `field` is required and `accessorKey` / `header` are refused by name
18
+ * (`ListColumnSchema` is a strict object). So the producer emitted a spelling
19
+ * the contract rejects, and the renderer grew a second de-facto contract to
20
+ * read it back — exactly the shape AGENTS.md #0.1 forbids, and the disposition
21
+ * objectui#3951 already settled: unify at the producer, no consumer-side alias.
22
+ *
23
+ * `accessorKey` is not retired as a concept — it is the TanStack adapter key
24
+ * `@object-ui/core` deliberately keeps OUT of the metadata identity fold
25
+ * (`column-identity.ts`'s `TABLE_ADAPTER_COLUMN_KEY`). `ObjectGrid` still
26
+ * applies it on the way OUT, at the one boundary that owns it. Metadata
27
+ * vocabulary comes in; adapter vocabulary goes out; one translation, one place.
28
+ *
29
+ * Nothing is invented on the way through, which is the second half of the fix.
30
+ * `header: col.label ?? col.field` used to turn "no label declared" into "the
31
+ * machine name was declared as the label", and that synthesized value then
32
+ * pre-empted `ObjectGrid`'s own header chain — `col.label` → the OBJECT
33
+ * FIELD's label → the prettified name — whose whole purpose is a column
34
+ * authored as a bare `{ field }`, and whose middle step exists so a localized
35
+ * field label wins over the name-derived English one on a non-English app. A
36
+ * bridged view therefore rendered raw machine names where a directly authored
37
+ * `object-grid` rendered the field's real, localizable label. It now forwards
38
+ * the declaration and lets the renderer resolve what was left unsaid.
39
+ */
8
40
  function mapColumn(col) {
9
- // Spec-legacy shorthand: a bare field name stands for a default column.
41
+ // Spec shorthand: a bare field name stands for a default column.
10
42
  if (typeof col === 'string') {
11
- return { accessorKey: col, header: col };
43
+ return { field: col };
12
44
  }
13
45
  const mapped = {
14
- accessorKey: col.field,
15
- header: col.label ?? col.field,
46
+ field: col.field,
16
47
  };
48
+ if (col.label != null)
49
+ mapped.label = col.label;
17
50
  if (col.width != null)
18
51
  mapped.width = col.width;
19
52
  if (col.align)
@@ -178,10 +211,6 @@ export const bridgeListView = (spec, _context) => {
178
211
  node.filterableFields = spec.filterableFields;
179
212
  if (spec.resizable != null)
180
213
  node.resizable = spec.resizable;
181
- if (spec.striped != null)
182
- node.striped = spec.striped;
183
- if (spec.bordered != null)
184
- node.bordered = spec.bordered;
185
214
  if (spec.navigation)
186
215
  node.navigation = spec.navigation;
187
216
  if (spec.kanban)
@@ -201,8 +230,6 @@ export const bridgeListView = (spec, _context) => {
201
230
  node.bulkActions = spec.bulkActions;
202
231
  if (spec.bulkActionDefs)
203
232
  node.bulkActionDefs = spec.bulkActionDefs;
204
- if (spec.virtualScroll != null)
205
- node.virtualScroll = spec.virtualScroll;
206
233
  if (spec.conditionalFormatting)
207
234
  node.conditionalFormatting = spec.conditionalFormatting;
208
235
  if (spec.inlineEdit != null)
@@ -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
- export { SpecBridge } from './SpecBridge';
9
- export type { BridgeContext, BridgeFn, ObjectDefLite } from './types';
10
- export { bridgeListView } from './bridges/list-view';
11
- export { bridgeFormView } from './bridges/form-view';
8
+ export { SpecBridge } from './SpecBridge.js';
9
+ export type { BridgeContext, BridgeFn, ObjectDefLite } from './types.js';
10
+ export { bridgeListView } from './bridges/list-view.js';
11
+ export { bridgeFormView } from './bridges/form-view.js';
@@ -5,6 +5,6 @@
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
- export { SpecBridge } from './SpecBridge';
9
- export { bridgeListView } from './bridges/list-view';
10
- export { bridgeFormView } from './bridges/form-view';
8
+ export { SpecBridge } from './SpecBridge.js';
9
+ export { bridgeListView } from './bridges/list-view.js';
10
+ export { bridgeFormView } from './bridges/form-view.js';
@@ -12,6 +12,40 @@
12
12
  * usable can be extracted, so callers fall back to a generic localized string.
13
13
  */
14
14
  export declare function extractWriteErrorMessage(err: unknown): string | null;
15
+ /**
16
+ * The refusal text the PRODUCER explicitly marked as addressed to the end user,
17
+ * or `null` when the refusal carries no marking.
18
+ *
19
+ * `userMessage` is the opt-in channel added by objectstack#9934 (maintainer
20
+ * ruling 2026-08-19 on objectui#5210), declared on `ApiErrorSchema` /
21
+ * `EnhancedApiErrorSchema` in the pinned `@objectstack/spec`.
22
+ * Three properties of the contract decide this reader's whole shape:
23
+ *
24
+ * - **Presence IS the marking.** The mark and the marked text are ONE value —
25
+ * a field carrying the text, not a boolean beside `message` — so no boundary
26
+ * that rewraps or substitutes `message` can promote platform prose into the
27
+ * marked channel. Platform and driver code never set it.
28
+ * - **Status-agnostic.** Not a 403 special case; any refusal status may carry
29
+ * it. Callers must not gate this read on a status.
30
+ * - **Verbatim.** The author wrote it for their user, already localized. It is
31
+ * returned untouched — none of {@link extractWriteErrorMessage}'s prefix
32
+ * stripping applies, because there is no machine prefix to strip.
33
+ *
34
+ * Read from the two places the adapter boundary parks the envelope, both under
35
+ * the SAME declared key: the error itself (`@objectstack/client` lifts a marked
36
+ * body onto `err.userMessage`, from either wire dialect) and `details`, which
37
+ * the client falls back to the whole response body — the identical pair
38
+ * {@link isPermissionError} reads `code` from. Deliberately NOT read: the raw
39
+ * JSON tail `ApiDataSource` bakes into its message string. Scraping a marking
40
+ * out of a transport's prose is the consumer-side guessing objectstack#3821
41
+ * exists to prevent; a transport that flattens the envelope should carry the
42
+ * field instead.
43
+ *
44
+ * Everything unmarked answers `null`, so callers keep their generic localized
45
+ * substitution and objectstack#3821's protection holds BY CONSTRUCTION: this
46
+ * function can never return text the producer did not opt in.
47
+ */
48
+ export declare function declaredUserMessage(err: unknown): string | null;
15
49
  /** One field the server rejected, normalised for `form.setError`. */
16
50
  export interface WriteFieldError {
17
51
  /** Field name, matching the form control's `name`. */
@@ -50,3 +84,28 @@ export declare function extractFieldErrors(err: unknown): WriteFieldError[] | nu
50
84
  * the raw server text.
51
85
  */
52
86
  export declare function isPermissionError(err: unknown): boolean;
87
+ /** What {@link classifyLoadError} answers. */
88
+ export type LoadErrorKind = 'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network';
89
+ /**
90
+ * Classify a failed data FETCH (as opposed to a failed write, above) by HTTP
91
+ * status / error code, so an error panel can say what actually happened. A
92
+ * 403 rendered as "check your connection" is indistinguishable from a real
93
+ * outage — users were told to debug their network when the server had
94
+ * (correctly) denied them access.
95
+ *
96
+ * Originally `ListView`'s module-local `classifyLoadError`
97
+ * (`packages/plugin-list/src/ListView.tsx`); lifted here (objectui#4693) so
98
+ * `RecordAttachmentsPanel` can reuse the same "is this retry-invariant"
99
+ * verdict for the `sys_attachment` list read instead of re-deriving it. Both
100
+ * `@object-ui/plugin-list` and `@object-ui/app-shell` already depend on this
101
+ * package, so the lift adds no new dependency edge to either.
102
+ *
103
+ * Purely a classifier — it returns a KIND, never rendered text. Each caller
104
+ * owns its own copy for what a kind means on its surface (`ListView` shows a
105
+ * title + message per kind and a Retry for every kind but `api-disabled`;
106
+ * `RecordAttachmentsPanel` only distinguishes `api-disabled` from everything
107
+ * else, folding `forbidden`/`unauthorized`/`rejected`/`network` into its own
108
+ * coarser `denied`/`unavailable` split via {@link isPermissionError}) — so
109
+ * lifting this function does not couple the two surfaces' i18n or copy.
110
+ */
111
+ export declare function classifyLoadError(err: unknown): LoadErrorKind;
@@ -6,7 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  /**
9
- * Helpers for turning a failed data-source write into user-facing feedback.
9
+ * Helpers for turning a failed data-source read or write into user-facing
10
+ * feedback.
10
11
  *
11
12
  * Different DataSource adapters throw differently shaped errors:
12
13
  * - the ObjectStack adapter (`@object-ui/data-objectstack`) decorates the
@@ -18,6 +19,7 @@
18
19
  * These helpers normalise across both so a caller's `catch` can surface the
19
20
  * real reason (e.g. a row-level-security denial) instead of swallowing it.
20
21
  */
22
+ import { isApiAccessDeniedError } from '@object-ui/data-objectstack';
21
23
  function firstString(...vals) {
22
24
  for (const v of vals) {
23
25
  if (typeof v === 'string' && v.trim())
@@ -68,6 +70,54 @@ export function extractWriteErrorMessage(err) {
68
70
  .trim();
69
71
  return clean || null;
70
72
  }
73
+ /**
74
+ * The refusal text the PRODUCER explicitly marked as addressed to the end user,
75
+ * or `null` when the refusal carries no marking.
76
+ *
77
+ * `userMessage` is the opt-in channel added by objectstack#9934 (maintainer
78
+ * ruling 2026-08-19 on objectui#5210), declared on `ApiErrorSchema` /
79
+ * `EnhancedApiErrorSchema` in the pinned `@objectstack/spec`.
80
+ * Three properties of the contract decide this reader's whole shape:
81
+ *
82
+ * - **Presence IS the marking.** The mark and the marked text are ONE value —
83
+ * a field carrying the text, not a boolean beside `message` — so no boundary
84
+ * that rewraps or substitutes `message` can promote platform prose into the
85
+ * marked channel. Platform and driver code never set it.
86
+ * - **Status-agnostic.** Not a 403 special case; any refusal status may carry
87
+ * it. Callers must not gate this read on a status.
88
+ * - **Verbatim.** The author wrote it for their user, already localized. It is
89
+ * returned untouched — none of {@link extractWriteErrorMessage}'s prefix
90
+ * stripping applies, because there is no machine prefix to strip.
91
+ *
92
+ * Read from the two places the adapter boundary parks the envelope, both under
93
+ * the SAME declared key: the error itself (`@objectstack/client` lifts a marked
94
+ * body onto `err.userMessage`, from either wire dialect) and `details`, which
95
+ * the client falls back to the whole response body — the identical pair
96
+ * {@link isPermissionError} reads `code` from. Deliberately NOT read: the raw
97
+ * JSON tail `ApiDataSource` bakes into its message string. Scraping a marking
98
+ * out of a transport's prose is the consumer-side guessing objectstack#3821
99
+ * exists to prevent; a transport that flattens the envelope should carry the
100
+ * field instead.
101
+ *
102
+ * Everything unmarked answers `null`, so callers keep their generic localized
103
+ * substitution and objectstack#3821's protection holds BY CONSTRUCTION: this
104
+ * function can never return text the producer did not opt in.
105
+ */
106
+ export function declaredUserMessage(err) {
107
+ if (!err || typeof err !== 'object')
108
+ return null;
109
+ const e = err;
110
+ // Non-empty after trimming, returned untrimmed — the same predicate
111
+ // `@objectstack/client` applies when it lifts the field off the wire.
112
+ if (typeof e.userMessage === 'string' && e.userMessage.trim())
113
+ return e.userMessage;
114
+ const details = e.details;
115
+ if (details && typeof details === 'object'
116
+ && typeof details.userMessage === 'string' && details.userMessage.trim()) {
117
+ return details.userMessage;
118
+ }
119
+ return null;
120
+ }
71
121
  /**
72
122
  * Per-field errors from a rejected write, or `null` when the failure was not
73
123
  * field-scoped.
@@ -136,3 +186,68 @@ export function isPermissionError(err) {
136
186
  const text = `${typeof e.message === 'string' ? e.message : ''} ${e.details?.error ?? e.error ?? ''}`;
137
187
  return /row-level security|access denied|permission denied|not permitted/i.test(text);
138
188
  }
189
+ /** 400-class error codes the data API returns for a request it will never accept. */
190
+ const REJECTED_REQUEST_CODES = new Set([
191
+ 'INVALID_FILTER',
192
+ 'UNSUPPORTED_QUERY_PARAM',
193
+ 'INVALID_QUERY',
194
+ ]);
195
+ /**
196
+ * Classify a failed data FETCH (as opposed to a failed write, above) by HTTP
197
+ * status / error code, so an error panel can say what actually happened. A
198
+ * 403 rendered as "check your connection" is indistinguishable from a real
199
+ * outage — users were told to debug their network when the server had
200
+ * (correctly) denied them access.
201
+ *
202
+ * Originally `ListView`'s module-local `classifyLoadError`
203
+ * (`packages/plugin-list/src/ListView.tsx`); lifted here (objectui#4693) so
204
+ * `RecordAttachmentsPanel` can reuse the same "is this retry-invariant"
205
+ * verdict for the `sys_attachment` list read instead of re-deriving it. Both
206
+ * `@object-ui/plugin-list` and `@object-ui/app-shell` already depend on this
207
+ * package, so the lift adds no new dependency edge to either.
208
+ *
209
+ * Purely a classifier — it returns a KIND, never rendered text. Each caller
210
+ * owns its own copy for what a kind means on its surface (`ListView` shows a
211
+ * title + message per kind and a Retry for every kind but `api-disabled`;
212
+ * `RecordAttachmentsPanel` only distinguishes `api-disabled` from everything
213
+ * else, folding `forbidden`/`unauthorized`/`rejected`/`network` into its own
214
+ * coarser `denied`/`unavailable` split via {@link isPermissionError}) — so
215
+ * lifting this function does not couple the two surfaces' i18n or copy.
216
+ */
217
+ export function classifyLoadError(err) {
218
+ const e = err;
219
+ // The ObjectStack client decorates errors with `httpStatus`; raw fetch
220
+ // wrappers surface `status` / `statusCode`; some adapters only embed the
221
+ // status in the message ("HTTP 403 Forbidden — …").
222
+ let status = [e?.httpStatus, e?.status, e?.statusCode]
223
+ .find((s) => typeof s === 'number');
224
+ if (status === undefined) {
225
+ const m = /HTTP (\d{3})\b/.exec(String(e?.message ?? ''));
226
+ if (m)
227
+ status = Number(m[1]);
228
+ }
229
+ const code = typeof e?.code === 'string' ? e.code.toUpperCase() : '';
230
+ // The object's `enable` block withholds this operation — checked FIRST, on
231
+ // the CODE alone (delegated to the shared predicate so this file and
232
+ // `@object-ui/data-objectstack` cannot drift on which codes count). Status
233
+ // cannot carry this verdict: the denial is a 404, the same status a missing
234
+ // collection and a missing record answer with, and its 405 sibling is the
235
+ // same status any other method rejection uses. Unlike every kind below it
236
+ // this one is not about the request, the session or the network — it is a
237
+ // permanent property of the object, identical for every persona and every
238
+ // retry (objectui#4408).
239
+ if (isApiAccessDeniedError(err))
240
+ return 'api-disabled';
241
+ if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN')
242
+ return 'forbidden';
243
+ if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED')
244
+ return 'unauthorized';
245
+ // The server understood the request and refused it as malformed. Retrying
246
+ // sends the identical bad request, so "check your connection and try again"
247
+ // is the same wrong advice this function exists to stop giving — one status
248
+ // code over. The server now rejects a `$filter` that is not a filter AST
249
+ // (objectstack#4121) and an unsupported `$`-parameter, both as 400.
250
+ if (status === 400 || REJECTED_REQUEST_CODES.has(code))
251
+ return 'rejected';
252
+ return 'network';
253
+ }
@@ -0,0 +1,69 @@
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
+ * Mirrors `ExpressionEvaluator.evaluate`: a `${` with no closing brace, or an
10
+ * empty `${}`, is not an expression to the evaluator (its interpolation regex
11
+ * requires at least one character), so it is not one here either. Matching the
12
+ * PRODUCER's definition is what keeps this from reporting strings the engine
13
+ * never intended to touch — prose and code samples that merely contain `${`.
14
+ */
15
+ export declare function findExpressionSources(value: string): string[];
16
+ /** Which authored channel a residual expression was found on. */
17
+ export type UnevaluatedChannel = 'schema' | 'properties' | 'props';
18
+ export interface UnevaluatedExpressionFinding {
19
+ /** The key holding the raw source. */
20
+ key: string;
21
+ /** The authored channel the key was read from. */
22
+ channel: UnevaluatedChannel;
23
+ /** The value, verbatim, as it will be handed to the renderer. */
24
+ value: string;
25
+ /** The `${…}` fragments inside it. */
26
+ expressions: string[];
27
+ }
28
+ /**
29
+ * Collect every unevaluated `${…}` that is about to be handed to a renderer.
30
+ *
31
+ * Pure and side-effect free — the reporting half is {@link reportUnevaluatedExpressions}.
32
+ * Kept separate so the scan can be asserted directly: a diagnostic whose only
33
+ * test is "a spy was called" goes green the moment someone no-ops it.
34
+ *
35
+ * @param spread The post-strip top-level bag — exactly what `SchemaRenderer`
36
+ * spreads as React props, which is also what renderers read as `schema.<key>`.
37
+ * Schema METADATA (`visible` / `hidden` / `disabled` / … ) must already be
38
+ * removed by the caller: those keys carry raw predicate source BY DESIGN and
39
+ * are evaluated as conditions, never placed.
40
+ * @param properties The node's `properties` bag, if any. Reported in
41
+ * preference to the top-level copy the hoist makes of it, so the finding
42
+ * names the key the author actually wrote.
43
+ * @param props The node's legacy `props` bag, if any.
44
+ */
45
+ export declare function collectUnevaluatedExpressions(spread: Record<string, unknown> | undefined | null, properties?: unknown, props?: unknown): UnevaluatedExpressionFinding[];
46
+ /**
47
+ * Prefix every diagnostic line starts with. Exported so tests can match on it
48
+ * and so an app can filter it out of its console transport if it must.
49
+ */
50
+ export declare const UNEVALUATED_EXPRESSION_PREFIX = "[ObjectUI] Unevaluated expression reached the DOM";
51
+ /**
52
+ * Build the message. Separate from the emit so a test can assert the words a
53
+ * developer is going to read, not merely that something was logged.
54
+ */
55
+ export declare function formatUnevaluatedExpressionMessage(type: unknown, id: unknown, findings: UnevaluatedExpressionFinding[]): string;
56
+ /**
57
+ * Dev-build only. Scans, and on a hit reports once per schema object via
58
+ * `console.error`.
59
+ *
60
+ * `console.error` and not `warn`: this is the loud refusal objectui#4795's
61
+ * ruling asked for, and it follows the assertion shape objectui#5092 landed in
62
+ * SchemaForm. (It is also the level that survives happy-dom, which swallows
63
+ * `console.log`.)
64
+ *
65
+ * The caller applies the production gate, so this stays a single dev-only
66
+ * branch at the call site and the whole module is dead code in a production
67
+ * build.
68
+ */
69
+ export declare function reportUnevaluatedExpressions(schema: object | null | undefined, type: unknown, id: unknown, spread: Record<string, unknown> | undefined | null, properties?: unknown, props?: unknown): UnevaluatedExpressionFinding[];
@@ -0,0 +1,194 @@
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
+ * Dev-build diagnostic: an UNEVALUATED template expression reached the DOM.
10
+ *
11
+ * ## The defect this names (objectui#4795, Direction 3)
12
+ *
13
+ * A value only reaches the user if it passes two gates: `SchemaRenderer`
14
+ * must EVALUATE it, and the renderer must READ IT BACK. Those two sets do not
15
+ * fully overlap, and where they miss, the failure is SILENT — measured on a
16
+ * real render with `dataSource: { total: 99 }`:
17
+ *
18
+ * { type: 'ui:statistic', value: '${data.n}' } -> screen shows
19
+ * `${data.n}`
20
+ * { type: 'ui:card', props: { title: '${data.total}' } } -> card body EMPTY
21
+ * (the evaluated
22
+ * `99` landed on a
23
+ * DOM attribute)
24
+ *
25
+ * The first shape is what this module catches: the memo in `SchemaRenderer`
26
+ * evaluates `content`, the `properties` / `props` bags and the predicate keys,
27
+ * and passes every other top-level key through untouched — so the raw source
28
+ * text is what the renderer receives, and it is placed verbatim. An author
29
+ * (increasingly, an AI authoring metadata) gets no signal at all: a literal
30
+ * `${data.n}` on screen reads like a data problem, not a contract violation.
31
+ *
32
+ * The second shape this module also catches, in its residue form: when an
33
+ * expression IS evaluated but the evaluation THROWS, `ExpressionEvaluator`
34
+ * returns the original source verbatim (`return match` in its interpolation
35
+ * branch, `defaultValue ?? expression` in its outer catch), so a failed
36
+ * evaluation is indistinguishable on screen from a key that was never
37
+ * evaluated at all. Both end as raw `${…}` in front of a user.
38
+ *
39
+ * ## What this deliberately does NOT do
40
+ *
41
+ * It changes NO evaluation behaviour — it reads the schema after the memo has
42
+ * run and reports. Widening the set of evaluated keys is objectui#4795's
43
+ * Direction 1, which the maintainer DEFERRED behind a named restart condition
44
+ * (ruling of 2026-08-17); this diagnostic is Direction 3 and must not
45
+ * pre-empt it. Merging `props` into the node (Direction 2) is permanently
46
+ * rejected.
47
+ *
48
+ * ## Depth: exactly the evaluator's own radius, and not one level more
49
+ *
50
+ * The scan is SHALLOW because evaluation is shallow. `ExpressionEvaluator`
51
+ * returns every non-string untouched, so a nested `aria: { label: '${…}' }`
52
+ * under either bag keeps its raw source today — deliberately, and pinned as
53
+ * such by objectui#4799's parity tests. A deeper scan would therefore report,
54
+ * as a defect, a shape the repo has explicitly decided not to change yet: the
55
+ * diagnostic would be louder than the contract it speaks for. Deepening
56
+ * evaluation is a separate decision, and this scan follows it the day it is
57
+ * taken, not before.
58
+ */
59
+ /** What counts as an expression — the evaluator's own definition. */
60
+ const EXPRESSION_PATTERN = /\$\{([^}]+)\}/g;
61
+ /**
62
+ * Mirrors `ExpressionEvaluator.evaluate`: a `${` with no closing brace, or an
63
+ * empty `${}`, is not an expression to the evaluator (its interpolation regex
64
+ * requires at least one character), so it is not one here either. Matching the
65
+ * PRODUCER's definition is what keeps this from reporting strings the engine
66
+ * never intended to touch — prose and code samples that merely contain `${`.
67
+ */
68
+ export function findExpressionSources(value) {
69
+ const found = [];
70
+ // `matchAll` needs the /g flag and a fresh lastIndex per call; the literal
71
+ // above is module-scope, so reset before use rather than allocating a regex
72
+ // per value (this runs per key, per node, per render, in dev).
73
+ EXPRESSION_PATTERN.lastIndex = 0;
74
+ let match = EXPRESSION_PATTERN.exec(value);
75
+ while (match !== null) {
76
+ found.push(match[0]);
77
+ match = EXPRESSION_PATTERN.exec(value);
78
+ }
79
+ return found;
80
+ }
81
+ function scanBag(bag, channel, into, skip) {
82
+ if (!bag || typeof bag !== 'object' || Array.isArray(bag))
83
+ return;
84
+ for (const [key, value] of Object.entries(bag)) {
85
+ if (typeof value !== 'string')
86
+ continue;
87
+ if (skip?.(key, value))
88
+ continue;
89
+ const expressions = findExpressionSources(value);
90
+ if (expressions.length > 0) {
91
+ into.push({ key, channel, value, expressions });
92
+ }
93
+ }
94
+ }
95
+ /**
96
+ * Collect every unevaluated `${…}` that is about to be handed to a renderer.
97
+ *
98
+ * Pure and side-effect free — the reporting half is {@link reportUnevaluatedExpressions}.
99
+ * Kept separate so the scan can be asserted directly: a diagnostic whose only
100
+ * test is "a spy was called" goes green the moment someone no-ops it.
101
+ *
102
+ * @param spread The post-strip top-level bag — exactly what `SchemaRenderer`
103
+ * spreads as React props, which is also what renderers read as `schema.<key>`.
104
+ * Schema METADATA (`visible` / `hidden` / `disabled` / … ) must already be
105
+ * removed by the caller: those keys carry raw predicate source BY DESIGN and
106
+ * are evaluated as conditions, never placed.
107
+ * @param properties The node's `properties` bag, if any. Reported in
108
+ * preference to the top-level copy the hoist makes of it, so the finding
109
+ * names the key the author actually wrote.
110
+ * @param props The node's legacy `props` bag, if any.
111
+ */
112
+ export function collectUnevaluatedExpressions(spread, properties, props) {
113
+ const findings = [];
114
+ scanBag(properties, 'properties', findings);
115
+ // The hoist copies every `properties.*` value onto the node's top level, so
116
+ // the same residue is visible twice. Report the authored spelling only.
117
+ const hoisted = properties && typeof properties === 'object' && !Array.isArray(properties)
118
+ ? properties
119
+ : undefined;
120
+ scanBag(spread, 'schema', findings, (key, value) => hoisted?.[key] === value);
121
+ scanBag(props, 'props', findings);
122
+ return findings;
123
+ }
124
+ /**
125
+ * Prefix every diagnostic line starts with. Exported so tests can match on it
126
+ * and so an app can filter it out of its console transport if it must.
127
+ */
128
+ export const UNEVALUATED_EXPRESSION_PREFIX = '[ObjectUI] Unevaluated expression reached the DOM';
129
+ /** Where a finding sits, spelled the way an author would search for it. */
130
+ function locate(finding) {
131
+ switch (finding.channel) {
132
+ case 'properties':
133
+ return 'properties.' + finding.key;
134
+ case 'props':
135
+ return 'props.' + finding.key;
136
+ default:
137
+ return finding.key;
138
+ }
139
+ }
140
+ /**
141
+ * Build the message. Separate from the emit so a test can assert the words a
142
+ * developer is going to read, not merely that something was logged.
143
+ */
144
+ export function formatUnevaluatedExpressionMessage(type, id, findings) {
145
+ const node = typeof type === 'string' && type ? '"' + type + '"' : '(untyped node)';
146
+ const where = typeof id === 'string' && id ? ' (id: "' + id + '")' : '';
147
+ const lines = findings.map(f => {
148
+ const site = locate(f);
149
+ return (' - ' + site + ': ' + JSON.stringify(f.value) +
150
+ ' [' + f.expressions.join(', ') + ']');
151
+ });
152
+ return (UNEVALUATED_EXPRESSION_PREFIX + ' - node ' + node + where + '\n' +
153
+ lines.join('\n') + '\n' +
154
+ 'The value above is placed VERBATIM: it is handed to the renderer, and spread\n' +
155
+ 'to the DOM, with its source text intact. Either SchemaRenderer does not\n' +
156
+ 'evaluate this key, or the expression threw and the evaluator returned the\n' +
157
+ 'source unchanged.\n' +
158
+ 'Channels that do evaluate and read back today: `content`, or resolve the\n' +
159
+ 'value in the host before handing the schema to SchemaRenderer.');
160
+ }
161
+ /**
162
+ * Reported nodes, so a re-render (or the post-mount forceUpdate that picks up
163
+ * lazy plugin registrations) does not repeat the message. Keyed on the ORIGINAL
164
+ * schema object, matching `validateSchemaOnce`'s dedup: the evaluated copy is a
165
+ * fresh object on every memo recompute and would dedup nothing.
166
+ */
167
+ const _reportedNodes = typeof WeakSet !== 'undefined'
168
+ ? new WeakSet()
169
+ : { add() { }, has() { return false; } };
170
+ /**
171
+ * Dev-build only. Scans, and on a hit reports once per schema object via
172
+ * `console.error`.
173
+ *
174
+ * `console.error` and not `warn`: this is the loud refusal objectui#4795's
175
+ * ruling asked for, and it follows the assertion shape objectui#5092 landed in
176
+ * SchemaForm. (It is also the level that survives happy-dom, which swallows
177
+ * `console.log`.)
178
+ *
179
+ * The caller applies the production gate, so this stays a single dev-only
180
+ * branch at the call site and the whole module is dead code in a production
181
+ * build.
182
+ */
183
+ export function reportUnevaluatedExpressions(schema, type, id, spread, properties, props) {
184
+ const findings = collectUnevaluatedExpressions(spread, properties, props);
185
+ if (findings.length === 0)
186
+ return findings;
187
+ if (schema && typeof schema === 'object') {
188
+ if (_reportedNodes.has(schema))
189
+ return findings;
190
+ _reportedNodes.add(schema);
191
+ }
192
+ console.error(formatUnevaluatedExpressionMessage(type, id, findings));
193
+ return findings;
194
+ }