@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
@@ -7,13 +7,22 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
7
7
  * LICENSE file in the root directory of this source tree.
8
8
  */
9
9
  import React, { forwardRef, useContext, useMemo, useEffect, useReducer, useState, Component } from 'react';
10
- import { ComponentRegistry, ExpressionEvaluator, isObjectUIError, ERROR_CODES, debugLog, debugTime, debugTimeEnd, DebugCollector, validateSchema, hasDeclaredPredicate, hasResponsiveStyles, scopeClassFor, compileScopedStyles, } from '@object-ui/core';
10
+ import { ComponentRegistry, ExpressionEvaluator, isObjectUIError, ERROR_CODES, debugLog, debugTime, debugTimeEnd, DebugCollector, validateSchema, hasDeclaredPredicate, hasResponsiveStyles, scopeClassFor, compileScopedStyles, recordSourceDataArmForType, } from '@object-ui/core';
11
11
  import { SchemaRendererContext } from './context/SchemaRendererContext.js';
12
12
  import { useRecordContext } from './context/RecordContext.js';
13
- import { usePredicateScope } from './hooks/useExpression.js';
13
+ import { usePredicateScope, PredicateScopeProvider } from './hooks/useExpression.js';
14
+ import { useElementApiDataSource } from './element-data-source/useElementApiDataSource.js';
15
+ import { ElementDataSourceLoadingPanel } from './element-data-source/ElementDataSourceGate.js';
16
+ import { ElementApiDataSourcePanel } from './element-data-source/ElementApiDataSourcePanel.js';
14
17
  import { usePageVariables } from './hooks/usePageVariables.js';
15
18
  import { resolveKeyedI18nLabel } from './utils/i18n.js';
19
+ import { isConfigBag } from './utils/configBag.js';
20
+ import { PARAMS_KEY, isParamsBag, mapParamsLeaves } from './utils/paramsBag.js';
21
+ import { isInlineLocaleMap, mapLocaleMapMembers } from './utils/localeMap.js';
16
22
  import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
23
+ import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js';
24
+ import { reportRefusedDataPropSpread } from './utils/refusedDataPropDiagnostic.js';
25
+ import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
17
26
  import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js';
18
27
  /**
19
28
  * Dev-mode schema validation.
@@ -41,6 +50,7 @@ const _validationCache = typeof WeakMap !== 'undefined'
41
50
  : { set() { }, get() { return undefined; }, has() { return false; } };
42
51
  const _warnedSchemas = typeof WeakSet !== 'undefined' ? new WeakSet() : { add() { }, has() { return false; } };
43
52
  function validateSchemaOnce(schema) {
53
+ // Render is not a validation door: a dev-only STRUCTURAL check, not the contract — the doors are named in "Render is not a validation door", content/docs/guide/schema-rendering.md.
44
54
  if (!__DEV__ || !schema || typeof schema !== 'object') {
45
55
  return { valid: true, messages: [] };
46
56
  }
@@ -81,6 +91,18 @@ function validateSchemaOnce(schema) {
81
91
  * ariaLabel: string | I18nLabel (→ aria-label)
82
92
  * ariaDescribedBy: string (→ aria-describedby)
83
93
  * role: string (→ role)
94
+ *
95
+ * ⚠️ This reads the FLAT node keys, and resolves `ariaLabel` in objectui's
96
+ * KEYED vocabulary (`resolveKeyedI18nLabel`), which returns `undefined` for an
97
+ * inline locale map. It does not read the NESTED `aria` bag a block's props
98
+ * carry. That bag is read by `resolveInlineAriaProps` (`utils/inlineAria.ts`,
99
+ * objectui#11051), and objectui#4580 Q2-B keeps the two readers separate.
100
+ *
101
+ * It takes the interpreter's working copy (objectui#11349). That copy used to
102
+ * arrive through a `Record<string, any>` parameter, which typed these reads
103
+ * `any`. `ariaLabel` is a `BaseSchema` member. `ariaDescribedBy` and `role`
104
+ * are not, so they read as `unknown` and are forwarded exactly as authored,
105
+ * the same bytes as before. The record is typed to say so.
84
106
  */
85
107
  function resolveAriaProps(schema) {
86
108
  const aria = {};
@@ -144,9 +166,13 @@ const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
144
166
  * - `type`/`id` are skipped, because the hoist never copied a canonical value
145
167
  * up for them, so dropping the alias would DELETE the prop rather than
146
168
  * replace it;
147
- * - a degenerate (non-object) `properties` is left alone — the hoist and
148
- * `readProps()` both merely object-spread it, and there is no canonical bag
149
- * to prefer.
169
+ * - a degenerate (non-object) `properties` is left alone — there is no
170
+ * canonical bag to prefer, because a degenerate bag declares no key for
171
+ * either spelling to win. (This bullet used to explain the carve-out by
172
+ * saying "the hoist and `readProps()` both merely object-spread it". Half
173
+ * of that stopped being true at objectui#6760, which stopped the hoist from
174
+ * enumerating a degenerate bag; the carve-out itself is unaffected, since
175
+ * it never rested on what the hoist does with the value.)
150
176
  *
151
177
  * Adjacent but NOT decided here: objectui#4795's pending question ② (whether
152
178
  * the `properties` envelope is an official `ui:*` authoring channel at all).
@@ -154,15 +180,22 @@ const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
154
180
  * position on whether either should exist.
155
181
  */
156
182
  function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
157
- if (!propsBag)
183
+ // A degenerate `props` contributes NO keys (objectui#6752). This used to be a
184
+ // bare `if (!propsBag)`, so a non-object bag was returned as-is and the
185
+ // `createElement` spread below re-enumerated it: measured on `b76ca6764`,
186
+ // `props: 'not-a-bag'` reached the element as nine React props named `0` … `8`.
187
+ // Returning `{}` also makes the declared return type true — the old signature
188
+ // said `Record<string, any>` while this line could hand back a string.
189
+ //
190
+ // This does NOT move objectui#5123's precedence. That rule only decides which
191
+ // of two co-present values a key carries, and a degenerate bag declares no
192
+ // key for either bag to win: the indices were never authored, they are the
193
+ // object spread's reading of a string.
194
+ if (!isConfigBag(propsBag))
158
195
  return {};
159
- // Only a real object bag can win a key. `typeof null === 'object'` is covered
160
- // by the truthiness check; arrays are excluded for the same reason the
161
- // evaluation guard excludes them — a degenerate `properties` must not have
162
- // its shape reinterpreted here.
163
- if (!propertiesBag ||
164
- typeof propertiesBag !== 'object' ||
165
- Array.isArray(propertiesBag)) {
196
+ // Only a real object bag can win a key — the same predicate, for the reason
197
+ // stated there: a degenerate bag must not have its shape reinterpreted here.
198
+ if (!isConfigBag(propertiesBag)) {
166
199
  return propsBag;
167
200
  }
168
201
  let narrowed = null;
@@ -192,8 +225,394 @@ function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
192
225
  * means *hidden* and the wider `!== undefined` test would vanish a node for
193
226
  * `hidden: ''` (objectui#3955). See {@link winningVisibilityKey}.
194
227
  */
195
- const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'];
228
+ const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn'];
196
229
  const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'];
230
+ /**
231
+ * The same five legs as a lookup, so the config-bag evaluation loops below can
232
+ * ask "is this key a visibility predicate?" off the SAME declaration the chain
233
+ * is built from rather than a second list that can drift from it.
234
+ *
235
+ * Since objectui#9107 those loops ask {@link PREDICATE_CHAIN_KEYS} — this set
236
+ * unioned with the enablement chain's — so this one is the VISIBILITY HALF of
237
+ * that union rather than the whole of it. Each chain still has exactly one
238
+ * declaration, which is the property this constant was added for.
239
+ */
240
+ const VISIBILITY_CHAIN_KEYS = new Set([
241
+ ...VISIBILITY_SHOW_KEYS,
242
+ ...VISIBILITY_HIDE_KEYS,
243
+ ]);
244
+ /**
245
+ * The ENABLEMENT-chain keys — the SECOND predicate chain this file carries, and
246
+ * deliberately not a seventh leg of the visibility chain above (objectui#9107).
247
+ *
248
+ * Split the way this file actually consults them, which is not a polarity split:
249
+ *
250
+ * * {@link ENABLEMENT_NODE_GATE_KEYS} are the two legs
251
+ * {@link evaluateEnablementPredicate} consults HERE, in this precedence
252
+ * order, as one early-return chain — the enablement counterpart of
253
+ * `shouldHide`. Their `true` means DISABLED, un-negated.
254
+ * * {@link ENABLEMENT_RENDERER_KEYS} is the legacy, non-spec `enabled` alias.
255
+ * This component never evaluates it: the node gate does not consult it at
256
+ * all, and the action renderers (`action:button`, `action:icon`) read it one
257
+ * layer down off the schema and NEGATE it (`disabled = !isEnabled`). It is
258
+ * declared here because the config-bag loops below flatten it exactly like
259
+ * the other two — which is the whole defect — not because anything in this
260
+ * file decides with it.
261
+ *
262
+ * ## Why a second declaration instead of widening the visibility chain
263
+ *
264
+ * {@link VisibilityChainKey} exists "so a seventh cannot be added to the chain
265
+ * without also being classified below", and {@link visibilityGateKind} is that
266
+ * classification — it hands each leg the consequence copy for ITS polarity.
267
+ * These three keys have neither property: they route through
268
+ * {@link evaluateEnablementPredicate}, which states `'enablement'` at its own
269
+ * call site, and `enabled` is not consulted here at all. Adding them to
270
+ * {@link VISIBILITY_SHOW_KEYS} / {@link VISIBILITY_HIDE_KEYS} to reach the
271
+ * guard would make that closed type assert something false about them, and
272
+ * would silently enrol them in `shouldHide`'s early-return chain and in the
273
+ * visibility consequence copy as a side effect of a config-bag repair.
274
+ *
275
+ * So the two chains stay two declarations, and the GUARD takes their union
276
+ * ({@link PREDICATE_CHAIN_KEYS}) — one lookup, derived, declared nowhere twice.
277
+ * The alternative shape (one flat predicate-chain set that both chains derive
278
+ * FROM) was rejected for the opposite reason: the chains do not share an order,
279
+ * a declared-test or a consequence, so the thing they genuinely share is only
280
+ * the guard's question, and that is exactly what a derived union expresses.
281
+ */
282
+ const ENABLEMENT_NODE_GATE_KEYS = ['disabled', 'disabledOn'];
283
+ const ENABLEMENT_RENDERER_KEYS = ['enabled'];
284
+ /**
285
+ * Every key either predicate chain owns, as ONE lookup for the config-bag
286
+ * evaluation loops. DERIVED from the four declarations above and declared
287
+ * nowhere else, so a leg added to either chain is covered by the guard in the
288
+ * same edit that adds it.
289
+ */
290
+ const PREDICATE_CHAIN_KEYS = new Set([
291
+ ...VISIBILITY_CHAIN_KEYS,
292
+ ...ENABLEMENT_NODE_GATE_KEYS,
293
+ ...ENABLEMENT_RENDERER_KEYS,
294
+ ]);
295
+ /**
296
+ * Every key a NODE GATE in this file actually consults, as ONE lookup for the
297
+ * objectui#9108 refusal below. DERIVED from the same two declarations the gates
298
+ * are built from, so a leg added to either chain is refused under `props` by the
299
+ * same edit that adds it.
300
+ *
301
+ * {@link PREDICATE_CHAIN_KEYS} minus {@link ENABLEMENT_RENDERER_KEYS}, and the
302
+ * subtraction is the whole reason this is a second derivation rather than a
303
+ * reuse: `enabled` is in that union because the config-bag evaluation loops
304
+ * flatten it, but NO gate here consults it - the action renderers read it one
305
+ * layer down off the schema and negate it. Refusing it here would state, of a
306
+ * key this file never asks about, that a gate in this file could not see it.
307
+ * Its own `props` drop is objectui#6708's subject and is reported there.
308
+ */
309
+ const NODE_GATE_PREDICATE_KEYS = new Set([
310
+ ...VISIBILITY_CHAIN_KEYS,
311
+ ...ENABLEMENT_NODE_GATE_KEYS,
312
+ ]);
313
+ /**
314
+ * Is this value the canonical CEL envelope — `{ dialect: 'cel', source }` —
315
+ * that `@objectstack/spec` normalizes an authored predicate into?
316
+ *
317
+ * Deliberately narrower than "has a `source`": `cron` and `template` envelopes
318
+ * are NOT matched, so the `${…}` / template behaviour of the loops below is
319
+ * byte-for-byte what it was. Only the dialect that `evaluateCondition` routes
320
+ * to the canonical `@objectstack/formula` engine is held back from the
321
+ * flattening described in {@link preservePredicateEnvelope}.
322
+ *
323
+ * The object-shape half is {@link isConfigBag}, ASKED rather than re-spelled:
324
+ * objectui#6761 pins that predicate to one definition, and an envelope is the
325
+ * same question about the same kind of value (a real object, never an array).
326
+ * This is a third READ of that one answer, not a fourth spelling of it.
327
+ */
328
+ const isCelEnvelope = (value) => isConfigBag(value)
329
+ && value.dialect === 'cel'
330
+ && typeof value.source === 'string';
331
+ /**
332
+ * Evaluate ONE value of a node's config bag (`properties` — the spec spelling
333
+ * — or its legacy `props` alias), EXCEPT a CEL predicate envelope, which is
334
+ * returned untouched.
335
+ *
336
+ * ## The defect this closes (objectui#9100)
337
+ *
338
+ * `ExpressionEvaluator.evaluate` unwraps ANY `{ source: string }` object to its
339
+ * bare `source` before it does anything else — that unwrap is what lets a
340
+ * `{ dialect: 'template', source: '${data.total}' }` value interpolate. Applied
341
+ * to a `{ dialect: 'cel' }` PREDICATE it is destructive instead: the envelope
342
+ * is the only thing that tells {@link ExpressionEvaluator.evaluateCondition}
343
+ * to route to the canonical `@objectstack/formula` engine, and the bag loops
344
+ * run BEFORE the hoist, so by the time either consumer sees the key the
345
+ * envelope is gone.
346
+ *
347
+ * Both consumers then read a bare string and both take the legacy JS path:
348
+ *
349
+ * 1. this component's own `shouldHide` chain, off the post-hoist node; and
350
+ * 2. the RENDERER's `toPredicateInput(…)` → `useCondition(…)` call, one
351
+ * layer down (`record:alert`, the four `action:*` blocks, …), which wraps
352
+ * the bare string as `${…}` exactly as it is documented to.
353
+ *
354
+ * On the legacy engine a CEL stdlib call is simply not a function, so
355
+ * `has(record.x) && …` throws — and the two polarities of the chain then fail
356
+ * in OPPOSITE directions, both silently:
357
+ *
358
+ * * on the three SHOW legs the fail-soft `true` is negated to "do not hide",
359
+ * so a gate authored to conceal renders on every row — measured in a real
360
+ * browser against a real app instance (the card), where a duplicate-lead
361
+ * banner showed on 6 of 6 leads whose field was `null`;
362
+ * * on the two HIDE legs the same `true` sets `_hidden`, so the node is not
363
+ * on screen at all.
364
+ *
365
+ * Neither is distinguishable from a predicate that said so on purpose. That is
366
+ * what makes this a fail-OPEN visibility gate rather than a rendering glitch.
367
+ *
368
+ * ## Why the fix is HERE and not in the normalizer
369
+ *
370
+ * `toPredicateInput` already preserves a `cel` envelope — that is its whole
371
+ * documented reason to exist (#2661 / #3314) — and the ACTION path that works
372
+ * calls the very same function. Measured: mounting the renderer DIRECTLY with
373
+ * the envelope gates correctly, and only the `SchemaRenderer` route fails, so
374
+ * the envelope dies upstream of every consumer, in the loops below. What makes
375
+ * the action path survive is that those loops are per-value and SHALLOW: an
376
+ * action's predicate sits one level down inside the `actions[]` ARRAY, and
377
+ * `evaluate` returns a non-string untouched, so nothing walks into it. A
378
+ * `record:alert` `properties.visible` is a TOP-LEVEL bag key and is hit
379
+ * head-on. Same normalizer, different depth.
380
+ *
381
+ * ## The enablement chain carries the identical defect (objectui#9107)
382
+ *
383
+ * `disabled` / `disabledOn` / `enabled` sit in the same two bags and are
384
+ * flattened by the same two loops, but they are NOT visibility legs — they
385
+ * route through {@link evaluateEnablementPredicate} and the action renderers'
386
+ * own `useCondition` call. Measured on the pre-fix tree through this component,
387
+ * with a CEL stdlib predicate authored at `properties` top level, and the two
388
+ * halves fail in OPPOSITE directions just as the visibility polarities do:
389
+ *
390
+ * * `disabled` / `disabledOn` — BOTH a holding and a failing predicate left
391
+ * the control DISABLED. The flattened source faults on the legacy engine
392
+ * and `evaluateCondition`'s fail-soft `true` is un-negated here, so every
393
+ * CEL-authored enablement gate greyed its control out on every row, with
394
+ * no predicate an author could write to re-enable it.
395
+ * * `enabled` — BOTH left the control ENABLED, because the renderers negate
396
+ * that leg (`disabled = !isEnabled`), so the same fail-soft `true` lands on
397
+ * "not disabled": a control the author disabled stayed pressable.
398
+ *
399
+ * ⇒ neither polarity alone detects this. A disabled-only suite is green on the
400
+ * broken `disabled` legs; an enabled-only suite is green on the broken
401
+ * `enabled` leg. Both are pinned, per key, in
402
+ * `__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx`.
403
+ *
404
+ * ## Scope
405
+ *
406
+ * Restricted to {@link PREDICATE_CHAIN_KEYS} — the union of the two closed
407
+ * chain declarations this file already carries, which `shouldHide` /
408
+ * {@link winningVisibilityKey} and {@link evaluateEnablementPredicate} already
409
+ * consult. Every consumer of those nine keys takes the envelope by contract
410
+ * (`evaluateCondition`, `toPredicateInput`). A non-predicate key keeps the
411
+ * flattening it has always had.
412
+ *
413
+ * Eight of the nine are stripped by the metadata destructure near
414
+ * `createElement`, so no object value can reach the DOM through them. The
415
+ * ninth, the legacy `enabled` alias, is not stripped — the action renderers
416
+ * read it off the schema, not off React props. Measured on a renderer that
417
+ * spreads what it is handed onto a DOM node: that attribute already carried the
418
+ * raw CEL SOURCE TEXT before this change and carries `[object Object]` after
419
+ * it, which is the same inert shape the `properties` bag itself already puts on
420
+ * every such node, with no React warning in either direction. Both spellings
421
+ * are meaningless to the DOM; the leak is pre-existing and is deliberately NOT
422
+ * repaired here.
423
+ */
424
+ const preservePredicateEnvelope = (key, value, evaluate) => (PREDICATE_CHAIN_KEYS.has(key) && isCelEnvelope(value) ? value : evaluate(value));
425
+ /**
426
+ * Is this value an Expression envelope in the spec's sense: an object carrying
427
+ * a string `dialect` AND a string `source`? (objectui#10288)
428
+ *
429
+ * `@objectstack/spec`'s `ExpressionSchema` declares `dialect` REQUIRED, so every
430
+ * envelope a spec-parsed artifact carries has one. The shape test is the one
431
+ * `@object-ui/core` already applies to a declared `defaultValue`
432
+ * (`isRuntimeDefault`, "Same shape test the engine applies before handing a
433
+ * default to `ExpressionEngine`"), where an object with a `source` and no
434
+ * `dialect` is a literal value, not an instruction. {@link isCelEnvelope} is
435
+ * this question narrowed to one dialect.
436
+ */
437
+ const isExpressionEnvelope = (value) => isConfigBag(value)
438
+ && typeof value.dialect === 'string'
439
+ && typeof value.source === 'string';
440
+ /**
441
+ * Is this config-bag value a DATA OBJECT, which the per-value loops hand to the
442
+ * renderer exactly as authored instead of passing it to
443
+ * `ExpressionEvaluator.evaluate`? (objectui#10288)
444
+ *
445
+ * ## The defect this closes
446
+ *
447
+ * `evaluate` unwraps ANY object with a string `source` to that bare string
448
+ * before it does anything else (see {@link preservePredicateEnvelope}). The
449
+ * loops handed it every value, so an authored data object that happened to
450
+ * carry a `source` field was silently replaced by that one string. Measured
451
+ * through the real `SchemaRenderer` -> `action:button` -> runner:
452
+ * `properties.bodyExtra: { source: 'web', campaign: 'spring' }` reached the
453
+ * handler as `bodyExtra: "web"`, while the same object written at node level
454
+ * (which no loop visits) arrived intact. So the canonical channel was the
455
+ * broken one. That pair is pinned end to end in `@object-ui/components`
456
+ * (`action-config-bag-source-key-10288.test.tsx`), and this guard by
457
+ * `__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx`.
458
+ * Every object-valued key a node carries in `properties` / `props`
459
+ * goes through the same loop, so the defect was never one key's: form
460
+ * `defaultValues`, a declarative `patch`, filter `values` and a detail view's
461
+ * `data` are all keyed by FIELD NAME, and a field named `source` is ordinary.
462
+ *
463
+ * ## What still reaches `evaluate`
464
+ *
465
+ * - every value that is not an object in {@link isConfigBag}'s sense (a
466
+ * string, any other primitive, an array), exactly as before: `evaluate`
467
+ * interpolates a string and returns the rest untouched;
468
+ * - a spec Expression envelope ({@link isExpressionEnvelope}), so a
469
+ * `{ dialect: 'template', source: '${…}' }` value interpolates as it always
470
+ * did;
471
+ * - every value of a predicate-chain key ({@link PREDICATE_CHAIN_KEYS}),
472
+ * unchanged. `BaseSchema.visible` / `.hidden` / `.disabled` declare the
473
+ * dialect-less `{ source }` envelope as an authorable wire form
474
+ * (`ExpressionWire`, objectui#7530), so on those keys it stays an
475
+ * expression, and {@link preservePredicateEnvelope} still holds back a CEL
476
+ * envelope (objectui#9100 / #9107).
477
+ *
478
+ * So the only value whose meaning changes is an object on a non-predicate key
479
+ * with a string `source` and no string `dialect`, and that object now arrives
480
+ * whole. Nothing is walked: a template nested inside a data object stays raw,
481
+ * as it always did for an object without a `source`. The loops stay per-value
482
+ * and shallow, which is the radius the unevaluated-expression diagnostic reads.
483
+ *
484
+ * One object this answers `true` for is not handed over as authored: an inline
485
+ * locale map (`utils/localeMap.ts`, objectui#12936). It is a display string
486
+ * written once per language, so {@link evaluateConfigValueWith} evaluates each
487
+ * member like a plain string on the same key. That is one level, and only for
488
+ * a map whose every key is a locale tag and every value a string, in the
489
+ * spec's own `InlineLocaleMapSchema` sense; every other data object keeps this
490
+ * rule, and the diagnostic reads the members through the same module.
491
+ */
492
+ const isDataObjectValue = (key, value) => !PREDICATE_CHAIN_KEYS.has(key) && isConfigBag(value) && !isExpressionEnvelope(value);
493
+ /**
494
+ * The scope a page node's values are evaluated against: its `visibleWhen`
495
+ * gate and every other expression the evaluation memo in
496
+ * {@link SchemaRenderer} evaluates on the node. Four roots, in this order:
497
+ *
498
+ * 1. **the ambient predicate scope**: every root `predicateScope` carries (the
499
+ * host scope `usePredicateScope()` publishes, plus a node's API-bound
500
+ * `data` once it has answered), spread first;
501
+ * 2. **`current_user`**: aliased to the ambient `user`, so `user.email` and
502
+ * `current_user.email` read the same value;
503
+ * 3. **`record`**: the page's bound row, ONLY when there is one (a non-array
504
+ * object). No row binds nothing, so a `record` the ambient scope supplies
505
+ * is not shadowed by an empty one; a row, written after the spread, wins;
506
+ * 4. **`page`**: the page variables (`PageSchema.variables`) as `page.<var>`.
507
+ *
508
+ * Why each root is (and `data` is not) bound is stated where the memo builds
509
+ * its evaluator from this.
510
+ *
511
+ * The ONE builder of this scope. The memo and {@link evaluateConfigBagInScope}
512
+ * call it, so they cannot build two different scopes (objectui#10290), and it
513
+ * is exported for the `page:tabs` derived badge in `@object-ui/components`,
514
+ * which decides the `visibleWhen` gates in front of a related list the way the
515
+ * tab body decides them (objectui#12987, objectui#12992). A root added here
516
+ * reaches every one of them.
517
+ */
518
+ export function buildNodeEvaluationScope(predicateScope, boundRecord, pageVariables) {
519
+ return {
520
+ ...predicateScope,
521
+ current_user: predicateScope?.user,
522
+ ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord)
523
+ ? { record: boundRecord }
524
+ : null),
525
+ page: pageVariables,
526
+ };
527
+ }
528
+ /**
529
+ * Evaluate ONE config value by its key. This is the per-key rule of the
530
+ * evaluation memo (the `params` rule, objectui#7867, is stated there): a
531
+ * `params` bag has every string leaf evaluated, an inline locale map has each
532
+ * member evaluated like a plain string on the same key (objectui#12936,
533
+ * `utils/localeMap.ts`), any other data object is handed over as authored, and
534
+ * every other value is evaluated per value and shallow, with a CEL predicate
535
+ * envelope preserved.
536
+ */
537
+ const evaluateConfigValueWith = (evaluator, key, value) => key === PARAMS_KEY && isParamsBag(value)
538
+ ? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf))
539
+ : isDataObjectValue(key, value)
540
+ ? isInlineLocaleMap(value)
541
+ ? mapLocaleMapMembers(value, (member) => evaluator.evaluate(member))
542
+ : value
543
+ : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v));
544
+ /**
545
+ * Evaluate every value of a config bag with {@link evaluateConfigValueWith}.
546
+ * The memo's `properties` loop, as a function. Returns a new object; the
547
+ * authored bag is never mutated.
548
+ */
549
+ const evaluateConfigBagWith = (evaluator, bag) => {
550
+ const evaluated = { ...bag };
551
+ for (const [key, val] of Object.entries(evaluated)) {
552
+ evaluated[key] = evaluateConfigValueWith(evaluator, key, val);
553
+ }
554
+ return evaluated;
555
+ };
556
+ /**
557
+ * The `properties` evaluation of the {@link SchemaRenderer} memo, for a config
558
+ * bag that does not pass through `SchemaRenderer` (objectui#10290): the same
559
+ * per-key rule, the same `ExpressionEvaluator`, and the same scope, built from
560
+ * the same three inputs. It is not a second template engine.
561
+ *
562
+ * The plain half of `useConfigBagEvaluator()` (`hooks/useConfigBagEvaluator`),
563
+ * which reads those inputs from the contexts the memo reads them from. Call
564
+ * the hook; this function exists because the rule it applies is this module's.
565
+ * (The hook is not declared in this module: a hook here puts this file under
566
+ * the React Compiler lint, which then reports the render-time
567
+ * `performance.now()` / `Date.now()` debug probes below.)
568
+ *
569
+ * A value that is not a config bag is returned unchanged; a bag comes back as a
570
+ * new, evaluated object and the authored one is never mutated.
571
+ */
572
+ export function evaluateConfigBagInScope(bag, predicateScope, boundRecord, pageVariables) {
573
+ if (!isConfigBag(bag))
574
+ return bag;
575
+ const evaluator = new ExpressionEvaluator(buildNodeEvaluationScope(predicateScope, boundRecord, pageVariables));
576
+ return evaluateConfigBagWith(evaluator, bag);
577
+ }
578
+ /**
579
+ * Which CONSEQUENCE the diagnostic should print for a faulting predicate on
580
+ * this leg (objectui#6503).
581
+ *
582
+ * ## The defect this closes
583
+ *
584
+ * `evaluateCondition` answers an unevaluable predicate with `true` on every
585
+ * path. On the three SHOW legs the chain negates that answer, so the node is
586
+ * SHOWN and the reporter's "the gate did NOT bite" is true. On the two HIDE
587
+ * legs the chain returns it UN-negated (see `shouldHide` below), so the same
588
+ * `true` sets `_hidden` and this component returns `null`: the gate bit, and
589
+ * bit harder than on either sibling gate — the node is not on screen at all.
590
+ * Both were routed to `'visibility'`, so an author whose block VANISHED read a
591
+ * line telling them the gate did not bite and went looking for a rendering bug
592
+ * that does not exist. The polarity was documented in
593
+ * `visibilityDiagnostic.ts` and printed anyway; this is the routing that makes
594
+ * the documented split the one that is actually applied.
595
+ *
596
+ * ## Why derived from the two constants, when the reporter refuses to deduce
597
+ *
598
+ * `PredicateGateKind`'s docblock refuses to deduce the gate from `key` INSIDE
599
+ * the reporter, and that refusal stands: the reporter is exported and called
600
+ * from other packages, where an unheard-of spelling would silently inherit
601
+ * some other gate's sentence. Here the caller IS the chain. The two arrays
602
+ * above are the same declaration `shouldHide` and {@link winningVisibilityKey}
603
+ * consult, and {@link VisibilityChainKey} closes the parameter over them, so
604
+ * this is not a second table that can drift from the first — it is the same
605
+ * one, read for a second question. It also removes the failure mode that
606
+ * produced this card: a leg added to the chain and left with a consequence
607
+ * sentence written about a different polarity is now a TYPE error here, not a
608
+ * line of false console copy.
609
+ *
610
+ * The `disabled` / `disabledOn` gate is deliberately not reachable from this
611
+ * function: those legs are not in the visibility chain and route through
612
+ * `evaluateEnablementPredicate`, which states `'enablement'` at its own call
613
+ * site.
614
+ */
615
+ const visibilityGateKind = (key) => VISIBILITY_HIDE_KEYS.includes(key) ? 'concealment' : 'visibility';
197
616
  /**
198
617
  * Which ONE visibility key actually decides a node's fate, mirroring both the
199
618
  * `properties` hoist's precedence (same-named key: `properties.<key>`
@@ -242,7 +661,7 @@ const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'];
242
661
  */
243
662
  function winningVisibilityKey(node) {
244
663
  const propertiesBag = node.properties;
245
- const hasPropertiesBag = propertiesBag != null && typeof propertiesBag === 'object' && !Array.isArray(propertiesBag);
664
+ const hasPropertiesBag = isConfigBag(propertiesBag);
246
665
  const effective = (key) => hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)
247
666
  ? propertiesBag[key]
248
667
  : node[key];
@@ -256,6 +675,46 @@ function winningVisibilityKey(node) {
256
675
  }
257
676
  return undefined;
258
677
  }
678
+ /**
679
+ * The registry key that differs from `type` only in case, when there is one.
680
+ *
681
+ * objectui#5247 ruled Option C — keep lookup strict, make the failure teach.
682
+ * Registry lookup stays exactly case-sensitive (`ComponentRegistry.get` is a
683
+ * plain `Map.get`), so a node typed `Page` still MISSES `page` and still
684
+ * renders the OBJUI-001 panel below. What changes is only what that panel
685
+ * SAYS: when the miss is a case mismatch and nothing else, it names the
686
+ * spelling that would have worked. Making `Page` resolve is the REJECTED
687
+ * option B — it would legalise `PAGE` / `pAge` across docs, the designer,
688
+ * `sdui-intrinsics.d.ts` and every registration site, permanently.
689
+ *
690
+ * The candidate set is read from the LIVE registry — `getKnownTypes()`, which
691
+ * is loaded registrations plus pending `registerLazy` stubs — never from a
692
+ * list typed alongside it. A hand-kept copy goes stale silently and then
693
+ * confidently suggests a type nothing registers any more; objectui#5115
694
+ * measured exactly that drift on the CLI's copy, in both directions at once.
695
+ *
696
+ * Case is the ONLY trigger the ruling grants. This is deliberately not an edit
697
+ * distance: `pge` suggests nothing.
698
+ *
699
+ * `objectui check` emits the same clause from
700
+ * `packages/cli/src/utils/known-type-case-suggestion.ts`. The two surfaces
701
+ * cannot share one implementation because they cannot share a candidate SET:
702
+ * the published CLI runs inside a USER's project and depends on neither
703
+ * `@object-ui/core` nor the plugin packages that register most types, so it
704
+ * answers from the generated `KNOWN_SCHEMA_TYPES` snapshot instead — the
705
+ * measurement is in `scripts/regenerate-known-schema-types.mjs`. Keep the
706
+ * emitted wording in step with that file; both are pinned by tests.
707
+ */
708
+ function suggestTypeByCase(type) {
709
+ if (typeof type !== 'string' || type === '')
710
+ return undefined;
711
+ const wanted = type.toLowerCase();
712
+ for (const candidate of ComponentRegistry.getKnownTypes()) {
713
+ if (candidate !== type && candidate.toLowerCase() === wanted)
714
+ return candidate;
715
+ }
716
+ return undefined;
717
+ }
259
718
  export class SchemaErrorBoundary extends Component {
260
719
  constructor() {
261
720
  super(...arguments);
@@ -308,6 +767,64 @@ export class SchemaErrorBoundary extends Component {
308
767
  * `useState` wiped (objectui#2954's latent hazard, made real by this line).
309
768
  */
310
769
  const NO_DATA_SOURCE = {};
770
+ /**
771
+ * One outgoing bag, minus an authored `data` key, on the object arm
772
+ * (objectui#9571, extended to the legacy alias by objectui#9758).
773
+ *
774
+ * ## Why a shared helper and not two copies of four lines
775
+ *
776
+ * `createElement` below spreads the node's own non-metadata keys and the legacy
777
+ * `props` alias bag as two separate spreads, in that order. objectui#9571
778
+ * stripped the first; the alias spread ran AFTER it, so the identical authored
779
+ * key spelled `props: { data }` walked straight back into the seat the ruling
780
+ * had just taken away — the same defect, one alias over (objectui#9758,
781
+ * decision batch #167 item 2, letter 剥, maintainer 「其他同意」). Two call
782
+ * sites answering the same question is exactly how the first carrier survived
783
+ * the first ruling, so the question is asked once, here.
784
+ *
785
+ * ## Identity is the signal, and that is load-bearing
786
+ *
787
+ * The `in` test comes FIRST, so a bag that declares no `data` — the
788
+ * overwhelmingly common one — is handed back ITSELF, with no copy allocated and
789
+ * no key moved. Callers read that same identity (`result !== bag`) to decide
790
+ * whether to warn, so the decision is never computed twice and cannot disagree
791
+ * with what was actually spread.
792
+ *
793
+ * ⛔ The HOST path is NOT routed through here. `...props` — this component's own
794
+ * React props, spread LAST — is how `plugin-list`'s `ListView` and `ObjectView`
795
+ * hand down rows they already fetched; gating that was Option B, and it was
796
+ * REFUSED on objectui#9571. Only the AUTHORED key loses its seat.
797
+ */
798
+ function withoutAuthoredDataKey(bag, refuse) {
799
+ if (!refuse || !('data' in bag))
800
+ return bag;
801
+ const { data: _refusedAuthoredData, ...rest } = bag;
802
+ return rest;
803
+ }
804
+ /**
805
+ * The legacy `props` alias bag, minus an authored `objectFields`
806
+ * (objectui#8818, completing decision batch #70 / objectui#7742).
807
+ *
808
+ * `objectFields` is the object's field catalogue — the predicate layer reads it
809
+ * to decide how a conditional-formatting rule compares a relation field — and
810
+ * batch #70 ruled it a RUNTIME React prop a host injects, never authorable
811
+ * metadata. The metadata strip in `SchemaRenderer` closes the top-level and
812
+ * hoisted `properties` spellings; this closes the third, because the alias bag
813
+ * is spread as its own bag after that strip and never passes through it.
814
+ *
815
+ * Every arm, unconditionally — unlike {@link withoutAuthoredDataKey}, the key
816
+ * has no authorable reading on any type. Identity is preserved when the bag
817
+ * declares no `objectFields`, so the common node allocates nothing.
818
+ *
819
+ * ⛔ The HOST path is NOT routed through here: `...props`, spread LAST, is how
820
+ * a host hands the catalogue down, and it still arrives.
821
+ */
822
+ function withoutAuthoredObjectFields(bag) {
823
+ if (!('objectFields' in bag))
824
+ return bag;
825
+ const { objectFields: _runtimeOnlyObjectFields, ...rest } = bag;
826
+ return rest;
827
+ }
311
828
  /**
312
829
  * The renderer loop.
313
830
  *
@@ -352,10 +869,41 @@ const NO_DATA_SOURCE = {};
352
869
  export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
353
870
  const context = useContext(SchemaRendererContext);
354
871
  const dataSource = context?.dataSource || NO_DATA_SOURCE;
355
- // Ambient host scope (user / app / features), fed by app-shell's
872
+ // Ambient host scope (user / features), fed by app-shell's
356
873
  // ExpressionProvider. Threaded into `visible`/expression evaluation so
357
874
  // component predicates can gate on the signed-in user & deployment flags.
358
- const predicateScope = usePredicateScope();
875
+ const ambientScope = usePredicateScope();
876
+ // ── The API binding (objectui#12197) ──────────────────────────────────────
877
+ //
878
+ // `@objectstack/spec`'s `ElementApiDataSourceSchema` declares what a node
879
+ // with `dataSource: { provider: 'api', read: { url }, path? }` does: it reads
880
+ // that one same-origin endpoint with the host's signed-in fetch and binds the
881
+ // selected value, "which the element reads as `data` in its expression holes
882
+ // and its `visibleWhen`". Both are evaluated in the memo below, so this is
883
+ // where the binding is honoured, for every node type: the read is the hook's
884
+ // (`element-data-source/useElementApiDataSource.ts`), and its answer becomes
885
+ // the `data` root of this node's evaluation scope.
886
+ //
887
+ // ⚠️ This is NOT the adapter-as-`data` root objectui#9308 retired. That root
888
+ // published the injected adapter, against which every `data.*` path resolved
889
+ // `undefined`; this one is the value the node's own binding selected, and it
890
+ // exists only on a node that declares one. A node with no API binding is
891
+ // byte-for-byte what it was: `ambientScope` passes through untouched.
892
+ //
893
+ // The answer is also published to the node's SUBTREE through
894
+ // `PredicateScopeProvider` (below), because a block's members are evaluated
895
+ // there — `useConfigBagEvaluator` reads the predicate scope, as does every
896
+ // nested `SchemaRenderer` — so a member's `${data.x}` resolves against the
897
+ // same value as the node's own.
898
+ //
899
+ // The evaluation memo builds its scope from the answer's VALUE (the hook's
900
+ // state) and keys on it, never on a memo's identity (AGENTS.md #10). The
901
+ // subtree's copy is memoised for cost only: an evaluation keyed on it
902
+ // recomputes the same values if React discards it.
903
+ const apiBinding = useElementApiDataSource(schema);
904
+ const apiStatus = apiBinding.status;
905
+ const apiData = apiBinding.data;
906
+ const subtreeScope = useMemo(() => (apiStatus === 'ready' ? { ...ambientScope, data: apiData } : ambientScope), [ambientScope, apiStatus, apiData]);
359
907
  // The row the page is about, from `RecordContextProvider` (objectui#5454).
360
908
  //
361
909
  // `@objectstack/spec` has always DECLARED that a component node's
@@ -418,11 +966,36 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
418
966
  // an object spread.
419
967
  if (!schema || typeof schema !== 'object')
420
968
  return schema;
421
- // `data` (record/datasource) plus the ambient host scope. `current_user`
422
- // is aliased to `user` so both `user.email` and `current_user.email`
423
- // resolve in component `visible`/`visibleOn` expressions. `page` exposes
424
- // page-local state so predicates can gate on `page.<var>` (e.g. a record
425
- // picker's selection toggling another component's visibility).
969
+ // The scope's host roots, plus the node's API-bound `data` once it has
970
+ // answered (see "The API binding" above).
971
+ const predicateScope = apiStatus === 'ready' ? { ...ambientScope, data: apiData } : ambientScope;
972
+ // The ambient host scope, plus the roots this tier can answer itself.
973
+ // `current_user` is aliased to `user` so both `user.email` and
974
+ // `current_user.email` resolve in component `visible`/`visibleOn`
975
+ // expressions. `page` exposes page-local state so predicates can gate on
976
+ // `page.<var>` (e.g. a record picker's selection toggling another
977
+ // component's visibility).
978
+ //
979
+ // ⛔ `data` is NOT here, and the absence is the decision (objectui#9308,
980
+ // maintainer ruling 2026-09-13 option B). This used to read
981
+ // `data: dataSource` — the host's injected ADAPTER, published as an
982
+ // expression root. `ExpressionProvider` states the governing principle for
983
+ // the tier above: "Every root below is one the engine accepts AND one this
984
+ // tier can actually answer", and objectui#8155 (`app`) and objectui#8166
985
+ // (`data`) applied it there. Against a conformant `DataSource` adapter
986
+ // every `data.*` path resolves `undefined`, so this tier could not answer
987
+ // the root it bound: it published a name that was silently constant on
988
+ // every row. ADR-0089 D3 puts `data` at the METADATA layer
989
+ // (`CANONICAL_ROOT_BY_LAYER = { runtime: 'record', metadata: 'data' }`) and
990
+ // the engine's per-surface `FIELD_RULE_BOUND_ROOTS` is
991
+ // `['record','previous','parent']`. The row is `record`.
992
+ //
993
+ // ⭐ Ordering consequence, and the second half of the same ruling: the
994
+ // spread below used to be followed by `data: dataSource`, so a host that
995
+ // legitimately published `data` through the documented scope channel
996
+ // (`PredicateScopeProvider`) was silently OVERWRITTEN by the adapter.
997
+ // Removing the line un-shadows that channel — a host root named `data` now
998
+ // survives, like every other root a host publishes.
426
999
  //
427
1000
  // `record` is written AFTER the ambient spread so a page's own row wins
428
1001
  // over anything a host put in the scope — the same precedence
@@ -433,17 +1006,23 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
433
1006
  // (how `action:group`'s dropdown leaf is driven), turning "this surface has
434
1007
  // no row of its own" into "this surface's row is empty" — only the latter
435
1008
  // is entitled to shadow.
436
- const evaluator = new ExpressionEvaluator({
437
- ...predicateScope,
438
- current_user: predicateScope?.user,
439
- ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord)
440
- ? { record: boundRecord }
441
- : null),
442
- data: dataSource,
443
- page: pageVariables,
444
- });
445
- // Shallow copy
1009
+ //
1010
+ // Built by `buildNodeEvaluationScope`, which `useConfigBagEvaluator`
1011
+ // shares, so an action container's members evaluate against this same
1012
+ // scope (objectui#10290), and which the `page:tabs` badge in
1013
+ // `@object-ui/components` calls for its `visibleWhen` gates
1014
+ // (objectui#12992).
1015
+ const evaluator = new ExpressionEvaluator(buildNodeEvaluationScope(predicateScope, boundRecord, pageVariables));
1016
+ // Shallow copy: the interpreter's working copy, typed as renderer state
1017
+ // rather than as the authoring face (objectui#11349). See
1018
+ // `InterpreterWorkingCopy` for what it declares and why.
446
1019
  const newSchema = { ...schema };
1020
+ // A node whose API binding has not answered is not evaluated: its holes
1021
+ // name a `data` that does not exist yet, so evaluating them would report
1022
+ // every one as unresolvable and decide its `visibleWhen` against nothing.
1023
+ // The render pass below draws the binding's state instead.
1024
+ if (apiStatus !== 'absent' && apiStatus !== 'ready')
1025
+ return newSchema;
447
1026
  /**
448
1027
  * Evaluate ONE visibility predicate, and make an unresolvable one LOUD
449
1028
  * (objectui#5454, leg 3 of the 2026-08-21 ruling).
@@ -473,7 +1052,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
473
1052
  * on which dialect they happened to write it in.
474
1053
  *
475
1054
  * Deduped per (node type, key, predicate source): a broken predicate is
476
- * re-evaluated on every render, and the point is one line, not a wall.
1055
+ * re-evaluated on every render, and the point is one line, not a wall. The
1056
+ * key is the predicate SOURCE TEXT plus the gate it was authored on — never
1057
+ * the render and never the schema object — so the same broken predicate
1058
+ * rendered over two hundred rows reports once, and a SECOND distinct
1059
+ * predicate still reports (objectui#6038 pins both halves).
1060
+ *
1061
+ * ## Production is loud too, since objectui#6038
1062
+ *
1063
+ * It was `__DEV__`-only, and the maintainer's 2026-08-25 ruling retired
1064
+ * that silence: a gate that stops biting in production used to leave
1065
+ * nothing on the console for the bare-string dialect, so a class-1 defect
1066
+ * could sit live and undiscovered (measured in objectstack#11254). The
1067
+ * `__DEV__` gate below no longer decides WHETHER the fault is reported,
1068
+ * only HOW it is detected — see the two branches.
477
1069
  *
478
1070
  * ## Defined HERE, ahead of the `properties` evaluation loop below
479
1071
  *
@@ -485,16 +1077,41 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
485
1077
  * on the POST-evaluation, POST-hoist schema, for the real verdict.
486
1078
  */
487
1079
  const evaluateVisibilityPredicate = (raw, key) => {
488
- // PRODUCTION IS THE UNTOUCHED CALL. `throwOnError` is how the fault is
489
- // detected, and on the CEL branch `evaluateCelCondition` implements it by
490
- // evaluating TWICE (once with each fallback — a value that tracks the
491
- // fallback both times is a fault). Spec-parsed metadata normalizes
492
- // `visibleWhen` into a `{ dialect: 'cel' }` envelope, so that branch is
493
- // the common one in production: paying for the probe unconditionally
494
- // would double the engine calls for every predicate of every node, to
495
- // build a message no production build ever prints.
496
- if (!__DEV__)
497
- return evaluator.evaluateCondition(raw);
1080
+ // WHICH consequence this leg's fail-soft default earns (objectui#6503).
1081
+ // Computed once, used by both branches, so the production and the
1082
+ // development report cannot disagree about what the default DID.
1083
+ const gate = visibilityGateKind(key);
1084
+ // PRODUCTION STILL MAKES THE SINGLE CALL — it just no longer makes it
1085
+ // in silence (objectui#6038, maintainer ruling 2026-08-25, option B).
1086
+ //
1087
+ // `throwOnError` remains the DEV probe and remains too expensive to ship:
1088
+ // on the CEL branch `evaluateCelCondition` implements it by evaluating
1089
+ // TWICE (once with each fallback — a value that tracks the fallback both
1090
+ // times is a fault), and spec-parsed metadata normalizes `visibleWhen`
1091
+ // into a `{ dialect: 'cel' }` envelope, so that branch is the common one
1092
+ // in production. Paying for the probe here would double the engine calls
1093
+ // for every predicate of every node.
1094
+ //
1095
+ // `onFault` is the way out of that trade: the evaluator hands back the
1096
+ // reason at the point it ALREADY knows the predicate faulted, inside the
1097
+ // catch it already runs, so the fault becomes observable at ONE engine
1098
+ // call. The verdict is `evaluateCondition(raw)`'s, unchanged — this card
1099
+ // is observability only, and the fail-open semantics are not its to move.
1100
+ //
1101
+ // The reporter is the SAME one the dev branch below uses: same message,
1102
+ // same severity, same dedupe `Set`, same key. Production and development
1103
+ // now print the identical line for the identical fault, which is the
1104
+ // property the `__DEV__` gate used to cost us.
1105
+ if (!__DEV__) {
1106
+ return evaluator.evaluateCondition(raw, {
1107
+ onFault: (reason) =>
1108
+ // `'page-component'` stated rather than defaulted (objectui#6487):
1109
+ // this is the tier whose roots the spec declares for a node gate,
1110
+ // and saying so here is what keeps the advice paragraph a decision
1111
+ // this call site owns.
1112
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, reason, 'page-component', gate),
1113
+ });
1114
+ }
498
1115
  try {
499
1116
  const verdict = evaluator.evaluateCondition(raw, { throwOnError: true });
500
1117
  // objectui#5687 — the NON-throwing half of the same silence, and it is
@@ -504,11 +1121,17 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
504
1121
  // `false`. Verdict untouched — `verdict` is returned exactly as
505
1122
  // computed, which is what keeps the ruling's "no verdict changes" true
506
1123
  // by construction rather than by review.
507
- reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource);
1124
+ //
1125
+ // objectui#9308: the object handed over is the `data` the HOST
1126
+ // published in the ambient scope — the one the evaluator above
1127
+ // actually resolved `data.*` against — and no longer the adapter. The
1128
+ // renderer binds no `data` of its own, so passing the adapter here
1129
+ // would report reads the evaluator never made against it.
1130
+ reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, predicateScope?.data);
508
1131
  return verdict;
509
1132
  }
510
1133
  catch (err) {
511
- reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err);
1134
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err, 'page-component', gate);
512
1135
  // The historical fail-soft answer, unchanged — and identical to what
513
1136
  // the production branch above returns for the same input, which is what
514
1137
  // keeps the two branches one behaviour rather than two. See the
@@ -517,6 +1140,156 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
517
1140
  return true;
518
1141
  }
519
1142
  };
1143
+ /**
1144
+ * Evaluate ONE enablement predicate (`disabled` / `disabledOn`), and make an
1145
+ * unresolvable one LOUD (objectui#6445).
1146
+ *
1147
+ * ## Why this pair needed its own card at all
1148
+ *
1149
+ * Six legs of the visibility chain above route through
1150
+ * {@link evaluateVisibilityPredicate} and report. These two called
1151
+ * `evaluateCondition` BARE, so a faulting `disabled` predicate had never
1152
+ * been reported in any build, in any dialect that does not report on its
1153
+ * own — the only uninstrumented predicate pair left in this file.
1154
+ *
1155
+ * And it is the pair where the fail-soft default BITES. `evaluateCondition`
1156
+ * answers an unevaluable predicate with `true`; on the negated visibility
1157
+ * legs that means SHOWN, here it means GREYED OUT. The asymmetry this file
1158
+ * already records for objectui#3862/#3955 cuts the other way for the
1159
+ * console: "a greyed-out control is still on screen", so the user has a
1160
+ * symptom and the author has nothing to grep for. That is why the reporter
1161
+ * is handed a different consequence paragraph (`'enablement'`) rather than
1162
+ * the visibility copy, which states the opposite of what this gate did.
1163
+ *
1164
+ * ## The verdict is byte-for-byte what it was
1165
+ *
1166
+ * `evaluateCondition(raw)` with an `onFault` callback returns exactly what
1167
+ * `evaluateCondition(raw)` returned: `onFault` is invoked for its side
1168
+ * effect and its return value is ignored (objectui#6038's seam). Fail-soft
1169
+ * is PRESERVED deliberately — flipping it is a shipped-behaviour change on
1170
+ * a live surface and is not this card's to make.
1171
+ *
1172
+ * ## One engine call, both builds — still true after objectui#6504
1173
+ *
1174
+ * The FAULT report above is unconditional either way: `onFault` alone
1175
+ * covers both builds, one engine call, one code path, dev and production
1176
+ * print the same line by construction. objectui#6504 adds a SECOND,
1177
+ * dev-only check after that same call returns — not a second evaluation,
1178
+ * a lexical scan of the already-available predicate source and the
1179
+ * already-available `dataSource` — so production keeps its one-call,
1180
+ * one-path shape exactly as this docblock described before this leg
1181
+ * existed; only development pays for the extra check, and only after a
1182
+ * clean (non-faulting) evaluation.
1183
+ *
1184
+ * ## objectui#5687's diagnostic, extended (objectui#6504, maintainer
1185
+ * ruling 2026-08-27 option A)
1186
+ *
1187
+ * Was deliberately NOT called here — see the card this replaces filed at
1188
+ * objectui#6504: the 2026-08-22 ruling was scoped to the visibility gate,
1189
+ * and its message text ("a constant `false` hides the node on every row")
1190
+ * is not what a constant does to a `disabled` gate. The 2026-08-27 ruling
1191
+ * answers both gaps: extend to `disabled` / `disabledOn`, dev-only exactly
1192
+ * as the visibility leg is (⛔ always-on was excluded, outside the #5687
1193
+ * precedent), with its OWN copy — the constant-`true` direction, since on
1194
+ * THIS gate that is the polarity that greys the control out in silence.
1195
+ * `ADAPTER_ONLY_GATE_COPY['enablement']` (`visibilityDiagnostic.ts`) is
1196
+ * that copy, and it carries the same #5330 dissolution pointer #5687's
1197
+ * entry does: both legs retire together when that window closes.
1198
+ *
1199
+ * ## The `key` parameter is CLOSED (objectui#9107)
1200
+ *
1201
+ * Typed {@link EnablementNodeGateKey} rather than `string`, mirroring what
1202
+ * {@link VisibilityChainKey} does for the sibling chain: the two legs this
1203
+ * function serves are declared in ONE place, and the config-bag guard
1204
+ * ({@link preservePredicateEnvelope}) reads that same declaration, so a
1205
+ * third leg cannot arrive here without the same edit also carrying its CEL
1206
+ * envelope through the bag loops.
1207
+ *
1208
+ * Reachable only on the branch where the predicate evaluated CLEANLY —
1209
+ * `faulted` mirrors {@link evaluateVisibilityPredicate}'s try/catch split
1210
+ * without paying for a second (`throwOnError`) engine call: `onFault` is
1211
+ * already told exactly when the single call it wraps did not produce a
1212
+ * clean verdict, so a local flag reuses that same signal instead of
1213
+ * re-deriving it. A predicate that faults is the OTHER reporter's case,
1214
+ * immediately above, in the same way it already is on the visibility leg.
1215
+ */
1216
+ const evaluateEnablementPredicate = (raw, key) => {
1217
+ let faulted = false;
1218
+ const verdict = evaluator.evaluateCondition(raw, {
1219
+ onFault: (reason) => {
1220
+ faulted = true;
1221
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, reason,
1222
+ // Stated, not defaulted, for both arguments (objectui#6487,
1223
+ // objectui#6445): this is the node tier, whose roots the spec
1224
+ // declares, and the gate whose safe default disables the control.
1225
+ 'page-component', 'enablement');
1226
+ },
1227
+ });
1228
+ if (__DEV__ && !faulted) {
1229
+ // objectui#9308 — same re-aim as the visibility leg above.
1230
+ reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, predicateScope?.data, 'enablement');
1231
+ }
1232
+ return verdict;
1233
+ };
1234
+ /**
1235
+ * Evaluate ONE config value by its key — THE place the `params` rule is
1236
+ * stated (objectui#7867, ruling A, maintainer 「其他同意」 2026-09-20):
1237
+ * *an action's `params` values are templates, evaluated where `properties`
1238
+ * are.*
1239
+ *
1240
+ * A `params` BAG (a plain object — {@link isParamsBag}) has every string
1241
+ * leaf evaluated at any depth, through the same `evaluator` and so against
1242
+ * the same scope as every other value in this memo; every other value
1243
+ * evaluates exactly as it did (per-value and shallow, with a CEL predicate
1244
+ * envelope preserved — {@link preservePredicateEnvelope}). The walk, and
1245
+ * what it will not look inside (non-plain objects, keys, cycles, an ARRAY
1246
+ * `params`, which is the `ActionParam[]` definition list), are defined once
1247
+ * in `utils/paramsBag.ts`, which the unevaluated-expression diagnostic reads
1248
+ * too — so what is evaluated and what is reported as left unresolved are
1249
+ * one radius, not two.
1250
+ *
1251
+ * Three carriers, one rule — the ruling names two of them and the third
1252
+ * follows its canonical bag, as every per-key rule in these loops does:
1253
+ *
1254
+ * 1. node-level `params`, immediately below;
1255
+ * 2. `properties.params`, in the `properties` loop, BEFORE the hoist —
1256
+ * so the object the hoist copies onto the node (and the renderer
1257
+ * reads as `schema.params`) is the evaluated one, and
1258
+ * `schema.properties.params` agrees with it;
1259
+ * 3. `props.params`, in the legacy-alias loop — the two bag loops must
1260
+ * not disagree about a key (objectui#5123, objectui#9100).
1261
+ *
1262
+ * Each authored value is evaluated exactly once: the node-level leg runs
1263
+ * before the hoist, so when `properties.params` wins the hoist it replaces
1264
+ * an evaluated node-level bag with an evaluated bag of its own, and nothing
1265
+ * already evaluated is evaluated again.
1266
+ *
1267
+ * Before this, the node-level bag was never evaluated at all, and
1268
+ * `properties.params` was ONE value of a shallow loop: measured through the
1269
+ * real `SchemaRenderer` -> `action:button` -> runner on a record page bound
1270
+ * to `{ id: 'rec_1' }`, both `params.recordId: '${record.id}'` spellings
1271
+ * reached the handler raw while `properties.label: 'L-${record.id}'`
1272
+ * rendered `L-rec_1`. A template that still cannot resolve keeps its source
1273
+ * text (that is the evaluator's own contract) and is reported by the
1274
+ * unevaluated-expression diagnostic, so a wrong template stays loud.
1275
+ */
1276
+ //
1277
+ // A DATA OBJECT on any other key ({@link isDataObjectValue},
1278
+ // objectui#10288) is handed over as authored: `evaluate` would collapse
1279
+ // one that carries a string `source` to that string. An inline locale map
1280
+ // is the one object that is not: each member is evaluated like a plain
1281
+ // string on the same key (objectui#12936, `utils/localeMap.ts`).
1282
+ //
1283
+ // The rule itself is `evaluateConfigValueWith`, at module scope, so that
1284
+ // `useConfigBagEvaluator` applies the same one to an action container's
1285
+ // members (objectui#10290).
1286
+ const evaluateConfigValue = (key, value) => evaluateConfigValueWith(evaluator, key, value);
1287
+ // Carrier 1 of the `params` rule above: the node-level bag. A non-bag
1288
+ // node-level `params` (the `ActionParam[]` definition list, or anything
1289
+ // degenerate) is left exactly as authored, as it always was.
1290
+ if (isParamsBag(newSchema[PARAMS_KEY])) {
1291
+ newSchema[PARAMS_KEY] = evaluateConfigValue(PARAMS_KEY, newSchema[PARAMS_KEY]);
1292
+ }
520
1293
  // Evaluate 'properties' — the SPEC spelling of a node's config bag, of
521
1294
  // which `props` (evaluated below) is the legacy alias.
522
1295
  //
@@ -546,24 +1319,59 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
546
1319
  // source today). Deepening that is a separate decision and would have to be
547
1320
  // taken for both spellings at once — not smuggled in on one side here.
548
1321
  //
549
- // The guard is wider than the `props` branch's bare truthiness on purpose:
550
- // this value FEEDS the hoist, so re-shaping a degenerate `properties`
551
- // (a string, an array) via the object spread would propagate. Non-objects
552
- // skip evaluation and reach the hoist exactly as they do today.
1322
+ // ONE key is deep, by that separate decision, and on both spellings at once:
1323
+ // `params` (objectui#7867, ruling A) — every string leaf of a `params` bag
1324
+ // is evaluated, through {@link evaluateConfigValue} above. ONE value shape
1325
+ // is one level deep, on any non-predicate key and both spellings: an inline
1326
+ // locale map's members (objectui#12936). Every other value keeps the
1327
+ // shallow reading this paragraph describes.
1328
+ //
1329
+ // "Passed through" used to have one exception: `evaluate` unwraps an object
1330
+ // with a string `source` to that string, so a data object carrying a
1331
+ // `source` field was collapsed rather than passed. Since objectui#10288 an
1332
+ // object on a non-predicate key reaches `evaluate` only when it is a spec
1333
+ // Expression envelope (it carries a `dialect`); see {@link isDataObjectValue}.
1334
+ //
1335
+ // Guarded by {@link isConfigBag}: a degenerate value must not have its shape
1336
+ // reinterpreted by an object spread. Non-objects skip evaluation and reach
1337
+ // the hoist — which, since objectui#6760, refuses them on its own.
1338
+ //
1339
+ // ⚠️ This guard used to say it was wider than the `props` branch BECAUSE
1340
+ // this value feeds the hoist. That reason did not survive measurement
1341
+ // (objectui#6752). Ablating this guard to bare truthiness and re-rendering
1342
+ // `{ type, properties: 'not-a-bag' }` on `b76ca6764` left the indexed
1343
+ // keys the hoist puts on the node completely UNCHANGED — `0` … `8` either
1344
+ // way, because the hoist's own `Object.entries` walk enumerated a string's
1345
+ // character indices whatever this line did — and moved exactly one thing:
1346
+ // whether `schema.properties` still holds the value the author wrote
1347
+ // (`'not-a-bag'` guarded, `{ '0': 'n', … }` ablated). So what this buys is
1348
+ // the AUTHORED value's shape, which is channel-independent, and the `props`
1349
+ // branch below carries the same guard for the same reason.
1350
+ //
1351
+ // ⭐ That measurement is also why objectui#6760 gave the HOIST its own
1352
+ // guard instead of widening this one: this line never reached the indexed
1353
+ // keys, so nothing written here could have removed them. With both guards
1354
+ // in place the two are in SERIES, not redundant, and re-running the same
1355
+ // ablation now reads the opposite way — measured on this card's branch,
1356
+ // ablating THIS line to bare truthiness brings `0` … `8` BACK, because the
1357
+ // bare spread manufactures a real `{ '0': 'n', … }` bag out of the string
1358
+ // and the hoist below then enumerates it legitimately. Each guard is load
1359
+ // bearing for a different half: this one keeps the authored value's shape,
1360
+ // the hoist's one declines to enumerate a value that never had keys.
553
1361
  // Snapshotted BEFORE evaluation — objectui#5756's diagnostic below reads
554
1362
  // the RAW (pre-collapse) text from this reference, since `newSchema.properties`
555
1363
  // is about to be replaced with the evaluated copy.
556
- const rawPropertiesBag = newSchema.properties &&
557
- typeof newSchema.properties === 'object' &&
558
- !Array.isArray(newSchema.properties)
1364
+ const rawPropertiesBag = isConfigBag(newSchema.properties)
559
1365
  ? newSchema.properties
560
1366
  : undefined;
561
1367
  if (rawPropertiesBag) {
562
- const newProperties = { ...rawPropertiesBag };
563
- for (const [key, val] of Object.entries(newProperties)) {
564
- newProperties[key] = evaluator.evaluate(val);
565
- }
566
- newSchema.properties = newProperties;
1368
+ // objectui#9100 — a CEL predicate envelope survives this loop; see
1369
+ // `preservePredicateEnvelope`. objectui#7867 — a `params` bag has every
1370
+ // string leaf evaluated (carrier 2 of `evaluateConfigValue`). Every
1371
+ // other value evaluates as before. The loop is `evaluateConfigBagWith`,
1372
+ // the one `useConfigBagEvaluator` runs over an action container's
1373
+ // member (objectui#10290).
1374
+ newSchema.properties = evaluateConfigBagWith(evaluator, rawPropertiesBag);
567
1375
  }
568
1376
  /**
569
1377
  * objectui#5756 — the `${…}` HALF of the #5687/#5454 silence, reached at
@@ -621,7 +1429,59 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
621
1429
  // (e.g. tab visual style: 'line' | 'card' | 'pill'). Keep `properties`
622
1430
  // intact on the schema so renderers can still read these collision-prone
623
1431
  // keys via `schema.properties.<key>`.
624
- if (newSchema.properties) {
1432
+ //
1433
+ // Guarded by {@link isConfigBag}: a DEGENERATE `properties` contributes no
1434
+ // node keys at all (objectui#6760). Measured on `c6732825d`, this branch's
1435
+ // base, with no part of that card in the tree: `properties: 'not-a-bag'`
1436
+ // reached the element as nine React props named `0` … `8`, and
1437
+ // `properties: ['x', 'y']` as `0`, `1`. Nobody authored those keys — they
1438
+ // are `Object.entries`' reading of a string's character indices, and the
1439
+ // values this loop writes onto the node are spread as React props at the
1440
+ // `createElement` below.
1441
+ //
1442
+ // ## Which arm, and why this one
1443
+ //
1444
+ // objectui#6760 left two open, both cheap, both reversible, neither on the
1445
+ // manual floor (the objectui#6708 census walked every JSON document, every
1446
+ // `json` doc fence and every TypeScript object literal and found ZERO
1447
+ // authored degenerate config bags):
1448
+ //
1449
+ // (a) guard the hoist the way the evaluation memo above is guarded;
1450
+ // (b) rule that the hoist may enumerate whatever it is handed, and say
1451
+ // so here.
1452
+ //
1453
+ // Arm (a), for three reasons, in the order they were weighed:
1454
+ //
1455
+ // 1. **One answer per key, whichever channel reads it.** That is
1456
+ // objectui#5123's ruling (maintainer, 2026-08-18), and arm (b) breaks
1457
+ // it in the loudest available place: after objectui#6752,
1458
+ // `props: 'not-a-bag'` contributes no keys, so arm (b) would answer the
1459
+ // SAME authored mistake two ways depending on which of two spellings of
1460
+ // ONE bag the author used — and the reinterpreting half would be
1461
+ // `properties`, the SPEC spelling, while the quiet half is `props`, the
1462
+ // annotated legacy alias. A rule that punishes the canonical spelling
1463
+ // is not a rule anyone can teach.
1464
+ // 2. **The reason for the guard was measured, and it is channel-**
1465
+ // **independent.** objectui#6752 established it by ablation rather than
1466
+ // by reading a comment (see the evaluation memo above): what a config-bag
1467
+ // guard buys is the AUTHORED value's shape, which has nothing to do with
1468
+ // which bag or which site is asking. The hoist is a third site asking the
1469
+ // same question about the same authored value; a third answer would be
1470
+ // drift, which is what {@link isConfigBag} exists to end (objectui#6761).
1471
+ // 3. **The failure it prevents is silent and lands on generated metadata.**
1472
+ // A degenerate bag is a mistake an author (increasingly, an AI writing
1473
+ // metadata) makes by writing `properties: 'text'` where a bag belongs.
1474
+ // Arm (b) turns that into nine plausible-looking props with no warning
1475
+ // anywhere; arm (a) makes it inert, so the mistake stays legible as the
1476
+ // `properties` value the author actually wrote.
1477
+ //
1478
+ // What arm (a) does NOT change, measured rather than assumed: a real object
1479
+ // bag hoists exactly as before (`type`/`id` still protected above), and
1480
+ // `properties: 42` / `properties: true` were already contributing nothing
1481
+ // — `Object.entries` yields no entries for them, so this guard only ever
1482
+ // moves the string and array cases. Pinned in
1483
+ // `__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx`.
1484
+ if (isConfigBag(newSchema.properties)) {
625
1485
  const outerType = newSchema.type;
626
1486
  const outerId = newSchema.id;
627
1487
  const props = newSchema.properties;
@@ -640,20 +1500,152 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
640
1500
  if (typeof newSchema.content === 'string') {
641
1501
  newSchema.content = evaluator.evaluate(newSchema.content);
642
1502
  }
643
- // Evaluate 'props'
644
- if (newSchema.props) {
1503
+ /**
1504
+ * Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
1505
+ * (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
1506
+ *
1507
+ * ## The hole this closes
1508
+ *
1509
+ * A value reaches the screen only if it is BOTH evaluated here AND read
1510
+ * back by the renderer off the node. `content` above satisfies both; the
1511
+ * other text keys satisfied neither at once, so
1512
+ * `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
1513
+ * `schema.value` (`data-display/statistic.tsx`) — put the literal
1514
+ * `${data.total}` on screen, and the `props`-envelope workaround the
1515
+ * objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
1516
+ * then spread as a React prop nobody reads). `statistic` is the dashboard
1517
+ * workhorse and had no way at all to bind a dynamic value.
1518
+ *
1519
+ * ## Why this is ONE leg here and not a patch in each renderer
1520
+ *
1521
+ * The ruling's own implementation caution: the read-back sites must be
1522
+ * "converged on evaluated values, not patched per component". They already
1523
+ * agree on WHERE to read (the node's top level) — what they lacked was
1524
+ * anything writing an evaluated value there. Writing it at the single
1525
+ * producer of evaluated schema means `statistic.tsx`, `card.tsx` and
1526
+ * `button.tsx` are UNCHANGED by this card and every future top-level reader
1527
+ * is covered by construction. Four fixed components and no contract was the
1528
+ * failure mode named at dispatch.
1529
+ *
1530
+ * ## The vocabulary is the spec's, and is consumed rather than copied
1531
+ *
1532
+ * `@objectstack/spec` declares it (objectstack#9599): the closed set
1533
+ * `title` / `label` / `value` / `description`, plus the per-component
1534
+ * carriage map saying which of them each component type actually reads
1535
+ * back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
1536
+ * declaration "rather than hard-coding a twin list" — so the only thing
1537
+ * this file knows is the name of the lookup. A row added upstream starts
1538
+ * working here with no edit; a row this file invented would be the second
1539
+ * dialect the declaration exists to prevent.
1540
+ *
1541
+ * ## The type string is passed VERBATIM
1542
+ *
1543
+ * `expressionBindableTextKeysFor` keys on the bare registry name, and the
1544
+ * spec states the answer for an unlisted type is the empty set — "closed
1545
+ * and mechanically answerable in both directions, never inferred from what
1546
+ * a renderer happens to read". So no prefix-stripping normalization: it
1547
+ * would look harmless (`ui:statistic` → `statistic`) and would in the same
1548
+ * motion grant rows to `element:button` and `page:card`, whose renderers
1549
+ * read their config out of the bag via `readProps()` and never touch these
1550
+ * keys on the node — re-manufacturing the evaluated-but-not-read-back half
1551
+ * of the very table this card exists to close. Measured on this tree: the
1552
+ * authored corpus spells these types bare (`statistic` 42, `card` 135,
1553
+ * `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
1554
+ * spelling authors actually use. `action:button` (5 nodes) has no row and
1555
+ * is reported upstream rather than inferred here.
1556
+ *
1557
+ * ## Ordering and idempotence
1558
+ *
1559
+ * After the `properties` hoist deliberately, exactly like `content`: a
1560
+ * value arriving through that channel was already evaluated by the
1561
+ * `properties` leg, so it no longer carries a `${…}` and
1562
+ * `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
1563
+ * guard is doing real work rather than mirroring the line above — it stops
1564
+ * this loop CREATING an absent key as `undefined`, which would change what
1565
+ * `{ ...schema }` spreads and what `key in schema` answers downstream.
1566
+ */
1567
+ //
1568
+ // Read once into a local, so the `typeof` guard narrows the value it
1569
+ // hands to `evaluate`. Two of the four keys are `BaseSchema` members, and
1570
+ // the other two are `unknown` on the working copy (objectui#11349). The
1571
+ // spec's carriage map is their declaration, and the guard narrows them.
1572
+ for (const key of expressionBindableTextKeysFor(typeof newSchema.type === 'string' ? newSchema.type : '')) {
1573
+ const authoredText = newSchema[key];
1574
+ if (typeof authoredText === 'string') {
1575
+ newSchema[key] = evaluator.evaluate(authoredText);
1576
+ }
1577
+ }
1578
+ // Evaluate 'props' — the legacy alias of the config bag.
1579
+ //
1580
+ // The guard MIRRORS the `properties` branch above rather than testing bare
1581
+ // truthiness (objectui#6752). Same predicate, both channels: a degenerate
1582
+ // bag must not have its shape reinterpreted by an object spread.
1583
+ //
1584
+ // Measured on `b76ca6764` through the real `SchemaRenderer`, before this
1585
+ // guard existed: a node written `{ type: 'card', props: 'not-a-bag' }`
1586
+ // reached `createElement` carrying NINE React props named `0` … `8`, one
1587
+ // per character, because `{ ...'not-a-bag' }` enumerates a string's
1588
+ // character indices. Nothing threw and nothing was logged, so the symptom
1589
+ // (a component handed nine props it never declared) sits a long way from
1590
+ // the `props` value that caused it.
1591
+ //
1592
+ // This guard is HALF the fix and does not on its own remove those props —
1593
+ // also measured, on the same base with only this line widened: the bag
1594
+ // reached `propsWithoutCanonicalKeys` as the authored string, was returned
1595
+ // unchanged, and `{ ...outgoingPropsBag }` at the `createElement` call
1596
+ // re-enumerated it. The other half is that function's own guard. What this
1597
+ // line buys is that `schema.props` keeps the value the author wrote instead
1598
+ // of the spread's reading of it.
1599
+ if (isConfigBag(newSchema.props)) {
645
1600
  const newProps = { ...newSchema.props };
646
1601
  for (const [key, val] of Object.entries(newProps)) {
647
- newProps[key] = evaluator.evaluate(val);
1602
+ // objectui#9100, same guard as the `properties` branch above — the two
1603
+ // channels must not disagree about whether a `cel` envelope survives,
1604
+ // nor (objectui#7867, carrier 3) about how deep a `params` bag goes.
1605
+ newProps[key] = evaluateConfigValue(key, val);
648
1606
  }
649
1607
  newSchema.props = newProps;
650
1608
  }
651
- // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn
1609
+ /**
1610
+ * REFUSE, by name, a node-gate predicate parked under the legacy `props`
1611
+ * alias (objectui#9108, maintainer ruling 2026-09-13, verbatim 「同意」 on
1612
+ * the `domain:spec` seat's recommendation).
1613
+ *
1614
+ * ## Sited HERE, immediately in front of the two gates
1615
+ *
1616
+ * This is the one point where the gates' own input is final: the
1617
+ * `properties` hoist above has run, both config-bag evaluation loops have
1618
+ * run, and neither gate has consulted anything yet. It is also the only
1619
+ * placement that survives its own subject - the late diagnostics near
1620
+ * `createElement` are downstream of `if (shouldHide) return null`, so a node
1621
+ * that parks `visible` under `props` while ALSO hiding through the canonical
1622
+ * spelling would never reach them, and the refusal would go missing on the
1623
+ * one shape that carries both spellings at once.
1624
+ *
1625
+ * ## Read-only, and that is the ruled outcome rather than a limitation
1626
+ *
1627
+ * Nothing below changes. The gates still read the post-hoist node only, so
1628
+ * every verdict, every hoisted value and every byte the element receives is
1629
+ * what it was - the alias is REFUSED, not honoured. The opposite arm was
1630
+ * built and closed (PR objectui#9144): honouring it would have made *"8
1631
+ * predicate keys work while the rest stayed silently dropped - and partly
1632
+ * working is harder to learn from than not working"*.
1633
+ *
1634
+ * The bag handed over is {@link propsWithoutCanonicalKeys}'s, the SAME
1635
+ * subtraction the outgoing props bag uses, so a key the canonical bag also
1636
+ * declares is not reported as parked: there the author is already getting
1637
+ * the canonical answer (objectui#5123). The key SET is
1638
+ * {@link NODE_GATE_PREDICATE_KEYS}, derived from the two chain declarations
1639
+ * above rather than re-listed here.
1640
+ */
1641
+ reportRefusedPropsPredicate(newSchema.type, newSchema.id, NODE_GATE_PREDICATE_KEYS, propsWithoutCanonicalKeys(newSchema.props, newSchema.properties));
1642
+ // Evaluate visibility: visibleWhen / visible / visibleOn / hidden / hiddenOn
652
1643
  const shouldHide = (() => {
653
1644
  // `visibleWhen` is the single canonical conditional-visibility predicate
654
1645
  // across every layer since ADR-0089 (show-when-truthy), and it is the one
655
1646
  // this shape DECLARES: `PageComponentSchema` is a closed object that
656
- // declares `visibleWhen` (+ the deprecated `visibility`) and REFUSES a
1647
+ // declares `visibleWhen` (its old `visibility` spelling is retired, see
1648
+ // `WorkingCopyAuthoredKeys`) and REFUSES a
657
1649
  // node-level `visible` outright, with the prescription "move it up one
658
1650
  // level to the component node's own `visibleWhen` … inside `properties`
659
1651
  // it is hoisted onto the node by the renderer but evaluated by nothing"
@@ -673,23 +1665,17 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
673
1665
  return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen');
674
1666
  }
675
1667
  // `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and
676
- // the landing spot of a hoisted `properties.visible`. Kept ABOVE the two
677
- // deprecated aliases: they normalize into `visibleWhen` at parse, so a
678
- // spec-parsed page never reaches them, and re-ranking them would move
1668
+ // the landing spot of a hoisted `properties.visible`. Kept ABOVE
1669
+ // `BaseSchema`'s deprecated `visibleOn`: re-ranking it would move
679
1670
  // verdicts for raw metadata that objectui#5454 did not rule on.
680
1671
  if (newSchema.visible !== undefined) {
681
1672
  return !evaluateVisibilityPredicate(newSchema.visible, 'visible');
682
1673
  }
683
- // @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw /
684
- // un-normalized metadata reaching the renderer.
1674
+ // `BaseSchema.visibleOn`, @deprecated ADR-0089 → `visibleWhen`.
1675
+ // objectui's own node key; the spec's page component never declared it.
685
1676
  if (newSchema.visibleOn !== undefined) {
686
1677
  return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn');
687
1678
  }
688
- // @deprecated ADR-0089 → `visibleWhen` (was PageNodeSchema.visibility,
689
- // an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
690
- if (newSchema.visibility !== undefined) {
691
- return !evaluateVisibilityPredicate(newSchema.visibility, 'visibility');
692
- }
693
1679
  // Ask "is a `hidden` gate DECLARED?" — not "is the key present?"
694
1680
  // (objectui#3955). These two legs are the only ones in this chain whose
695
1681
  // verdict is NOT negated, so the evaluator's single default for "there is
@@ -733,7 +1719,7 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
733
1719
  // as an internal flag either: `_disabled` is forwarded below as a real
734
1720
  // `disabled` prop.
735
1721
  //
736
- // The `visible` / `visibleWhen` / `visibleOn` / `visibility` legs above keep
1722
+ // The `visible` / `visibleWhen` / `visibleOn` legs above keep
737
1723
  // `!== undefined` deliberately: their `true` is NEGATED, so an empty predicate
738
1724
  // already lands on "shown", which is what "no gate" means anyway, and
739
1725
  // narrowing them would change ALIAS PRECEDENCE rather than fix anything. The
@@ -750,12 +1736,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
750
1736
  // value, exactly as before — only the gate in front of it narrowed. An
751
1737
  // undeclared `disabled` now falls through to `disabledOn` instead of
752
1738
  // short-circuiting on an empty predicate.
1739
+ //
1740
+ // Both legs route through `evaluateEnablementPredicate` (objectui#6445), so
1741
+ // a predicate that cannot be evaluated is reported instead of silently
1742
+ // greying the control out. The verdicts below are unchanged: that helper
1743
+ // returns `evaluateCondition`'s own answer, and an UNDECLARED gate never
1744
+ // reaches it at all — `hasDeclaredPredicate` still decides that, one line
1745
+ // earlier, which is what keeps the objectui#3862 empty-shape rows silent
1746
+ // as well as enabled.
753
1747
  const isDisabled = (() => {
754
1748
  if (hasDeclaredPredicate(newSchema.disabled)) {
755
- return evaluator.evaluateCondition(newSchema.disabled);
1749
+ return evaluateEnablementPredicate(newSchema.disabled, 'disabled');
756
1750
  }
757
1751
  if (hasDeclaredPredicate(newSchema.disabledOn)) {
758
- return evaluator.evaluateCondition(newSchema.disabledOn);
1752
+ return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn');
759
1753
  }
760
1754
  return false;
761
1755
  })();
@@ -763,7 +1757,73 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
763
1757
  newSchema._disabled = true;
764
1758
  }
765
1759
  return newSchema;
766
- }, [schema, dataSource, predicateScope, pageVariables, boundRecord]);
1760
+ }, [schema, dataSource, ambientScope, pageVariables, boundRecord, apiStatus, apiData]);
1761
+ /**
1762
+ * SDUI scoped styling (ADR-0065): a node's `responsiveStyles` compiles to
1763
+ * id-scoped CSS injected as a `<style>` tag, and a scope class is appended to
1764
+ * the node's className.
1765
+ *
1766
+ * ## Why this is a memo, and why it is HERE and not at its use site
1767
+ *
1768
+ * The scope-class branch rebuilds the schema object (`{ ...evaluatedSchema,
1769
+ * className: mergedClassName }`) so renderers that read `schema.className`
1770
+ * see the scope class. Unmemoised, that spread allocated a NEW object on
1771
+ * every render of this component — even when the `evaluatedSchema` memo
1772
+ * directly above it HELD — so every downstream renderer that memoises on
1773
+ * `[schema]` (e.g. `ObjectMap`'s `dataConfig` / `mapConfig`, and the whole
1774
+ * marker cascade below them) saw a fresh identity and re-ran (objectui#6270).
1775
+ * Only nodes taking this branch were affected: a plain node was handed
1776
+ * `evaluatedSchema` itself and was already stable.
1777
+ *
1778
+ * It is hoisted ABOVE the early returns below because it is a HOOK. Its use
1779
+ * site sits after `if (!evaluatedSchema) return null`, the `_hidden` return
1780
+ * and the unresolved-component returns, so a `useMemo` written there is a
1781
+ * CONDITIONAL hook: a node toggling hidden -> visible would call one more
1782
+ * hook than the previous render and React would throw "Rendered more hooks
1783
+ * than during the previous render". Hoisting is what makes the memo legal,
1784
+ * not a stylistic preference.
1785
+ *
1786
+ * `[evaluatedSchema, autoStyleId]` is the complete dependency set: every
1787
+ * value below is a pure function of the evaluated node (`className`, `id`,
1788
+ * `responsiveStyles`) and the `useId` fallback. `evaluatedSchema` is itself a
1789
+ * fresh object whenever anything it interpolates changes, so a genuinely
1790
+ * changed className, value or breakpoint still produces a NEW identity here —
1791
+ * memoising cannot make a live value go stale.
1792
+ *
1793
+ * Non-object / primitive nodes fall through untouched: the render pass below
1794
+ * returns them as text before any of this is read.
1795
+ */
1796
+ const scopedStyling = useMemo(() => {
1797
+ const node = evaluatedSchema;
1798
+ if (!node || typeof node !== 'object') {
1799
+ return { scopeClass: '', scopedCss: '', mergedClassName: undefined, schemaForComponent: node };
1800
+ }
1801
+ // `unknown` on the working copy (objectui#11349); `hasResponsiveStyles`
1802
+ // narrows it to the spec's `ResponsiveStyles` before anything compiles it.
1803
+ const responsiveStyles = node.responsiveStyles;
1804
+ if (!hasResponsiveStyles(responsiveStyles)) {
1805
+ // No scope class: hand the component `evaluatedSchema` ITSELF, which the
1806
+ // memo above already keeps stable. Never a copy — a copy here would
1807
+ // reintroduce the same instability for every node in the tree.
1808
+ return {
1809
+ scopeClass: '',
1810
+ scopedCss: '',
1811
+ mergedClassName: node.className,
1812
+ schemaForComponent: node,
1813
+ };
1814
+ }
1815
+ const scopeClass = scopeClassFor(node.id ?? autoStyleId);
1816
+ const mergedClassName = [node.className, scopeClass].filter(Boolean).join(' ');
1817
+ return {
1818
+ scopeClass,
1819
+ scopedCss: compileScopedStyles(`.${scopeClass}`, responsiveStyles),
1820
+ mergedClassName,
1821
+ // Some renderers read `schema.className` directly (e.g. element:text)
1822
+ // while others read the `className` prop (e.g. flex/container). Set both
1823
+ // so the scope class lands regardless of which channel a renderer honours.
1824
+ schemaForComponent: { ...node, className: mergedClassName },
1825
+ };
1826
+ }, [evaluatedSchema, autoStyleId]);
767
1827
  if (!evaluatedSchema)
768
1828
  return null;
769
1829
  // If schema is just a string, render it as text
@@ -776,6 +1836,17 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
776
1836
  // component type: undefined" box, which said nothing true about the input.
777
1837
  if (typeof evaluatedSchema !== 'object')
778
1838
  return _jsx(_Fragment, { children: String(evaluatedSchema) });
1839
+ // The API binding's non-final states (objectui#12197), drawn in place of the
1840
+ // node — before its visibility, which may read `data` and so cannot be
1841
+ // decided yet. A refused binding names the spec's refusal; nothing was read.
1842
+ // The error panel titles itself in the page's language: its translation
1843
+ // hook mounts with it, never in this body, which runs for every node.
1844
+ if (apiStatus === 'loading') {
1845
+ return _jsx(ElementDataSourceLoadingPanel, { testId: String(evaluatedSchema.type) });
1846
+ }
1847
+ if (apiStatus === 'refused' || apiStatus === 'error') {
1848
+ return (_jsx(ElementApiDataSourcePanel, { testId: String(evaluatedSchema.type), state: apiStatus, message: apiBinding.error }));
1849
+ }
779
1850
  // Handle visibility: if evaluated schema is hidden, render nothing.
780
1851
  //
781
1852
  // Reads AFTER the primitive narrowing above (objectui#4548) so the property
@@ -806,12 +1877,15 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
806
1877
  }
807
1878
  debugLog('schema', 'Component not found in registry', { type: evaluatedSchema.type });
808
1879
  const errorInfo = ERROR_CODES['OBJUI-001'];
809
- return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", role: "alert", children: [_jsxs("p", { className: "font-medium", children: ["Unknown component type: ", _jsx("strong", { children: evaluatedSchema.type })] }), lazyError && (_jsxs("p", { className: "text-xs mt-1", children: ["Failed to load plugin: ", lazyError.message] })), globalThis.process?.env?.NODE_ENV !== 'production' && (_jsxs("p", { className: "text-xs mt-1", children: ["\uD83D\uDCA1 ", errorInfo.suggestion, " (OBJUI-001)"] })), _jsx("pre", { className: "text-xs mt-2 overflow-auto", children: JSON.stringify(evaluatedSchema, null, 2) })] }));
1880
+ // objectui#5247 — the lookup above still missed and this node still fails.
1881
+ // The suggestion only names the spelling that would have resolved.
1882
+ const caseSuggestion = suggestTypeByCase(evaluatedSchema.type);
1883
+ return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", role: "alert", children: [_jsxs("p", { className: "font-medium", children: ["Unknown component type: ", _jsx("strong", { children: evaluatedSchema.type }), caseSuggestion !== undefined ? (_jsxs(_Fragment, { children: [" — did you mean '", _jsx("strong", { children: caseSuggestion }), "'?"] })) : null] }), lazyError && (_jsxs("p", { className: "text-xs mt-1", children: ["Failed to load plugin: ", lazyError.message] })), globalThis.process?.env?.NODE_ENV !== 'production' && (_jsxs("p", { className: "text-xs mt-1", children: ["\uD83D\uDCA1 ", errorInfo.suggestion, " (OBJUI-001)"] })), _jsx("pre", { className: "text-xs mt-2 overflow-auto", children: JSON.stringify(evaluatedSchema, null, 2) })] }));
810
1884
  }
811
1885
  // Note: We don't forward the ref to the Component because components in the registry
812
1886
  // may not support refs. The SchemaRenderer itself can still receive refs for its own use.
813
1887
  // Extract schema metadata properties that should NOT be passed as React props
814
- const { type: _type, children: _children, body: _body, schema: _schema, visible: _visible, visibleWhen: _visibleWhen, visibleOn: _visibleOn, visibility: _visibility, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn,
1888
+ const { type: _type, children: _children, body: _body, schema: _schema, visible: _visible, visibleWhen: _visibleWhen, visibleOn: _visibleOn, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn,
815
1889
  // stripped: `PageComponentSchema.dataSource` is the spec's per-element data
816
1890
  // BINDING (`{ object, view, filter, sort, limit }`) — schema metadata, like
817
1891
  // `visibleWhen` above, not a visual prop. Renderers that consume it read
@@ -823,7 +1897,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
823
1897
  // block reported "Couldn't load records" — writing the binding the spec
824
1898
  // documents BROKE the component (objectstack#5576). An explicit React
825
1899
  // `dataSource` prop is unaffected: it arrives via `...props`, spread last.
826
- dataSource: _dataSource, _hidden: __hidden, // stripped: internal visibility flag
1900
+ dataSource: _dataSource,
1901
+ // stripped: `BaseSchema.testId` is the author's stable-locator handle, and
1902
+ // this renderer is what makes its declaration true — it is re-emitted as
1903
+ // `data-testid` beside `data-obj-id` below (objectui#8268, ADR-0054 C4).
1904
+ // Leaving it in the spread was the defect: React does not recognise a
1905
+ // `testId` DOM prop, so it landed as a non-standard lowercase `testid`
1906
+ // attribute that no test-library query helper looks for, while React's own
1907
+ // dev warning told the author to spell it `testid` — steering them further
1908
+ // from the documented `data-testid`.
1909
+ testId: _testId,
1910
+ // stripped: a RUNTIME prop a host injects, never authorable metadata —
1911
+ // decision batch #70 (objectui#7742); objectui#8818 closes the key here.
1912
+ // The `props` alias carrier is closed by `withoutAuthoredObjectFields`.
1913
+ objectFields: _objectFields, _hidden: __hidden, // stripped: internal visibility flag
827
1914
  _disabled: __disabled, // stripped: internal disabled flag
828
1915
  responsiveStyles: _responsiveStyles, // stripped: compiled to scoped CSS, not a DOM prop
829
1916
  ...componentProps } = evaluatedSchema;
@@ -831,35 +1918,146 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
831
1918
  // `${…}` about to be placed verbatim in front of a user.
832
1919
  //
833
1920
  // Sited HERE, after the metadata destructure, on purpose. `componentProps` is
834
- // precisely the set of values that leaves this component for the DOM — it is
835
- // spread as React props below, and the same values are what renderers read as
836
- // `schema.<key>`. Everything the destructure stripped is schema METADATA:
1921
+ // the set of values the node OFFERS the component — spread as React props
1922
+ // below, and the same values renderers read as `schema.<key>`. Everything the
1923
+ // destructure stripped is schema METADATA:
837
1924
  // `visible` / `visibleWhen` / `hidden` / `disabled` / … hold raw predicate
838
1925
  // SOURCE by design (they are evaluated as conditions, never placed), so
839
1926
  // scanning before the strip would report every correctly-authored predicate
840
1927
  // in the repo. The strip list is therefore the diagnostic's exclusion list,
841
1928
  // for free and without a second copy of it to drift.
842
1929
  //
1930
+ // ⚠️ It scans `componentProps` and the raw `props` / `properties` bags, NOT
1931
+ // the narrowed bags the objectui#9571 / objectui#9758 strips build below.
1932
+ // Deliberate, and the objectui#8268 `testId` precedent applied a second time:
1933
+ // `data` loses its PROPS seat on the object arm, but it is still authored,
1934
+ // still evaluated, and still read off the schema by the block — so an
1935
+ // unevaluated expression inside it is still in front of a user, and excluding
1936
+ // it here would narrow objectui#4795's coverage by one key for exactly the
1937
+ // blocks the ruling touches. That holds for BOTH carriers: an authored
1938
+ // `props: { data: '${…}' }` is still reported after objectui#9758 refused it
1939
+ // a prop seat.
1940
+ //
843
1941
  // Read-only: it reports what evaluation already produced and changes nothing
844
1942
  // about what is rendered — no DOM attribute either, so no snapshot moves.
845
1943
  if (__DEV__) {
846
- reportUnevaluatedExpressions(schema, evaluatedSchema.type, evaluatedSchema.id, componentProps, evaluatedSchema.properties, evaluatedSchema.props);
1944
+ // `testId` is stripped above but stays in THIS scan set. The strip list is
1945
+ // this diagnostic's exclusion list because every other member holds raw
1946
+ // predicate SOURCE by design; `testId` holds a literal, so excluding it
1947
+ // would silently narrow objectui#4795's coverage by one key — measured on
1948
+ // `93127bd6f`, an authored `testId: '${data.x}'` was reported before this
1949
+ // change, and must still be.
1950
+ reportUnevaluatedExpressions(schema, evaluatedSchema.type, evaluatedSchema.id, _testId === undefined ? componentProps : { ...componentProps, testId: _testId }, evaluatedSchema.properties, evaluatedSchema.props);
1951
+ }
1952
+ // The legacy `props` alias, narrowed exactly as objectui#5123 ruled: for a
1953
+ // key BOTH bags declare, `properties` wins here as it already wins in
1954
+ // `readProps()`, so one key has one answer on both channels.
1955
+ //
1956
+ // HOISTED out of the `createElement` call below (objectui#6708) so the
1957
+ // diagnostic and the spread read the SAME bag. It is the same pure call with
1958
+ // the same arguments producing the same object in the same spread position —
1959
+ // nothing about what any renderer receives moves — but it removes the one way
1960
+ // this diagnostic could go wrong: reporting a set of keys that is not the set
1961
+ // actually handed to the component.
1962
+ //
1963
+ // objectui#9758 narrows it once more, for the same key and on the same arm as
1964
+ // the strip below — see {@link withoutAuthoredDataKey}. The arm is read ONCE,
1965
+ // here, and handed to both call sites: two independent lookups of the same
1966
+ // question is the shape that let the alias keep the seat in the first place.
1967
+ const refusesAuthoredDataProp = recordSourceDataArmForType(evaluatedSchema.type) === 'view-data';
1968
+ // objectui#8818 strips `objectFields` HERE, before the `data` strip below:
1969
+ // that strip's diagnostic decides by identity, so it must compare against a
1970
+ // bag this strip has already settled.
1971
+ const aliasBagAsAuthored = withoutAuthoredObjectFields(propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties));
1972
+ const outgoingPropsBag = withoutAuthoredDataKey(aliasBagAsAuthored, refusesAuthoredDataProp);
1973
+ if (__DEV__ && outgoingPropsBag !== aliasBagAsAuthored) {
1974
+ reportRefusedDataPropSpread(evaluatedSchema.type, evaluatedSchema.id);
1975
+ }
1976
+ // Dev-build diagnostic (objectui#6708, maintainer ruling 2026-08-29, option
1977
+ // 2): those keys are spread as React props and never hoisted onto the node,
1978
+ // so a renderer that reads its config from `schema` — every family except
1979
+ // `element:*`'s `readProps()` — drops them without a word.
1980
+ //
1981
+ // Sited HERE, beside its objectui#4795 neighbour and after the metadata
1982
+ // destructure, for the same reason: this is the point where what leaves this
1983
+ // component for the renderer is finally known. Read-only — it reports what
1984
+ // the line above already computed and changes nothing that is rendered.
1985
+ //
1986
+ // The AUTHORED bag comes from `schema`, not `evaluatedSchema`: the evaluation
1987
+ // memo rebuilds `props` with an object spread, which turns a degenerate
1988
+ // `props: 'text'` into `{ '0': 't', … }` long before this line. See
1989
+ // `collectDroppedPropsKeys`.
1990
+ //
1991
+ // ⚠️ It reads the POST-strip bag (objectui#9758), and that direction is the
1992
+ // opposite of its objectui#4795 neighbour above ON PURPOSE. This message
1993
+ // states that the key "is spread as React props on the created element" and
1994
+ // tells the author to "write them under `properties` instead" — after the
1995
+ // strip BOTH sentences are false for `data` on this arm: it is not spread,
1996
+ // and the canonical spelling loses the seat as well. Its subject stops being
1997
+ // true, so it stops naming the key, and `reportRefusedDataPropSpread` is what
1998
+ // tells the author instead. The objectui#4795 scan reads the WIDER bag for
1999
+ // the mirror-image reason: an unevaluated `${…}` is still in front of a user
2000
+ // after the strip, so its subject survives.
2001
+ if (__DEV__) {
2002
+ reportDroppedPropsBag(evaluatedSchema.type, evaluatedSchema.id, schema?.props, outgoingPropsBag);
2003
+ }
2004
+ /**
2005
+ * objectui#9571 (ruling objectui#8348 Q2-C, decision batch #136 item 3,
2006
+ * maintainer 「同意」): an authored `data` key does NOT take a React prop seat
2007
+ * on a block whose published `data` row is the OBJECT arm (`ViewData`).
2008
+ *
2009
+ * ## The defect this closes
2010
+ *
2011
+ * The spread below hands every non-metadata node key to the component, so an
2012
+ * authored `data` reached such a block TWICE — as `schema.data`, which the
2013
+ * shared ladder judges against the block's row (`resolveRecordSourceConfig`,
2014
+ * objectui#8348), and as the `data` PROP, which `ObjectGrid` (`passedData`),
2015
+ * `ObjectMap` (`dataProp`) and `ObjectGantt` each lift with an unconditional
2016
+ * `Array.isArray` at HIGHER priority than that ladder. The ruling had one
2017
+ * carrier it did not reach, and it was the one that wins: a bare array under
2018
+ * `data` still drew through this renderer after the ladder had retired it.
2019
+ *
2020
+ * ## ⛔ The HOST path is NOT touched, and that is the ruled half
2021
+ *
2022
+ * Option B — gating the PROP on the arm — was refused, because the prop is how
2023
+ * a host (`plugin-list`'s `ListView`, `ObjectView`) hands down rows it already
2024
+ * fetched. Only the AUTHORED key loses its seat: `...props` below is this
2025
+ * component's own React props, spread LAST, so a host rendering
2026
+ * `<SchemaRenderer data={rows} …/>` still delivers `rows`, and a host that
2027
+ * renders the block component directly never passes through here at all.
2028
+ *
2029
+ * ## Scope is the ARM, not a block list
2030
+ *
2031
+ * `recordSourceDataArmForType` answers per registered type, and every type it
2032
+ * does not list keeps today's behaviour verbatim. ⛔ No allowlist of block
2033
+ * NAMES is minted here — that is the second de-facto contract AGENTS.md #0.1
2034
+ * forbids, and the ruling is stated over the arm, not over a census.
2035
+ *
2036
+ * ## Byte-identical when nothing is authored
2037
+ *
2038
+ * The `in` test comes FIRST, so a node that authors no `data` is handed
2039
+ * `componentProps` itself — the same object, in the same spread position, with
2040
+ * no copy allocated. Same discipline as the conditional `data-testid` below,
2041
+ * and for the same measured reason.
2042
+ *
2043
+ * ## ⚠️ TWO bags reach the spread, and this one is only the first
2044
+ *
2045
+ * objectui#9758: the legacy `props` alias bag is spread AFTER this one, so for
2046
+ * the first ruling's whole life an author who spelled the identical key
2047
+ * `props: { data: [...] }` kept the seat this block takes away — the arm
2048
+ * predicate was right and the corpus was one bag short. Both bags now go
2049
+ * through {@link withoutAuthoredDataKey} with the ONE arm reading computed
2050
+ * above (decision batch #167 item 2, letter 剥, maintainer 「其他同意」).
2051
+ * ⛔ Still no validator refusal: whether the alias exists at all is
2052
+ * objectui#4795's pending question ②, and nothing else it carries moves.
2053
+ */
2054
+ const outgoingComponentProps = withoutAuthoredDataKey(componentProps, refusesAuthoredDataProp);
2055
+ if (__DEV__ && outgoingComponentProps !== componentProps) {
2056
+ reportRefusedDataPropSpread(evaluatedSchema.type, evaluatedSchema.id);
847
2057
  }
848
- // SDUI scoped styling (ADR-0065): a node's `responsiveStyles` compiles to
849
- // id-scoped CSS injected as a <style> tag, and a scope class is appended to
850
- // the node's className. Build-independent, collision-free, responsive-correct.
851
- const hasScopedStyles = hasResponsiveStyles(_responsiveStyles);
852
- const scopeClass = hasScopedStyles ? scopeClassFor(evaluatedSchema.id ?? autoStyleId) : '';
853
- const scopedCss = hasScopedStyles ? compileScopedStyles(`.${scopeClass}`, _responsiveStyles) : '';
854
- const mergedClassName = scopeClass
855
- ? [evaluatedSchema.className, scopeClass].filter(Boolean).join(' ')
856
- : evaluatedSchema.className;
857
- // Some renderers read `schema.className` directly (e.g. element:text) while
858
- // others read the `className` prop (e.g. flex/container). Set both so the
859
- // scope class lands regardless of which channel a renderer honours.
860
- const schemaForComponent = scopeClass
861
- ? { ...evaluatedSchema, className: mergedClassName }
862
- : evaluatedSchema;
2058
+ // SDUI scoped styling (ADR-0065) — computed in the memo hoisted above the
2059
+ // early returns; see the doc comment there for why it cannot live here.
2060
+ const { scopeClass, scopedCss, mergedClassName, schemaForComponent } = scopedStyling;
863
2061
  // Extract AriaPropsSchema properties for accessibility
864
2062
  const ariaProps = resolveAriaProps(evaluatedSchema);
865
2063
  // Debug-mode enhancements: extra data attributes + perf tracking
@@ -875,23 +2073,45 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
875
2073
  const renderStart = isDebug ? performance.now() : 0;
876
2074
  const rendered = (_jsxs(SchemaErrorBoundary, { componentType: evaluatedSchema.type, resetKey: evaluatedSchema.id ?? null, children: [scopedCss ? (_jsx("style", { "data-os-scope": scopeClass, dangerouslySetInnerHTML: { __html: scopedCss } })) : null, React.createElement(Component, {
877
2075
  schema: schemaForComponent,
878
- ...componentProps, // Spread non-metadata schema properties as props
2076
+ // Spread non-metadata schema properties as props — minus an authored
2077
+ // `data` on the object arm, which the objectui#9571 strip above removed.
2078
+ ...outgoingComponentProps,
879
2079
  // The legacy `props` alias still overrides plain top-level keys, but no
880
- // longer overrides the canonical `properties` bag: for a key BOTH bags
881
- // declare, `properties` wins here exactly as it already wins in
882
- // `readProps()`, so one key has one answer on both channels
883
- // (objectui#5123, maintainer ruling 2026-08-18).
884
- ...propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties),
2080
+ // longer overrides the canonical `properties` bag (objectui#5123,
2081
+ // maintainer ruling 2026-08-18) — and, since objectui#9758, no longer
2082
+ // re-seats an authored `data` on the object arm that the spread above
2083
+ // just refused. Computed above rather than inline, so the objectui#6708
2084
+ // diagnostic names this exact bag — see there.
2085
+ ...outgoingPropsBag,
885
2086
  ...ariaProps, // Inject ARIA attributes from AriaPropsSchema
886
2087
  ...debugAttrs, // Debug-mode data attributes
887
2088
  disabled: __disabled || undefined,
888
2089
  className: mergedClassName,
889
2090
  'data-obj-id': evaluatedSchema.id,
890
2091
  'data-obj-type': evaluatedSchema.type,
2092
+ // objectui#8268 — `BaseSchema.testId`'s declaration promises this
2093
+ // attribute. Emitted here, not spread above, so the author gets the
2094
+ // `data-testid` every query helper looks for instead of the
2095
+ // non-standard `testid` the raw spread produced.
2096
+ //
2097
+ // ⚠️ CONDITIONALLY, and that is load-bearing, not tidiness. Passing
2098
+ // `'data-testid': undefined` unconditionally would put the key in every
2099
+ // node's props bag, and a component that sets its own `data-testid` and
2100
+ // then spreads what it is handed — the ADR-0054 C4 shape, which many
2101
+ // renderers here have — would have its locator overwritten with
2102
+ // `undefined` and lose it. Measured: the unconditional form turned 9
2103
+ // tests red across 5 files, including the three that assert the props
2104
+ // bag is byte-for-byte unchanged (objectui#6708 / #6752 / #6760). A node
2105
+ // that authors no `testId` must be byte-identical to before this card.
2106
+ ...(_testId === undefined ? {} : { 'data-testid': _testId }),
891
2107
  ...(__DEV__ && !_validation.valid ? { 'data-obj-schema-invalid': 'true' } : {}),
892
2108
  ...props
893
2109
  })] }));
894
2110
  debugTimeEnd(`render:${evaluatedSchema.type}:${evaluatedSchema.id ?? 'anon'}`);
2111
+ // The bound value reaches the subtree as `data` (see "The API binding").
2112
+ const output = apiStatus === 'ready'
2113
+ ? _jsx(PredicateScopeProvider, { scope: subtreeScope, children: rendered })
2114
+ : rendered;
895
2115
  // Report render perf to DebugCollector when debug mode is active
896
2116
  if (isDebug && renderStart) {
897
2117
  const durationMs = performance.now() - renderStart;
@@ -902,6 +2122,6 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
902
2122
  timestamp: Date.now(),
903
2123
  });
904
2124
  }
905
- return rendered;
2125
+ return output;
906
2126
  });
907
2127
  SchemaRenderer.displayName = 'SchemaRenderer';