@object-ui/react 17.6.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +3766 -0
  2. package/README.md +294 -38
  3. package/dist/SchemaRenderer.d.ts +38 -7
  4. package/dist/SchemaRenderer.js +1218 -87
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/DiscussionContext.d.ts +12 -0
  7. package/dist/context/DiscussionContext.js +2 -0
  8. package/dist/context/DrillNavigationContext.d.ts +13 -0
  9. package/dist/context/InlineEditContext.d.ts +81 -1
  10. package/dist/context/InlineEditContext.js +8 -2
  11. package/dist/context/RecordContext.d.ts +48 -5
  12. package/dist/context/RecordContext.js +10 -1
  13. package/dist/context/RelatedRecordActionsContext.d.ts +5 -1
  14. package/dist/context/SchemaRendererContext.d.ts +54 -2
  15. package/dist/context/SchemaRendererContext.js +38 -5
  16. package/dist/context/ThemeContext.js +1 -1
  17. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  18. package/dist/context/actionRunnerTranslator.js +37 -0
  19. package/dist/element-data-source/ElementDataSourceGate.d.ts +88 -1
  20. package/dist/element-data-source/ElementDataSourceGate.js +326 -13
  21. package/dist/hooks/index.d.ts +3 -1
  22. package/dist/hooks/index.js +10 -1
  23. package/dist/hooks/useActionEngine.js +46 -3
  24. package/dist/hooks/useActionRunner.js +4 -0
  25. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  26. package/dist/hooks/useActionTextLocalizer.js +33 -8
  27. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  28. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  29. package/dist/hooks/useDensityMode.d.ts +6 -2
  30. package/dist/hooks/useDensityMode.js +6 -2
  31. package/dist/hooks/useDiscovery.d.ts +1 -10
  32. package/dist/hooks/useETagCache.js +25 -4
  33. package/dist/hooks/useElementDataSource.d.ts +15 -2
  34. package/dist/hooks/useElementDataSource.js +24 -7
  35. package/dist/hooks/useExpression.d.ts +52 -59
  36. package/dist/hooks/useExpression.js +58 -90
  37. package/dist/hooks/useFilterScope.d.ts +22 -2
  38. package/dist/hooks/useFilterScope.js +29 -4
  39. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  40. package/dist/hooks/useGlobalUndo.js +112 -46
  41. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  42. package/dist/hooks/useNavigationOverlay.js +119 -9
  43. package/dist/hooks/useOffline.js +65 -11
  44. package/dist/hooks/usePageAssignment.d.ts +8 -5
  45. package/dist/hooks/usePageAssignment.js +37 -16
  46. package/dist/hooks/useRecordSearch.d.ts +34 -1
  47. package/dist/hooks/useRecordSearch.js +42 -7
  48. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  49. package/dist/hooks/useResolvedFilter.js +130 -0
  50. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  51. package/dist/hooks/useSchemaPersistence.js +144 -5
  52. package/dist/hooks/useSettledSchema.d.ts +123 -0
  53. package/dist/hooks/useSettledSchema.js +186 -0
  54. package/dist/hooks/useViewData.d.ts +7 -2
  55. package/dist/index.d.ts +8 -1
  56. package/dist/index.js +45 -1
  57. package/dist/schema-input.d.ts +38 -8
  58. package/dist/schema-input.js +39 -7
  59. package/dist/utils/configBag.d.ts +68 -0
  60. package/dist/utils/configBag.js +70 -0
  61. package/dist/utils/error-message.d.ts +1 -1
  62. package/dist/utils/error-message.js +1 -1
  63. package/dist/utils/inlineAria.d.ts +61 -0
  64. package/dist/utils/inlineAria.js +70 -0
  65. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  66. package/dist/utils/nonGridRowCeiling.js +78 -0
  67. package/dist/utils/paramsBag.d.ts +28 -0
  68. package/dist/utils/paramsBag.js +146 -0
  69. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  70. package/dist/utils/propsBagDiagnostic.js +421 -0
  71. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  72. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  73. package/dist/utils/unevaluatedExpression.d.ts +42 -1
  74. package/dist/utils/unevaluatedExpression.js +74 -11
  75. package/dist/utils/visibilityDiagnostic.d.ts +271 -19
  76. package/dist/utils/visibilityDiagnostic.js +311 -44
  77. package/package.json +10 -10
  78. package/dist/hooks/useClientNotifications.d.ts +0 -47
  79. package/dist/hooks/useClientNotifications.js +0 -162
  80. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  81. package/dist/spec-bridge/SpecBridge.js +0 -56
  82. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  83. package/dist/spec-bridge/bridges/form-view.js +0 -170
  84. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  85. package/dist/spec-bridge/bridges/list-view.js +0 -268
  86. package/dist/spec-bridge/index.d.ts +0 -11
  87. package/dist/spec-bridge/index.js +0 -10
  88. package/dist/spec-bridge/types.d.ts +0 -55
  89. package/dist/spec-bridge/types.js +0 -8
@@ -7,13 +7,18 @@ 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
13
  import { usePredicateScope } from './hooks/useExpression.js';
14
14
  import { usePageVariables } from './hooks/usePageVariables.js';
15
15
  import { resolveKeyedI18nLabel } from './utils/i18n.js';
16
+ import { isConfigBag } from './utils/configBag.js';
17
+ import { PARAMS_KEY, isParamsBag, mapParamsLeaves } from './utils/paramsBag.js';
16
18
  import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
19
+ import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js';
20
+ import { reportRefusedDataPropSpread } from './utils/refusedDataPropDiagnostic.js';
21
+ import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
17
22
  import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js';
18
23
  /**
19
24
  * Dev-mode schema validation.
@@ -41,6 +46,7 @@ const _validationCache = typeof WeakMap !== 'undefined'
41
46
  : { set() { }, get() { return undefined; }, has() { return false; } };
42
47
  const _warnedSchemas = typeof WeakSet !== 'undefined' ? new WeakSet() : { add() { }, has() { return false; } };
43
48
  function validateSchemaOnce(schema) {
49
+ // 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
50
  if (!__DEV__ || !schema || typeof schema !== 'object') {
45
51
  return { valid: true, messages: [] };
46
52
  }
@@ -81,6 +87,18 @@ function validateSchemaOnce(schema) {
81
87
  * ariaLabel: string | I18nLabel (→ aria-label)
82
88
  * ariaDescribedBy: string (→ aria-describedby)
83
89
  * role: string (→ role)
90
+ *
91
+ * ⚠️ This reads the FLAT node keys, and resolves `ariaLabel` in objectui's
92
+ * KEYED vocabulary (`resolveKeyedI18nLabel`), which returns `undefined` for an
93
+ * inline locale map. It does not read the NESTED `aria` bag a block's props
94
+ * carry. That bag is read by `resolveInlineAriaProps` (`utils/inlineAria.ts`,
95
+ * objectui#11051), and objectui#4580 Q2-B keeps the two readers separate.
96
+ *
97
+ * It takes the interpreter's working copy (objectui#11349). That copy used to
98
+ * arrive through a `Record<string, any>` parameter, which typed these reads
99
+ * `any`. `ariaLabel` is a `BaseSchema` member. `ariaDescribedBy` and `role`
100
+ * are not, so they read as `unknown` and are forwarded exactly as authored,
101
+ * the same bytes as before. The record is typed to say so.
84
102
  */
85
103
  function resolveAriaProps(schema) {
86
104
  const aria = {};
@@ -144,9 +162,13 @@ const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
144
162
  * - `type`/`id` are skipped, because the hoist never copied a canonical value
145
163
  * up for them, so dropping the alias would DELETE the prop rather than
146
164
  * 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.
165
+ * - a degenerate (non-object) `properties` is left alone — there is no
166
+ * canonical bag to prefer, because a degenerate bag declares no key for
167
+ * either spelling to win. (This bullet used to explain the carve-out by
168
+ * saying "the hoist and `readProps()` both merely object-spread it". Half
169
+ * of that stopped being true at objectui#6760, which stopped the hoist from
170
+ * enumerating a degenerate bag; the carve-out itself is unaffected, since
171
+ * it never rested on what the hoist does with the value.)
150
172
  *
151
173
  * Adjacent but NOT decided here: objectui#4795's pending question ② (whether
152
174
  * the `properties` envelope is an official `ui:*` authoring channel at all).
@@ -154,15 +176,22 @@ const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
154
176
  * position on whether either should exist.
155
177
  */
156
178
  function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
157
- if (!propsBag)
179
+ // A degenerate `props` contributes NO keys (objectui#6752). This used to be a
180
+ // bare `if (!propsBag)`, so a non-object bag was returned as-is and the
181
+ // `createElement` spread below re-enumerated it: measured on `b76ca6764`,
182
+ // `props: 'not-a-bag'` reached the element as nine React props named `0` … `8`.
183
+ // Returning `{}` also makes the declared return type true — the old signature
184
+ // said `Record<string, any>` while this line could hand back a string.
185
+ //
186
+ // This does NOT move objectui#5123's precedence. That rule only decides which
187
+ // of two co-present values a key carries, and a degenerate bag declares no
188
+ // key for either bag to win: the indices were never authored, they are the
189
+ // object spread's reading of a string.
190
+ if (!isConfigBag(propsBag))
158
191
  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)) {
192
+ // Only a real object bag can win a key — the same predicate, for the reason
193
+ // stated there: a degenerate bag must not have its shape reinterpreted here.
194
+ if (!isConfigBag(propertiesBag)) {
166
195
  return propsBag;
167
196
  }
168
197
  let narrowed = null;
@@ -194,6 +223,363 @@ function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
194
223
  */
195
224
  const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'];
196
225
  const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'];
226
+ /**
227
+ * The same six legs as a lookup, so the config-bag evaluation loops below can
228
+ * ask "is this key a visibility predicate?" off the SAME declaration the chain
229
+ * is built from rather than a second list that can drift from it.
230
+ *
231
+ * Since objectui#9107 those loops ask {@link PREDICATE_CHAIN_KEYS} — this set
232
+ * unioned with the enablement chain's — so this one is the VISIBILITY HALF of
233
+ * that union rather than the whole of it. Each chain still has exactly one
234
+ * declaration, which is the property this constant was added for.
235
+ */
236
+ const VISIBILITY_CHAIN_KEYS = new Set([
237
+ ...VISIBILITY_SHOW_KEYS,
238
+ ...VISIBILITY_HIDE_KEYS,
239
+ ]);
240
+ /**
241
+ * The ENABLEMENT-chain keys — the SECOND predicate chain this file carries, and
242
+ * deliberately not a seventh leg of the visibility chain above (objectui#9107).
243
+ *
244
+ * Split the way this file actually consults them, which is not a polarity split:
245
+ *
246
+ * * {@link ENABLEMENT_NODE_GATE_KEYS} are the two legs
247
+ * {@link evaluateEnablementPredicate} consults HERE, in this precedence
248
+ * order, as one early-return chain — the enablement counterpart of
249
+ * `shouldHide`. Their `true` means DISABLED, un-negated.
250
+ * * {@link ENABLEMENT_RENDERER_KEYS} is the legacy, non-spec `enabled` alias.
251
+ * This component never evaluates it: the node gate does not consult it at
252
+ * all, and the action renderers (`action:button`, `action:icon`) read it one
253
+ * layer down off the schema and NEGATE it (`disabled = !isEnabled`). It is
254
+ * declared here because the config-bag loops below flatten it exactly like
255
+ * the other two — which is the whole defect — not because anything in this
256
+ * file decides with it.
257
+ *
258
+ * ## Why a second declaration instead of widening the visibility chain
259
+ *
260
+ * {@link VisibilityChainKey} exists "so a seventh cannot be added to the chain
261
+ * without also being classified below", and {@link visibilityGateKind} is that
262
+ * classification — it hands each leg the consequence copy for ITS polarity.
263
+ * These three keys have neither property: they route through
264
+ * {@link evaluateEnablementPredicate}, which states `'enablement'` at its own
265
+ * call site, and `enabled` is not consulted here at all. Adding them to
266
+ * {@link VISIBILITY_SHOW_KEYS} / {@link VISIBILITY_HIDE_KEYS} to reach the
267
+ * guard would make that closed type assert something false about them, and
268
+ * would silently enrol them in `shouldHide`'s early-return chain and in the
269
+ * visibility consequence copy as a side effect of a config-bag repair.
270
+ *
271
+ * So the two chains stay two declarations, and the GUARD takes their union
272
+ * ({@link PREDICATE_CHAIN_KEYS}) — one lookup, derived, declared nowhere twice.
273
+ * The alternative shape (one flat predicate-chain set that both chains derive
274
+ * FROM) was rejected for the opposite reason: the chains do not share an order,
275
+ * a declared-test or a consequence, so the thing they genuinely share is only
276
+ * the guard's question, and that is exactly what a derived union expresses.
277
+ */
278
+ const ENABLEMENT_NODE_GATE_KEYS = ['disabled', 'disabledOn'];
279
+ const ENABLEMENT_RENDERER_KEYS = ['enabled'];
280
+ /**
281
+ * Every key either predicate chain owns, as ONE lookup for the config-bag
282
+ * evaluation loops. DERIVED from the four declarations above and declared
283
+ * nowhere else, so a leg added to either chain is covered by the guard in the
284
+ * same edit that adds it.
285
+ */
286
+ const PREDICATE_CHAIN_KEYS = new Set([
287
+ ...VISIBILITY_CHAIN_KEYS,
288
+ ...ENABLEMENT_NODE_GATE_KEYS,
289
+ ...ENABLEMENT_RENDERER_KEYS,
290
+ ]);
291
+ /**
292
+ * Every key a NODE GATE in this file actually consults, as ONE lookup for the
293
+ * objectui#9108 refusal below. DERIVED from the same two declarations the gates
294
+ * are built from, so a leg added to either chain is refused under `props` by the
295
+ * same edit that adds it.
296
+ *
297
+ * {@link PREDICATE_CHAIN_KEYS} minus {@link ENABLEMENT_RENDERER_KEYS}, and the
298
+ * subtraction is the whole reason this is a second derivation rather than a
299
+ * reuse: `enabled` is in that union because the config-bag evaluation loops
300
+ * flatten it, but NO gate here consults it - the action renderers read it one
301
+ * layer down off the schema and negate it. Refusing it here would state, of a
302
+ * key this file never asks about, that a gate in this file could not see it.
303
+ * Its own `props` drop is objectui#6708's subject and is reported there.
304
+ */
305
+ const NODE_GATE_PREDICATE_KEYS = new Set([
306
+ ...VISIBILITY_CHAIN_KEYS,
307
+ ...ENABLEMENT_NODE_GATE_KEYS,
308
+ ]);
309
+ /**
310
+ * Is this value the canonical CEL envelope — `{ dialect: 'cel', source }` —
311
+ * that `@objectstack/spec` normalizes an authored predicate into?
312
+ *
313
+ * Deliberately narrower than "has a `source`": `cron` and `template` envelopes
314
+ * are NOT matched, so the `${…}` / template behaviour of the loops below is
315
+ * byte-for-byte what it was. Only the dialect that `evaluateCondition` routes
316
+ * to the canonical `@objectstack/formula` engine is held back from the
317
+ * flattening described in {@link preservePredicateEnvelope}.
318
+ *
319
+ * The object-shape half is {@link isConfigBag}, ASKED rather than re-spelled:
320
+ * objectui#6761 pins that predicate to one definition, and an envelope is the
321
+ * same question about the same kind of value (a real object, never an array).
322
+ * This is a third READ of that one answer, not a fourth spelling of it.
323
+ */
324
+ const isCelEnvelope = (value) => isConfigBag(value)
325
+ && value.dialect === 'cel'
326
+ && typeof value.source === 'string';
327
+ /**
328
+ * Evaluate ONE value of a node's config bag (`properties` — the spec spelling
329
+ * — or its legacy `props` alias), EXCEPT a CEL predicate envelope, which is
330
+ * returned untouched.
331
+ *
332
+ * ## The defect this closes (objectui#9100)
333
+ *
334
+ * `ExpressionEvaluator.evaluate` unwraps ANY `{ source: string }` object to its
335
+ * bare `source` before it does anything else — that unwrap is what lets a
336
+ * `{ dialect: 'template', source: '${data.total}' }` value interpolate. Applied
337
+ * to a `{ dialect: 'cel' }` PREDICATE it is destructive instead: the envelope
338
+ * is the only thing that tells {@link ExpressionEvaluator.evaluateCondition}
339
+ * to route to the canonical `@objectstack/formula` engine, and the bag loops
340
+ * run BEFORE the hoist, so by the time either consumer sees the key the
341
+ * envelope is gone.
342
+ *
343
+ * Both consumers then read a bare string and both take the legacy JS path:
344
+ *
345
+ * 1. this component's own `shouldHide` chain, off the post-hoist node; and
346
+ * 2. the RENDERER's `toPredicateInput(…)` → `useCondition(…)` call, one
347
+ * layer down (`record:alert`, the four `action:*` blocks, …), which wraps
348
+ * the bare string as `${…}` exactly as it is documented to.
349
+ *
350
+ * On the legacy engine a CEL stdlib call is simply not a function, so
351
+ * `has(record.x) && …` throws — and the two polarities of the chain then fail
352
+ * in OPPOSITE directions, both silently:
353
+ *
354
+ * * on the four SHOW legs the fail-soft `true` is negated to "do not hide",
355
+ * so a gate authored to conceal renders on every row — measured in a real
356
+ * browser against a real app instance (the card), where a duplicate-lead
357
+ * banner showed on 6 of 6 leads whose field was `null`;
358
+ * * on the two HIDE legs the same `true` sets `_hidden`, so the node is not
359
+ * on screen at all.
360
+ *
361
+ * Neither is distinguishable from a predicate that said so on purpose. That is
362
+ * what makes this a fail-OPEN visibility gate rather than a rendering glitch.
363
+ *
364
+ * ## Why the fix is HERE and not in the normalizer
365
+ *
366
+ * `toPredicateInput` already preserves a `cel` envelope — that is its whole
367
+ * documented reason to exist (#2661 / #3314) — and the ACTION path that works
368
+ * calls the very same function. Measured: mounting the renderer DIRECTLY with
369
+ * the envelope gates correctly, and only the `SchemaRenderer` route fails, so
370
+ * the envelope dies upstream of every consumer, in the loops below. What makes
371
+ * the action path survive is that those loops are per-value and SHALLOW: an
372
+ * action's predicate sits one level down inside the `actions[]` ARRAY, and
373
+ * `evaluate` returns a non-string untouched, so nothing walks into it. A
374
+ * `record:alert` `properties.visible` is a TOP-LEVEL bag key and is hit
375
+ * head-on. Same normalizer, different depth.
376
+ *
377
+ * ## The enablement chain carries the identical defect (objectui#9107)
378
+ *
379
+ * `disabled` / `disabledOn` / `enabled` sit in the same two bags and are
380
+ * flattened by the same two loops, but they are NOT visibility legs — they
381
+ * route through {@link evaluateEnablementPredicate} and the action renderers'
382
+ * own `useCondition` call. Measured on the pre-fix tree through this component,
383
+ * with a CEL stdlib predicate authored at `properties` top level, and the two
384
+ * halves fail in OPPOSITE directions just as the visibility polarities do:
385
+ *
386
+ * * `disabled` / `disabledOn` — BOTH a holding and a failing predicate left
387
+ * the control DISABLED. The flattened source faults on the legacy engine
388
+ * and `evaluateCondition`'s fail-soft `true` is un-negated here, so every
389
+ * CEL-authored enablement gate greyed its control out on every row, with
390
+ * no predicate an author could write to re-enable it.
391
+ * * `enabled` — BOTH left the control ENABLED, because the renderers negate
392
+ * that leg (`disabled = !isEnabled`), so the same fail-soft `true` lands on
393
+ * "not disabled": a control the author disabled stayed pressable.
394
+ *
395
+ * ⇒ neither polarity alone detects this. A disabled-only suite is green on the
396
+ * broken `disabled` legs; an enabled-only suite is green on the broken
397
+ * `enabled` leg. Both are pinned, per key, in
398
+ * `__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx`.
399
+ *
400
+ * ## Scope
401
+ *
402
+ * Restricted to {@link PREDICATE_CHAIN_KEYS} — the union of the two closed
403
+ * chain declarations this file already carries, which `shouldHide` /
404
+ * {@link winningVisibilityKey} and {@link evaluateEnablementPredicate} already
405
+ * consult. Every consumer of those nine keys takes the envelope by contract
406
+ * (`evaluateCondition`, `toPredicateInput`). A non-predicate key keeps the
407
+ * flattening it has always had.
408
+ *
409
+ * Eight of the nine are stripped by the metadata destructure near
410
+ * `createElement`, so no object value can reach the DOM through them. The
411
+ * ninth, the legacy `enabled` alias, is not stripped — the action renderers
412
+ * read it off the schema, not off React props. Measured on a renderer that
413
+ * spreads what it is handed onto a DOM node: that attribute already carried the
414
+ * raw CEL SOURCE TEXT before this change and carries `[object Object]` after
415
+ * it, which is the same inert shape the `properties` bag itself already puts on
416
+ * every such node, with no React warning in either direction. Both spellings
417
+ * are meaningless to the DOM; the leak is pre-existing and is deliberately NOT
418
+ * repaired here.
419
+ */
420
+ const preservePredicateEnvelope = (key, value, evaluate) => (PREDICATE_CHAIN_KEYS.has(key) && isCelEnvelope(value) ? value : evaluate(value));
421
+ /**
422
+ * Is this value an Expression envelope in the spec's sense: an object carrying
423
+ * a string `dialect` AND a string `source`? (objectui#10288)
424
+ *
425
+ * `@objectstack/spec`'s `ExpressionSchema` declares `dialect` REQUIRED, so every
426
+ * envelope a spec-parsed artifact carries has one. The shape test is the one
427
+ * `@object-ui/core` already applies to a declared `defaultValue`
428
+ * (`isRuntimeDefault`, "Same shape test the engine applies before handing a
429
+ * default to `ExpressionEngine`"), where an object with a `source` and no
430
+ * `dialect` is a literal value, not an instruction. {@link isCelEnvelope} is
431
+ * this question narrowed to one dialect.
432
+ */
433
+ const isExpressionEnvelope = (value) => isConfigBag(value)
434
+ && typeof value.dialect === 'string'
435
+ && typeof value.source === 'string';
436
+ /**
437
+ * Is this config-bag value a DATA OBJECT, which the per-value loops hand to the
438
+ * renderer exactly as authored instead of passing it to
439
+ * `ExpressionEvaluator.evaluate`? (objectui#10288)
440
+ *
441
+ * ## The defect this closes
442
+ *
443
+ * `evaluate` unwraps ANY object with a string `source` to that bare string
444
+ * before it does anything else (see {@link preservePredicateEnvelope}). The
445
+ * loops handed it every value, so an authored data object that happened to
446
+ * carry a `source` field was silently replaced by that one string. Measured
447
+ * through the real `SchemaRenderer` -> `action:button` -> runner:
448
+ * `properties.bodyExtra: { source: 'web', campaign: 'spring' }` reached the
449
+ * handler as `bodyExtra: "web"`, while the same object written at node level
450
+ * (which no loop visits) arrived intact. So the canonical channel was the
451
+ * broken one. That pair is pinned end to end in `@object-ui/components`
452
+ * (`action-config-bag-source-key-10288.test.tsx`), and this guard by
453
+ * `__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx`.
454
+ * Every object-valued key a node carries in `properties` / `props`
455
+ * goes through the same loop, so the defect was never one key's: form
456
+ * `defaultValues`, a declarative `patch`, filter `values` and a detail view's
457
+ * `data` are all keyed by FIELD NAME, and a field named `source` is ordinary.
458
+ *
459
+ * ## What still reaches `evaluate`
460
+ *
461
+ * - every value that is not an object in {@link isConfigBag}'s sense (a
462
+ * string, any other primitive, an array), exactly as before: `evaluate`
463
+ * interpolates a string and returns the rest untouched;
464
+ * - a spec Expression envelope ({@link isExpressionEnvelope}), so a
465
+ * `{ dialect: 'template', source: '${…}' }` value interpolates as it always
466
+ * did;
467
+ * - every value of a predicate-chain key ({@link PREDICATE_CHAIN_KEYS}),
468
+ * unchanged. `BaseSchema.visible` / `.hidden` / `.disabled` declare the
469
+ * dialect-less `{ source }` envelope as an authorable wire form
470
+ * (`ExpressionWire`, objectui#7530), so on those keys it stays an
471
+ * expression, and {@link preservePredicateEnvelope} still holds back a CEL
472
+ * envelope (objectui#9100 / #9107).
473
+ *
474
+ * So the only value whose meaning changes is an object on a non-predicate key
475
+ * with a string `source` and no string `dialect`, and that object now arrives
476
+ * whole. Nothing is walked: a template nested inside a data object stays raw,
477
+ * as it always did for an object without a `source`. The loops stay per-value
478
+ * and shallow, which is the radius the unevaluated-expression diagnostic reads.
479
+ */
480
+ const isDataObjectValue = (key, value) => !PREDICATE_CHAIN_KEYS.has(key) && isConfigBag(value) && !isExpressionEnvelope(value);
481
+ /**
482
+ * The scope the evaluation memo in {@link SchemaRenderer} evaluates a node's
483
+ * values against: the ambient host scope, `current_user` aliased to `user`, the
484
+ * page's bound row as `record` (only when there is one), and page variables as
485
+ * `page`. Why each root is (and `data` is not) bound is stated where the memo
486
+ * builds its evaluator from this.
487
+ *
488
+ * One function, so the memo and {@link evaluateConfigBagInScope} cannot build
489
+ * two different scopes (objectui#10290).
490
+ */
491
+ const configEvaluationScope = (predicateScope, boundRecord, pageVariables) => ({
492
+ ...predicateScope,
493
+ current_user: predicateScope?.user,
494
+ ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord)
495
+ ? { record: boundRecord }
496
+ : null),
497
+ page: pageVariables,
498
+ });
499
+ /**
500
+ * Evaluate ONE config value by its key. This is the per-key rule of the
501
+ * evaluation memo (the `params` rule, objectui#7867, is stated there): a
502
+ * `params` bag has every string leaf evaluated, a data object is handed over as
503
+ * authored, and every other value is evaluated per value and shallow, with a
504
+ * CEL predicate envelope preserved.
505
+ */
506
+ const evaluateConfigValueWith = (evaluator, key, value) => key === PARAMS_KEY && isParamsBag(value)
507
+ ? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf))
508
+ : isDataObjectValue(key, value)
509
+ ? value
510
+ : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v));
511
+ /**
512
+ * Evaluate every value of a config bag with {@link evaluateConfigValueWith}.
513
+ * The memo's `properties` loop, as a function. Returns a new object; the
514
+ * authored bag is never mutated.
515
+ */
516
+ const evaluateConfigBagWith = (evaluator, bag) => {
517
+ const evaluated = { ...bag };
518
+ for (const [key, val] of Object.entries(evaluated)) {
519
+ evaluated[key] = evaluateConfigValueWith(evaluator, key, val);
520
+ }
521
+ return evaluated;
522
+ };
523
+ /**
524
+ * The `properties` evaluation of the {@link SchemaRenderer} memo, for a config
525
+ * bag that does not pass through `SchemaRenderer` (objectui#10290): the same
526
+ * per-key rule, the same `ExpressionEvaluator`, and the same scope, built from
527
+ * the same three inputs. It is not a second template engine.
528
+ *
529
+ * The plain half of `useConfigBagEvaluator()` (`hooks/useConfigBagEvaluator`),
530
+ * which reads those inputs from the contexts the memo reads them from. Call
531
+ * the hook; this function exists because the rule it applies is this module's.
532
+ * (The hook is not declared in this module: a hook here puts this file under
533
+ * the React Compiler lint, which then reports the render-time
534
+ * `performance.now()` / `Date.now()` debug probes below.)
535
+ *
536
+ * A value that is not a config bag is returned unchanged; a bag comes back as a
537
+ * new, evaluated object and the authored one is never mutated.
538
+ */
539
+ export function evaluateConfigBagInScope(bag, predicateScope, boundRecord, pageVariables) {
540
+ if (!isConfigBag(bag))
541
+ return bag;
542
+ const evaluator = new ExpressionEvaluator(configEvaluationScope(predicateScope, boundRecord, pageVariables));
543
+ return evaluateConfigBagWith(evaluator, bag);
544
+ }
545
+ /**
546
+ * Which CONSEQUENCE the diagnostic should print for a faulting predicate on
547
+ * this leg (objectui#6503).
548
+ *
549
+ * ## The defect this closes
550
+ *
551
+ * `evaluateCondition` answers an unevaluable predicate with `true` on every
552
+ * path. On the four SHOW legs the chain negates that answer, so the node is
553
+ * SHOWN and the reporter's "the gate did NOT bite" is true. On the two HIDE
554
+ * legs the chain returns it UN-negated (see `shouldHide` below), so the same
555
+ * `true` sets `_hidden` and this component returns `null`: the gate bit, and
556
+ * bit harder than on either sibling gate — the node is not on screen at all.
557
+ * Both were routed to `'visibility'`, so an author whose block VANISHED read a
558
+ * line telling them the gate did not bite and went looking for a rendering bug
559
+ * that does not exist. The polarity was documented in
560
+ * `visibilityDiagnostic.ts` and printed anyway; this is the routing that makes
561
+ * the documented split the one that is actually applied.
562
+ *
563
+ * ## Why derived from the two constants, when the reporter refuses to deduce
564
+ *
565
+ * `PredicateGateKind`'s docblock refuses to deduce the gate from `key` INSIDE
566
+ * the reporter, and that refusal stands: the reporter is exported and called
567
+ * from other packages, where an unheard-of spelling would silently inherit
568
+ * some other gate's sentence. Here the caller IS the chain. The two arrays
569
+ * above are the same declaration `shouldHide` and {@link winningVisibilityKey}
570
+ * consult, and {@link VisibilityChainKey} closes the parameter over them, so
571
+ * this is not a second table that can drift from the first — it is the same
572
+ * one, read for a second question. It also removes the failure mode that
573
+ * produced this card: a leg added to the chain and left with a consequence
574
+ * sentence written about a different polarity is now a TYPE error here, not a
575
+ * line of false console copy.
576
+ *
577
+ * The `disabled` / `disabledOn` gate is deliberately not reachable from this
578
+ * function: those legs are not in the visibility chain and route through
579
+ * `evaluateEnablementPredicate`, which states `'enablement'` at its own call
580
+ * site.
581
+ */
582
+ const visibilityGateKind = (key) => VISIBILITY_HIDE_KEYS.includes(key) ? 'concealment' : 'visibility';
197
583
  /**
198
584
  * Which ONE visibility key actually decides a node's fate, mirroring both the
199
585
  * `properties` hoist's precedence (same-named key: `properties.<key>`
@@ -242,7 +628,7 @@ const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'];
242
628
  */
243
629
  function winningVisibilityKey(node) {
244
630
  const propertiesBag = node.properties;
245
- const hasPropertiesBag = propertiesBag != null && typeof propertiesBag === 'object' && !Array.isArray(propertiesBag);
631
+ const hasPropertiesBag = isConfigBag(propertiesBag);
246
632
  const effective = (key) => hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)
247
633
  ? propertiesBag[key]
248
634
  : node[key];
@@ -256,6 +642,46 @@ function winningVisibilityKey(node) {
256
642
  }
257
643
  return undefined;
258
644
  }
645
+ /**
646
+ * The registry key that differs from `type` only in case, when there is one.
647
+ *
648
+ * objectui#5247 ruled Option C — keep lookup strict, make the failure teach.
649
+ * Registry lookup stays exactly case-sensitive (`ComponentRegistry.get` is a
650
+ * plain `Map.get`), so a node typed `Page` still MISSES `page` and still
651
+ * renders the OBJUI-001 panel below. What changes is only what that panel
652
+ * SAYS: when the miss is a case mismatch and nothing else, it names the
653
+ * spelling that would have worked. Making `Page` resolve is the REJECTED
654
+ * option B — it would legalise `PAGE` / `pAge` across docs, the designer,
655
+ * `sdui-intrinsics.d.ts` and every registration site, permanently.
656
+ *
657
+ * The candidate set is read from the LIVE registry — `getKnownTypes()`, which
658
+ * is loaded registrations plus pending `registerLazy` stubs — never from a
659
+ * list typed alongside it. A hand-kept copy goes stale silently and then
660
+ * confidently suggests a type nothing registers any more; objectui#5115
661
+ * measured exactly that drift on the CLI's copy, in both directions at once.
662
+ *
663
+ * Case is the ONLY trigger the ruling grants. This is deliberately not an edit
664
+ * distance: `pge` suggests nothing.
665
+ *
666
+ * `objectui check` emits the same clause from
667
+ * `packages/cli/src/utils/known-type-case-suggestion.ts`. The two surfaces
668
+ * cannot share one implementation because they cannot share a candidate SET:
669
+ * the published CLI runs inside a USER's project and depends on neither
670
+ * `@object-ui/core` nor the plugin packages that register most types, so it
671
+ * answers from the generated `KNOWN_SCHEMA_TYPES` snapshot instead — the
672
+ * measurement is in `scripts/regenerate-known-schema-types.mjs`. Keep the
673
+ * emitted wording in step with that file; both are pinned by tests.
674
+ */
675
+ function suggestTypeByCase(type) {
676
+ if (typeof type !== 'string' || type === '')
677
+ return undefined;
678
+ const wanted = type.toLowerCase();
679
+ for (const candidate of ComponentRegistry.getKnownTypes()) {
680
+ if (candidate !== type && candidate.toLowerCase() === wanted)
681
+ return candidate;
682
+ }
683
+ return undefined;
684
+ }
259
685
  export class SchemaErrorBoundary extends Component {
260
686
  constructor() {
261
687
  super(...arguments);
@@ -308,6 +734,64 @@ export class SchemaErrorBoundary extends Component {
308
734
  * `useState` wiped (objectui#2954's latent hazard, made real by this line).
309
735
  */
310
736
  const NO_DATA_SOURCE = {};
737
+ /**
738
+ * One outgoing bag, minus an authored `data` key, on the object arm
739
+ * (objectui#9571, extended to the legacy alias by objectui#9758).
740
+ *
741
+ * ## Why a shared helper and not two copies of four lines
742
+ *
743
+ * `createElement` below spreads the node's own non-metadata keys and the legacy
744
+ * `props` alias bag as two separate spreads, in that order. objectui#9571
745
+ * stripped the first; the alias spread ran AFTER it, so the identical authored
746
+ * key spelled `props: { data }` walked straight back into the seat the ruling
747
+ * had just taken away — the same defect, one alias over (objectui#9758,
748
+ * decision batch #167 item 2, letter 剥, maintainer 「其他同意」). Two call
749
+ * sites answering the same question is exactly how the first carrier survived
750
+ * the first ruling, so the question is asked once, here.
751
+ *
752
+ * ## Identity is the signal, and that is load-bearing
753
+ *
754
+ * The `in` test comes FIRST, so a bag that declares no `data` — the
755
+ * overwhelmingly common one — is handed back ITSELF, with no copy allocated and
756
+ * no key moved. Callers read that same identity (`result !== bag`) to decide
757
+ * whether to warn, so the decision is never computed twice and cannot disagree
758
+ * with what was actually spread.
759
+ *
760
+ * ⛔ The HOST path is NOT routed through here. `...props` — this component's own
761
+ * React props, spread LAST — is how `plugin-list`'s `ListView` and `ObjectView`
762
+ * hand down rows they already fetched; gating that was Option B, and it was
763
+ * REFUSED on objectui#9571. Only the AUTHORED key loses its seat.
764
+ */
765
+ function withoutAuthoredDataKey(bag, refuse) {
766
+ if (!refuse || !('data' in bag))
767
+ return bag;
768
+ const { data: _refusedAuthoredData, ...rest } = bag;
769
+ return rest;
770
+ }
771
+ /**
772
+ * The legacy `props` alias bag, minus an authored `objectFields`
773
+ * (objectui#8818, completing decision batch #70 / objectui#7742).
774
+ *
775
+ * `objectFields` is the object's field catalogue — the predicate layer reads it
776
+ * to decide how a conditional-formatting rule compares a relation field — and
777
+ * batch #70 ruled it a RUNTIME React prop a host injects, never authorable
778
+ * metadata. The metadata strip in `SchemaRenderer` closes the top-level and
779
+ * hoisted `properties` spellings; this closes the third, because the alias bag
780
+ * is spread as its own bag after that strip and never passes through it.
781
+ *
782
+ * Every arm, unconditionally — unlike {@link withoutAuthoredDataKey}, the key
783
+ * has no authorable reading on any type. Identity is preserved when the bag
784
+ * declares no `objectFields`, so the common node allocates nothing.
785
+ *
786
+ * ⛔ The HOST path is NOT routed through here: `...props`, spread LAST, is how
787
+ * a host hands the catalogue down, and it still arrives.
788
+ */
789
+ function withoutAuthoredObjectFields(bag) {
790
+ if (!('objectFields' in bag))
791
+ return bag;
792
+ const { objectFields: _runtimeOnlyObjectFields, ...rest } = bag;
793
+ return rest;
794
+ }
311
795
  /**
312
796
  * The renderer loop.
313
797
  *
@@ -352,7 +836,7 @@ const NO_DATA_SOURCE = {};
352
836
  export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
353
837
  const context = useContext(SchemaRendererContext);
354
838
  const dataSource = context?.dataSource || NO_DATA_SOURCE;
355
- // Ambient host scope (user / app / features), fed by app-shell's
839
+ // Ambient host scope (user / features), fed by app-shell's
356
840
  // ExpressionProvider. Threaded into `visible`/expression evaluation so
357
841
  // component predicates can gate on the signed-in user & deployment flags.
358
842
  const predicateScope = usePredicateScope();
@@ -418,11 +902,33 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
418
902
  // an object spread.
419
903
  if (!schema || typeof schema !== 'object')
420
904
  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).
905
+ // The ambient host scope, plus the roots this tier can answer itself.
906
+ // `current_user` is aliased to `user` so both `user.email` and
907
+ // `current_user.email` resolve in component `visible`/`visibleOn`
908
+ // expressions. `page` exposes page-local state so predicates can gate on
909
+ // `page.<var>` (e.g. a record picker's selection toggling another
910
+ // component's visibility).
911
+ //
912
+ // ⛔ `data` is NOT here, and the absence is the decision (objectui#9308,
913
+ // maintainer ruling 2026-09-13 option B). This used to read
914
+ // `data: dataSource` — the host's injected ADAPTER, published as an
915
+ // expression root. `ExpressionProvider` states the governing principle for
916
+ // the tier above: "Every root below is one the engine accepts AND one this
917
+ // tier can actually answer", and objectui#8155 (`app`) and objectui#8166
918
+ // (`data`) applied it there. Against a conformant `DataSource` adapter
919
+ // every `data.*` path resolves `undefined`, so this tier could not answer
920
+ // the root it bound: it published a name that was silently constant on
921
+ // every row. ADR-0089 D3 puts `data` at the METADATA layer
922
+ // (`CANONICAL_ROOT_BY_LAYER = { runtime: 'record', metadata: 'data' }`) and
923
+ // the engine's per-surface `FIELD_RULE_BOUND_ROOTS` is
924
+ // `['record','previous','parent']`. The row is `record`.
925
+ //
926
+ // ⭐ Ordering consequence, and the second half of the same ruling: the
927
+ // spread below used to be followed by `data: dataSource`, so a host that
928
+ // legitimately published `data` through the documented scope channel
929
+ // (`PredicateScopeProvider`) was silently OVERWRITTEN by the adapter.
930
+ // Removing the line un-shadows that channel — a host root named `data` now
931
+ // survives, like every other root a host publishes.
426
932
  //
427
933
  // `record` is written AFTER the ambient spread so a page's own row wins
428
934
  // over anything a host put in the scope — the same precedence
@@ -433,16 +939,14 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
433
939
  // (how `action:group`'s dropdown leaf is driven), turning "this surface has
434
940
  // no row of its own" into "this surface's row is empty" — only the latter
435
941
  // 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
942
+ //
943
+ // Built by `configEvaluationScope`, which `useConfigBagEvaluator` shares, so
944
+ // an action container's members evaluate against this same scope
945
+ // (objectui#10290).
946
+ const evaluator = new ExpressionEvaluator(configEvaluationScope(predicateScope, boundRecord, pageVariables));
947
+ // Shallow copy: the interpreter's working copy, typed as renderer state
948
+ // rather than as the authoring face (objectui#11349). See
949
+ // `InterpreterWorkingCopy` for what it declares and why.
446
950
  const newSchema = { ...schema };
447
951
  /**
448
952
  * Evaluate ONE visibility predicate, and make an unresolvable one LOUD
@@ -473,7 +977,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
473
977
  * on which dialect they happened to write it in.
474
978
  *
475
979
  * 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.
980
+ * re-evaluated on every render, and the point is one line, not a wall. The
981
+ * key is the predicate SOURCE TEXT plus the gate it was authored on — never
982
+ * the render and never the schema object — so the same broken predicate
983
+ * rendered over two hundred rows reports once, and a SECOND distinct
984
+ * predicate still reports (objectui#6038 pins both halves).
985
+ *
986
+ * ## Production is loud too, since objectui#6038
987
+ *
988
+ * It was `__DEV__`-only, and the maintainer's 2026-08-25 ruling retired
989
+ * that silence: a gate that stops biting in production used to leave
990
+ * nothing on the console for the bare-string dialect, so a class-1 defect
991
+ * could sit live and undiscovered (measured in objectstack#11254). The
992
+ * `__DEV__` gate below no longer decides WHETHER the fault is reported,
993
+ * only HOW it is detected — see the two branches.
477
994
  *
478
995
  * ## Defined HERE, ahead of the `properties` evaluation loop below
479
996
  *
@@ -485,16 +1002,41 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
485
1002
  * on the POST-evaluation, POST-hoist schema, for the real verdict.
486
1003
  */
487
1004
  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);
1005
+ // WHICH consequence this leg's fail-soft default earns (objectui#6503).
1006
+ // Computed once, used by both branches, so the production and the
1007
+ // development report cannot disagree about what the default DID.
1008
+ const gate = visibilityGateKind(key);
1009
+ // PRODUCTION STILL MAKES THE SINGLE CALL — it just no longer makes it
1010
+ // in silence (objectui#6038, maintainer ruling 2026-08-25, option B).
1011
+ //
1012
+ // `throwOnError` remains the DEV probe and remains too expensive to ship:
1013
+ // on the CEL branch `evaluateCelCondition` implements it by evaluating
1014
+ // TWICE (once with each fallback — a value that tracks the fallback both
1015
+ // times is a fault), and spec-parsed metadata normalizes `visibleWhen`
1016
+ // into a `{ dialect: 'cel' }` envelope, so that branch is the common one
1017
+ // in production. Paying for the probe here would double the engine calls
1018
+ // for every predicate of every node.
1019
+ //
1020
+ // `onFault` is the way out of that trade: the evaluator hands back the
1021
+ // reason at the point it ALREADY knows the predicate faulted, inside the
1022
+ // catch it already runs, so the fault becomes observable at ONE engine
1023
+ // call. The verdict is `evaluateCondition(raw)`'s, unchanged — this card
1024
+ // is observability only, and the fail-open semantics are not its to move.
1025
+ //
1026
+ // The reporter is the SAME one the dev branch below uses: same message,
1027
+ // same severity, same dedupe `Set`, same key. Production and development
1028
+ // now print the identical line for the identical fault, which is the
1029
+ // property the `__DEV__` gate used to cost us.
1030
+ if (!__DEV__) {
1031
+ return evaluator.evaluateCondition(raw, {
1032
+ onFault: (reason) =>
1033
+ // `'page-component'` stated rather than defaulted (objectui#6487):
1034
+ // this is the tier whose roots the spec declares for a node gate,
1035
+ // and saying so here is what keeps the advice paragraph a decision
1036
+ // this call site owns.
1037
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, reason, 'page-component', gate),
1038
+ });
1039
+ }
498
1040
  try {
499
1041
  const verdict = evaluator.evaluateCondition(raw, { throwOnError: true });
500
1042
  // objectui#5687 — the NON-throwing half of the same silence, and it is
@@ -504,11 +1046,17 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
504
1046
  // `false`. Verdict untouched — `verdict` is returned exactly as
505
1047
  // computed, which is what keeps the ruling's "no verdict changes" true
506
1048
  // by construction rather than by review.
507
- reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource);
1049
+ //
1050
+ // objectui#9308: the object handed over is the `data` the HOST
1051
+ // published in the ambient scope — the one the evaluator above
1052
+ // actually resolved `data.*` against — and no longer the adapter. The
1053
+ // renderer binds no `data` of its own, so passing the adapter here
1054
+ // would report reads the evaluator never made against it.
1055
+ reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, predicateScope?.data);
508
1056
  return verdict;
509
1057
  }
510
1058
  catch (err) {
511
- reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err);
1059
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err, 'page-component', gate);
512
1060
  // The historical fail-soft answer, unchanged — and identical to what
513
1061
  // the production branch above returns for the same input, which is what
514
1062
  // keeps the two branches one behaviour rather than two. See the
@@ -517,6 +1065,154 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
517
1065
  return true;
518
1066
  }
519
1067
  };
1068
+ /**
1069
+ * Evaluate ONE enablement predicate (`disabled` / `disabledOn`), and make an
1070
+ * unresolvable one LOUD (objectui#6445).
1071
+ *
1072
+ * ## Why this pair needed its own card at all
1073
+ *
1074
+ * Six legs of the visibility chain above route through
1075
+ * {@link evaluateVisibilityPredicate} and report. These two called
1076
+ * `evaluateCondition` BARE, so a faulting `disabled` predicate had never
1077
+ * been reported in any build, in any dialect that does not report on its
1078
+ * own — the only uninstrumented predicate pair left in this file.
1079
+ *
1080
+ * And it is the pair where the fail-soft default BITES. `evaluateCondition`
1081
+ * answers an unevaluable predicate with `true`; on the negated visibility
1082
+ * legs that means SHOWN, here it means GREYED OUT. The asymmetry this file
1083
+ * already records for objectui#3862/#3955 cuts the other way for the
1084
+ * console: "a greyed-out control is still on screen", so the user has a
1085
+ * symptom and the author has nothing to grep for. That is why the reporter
1086
+ * is handed a different consequence paragraph (`'enablement'`) rather than
1087
+ * the visibility copy, which states the opposite of what this gate did.
1088
+ *
1089
+ * ## The verdict is byte-for-byte what it was
1090
+ *
1091
+ * `evaluateCondition(raw)` with an `onFault` callback returns exactly what
1092
+ * `evaluateCondition(raw)` returned: `onFault` is invoked for its side
1093
+ * effect and its return value is ignored (objectui#6038's seam). Fail-soft
1094
+ * is PRESERVED deliberately — flipping it is a shipped-behaviour change on
1095
+ * a live surface and is not this card's to make.
1096
+ *
1097
+ * ## One engine call, both builds — still true after objectui#6504
1098
+ *
1099
+ * The FAULT report above is unconditional either way: `onFault` alone
1100
+ * covers both builds, one engine call, one code path, dev and production
1101
+ * print the same line by construction. objectui#6504 adds a SECOND,
1102
+ * dev-only check after that same call returns — not a second evaluation,
1103
+ * a lexical scan of the already-available predicate source and the
1104
+ * already-available `dataSource` — so production keeps its one-call,
1105
+ * one-path shape exactly as this docblock described before this leg
1106
+ * existed; only development pays for the extra check, and only after a
1107
+ * clean (non-faulting) evaluation.
1108
+ *
1109
+ * ## objectui#5687's diagnostic, extended (objectui#6504, maintainer
1110
+ * ruling 2026-08-27 option A)
1111
+ *
1112
+ * Was deliberately NOT called here — see the card this replaces filed at
1113
+ * objectui#6504: the 2026-08-22 ruling was scoped to the visibility gate,
1114
+ * and its message text ("a constant `false` hides the node on every row")
1115
+ * is not what a constant does to a `disabled` gate. The 2026-08-27 ruling
1116
+ * answers both gaps: extend to `disabled` / `disabledOn`, dev-only exactly
1117
+ * as the visibility leg is (⛔ always-on was excluded, outside the #5687
1118
+ * precedent), with its OWN copy — the constant-`true` direction, since on
1119
+ * THIS gate that is the polarity that greys the control out in silence.
1120
+ * `ADAPTER_ONLY_GATE_COPY['enablement']` (`visibilityDiagnostic.ts`) is
1121
+ * that copy, and it carries the same #5330 dissolution pointer #5687's
1122
+ * entry does: both legs retire together when that window closes.
1123
+ *
1124
+ * ## The `key` parameter is CLOSED (objectui#9107)
1125
+ *
1126
+ * Typed {@link EnablementNodeGateKey} rather than `string`, mirroring what
1127
+ * {@link VisibilityChainKey} does for the sibling chain: the two legs this
1128
+ * function serves are declared in ONE place, and the config-bag guard
1129
+ * ({@link preservePredicateEnvelope}) reads that same declaration, so a
1130
+ * third leg cannot arrive here without the same edit also carrying its CEL
1131
+ * envelope through the bag loops.
1132
+ *
1133
+ * Reachable only on the branch where the predicate evaluated CLEANLY —
1134
+ * `faulted` mirrors {@link evaluateVisibilityPredicate}'s try/catch split
1135
+ * without paying for a second (`throwOnError`) engine call: `onFault` is
1136
+ * already told exactly when the single call it wraps did not produce a
1137
+ * clean verdict, so a local flag reuses that same signal instead of
1138
+ * re-deriving it. A predicate that faults is the OTHER reporter's case,
1139
+ * immediately above, in the same way it already is on the visibility leg.
1140
+ */
1141
+ const evaluateEnablementPredicate = (raw, key) => {
1142
+ let faulted = false;
1143
+ const verdict = evaluator.evaluateCondition(raw, {
1144
+ onFault: (reason) => {
1145
+ faulted = true;
1146
+ reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, reason,
1147
+ // Stated, not defaulted, for both arguments (objectui#6487,
1148
+ // objectui#6445): this is the node tier, whose roots the spec
1149
+ // declares, and the gate whose safe default disables the control.
1150
+ 'page-component', 'enablement');
1151
+ },
1152
+ });
1153
+ if (__DEV__ && !faulted) {
1154
+ // objectui#9308 — same re-aim as the visibility leg above.
1155
+ reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, predicateScope?.data, 'enablement');
1156
+ }
1157
+ return verdict;
1158
+ };
1159
+ /**
1160
+ * Evaluate ONE config value by its key — THE place the `params` rule is
1161
+ * stated (objectui#7867, ruling A, maintainer 「其他同意」 2026-09-20):
1162
+ * *an action's `params` values are templates, evaluated where `properties`
1163
+ * are.*
1164
+ *
1165
+ * A `params` BAG (a plain object — {@link isParamsBag}) has every string
1166
+ * leaf evaluated at any depth, through the same `evaluator` and so against
1167
+ * the same scope as every other value in this memo; every other value
1168
+ * evaluates exactly as it did (per-value and shallow, with a CEL predicate
1169
+ * envelope preserved — {@link preservePredicateEnvelope}). The walk, and
1170
+ * what it will not look inside (non-plain objects, keys, cycles, an ARRAY
1171
+ * `params`, which is the `ActionParam[]` definition list), are defined once
1172
+ * in `utils/paramsBag.ts`, which the unevaluated-expression diagnostic reads
1173
+ * too — so what is evaluated and what is reported as left unresolved are
1174
+ * one radius, not two.
1175
+ *
1176
+ * Three carriers, one rule — the ruling names two of them and the third
1177
+ * follows its canonical bag, as every per-key rule in these loops does:
1178
+ *
1179
+ * 1. node-level `params`, immediately below;
1180
+ * 2. `properties.params`, in the `properties` loop, BEFORE the hoist —
1181
+ * so the object the hoist copies onto the node (and the renderer
1182
+ * reads as `schema.params`) is the evaluated one, and
1183
+ * `schema.properties.params` agrees with it;
1184
+ * 3. `props.params`, in the legacy-alias loop — the two bag loops must
1185
+ * not disagree about a key (objectui#5123, objectui#9100).
1186
+ *
1187
+ * Each authored value is evaluated exactly once: the node-level leg runs
1188
+ * before the hoist, so when `properties.params` wins the hoist it replaces
1189
+ * an evaluated node-level bag with an evaluated bag of its own, and nothing
1190
+ * already evaluated is evaluated again.
1191
+ *
1192
+ * Before this, the node-level bag was never evaluated at all, and
1193
+ * `properties.params` was ONE value of a shallow loop: measured through the
1194
+ * real `SchemaRenderer` -> `action:button` -> runner on a record page bound
1195
+ * to `{ id: 'rec_1' }`, both `params.recordId: '${record.id}'` spellings
1196
+ * reached the handler raw while `properties.label: 'L-${record.id}'`
1197
+ * rendered `L-rec_1`. A template that still cannot resolve keeps its source
1198
+ * text (that is the evaluator's own contract) and is reported by the
1199
+ * unevaluated-expression diagnostic, so a wrong template stays loud.
1200
+ */
1201
+ //
1202
+ // A DATA OBJECT on any other key ({@link isDataObjectValue},
1203
+ // objectui#10288) is handed over as authored: `evaluate` would collapse
1204
+ // one that carries a string `source` to that string.
1205
+ //
1206
+ // The rule itself is `evaluateConfigValueWith`, at module scope, so that
1207
+ // `useConfigBagEvaluator` applies the same one to an action container's
1208
+ // members (objectui#10290).
1209
+ const evaluateConfigValue = (key, value) => evaluateConfigValueWith(evaluator, key, value);
1210
+ // Carrier 1 of the `params` rule above: the node-level bag. A non-bag
1211
+ // node-level `params` (the `ActionParam[]` definition list, or anything
1212
+ // degenerate) is left exactly as authored, as it always was.
1213
+ if (isParamsBag(newSchema[PARAMS_KEY])) {
1214
+ newSchema[PARAMS_KEY] = evaluateConfigValue(PARAMS_KEY, newSchema[PARAMS_KEY]);
1215
+ }
520
1216
  // Evaluate 'properties' — the SPEC spelling of a node's config bag, of
521
1217
  // which `props` (evaluated below) is the legacy alias.
522
1218
  //
@@ -546,24 +1242,57 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
546
1242
  // source today). Deepening that is a separate decision and would have to be
547
1243
  // taken for both spellings at once — not smuggled in on one side here.
548
1244
  //
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.
1245
+ // ONE key is deep, by that separate decision, and on both spellings at once:
1246
+ // `params` (objectui#7867, ruling A) — every string leaf of a `params` bag
1247
+ // is evaluated, through {@link evaluateConfigValue} above. Every other key
1248
+ // keeps the shallow reading this paragraph describes.
1249
+ //
1250
+ // "Passed through" used to have one exception: `evaluate` unwraps an object
1251
+ // with a string `source` to that string, so a data object carrying a
1252
+ // `source` field was collapsed rather than passed. Since objectui#10288 an
1253
+ // object on a non-predicate key reaches `evaluate` only when it is a spec
1254
+ // Expression envelope (it carries a `dialect`); see {@link isDataObjectValue}.
1255
+ //
1256
+ // Guarded by {@link isConfigBag}: a degenerate value must not have its shape
1257
+ // reinterpreted by an object spread. Non-objects skip evaluation and reach
1258
+ // the hoist — which, since objectui#6760, refuses them on its own.
1259
+ //
1260
+ // ⚠️ This guard used to say it was wider than the `props` branch BECAUSE
1261
+ // this value feeds the hoist. That reason did not survive measurement
1262
+ // (objectui#6752). Ablating this guard to bare truthiness and re-rendering
1263
+ // `{ type, properties: 'not-a-bag' }` on `b76ca6764` left the indexed
1264
+ // keys the hoist puts on the node completely UNCHANGED — `0` … `8` either
1265
+ // way, because the hoist's own `Object.entries` walk enumerated a string's
1266
+ // character indices whatever this line did — and moved exactly one thing:
1267
+ // whether `schema.properties` still holds the value the author wrote
1268
+ // (`'not-a-bag'` guarded, `{ '0': 'n', … }` ablated). So what this buys is
1269
+ // the AUTHORED value's shape, which is channel-independent, and the `props`
1270
+ // branch below carries the same guard for the same reason.
1271
+ //
1272
+ // ⭐ That measurement is also why objectui#6760 gave the HOIST its own
1273
+ // guard instead of widening this one: this line never reached the indexed
1274
+ // keys, so nothing written here could have removed them. With both guards
1275
+ // in place the two are in SERIES, not redundant, and re-running the same
1276
+ // ablation now reads the opposite way — measured on this card's branch,
1277
+ // ablating THIS line to bare truthiness brings `0` … `8` BACK, because the
1278
+ // bare spread manufactures a real `{ '0': 'n', … }` bag out of the string
1279
+ // and the hoist below then enumerates it legitimately. Each guard is load
1280
+ // bearing for a different half: this one keeps the authored value's shape,
1281
+ // the hoist's one declines to enumerate a value that never had keys.
553
1282
  // Snapshotted BEFORE evaluation — objectui#5756's diagnostic below reads
554
1283
  // the RAW (pre-collapse) text from this reference, since `newSchema.properties`
555
1284
  // is about to be replaced with the evaluated copy.
556
- const rawPropertiesBag = newSchema.properties &&
557
- typeof newSchema.properties === 'object' &&
558
- !Array.isArray(newSchema.properties)
1285
+ const rawPropertiesBag = isConfigBag(newSchema.properties)
559
1286
  ? newSchema.properties
560
1287
  : undefined;
561
1288
  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;
1289
+ // objectui#9100 — a CEL predicate envelope survives this loop; see
1290
+ // `preservePredicateEnvelope`. objectui#7867 — a `params` bag has every
1291
+ // string leaf evaluated (carrier 2 of `evaluateConfigValue`). Every
1292
+ // other value evaluates as before. The loop is `evaluateConfigBagWith`,
1293
+ // the one `useConfigBagEvaluator` runs over an action container's
1294
+ // member (objectui#10290).
1295
+ newSchema.properties = evaluateConfigBagWith(evaluator, rawPropertiesBag);
567
1296
  }
568
1297
  /**
569
1298
  * objectui#5756 — the `${…}` HALF of the #5687/#5454 silence, reached at
@@ -621,7 +1350,59 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
621
1350
  // (e.g. tab visual style: 'line' | 'card' | 'pill'). Keep `properties`
622
1351
  // intact on the schema so renderers can still read these collision-prone
623
1352
  // keys via `schema.properties.<key>`.
624
- if (newSchema.properties) {
1353
+ //
1354
+ // Guarded by {@link isConfigBag}: a DEGENERATE `properties` contributes no
1355
+ // node keys at all (objectui#6760). Measured on `c6732825d`, this branch's
1356
+ // base, with no part of that card in the tree: `properties: 'not-a-bag'`
1357
+ // reached the element as nine React props named `0` … `8`, and
1358
+ // `properties: ['x', 'y']` as `0`, `1`. Nobody authored those keys — they
1359
+ // are `Object.entries`' reading of a string's character indices, and the
1360
+ // values this loop writes onto the node are spread as React props at the
1361
+ // `createElement` below.
1362
+ //
1363
+ // ## Which arm, and why this one
1364
+ //
1365
+ // objectui#6760 left two open, both cheap, both reversible, neither on the
1366
+ // manual floor (the objectui#6708 census walked every JSON document, every
1367
+ // `json` doc fence and every TypeScript object literal and found ZERO
1368
+ // authored degenerate config bags):
1369
+ //
1370
+ // (a) guard the hoist the way the evaluation memo above is guarded;
1371
+ // (b) rule that the hoist may enumerate whatever it is handed, and say
1372
+ // so here.
1373
+ //
1374
+ // Arm (a), for three reasons, in the order they were weighed:
1375
+ //
1376
+ // 1. **One answer per key, whichever channel reads it.** That is
1377
+ // objectui#5123's ruling (maintainer, 2026-08-18), and arm (b) breaks
1378
+ // it in the loudest available place: after objectui#6752,
1379
+ // `props: 'not-a-bag'` contributes no keys, so arm (b) would answer the
1380
+ // SAME authored mistake two ways depending on which of two spellings of
1381
+ // ONE bag the author used — and the reinterpreting half would be
1382
+ // `properties`, the SPEC spelling, while the quiet half is `props`, the
1383
+ // annotated legacy alias. A rule that punishes the canonical spelling
1384
+ // is not a rule anyone can teach.
1385
+ // 2. **The reason for the guard was measured, and it is channel-**
1386
+ // **independent.** objectui#6752 established it by ablation rather than
1387
+ // by reading a comment (see the evaluation memo above): what a config-bag
1388
+ // guard buys is the AUTHORED value's shape, which has nothing to do with
1389
+ // which bag or which site is asking. The hoist is a third site asking the
1390
+ // same question about the same authored value; a third answer would be
1391
+ // drift, which is what {@link isConfigBag} exists to end (objectui#6761).
1392
+ // 3. **The failure it prevents is silent and lands on generated metadata.**
1393
+ // A degenerate bag is a mistake an author (increasingly, an AI writing
1394
+ // metadata) makes by writing `properties: 'text'` where a bag belongs.
1395
+ // Arm (b) turns that into nine plausible-looking props with no warning
1396
+ // anywhere; arm (a) makes it inert, so the mistake stays legible as the
1397
+ // `properties` value the author actually wrote.
1398
+ //
1399
+ // What arm (a) does NOT change, measured rather than assumed: a real object
1400
+ // bag hoists exactly as before (`type`/`id` still protected above), and
1401
+ // `properties: 42` / `properties: true` were already contributing nothing
1402
+ // — `Object.entries` yields no entries for them, so this guard only ever
1403
+ // moves the string and array cases. Pinned in
1404
+ // `__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx`.
1405
+ if (isConfigBag(newSchema.properties)) {
625
1406
  const outerType = newSchema.type;
626
1407
  const outerId = newSchema.id;
627
1408
  const props = newSchema.properties;
@@ -640,14 +1421,145 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
640
1421
  if (typeof newSchema.content === 'string') {
641
1422
  newSchema.content = evaluator.evaluate(newSchema.content);
642
1423
  }
643
- // Evaluate 'props'
644
- if (newSchema.props) {
1424
+ /**
1425
+ * Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
1426
+ * (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
1427
+ *
1428
+ * ## The hole this closes
1429
+ *
1430
+ * A value reaches the screen only if it is BOTH evaluated here AND read
1431
+ * back by the renderer off the node. `content` above satisfies both; the
1432
+ * other text keys satisfied neither at once, so
1433
+ * `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
1434
+ * `schema.value` (`data-display/statistic.tsx`) — put the literal
1435
+ * `${data.total}` on screen, and the `props`-envelope workaround the
1436
+ * objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
1437
+ * then spread as a React prop nobody reads). `statistic` is the dashboard
1438
+ * workhorse and had no way at all to bind a dynamic value.
1439
+ *
1440
+ * ## Why this is ONE leg here and not a patch in each renderer
1441
+ *
1442
+ * The ruling's own implementation caution: the read-back sites must be
1443
+ * "converged on evaluated values, not patched per component". They already
1444
+ * agree on WHERE to read (the node's top level) — what they lacked was
1445
+ * anything writing an evaluated value there. Writing it at the single
1446
+ * producer of evaluated schema means `statistic.tsx`, `card.tsx` and
1447
+ * `button.tsx` are UNCHANGED by this card and every future top-level reader
1448
+ * is covered by construction. Four fixed components and no contract was the
1449
+ * failure mode named at dispatch.
1450
+ *
1451
+ * ## The vocabulary is the spec's, and is consumed rather than copied
1452
+ *
1453
+ * `@objectstack/spec` declares it (objectstack#9599): the closed set
1454
+ * `title` / `label` / `value` / `description`, plus the per-component
1455
+ * carriage map saying which of them each component type actually reads
1456
+ * back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
1457
+ * declaration "rather than hard-coding a twin list" — so the only thing
1458
+ * this file knows is the name of the lookup. A row added upstream starts
1459
+ * working here with no edit; a row this file invented would be the second
1460
+ * dialect the declaration exists to prevent.
1461
+ *
1462
+ * ## The type string is passed VERBATIM
1463
+ *
1464
+ * `expressionBindableTextKeysFor` keys on the bare registry name, and the
1465
+ * spec states the answer for an unlisted type is the empty set — "closed
1466
+ * and mechanically answerable in both directions, never inferred from what
1467
+ * a renderer happens to read". So no prefix-stripping normalization: it
1468
+ * would look harmless (`ui:statistic` → `statistic`) and would in the same
1469
+ * motion grant rows to `element:button` and `page:card`, whose renderers
1470
+ * read their config out of the bag via `readProps()` and never touch these
1471
+ * keys on the node — re-manufacturing the evaluated-but-not-read-back half
1472
+ * of the very table this card exists to close. Measured on this tree: the
1473
+ * authored corpus spells these types bare (`statistic` 42, `card` 135,
1474
+ * `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
1475
+ * spelling authors actually use. `action:button` (5 nodes) has no row and
1476
+ * is reported upstream rather than inferred here.
1477
+ *
1478
+ * ## Ordering and idempotence
1479
+ *
1480
+ * After the `properties` hoist deliberately, exactly like `content`: a
1481
+ * value arriving through that channel was already evaluated by the
1482
+ * `properties` leg, so it no longer carries a `${…}` and
1483
+ * `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
1484
+ * guard is doing real work rather than mirroring the line above — it stops
1485
+ * this loop CREATING an absent key as `undefined`, which would change what
1486
+ * `{ ...schema }` spreads and what `key in schema` answers downstream.
1487
+ */
1488
+ //
1489
+ // Read once into a local, so the `typeof` guard narrows the value it
1490
+ // hands to `evaluate`. Two of the four keys are `BaseSchema` members, and
1491
+ // the other two are `unknown` on the working copy (objectui#11349). The
1492
+ // spec's carriage map is their declaration, and the guard narrows them.
1493
+ for (const key of expressionBindableTextKeysFor(typeof newSchema.type === 'string' ? newSchema.type : '')) {
1494
+ const authoredText = newSchema[key];
1495
+ if (typeof authoredText === 'string') {
1496
+ newSchema[key] = evaluator.evaluate(authoredText);
1497
+ }
1498
+ }
1499
+ // Evaluate 'props' — the legacy alias of the config bag.
1500
+ //
1501
+ // The guard MIRRORS the `properties` branch above rather than testing bare
1502
+ // truthiness (objectui#6752). Same predicate, both channels: a degenerate
1503
+ // bag must not have its shape reinterpreted by an object spread.
1504
+ //
1505
+ // Measured on `b76ca6764` through the real `SchemaRenderer`, before this
1506
+ // guard existed: a node written `{ type: 'card', props: 'not-a-bag' }`
1507
+ // reached `createElement` carrying NINE React props named `0` … `8`, one
1508
+ // per character, because `{ ...'not-a-bag' }` enumerates a string's
1509
+ // character indices. Nothing threw and nothing was logged, so the symptom
1510
+ // (a component handed nine props it never declared) sits a long way from
1511
+ // the `props` value that caused it.
1512
+ //
1513
+ // This guard is HALF the fix and does not on its own remove those props —
1514
+ // also measured, on the same base with only this line widened: the bag
1515
+ // reached `propsWithoutCanonicalKeys` as the authored string, was returned
1516
+ // unchanged, and `{ ...outgoingPropsBag }` at the `createElement` call
1517
+ // re-enumerated it. The other half is that function's own guard. What this
1518
+ // line buys is that `schema.props` keeps the value the author wrote instead
1519
+ // of the spread's reading of it.
1520
+ if (isConfigBag(newSchema.props)) {
645
1521
  const newProps = { ...newSchema.props };
646
1522
  for (const [key, val] of Object.entries(newProps)) {
647
- newProps[key] = evaluator.evaluate(val);
1523
+ // objectui#9100, same guard as the `properties` branch above — the two
1524
+ // channels must not disagree about whether a `cel` envelope survives,
1525
+ // nor (objectui#7867, carrier 3) about how deep a `params` bag goes.
1526
+ newProps[key] = evaluateConfigValue(key, val);
648
1527
  }
649
1528
  newSchema.props = newProps;
650
1529
  }
1530
+ /**
1531
+ * REFUSE, by name, a node-gate predicate parked under the legacy `props`
1532
+ * alias (objectui#9108, maintainer ruling 2026-09-13, verbatim 「同意」 on
1533
+ * the `domain:spec` seat's recommendation).
1534
+ *
1535
+ * ## Sited HERE, immediately in front of the two gates
1536
+ *
1537
+ * This is the one point where the gates' own input is final: the
1538
+ * `properties` hoist above has run, both config-bag evaluation loops have
1539
+ * run, and neither gate has consulted anything yet. It is also the only
1540
+ * placement that survives its own subject - the late diagnostics near
1541
+ * `createElement` are downstream of `if (shouldHide) return null`, so a node
1542
+ * that parks `visible` under `props` while ALSO hiding through the canonical
1543
+ * spelling would never reach them, and the refusal would go missing on the
1544
+ * one shape that carries both spellings at once.
1545
+ *
1546
+ * ## Read-only, and that is the ruled outcome rather than a limitation
1547
+ *
1548
+ * Nothing below changes. The gates still read the post-hoist node only, so
1549
+ * every verdict, every hoisted value and every byte the element receives is
1550
+ * what it was - the alias is REFUSED, not honoured. The opposite arm was
1551
+ * built and closed (PR objectui#9144): honouring it would have made *"8
1552
+ * predicate keys work while the rest stayed silently dropped - and partly
1553
+ * working is harder to learn from than not working"*.
1554
+ *
1555
+ * The bag handed over is {@link propsWithoutCanonicalKeys}'s, the SAME
1556
+ * subtraction the outgoing props bag uses, so a key the canonical bag also
1557
+ * declares is not reported as parked: there the author is already getting
1558
+ * the canonical answer (objectui#5123). The key SET is
1559
+ * {@link NODE_GATE_PREDICATE_KEYS}, derived from the two chain declarations
1560
+ * above rather than re-listed here.
1561
+ */
1562
+ reportRefusedPropsPredicate(newSchema.type, newSchema.id, NODE_GATE_PREDICATE_KEYS, propsWithoutCanonicalKeys(newSchema.props, newSchema.properties));
651
1563
  // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn
652
1564
  const shouldHide = (() => {
653
1565
  // `visibleWhen` is the single canonical conditional-visibility predicate
@@ -750,12 +1662,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
750
1662
  // value, exactly as before — only the gate in front of it narrowed. An
751
1663
  // undeclared `disabled` now falls through to `disabledOn` instead of
752
1664
  // short-circuiting on an empty predicate.
1665
+ //
1666
+ // Both legs route through `evaluateEnablementPredicate` (objectui#6445), so
1667
+ // a predicate that cannot be evaluated is reported instead of silently
1668
+ // greying the control out. The verdicts below are unchanged: that helper
1669
+ // returns `evaluateCondition`'s own answer, and an UNDECLARED gate never
1670
+ // reaches it at all — `hasDeclaredPredicate` still decides that, one line
1671
+ // earlier, which is what keeps the objectui#3862 empty-shape rows silent
1672
+ // as well as enabled.
753
1673
  const isDisabled = (() => {
754
1674
  if (hasDeclaredPredicate(newSchema.disabled)) {
755
- return evaluator.evaluateCondition(newSchema.disabled);
1675
+ return evaluateEnablementPredicate(newSchema.disabled, 'disabled');
756
1676
  }
757
1677
  if (hasDeclaredPredicate(newSchema.disabledOn)) {
758
- return evaluator.evaluateCondition(newSchema.disabledOn);
1678
+ return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn');
759
1679
  }
760
1680
  return false;
761
1681
  })();
@@ -764,6 +1684,72 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
764
1684
  }
765
1685
  return newSchema;
766
1686
  }, [schema, dataSource, predicateScope, pageVariables, boundRecord]);
1687
+ /**
1688
+ * SDUI scoped styling (ADR-0065): a node's `responsiveStyles` compiles to
1689
+ * id-scoped CSS injected as a `<style>` tag, and a scope class is appended to
1690
+ * the node's className.
1691
+ *
1692
+ * ## Why this is a memo, and why it is HERE and not at its use site
1693
+ *
1694
+ * The scope-class branch rebuilds the schema object (`{ ...evaluatedSchema,
1695
+ * className: mergedClassName }`) so renderers that read `schema.className`
1696
+ * see the scope class. Unmemoised, that spread allocated a NEW object on
1697
+ * every render of this component — even when the `evaluatedSchema` memo
1698
+ * directly above it HELD — so every downstream renderer that memoises on
1699
+ * `[schema]` (e.g. `ObjectMap`'s `dataConfig` / `mapConfig`, and the whole
1700
+ * marker cascade below them) saw a fresh identity and re-ran (objectui#6270).
1701
+ * Only nodes taking this branch were affected: a plain node was handed
1702
+ * `evaluatedSchema` itself and was already stable.
1703
+ *
1704
+ * It is hoisted ABOVE the early returns below because it is a HOOK. Its use
1705
+ * site sits after `if (!evaluatedSchema) return null`, the `_hidden` return
1706
+ * and the unresolved-component returns, so a `useMemo` written there is a
1707
+ * CONDITIONAL hook: a node toggling hidden -> visible would call one more
1708
+ * hook than the previous render and React would throw "Rendered more hooks
1709
+ * than during the previous render". Hoisting is what makes the memo legal,
1710
+ * not a stylistic preference.
1711
+ *
1712
+ * `[evaluatedSchema, autoStyleId]` is the complete dependency set: every
1713
+ * value below is a pure function of the evaluated node (`className`, `id`,
1714
+ * `responsiveStyles`) and the `useId` fallback. `evaluatedSchema` is itself a
1715
+ * fresh object whenever anything it interpolates changes, so a genuinely
1716
+ * changed className, value or breakpoint still produces a NEW identity here —
1717
+ * memoising cannot make a live value go stale.
1718
+ *
1719
+ * Non-object / primitive nodes fall through untouched: the render pass below
1720
+ * returns them as text before any of this is read.
1721
+ */
1722
+ const scopedStyling = useMemo(() => {
1723
+ const node = evaluatedSchema;
1724
+ if (!node || typeof node !== 'object') {
1725
+ return { scopeClass: '', scopedCss: '', mergedClassName: undefined, schemaForComponent: node };
1726
+ }
1727
+ // `unknown` on the working copy (objectui#11349); `hasResponsiveStyles`
1728
+ // narrows it to the spec's `ResponsiveStyles` before anything compiles it.
1729
+ const responsiveStyles = node.responsiveStyles;
1730
+ if (!hasResponsiveStyles(responsiveStyles)) {
1731
+ // No scope class: hand the component `evaluatedSchema` ITSELF, which the
1732
+ // memo above already keeps stable. Never a copy — a copy here would
1733
+ // reintroduce the same instability for every node in the tree.
1734
+ return {
1735
+ scopeClass: '',
1736
+ scopedCss: '',
1737
+ mergedClassName: node.className,
1738
+ schemaForComponent: node,
1739
+ };
1740
+ }
1741
+ const scopeClass = scopeClassFor(node.id ?? autoStyleId);
1742
+ const mergedClassName = [node.className, scopeClass].filter(Boolean).join(' ');
1743
+ return {
1744
+ scopeClass,
1745
+ scopedCss: compileScopedStyles(`.${scopeClass}`, responsiveStyles),
1746
+ mergedClassName,
1747
+ // Some renderers read `schema.className` directly (e.g. element:text)
1748
+ // while others read the `className` prop (e.g. flex/container). Set both
1749
+ // so the scope class lands regardless of which channel a renderer honours.
1750
+ schemaForComponent: { ...node, className: mergedClassName },
1751
+ };
1752
+ }, [evaluatedSchema, autoStyleId]);
767
1753
  if (!evaluatedSchema)
768
1754
  return null;
769
1755
  // If schema is just a string, render it as text
@@ -806,7 +1792,10 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
806
1792
  }
807
1793
  debugLog('schema', 'Component not found in registry', { type: evaluatedSchema.type });
808
1794
  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) })] }));
1795
+ // objectui#5247 — the lookup above still missed and this node still fails.
1796
+ // The suggestion only names the spelling that would have resolved.
1797
+ const caseSuggestion = suggestTypeByCase(evaluatedSchema.type);
1798
+ 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
1799
  }
811
1800
  // Note: We don't forward the ref to the Component because components in the registry
812
1801
  // may not support refs. The SchemaRenderer itself can still receive refs for its own use.
@@ -823,7 +1812,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
823
1812
  // block reported "Couldn't load records" — writing the binding the spec
824
1813
  // documents BROKE the component (objectstack#5576). An explicit React
825
1814
  // `dataSource` prop is unaffected: it arrives via `...props`, spread last.
826
- dataSource: _dataSource, _hidden: __hidden, // stripped: internal visibility flag
1815
+ dataSource: _dataSource,
1816
+ // stripped: `BaseSchema.testId` is the author's stable-locator handle, and
1817
+ // this renderer is what makes its declaration true — it is re-emitted as
1818
+ // `data-testid` beside `data-obj-id` below (objectui#8268, ADR-0054 C4).
1819
+ // Leaving it in the spread was the defect: React does not recognise a
1820
+ // `testId` DOM prop, so it landed as a non-standard lowercase `testid`
1821
+ // attribute that no test-library query helper looks for, while React's own
1822
+ // dev warning told the author to spell it `testid` — steering them further
1823
+ // from the documented `data-testid`.
1824
+ testId: _testId,
1825
+ // stripped: a RUNTIME prop a host injects, never authorable metadata —
1826
+ // decision batch #70 (objectui#7742); objectui#8818 closes the key here.
1827
+ // The `props` alias carrier is closed by `withoutAuthoredObjectFields`.
1828
+ objectFields: _objectFields, _hidden: __hidden, // stripped: internal visibility flag
827
1829
  _disabled: __disabled, // stripped: internal disabled flag
828
1830
  responsiveStyles: _responsiveStyles, // stripped: compiled to scoped CSS, not a DOM prop
829
1831
  ...componentProps } = evaluatedSchema;
@@ -831,35 +1833,146 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
831
1833
  // `${…}` about to be placed verbatim in front of a user.
832
1834
  //
833
1835
  // 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:
1836
+ // the set of values the node OFFERS the component — spread as React props
1837
+ // below, and the same values renderers read as `schema.<key>`. Everything the
1838
+ // destructure stripped is schema METADATA:
837
1839
  // `visible` / `visibleWhen` / `hidden` / `disabled` / … hold raw predicate
838
1840
  // SOURCE by design (they are evaluated as conditions, never placed), so
839
1841
  // scanning before the strip would report every correctly-authored predicate
840
1842
  // in the repo. The strip list is therefore the diagnostic's exclusion list,
841
1843
  // for free and without a second copy of it to drift.
842
1844
  //
1845
+ // ⚠️ It scans `componentProps` and the raw `props` / `properties` bags, NOT
1846
+ // the narrowed bags the objectui#9571 / objectui#9758 strips build below.
1847
+ // Deliberate, and the objectui#8268 `testId` precedent applied a second time:
1848
+ // `data` loses its PROPS seat on the object arm, but it is still authored,
1849
+ // still evaluated, and still read off the schema by the block — so an
1850
+ // unevaluated expression inside it is still in front of a user, and excluding
1851
+ // it here would narrow objectui#4795's coverage by one key for exactly the
1852
+ // blocks the ruling touches. That holds for BOTH carriers: an authored
1853
+ // `props: { data: '${…}' }` is still reported after objectui#9758 refused it
1854
+ // a prop seat.
1855
+ //
843
1856
  // Read-only: it reports what evaluation already produced and changes nothing
844
1857
  // about what is rendered — no DOM attribute either, so no snapshot moves.
845
1858
  if (__DEV__) {
846
- reportUnevaluatedExpressions(schema, evaluatedSchema.type, evaluatedSchema.id, componentProps, evaluatedSchema.properties, evaluatedSchema.props);
1859
+ // `testId` is stripped above but stays in THIS scan set. The strip list is
1860
+ // this diagnostic's exclusion list because every other member holds raw
1861
+ // predicate SOURCE by design; `testId` holds a literal, so excluding it
1862
+ // would silently narrow objectui#4795's coverage by one key — measured on
1863
+ // `93127bd6f`, an authored `testId: '${data.x}'` was reported before this
1864
+ // change, and must still be.
1865
+ reportUnevaluatedExpressions(schema, evaluatedSchema.type, evaluatedSchema.id, _testId === undefined ? componentProps : { ...componentProps, testId: _testId }, evaluatedSchema.properties, evaluatedSchema.props);
1866
+ }
1867
+ // The legacy `props` alias, narrowed exactly as objectui#5123 ruled: for a
1868
+ // key BOTH bags declare, `properties` wins here as it already wins in
1869
+ // `readProps()`, so one key has one answer on both channels.
1870
+ //
1871
+ // HOISTED out of the `createElement` call below (objectui#6708) so the
1872
+ // diagnostic and the spread read the SAME bag. It is the same pure call with
1873
+ // the same arguments producing the same object in the same spread position —
1874
+ // nothing about what any renderer receives moves — but it removes the one way
1875
+ // this diagnostic could go wrong: reporting a set of keys that is not the set
1876
+ // actually handed to the component.
1877
+ //
1878
+ // objectui#9758 narrows it once more, for the same key and on the same arm as
1879
+ // the strip below — see {@link withoutAuthoredDataKey}. The arm is read ONCE,
1880
+ // here, and handed to both call sites: two independent lookups of the same
1881
+ // question is the shape that let the alias keep the seat in the first place.
1882
+ const refusesAuthoredDataProp = recordSourceDataArmForType(evaluatedSchema.type) === 'view-data';
1883
+ // objectui#8818 strips `objectFields` HERE, before the `data` strip below:
1884
+ // that strip's diagnostic decides by identity, so it must compare against a
1885
+ // bag this strip has already settled.
1886
+ const aliasBagAsAuthored = withoutAuthoredObjectFields(propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties));
1887
+ const outgoingPropsBag = withoutAuthoredDataKey(aliasBagAsAuthored, refusesAuthoredDataProp);
1888
+ if (__DEV__ && outgoingPropsBag !== aliasBagAsAuthored) {
1889
+ reportRefusedDataPropSpread(evaluatedSchema.type, evaluatedSchema.id);
1890
+ }
1891
+ // Dev-build diagnostic (objectui#6708, maintainer ruling 2026-08-29, option
1892
+ // 2): those keys are spread as React props and never hoisted onto the node,
1893
+ // so a renderer that reads its config from `schema` — every family except
1894
+ // `element:*`'s `readProps()` — drops them without a word.
1895
+ //
1896
+ // Sited HERE, beside its objectui#4795 neighbour and after the metadata
1897
+ // destructure, for the same reason: this is the point where what leaves this
1898
+ // component for the renderer is finally known. Read-only — it reports what
1899
+ // the line above already computed and changes nothing that is rendered.
1900
+ //
1901
+ // The AUTHORED bag comes from `schema`, not `evaluatedSchema`: the evaluation
1902
+ // memo rebuilds `props` with an object spread, which turns a degenerate
1903
+ // `props: 'text'` into `{ '0': 't', … }` long before this line. See
1904
+ // `collectDroppedPropsKeys`.
1905
+ //
1906
+ // ⚠️ It reads the POST-strip bag (objectui#9758), and that direction is the
1907
+ // opposite of its objectui#4795 neighbour above ON PURPOSE. This message
1908
+ // states that the key "is spread as React props on the created element" and
1909
+ // tells the author to "write them under `properties` instead" — after the
1910
+ // strip BOTH sentences are false for `data` on this arm: it is not spread,
1911
+ // and the canonical spelling loses the seat as well. Its subject stops being
1912
+ // true, so it stops naming the key, and `reportRefusedDataPropSpread` is what
1913
+ // tells the author instead. The objectui#4795 scan reads the WIDER bag for
1914
+ // the mirror-image reason: an unevaluated `${…}` is still in front of a user
1915
+ // after the strip, so its subject survives.
1916
+ if (__DEV__) {
1917
+ reportDroppedPropsBag(evaluatedSchema.type, evaluatedSchema.id, schema?.props, outgoingPropsBag);
1918
+ }
1919
+ /**
1920
+ * objectui#9571 (ruling objectui#8348 Q2-C, decision batch #136 item 3,
1921
+ * maintainer 「同意」): an authored `data` key does NOT take a React prop seat
1922
+ * on a block whose published `data` row is the OBJECT arm (`ViewData`).
1923
+ *
1924
+ * ## The defect this closes
1925
+ *
1926
+ * The spread below hands every non-metadata node key to the component, so an
1927
+ * authored `data` reached such a block TWICE — as `schema.data`, which the
1928
+ * shared ladder judges against the block's row (`resolveRecordSourceConfig`,
1929
+ * objectui#8348), and as the `data` PROP, which `ObjectGrid` (`passedData`),
1930
+ * `ObjectMap` (`dataProp`) and `ObjectGantt` each lift with an unconditional
1931
+ * `Array.isArray` at HIGHER priority than that ladder. The ruling had one
1932
+ * carrier it did not reach, and it was the one that wins: a bare array under
1933
+ * `data` still drew through this renderer after the ladder had retired it.
1934
+ *
1935
+ * ## ⛔ The HOST path is NOT touched, and that is the ruled half
1936
+ *
1937
+ * Option B — gating the PROP on the arm — was refused, because the prop is how
1938
+ * a host (`plugin-list`'s `ListView`, `ObjectView`) hands down rows it already
1939
+ * fetched. Only the AUTHORED key loses its seat: `...props` below is this
1940
+ * component's own React props, spread LAST, so a host rendering
1941
+ * `<SchemaRenderer data={rows} …/>` still delivers `rows`, and a host that
1942
+ * renders the block component directly never passes through here at all.
1943
+ *
1944
+ * ## Scope is the ARM, not a block list
1945
+ *
1946
+ * `recordSourceDataArmForType` answers per registered type, and every type it
1947
+ * does not list keeps today's behaviour verbatim. ⛔ No allowlist of block
1948
+ * NAMES is minted here — that is the second de-facto contract AGENTS.md #0.1
1949
+ * forbids, and the ruling is stated over the arm, not over a census.
1950
+ *
1951
+ * ## Byte-identical when nothing is authored
1952
+ *
1953
+ * The `in` test comes FIRST, so a node that authors no `data` is handed
1954
+ * `componentProps` itself — the same object, in the same spread position, with
1955
+ * no copy allocated. Same discipline as the conditional `data-testid` below,
1956
+ * and for the same measured reason.
1957
+ *
1958
+ * ## ⚠️ TWO bags reach the spread, and this one is only the first
1959
+ *
1960
+ * objectui#9758: the legacy `props` alias bag is spread AFTER this one, so for
1961
+ * the first ruling's whole life an author who spelled the identical key
1962
+ * `props: { data: [...] }` kept the seat this block takes away — the arm
1963
+ * predicate was right and the corpus was one bag short. Both bags now go
1964
+ * through {@link withoutAuthoredDataKey} with the ONE arm reading computed
1965
+ * above (decision batch #167 item 2, letter 剥, maintainer 「其他同意」).
1966
+ * ⛔ Still no validator refusal: whether the alias exists at all is
1967
+ * objectui#4795's pending question ②, and nothing else it carries moves.
1968
+ */
1969
+ const outgoingComponentProps = withoutAuthoredDataKey(componentProps, refusesAuthoredDataProp);
1970
+ if (__DEV__ && outgoingComponentProps !== componentProps) {
1971
+ reportRefusedDataPropSpread(evaluatedSchema.type, evaluatedSchema.id);
847
1972
  }
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;
1973
+ // SDUI scoped styling (ADR-0065) — computed in the memo hoisted above the
1974
+ // early returns; see the doc comment there for why it cannot live here.
1975
+ const { scopeClass, scopedCss, mergedClassName, schemaForComponent } = scopedStyling;
863
1976
  // Extract AriaPropsSchema properties for accessibility
864
1977
  const ariaProps = resolveAriaProps(evaluatedSchema);
865
1978
  // Debug-mode enhancements: extra data attributes + perf tracking
@@ -875,19 +1988,37 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
875
1988
  const renderStart = isDebug ? performance.now() : 0;
876
1989
  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
1990
  schema: schemaForComponent,
878
- ...componentProps, // Spread non-metadata schema properties as props
1991
+ // Spread non-metadata schema properties as props — minus an authored
1992
+ // `data` on the object arm, which the objectui#9571 strip above removed.
1993
+ ...outgoingComponentProps,
879
1994
  // 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),
1995
+ // longer overrides the canonical `properties` bag (objectui#5123,
1996
+ // maintainer ruling 2026-08-18) — and, since objectui#9758, no longer
1997
+ // re-seats an authored `data` on the object arm that the spread above
1998
+ // just refused. Computed above rather than inline, so the objectui#6708
1999
+ // diagnostic names this exact bag — see there.
2000
+ ...outgoingPropsBag,
885
2001
  ...ariaProps, // Inject ARIA attributes from AriaPropsSchema
886
2002
  ...debugAttrs, // Debug-mode data attributes
887
2003
  disabled: __disabled || undefined,
888
2004
  className: mergedClassName,
889
2005
  'data-obj-id': evaluatedSchema.id,
890
2006
  'data-obj-type': evaluatedSchema.type,
2007
+ // objectui#8268 — `BaseSchema.testId`'s declaration promises this
2008
+ // attribute. Emitted here, not spread above, so the author gets the
2009
+ // `data-testid` every query helper looks for instead of the
2010
+ // non-standard `testid` the raw spread produced.
2011
+ //
2012
+ // ⚠️ CONDITIONALLY, and that is load-bearing, not tidiness. Passing
2013
+ // `'data-testid': undefined` unconditionally would put the key in every
2014
+ // node's props bag, and a component that sets its own `data-testid` and
2015
+ // then spreads what it is handed — the ADR-0054 C4 shape, which many
2016
+ // renderers here have — would have its locator overwritten with
2017
+ // `undefined` and lose it. Measured: the unconditional form turned 9
2018
+ // tests red across 5 files, including the three that assert the props
2019
+ // bag is byte-for-byte unchanged (objectui#6708 / #6752 / #6760). A node
2020
+ // that authors no `testId` must be byte-identical to before this card.
2021
+ ...(_testId === undefined ? {} : { 'data-testid': _testId }),
891
2022
  ...(__DEV__ && !_validation.valid ? { 'data-obj-schema-invalid': 'true' } : {}),
892
2023
  ...props
893
2024
  })] }));