@object-ui/react 17.5.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4100 -0
- package/README.md +303 -46
- package/dist/LazyPluginLoader.d.ts +14 -7
- package/dist/LazyPluginLoader.js +14 -7
- package/dist/SchemaRenderer.d.ts +38 -7
- package/dist/SchemaRenderer.js +1603 -60
- package/dist/context/ActionContext.js +40 -1
- package/dist/context/DiscussionContext.d.ts +12 -0
- package/dist/context/DiscussionContext.js +2 -0
- package/dist/context/DrillNavigationContext.d.ts +13 -0
- package/dist/context/HostNavigationContext.d.ts +82 -0
- package/dist/context/HostNavigationContext.js +65 -0
- package/dist/context/InlineEditContext.d.ts +81 -1
- package/dist/context/InlineEditContext.js +8 -2
- package/dist/context/RecordContext.d.ts +48 -5
- package/dist/context/RecordContext.js +10 -1
- package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
- package/dist/context/SchemaRendererContext.d.ts +54 -2
- package/dist/context/SchemaRendererContext.js +38 -5
- package/dist/context/ThemeContext.js +1 -1
- package/dist/context/actionRunnerTranslator.d.ts +27 -0
- package/dist/context/actionRunnerTranslator.js +37 -0
- package/dist/context/index.d.ts +13 -12
- package/dist/context/index.js +13 -12
- package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
- package/dist/element-data-source/ElementDataSourceGate.js +407 -15
- package/dist/hooks/index.d.ts +37 -35
- package/dist/hooks/index.js +44 -35
- package/dist/hooks/useActionEngine.js +47 -4
- package/dist/hooks/useActionRunner.js +4 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
- package/dist/hooks/useActionTextLocalizer.js +33 -8
- package/dist/hooks/useCapabilityGate.js +6 -5
- package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
- package/dist/hooks/useConfigBagEvaluator.js +39 -0
- package/dist/hooks/useCrudShortcuts.js +1 -1
- package/dist/hooks/useDatasetDimensionLabels.js +1 -1
- package/dist/hooks/useDensityMode.d.ts +6 -2
- package/dist/hooks/useDensityMode.js +6 -2
- package/dist/hooks/useDiscovery.d.ts +1 -10
- package/dist/hooks/useDiscovery.js +1 -1
- package/dist/hooks/useETagCache.js +25 -4
- package/dist/hooks/useElementDataSource.d.ts +15 -2
- package/dist/hooks/useElementDataSource.js +29 -9
- package/dist/hooks/useExpression.d.ts +52 -38
- package/dist/hooks/useExpression.js +62 -42
- package/dist/hooks/useFilterScope.d.ts +22 -2
- package/dist/hooks/useFilterScope.js +29 -4
- package/dist/hooks/useGlobalUndo.d.ts +10 -0
- package/dist/hooks/useGlobalUndo.js +112 -46
- package/dist/hooks/useNavigationOverlay.d.ts +77 -12
- package/dist/hooks/useNavigationOverlay.js +119 -9
- package/dist/hooks/useOffline.js +65 -11
- package/dist/hooks/usePageAssignment.d.ts +8 -5
- package/dist/hooks/usePageAssignment.js +38 -17
- package/dist/hooks/usePageTransition.js +1 -1
- package/dist/hooks/usePageVariableActionBridge.js +2 -2
- package/dist/hooks/useRecordSearch.d.ts +34 -1
- package/dist/hooks/useRecordSearch.js +42 -7
- package/dist/hooks/useResolvedFilter.d.ts +46 -0
- package/dist/hooks/useResolvedFilter.js +130 -0
- package/dist/hooks/useSchemaPersistence.d.ts +7 -0
- package/dist/hooks/useSchemaPersistence.js +144 -5
- package/dist/hooks/useSettledSchema.d.ts +123 -0
- package/dist/hooks/useSettledSchema.js +186 -0
- package/dist/hooks/useViewData.d.ts +7 -2
- package/dist/hooks/useViewData.js +1 -1
- package/dist/hooks/useViewTransition.js +1 -1
- package/dist/index.d.ts +18 -11
- package/dist/index.js +54 -10
- package/dist/schema-input.d.ts +55 -14
- package/dist/schema-input.js +55 -12
- package/dist/utils/configBag.d.ts +68 -0
- package/dist/utils/configBag.js +70 -0
- package/dist/utils/error-message.d.ts +59 -0
- package/dist/utils/error-message.js +116 -1
- package/dist/utils/inlineAria.d.ts +61 -0
- package/dist/utils/inlineAria.js +70 -0
- package/dist/utils/nonGridRowCeiling.d.ts +44 -0
- package/dist/utils/nonGridRowCeiling.js +78 -0
- package/dist/utils/paramsBag.d.ts +28 -0
- package/dist/utils/paramsBag.js +146 -0
- package/dist/utils/propsBagDiagnostic.d.ts +196 -0
- package/dist/utils/propsBagDiagnostic.js +421 -0
- package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
- package/dist/utils/refusedDataPropDiagnostic.js +94 -0
- package/dist/utils/unevaluatedExpression.d.ts +110 -0
- package/dist/utils/unevaluatedExpression.js +257 -0
- package/dist/utils/visibilityDiagnostic.d.ts +371 -0
- package/dist/utils/visibilityDiagnostic.js +551 -0
- package/package.json +10 -10
- package/dist/hooks/useClientNotifications.d.ts +0 -47
- package/dist/hooks/useClientNotifications.js +0 -162
- package/dist/spec-bridge/SpecBridge.d.ts +0 -28
- package/dist/spec-bridge/SpecBridge.js +0 -56
- package/dist/spec-bridge/bridges/form-view.d.ts +0 -82
- package/dist/spec-bridge/bridges/form-view.js +0 -126
- package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
- package/dist/spec-bridge/bridges/list-view.js +0 -241
- package/dist/spec-bridge/index.d.ts +0 -11
- package/dist/spec-bridge/index.js +0 -10
- package/dist/spec-bridge/types.d.ts +0 -55
- package/dist/spec-bridge/types.js +0 -8
package/dist/SchemaRenderer.js
CHANGED
|
@@ -7,11 +7,19 @@ 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';
|
|
11
|
-
import { SchemaRendererContext } from './context/SchemaRendererContext';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
10
|
+
import { ComponentRegistry, ExpressionEvaluator, isObjectUIError, ERROR_CODES, debugLog, debugTime, debugTimeEnd, DebugCollector, validateSchema, hasDeclaredPredicate, hasResponsiveStyles, scopeClassFor, compileScopedStyles, recordSourceDataArmForType, } from '@object-ui/core';
|
|
11
|
+
import { SchemaRendererContext } from './context/SchemaRendererContext.js';
|
|
12
|
+
import { useRecordContext } from './context/RecordContext.js';
|
|
13
|
+
import { usePredicateScope } from './hooks/useExpression.js';
|
|
14
|
+
import { usePageVariables } from './hooks/usePageVariables.js';
|
|
15
|
+
import { resolveKeyedI18nLabel } from './utils/i18n.js';
|
|
16
|
+
import { isConfigBag } from './utils/configBag.js';
|
|
17
|
+
import { PARAMS_KEY, isParamsBag, mapParamsLeaves } from './utils/paramsBag.js';
|
|
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';
|
|
22
|
+
import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js';
|
|
15
23
|
/**
|
|
16
24
|
* Dev-mode schema validation.
|
|
17
25
|
*
|
|
@@ -38,6 +46,7 @@ const _validationCache = typeof WeakMap !== 'undefined'
|
|
|
38
46
|
: { set() { }, get() { return undefined; }, has() { return false; } };
|
|
39
47
|
const _warnedSchemas = typeof WeakSet !== 'undefined' ? new WeakSet() : { add() { }, has() { return false; } };
|
|
40
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.
|
|
41
50
|
if (!__DEV__ || !schema || typeof schema !== 'object') {
|
|
42
51
|
return { valid: true, messages: [] };
|
|
43
52
|
}
|
|
@@ -56,7 +65,6 @@ function validateSchemaOnce(schema) {
|
|
|
56
65
|
entry = { valid: false, messages: msgs };
|
|
57
66
|
if (!_warnedSchemas.has(schema)) {
|
|
58
67
|
_warnedSchemas.add(schema);
|
|
59
|
-
// eslint-disable-next-line no-console
|
|
60
68
|
console.warn('[ObjectUI] Invalid schema detected:\n' + msgs.join('\n'), schema);
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -65,7 +73,6 @@ function validateSchemaOnce(schema) {
|
|
|
65
73
|
// Validator itself failed — surface but don't crash render.
|
|
66
74
|
if (!_warnedSchemas.has(schema)) {
|
|
67
75
|
_warnedSchemas.add(schema);
|
|
68
|
-
// eslint-disable-next-line no-console
|
|
69
76
|
console.warn('[ObjectUI] Schema validator threw:', err);
|
|
70
77
|
}
|
|
71
78
|
}
|
|
@@ -80,6 +87,18 @@ function validateSchemaOnce(schema) {
|
|
|
80
87
|
* ariaLabel: string | I18nLabel (→ aria-label)
|
|
81
88
|
* ariaDescribedBy: string (→ aria-describedby)
|
|
82
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.
|
|
83
102
|
*/
|
|
84
103
|
function resolveAriaProps(schema) {
|
|
85
104
|
const aria = {};
|
|
@@ -94,6 +113,575 @@ function resolveAriaProps(schema) {
|
|
|
94
113
|
}
|
|
95
114
|
return aria;
|
|
96
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Keys the `properties` hoist deliberately refuses to copy onto the node — they
|
|
118
|
+
* identify WHICH renderer to dispatch to, so an inner `properties.type` (e.g. a
|
|
119
|
+
* tab's visual style `line` | `card` | `pill`) must never shadow the outer
|
|
120
|
+
* component descriptor. See the hoist in the evaluation memo below.
|
|
121
|
+
*/
|
|
122
|
+
const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
|
|
123
|
+
/**
|
|
124
|
+
* The legacy `props` bag, minus every key the canonical `properties` bag also
|
|
125
|
+
* declares (objectui#5123).
|
|
126
|
+
*
|
|
127
|
+
* A node may spell its config bag `properties` (the spec spelling) or `props`
|
|
128
|
+
* (the annotated legacy alias). A node that writes BOTH used to get a DIFFERENT
|
|
129
|
+
* answer for the same key depending on which channel read it, and the two
|
|
130
|
+
* channels' precedence was exactly OPPOSITE:
|
|
131
|
+
*
|
|
132
|
+
* - config bag: `readProps()` in the `element:*` family merges
|
|
133
|
+
* `{ ...schema.props, ...schema.properties }` — `properties` wins.
|
|
134
|
+
* - React prop: `createElement` below spread `...componentProps` (which
|
|
135
|
+
* carries the hoisted `properties.*` values) and then
|
|
136
|
+
* `...(evaluatedSchema.props || {})` LAST, which overwrote them —
|
|
137
|
+
* `props` won.
|
|
138
|
+
*
|
|
139
|
+
* Measured on one render of one node carrying both:
|
|
140
|
+
* `bagRead(properties.content)="FROM_PROPERTIES"` while
|
|
141
|
+
* `reactPropRead(content)="FROM_PROPS"`. Which one reached the screen depended
|
|
142
|
+
* only on whether the renderer happened to be in the `readProps()` family.
|
|
143
|
+
*
|
|
144
|
+
* Maintainer ruling 2026-08-18: **`properties` wins on BOTH channels — one
|
|
145
|
+
* answer per key.** The config-bag order was already correct and is untouched;
|
|
146
|
+
* this narrowing is the React-prop half. It restores the declared posture
|
|
147
|
+
* (`props` is an annotated legacy alias) rather than changing it.
|
|
148
|
+
*
|
|
149
|
+
* Implemented by REMOVING the overlapping keys from the alias spread rather
|
|
150
|
+
* than by re-spreading `properties` after it. That matters: `properties.*` is
|
|
151
|
+
* already on the node via the hoist, so the values are in `componentProps`
|
|
152
|
+
* having passed the metadata strip. Re-spreading the raw bag would push
|
|
153
|
+
* stripped schema metadata back out as React props — exactly the regression
|
|
154
|
+
* that made a spec-documented `dataSource` binding shadow the injected adapter
|
|
155
|
+
* and break the component (objectstack#5576). Subtracting adds no key to the
|
|
156
|
+
* outgoing bag; it only decides which of two co-present values a key carries.
|
|
157
|
+
*
|
|
158
|
+
* Scope, deliberately narrow — this only moves a reading where BOTH bags
|
|
159
|
+
* declare the same key:
|
|
160
|
+
* - a key only `props` declares is untouched (the alias keeps working);
|
|
161
|
+
* - a key only `properties` declares is untouched (it already won);
|
|
162
|
+
* - `type`/`id` are skipped, because the hoist never copied a canonical value
|
|
163
|
+
* up for them, so dropping the alias would DELETE the prop rather than
|
|
164
|
+
* replace it;
|
|
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.)
|
|
172
|
+
*
|
|
173
|
+
* Adjacent but NOT decided here: objectui#4795's pending question ② (whether
|
|
174
|
+
* the `properties` envelope is an official `ui:*` authoring channel at all).
|
|
175
|
+
* This is a precedence rule between two co-present spellings and takes no
|
|
176
|
+
* position on whether either should exist.
|
|
177
|
+
*/
|
|
178
|
+
function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
|
|
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))
|
|
191
|
+
return {};
|
|
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)) {
|
|
195
|
+
return propsBag;
|
|
196
|
+
}
|
|
197
|
+
let narrowed = null;
|
|
198
|
+
for (const key of Object.keys(propertiesBag)) {
|
|
199
|
+
if (HOIST_PROTECTED_KEYS.has(key))
|
|
200
|
+
continue;
|
|
201
|
+
if (!Object.prototype.hasOwnProperty.call(propsBag, key))
|
|
202
|
+
continue;
|
|
203
|
+
// Copy lazily: the overwhelmingly common node declares one bag or neither,
|
|
204
|
+
// and this runs on every render of every node.
|
|
205
|
+
if (!narrowed)
|
|
206
|
+
narrowed = { ...propsBag };
|
|
207
|
+
delete narrowed[key];
|
|
208
|
+
}
|
|
209
|
+
return narrowed ?? propsBag;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The visibility-chain keys, in the EXACT precedence order the `shouldHide`
|
|
213
|
+
* IIFE in the evaluation memo consults them (objectui#5756).
|
|
214
|
+
*
|
|
215
|
+
* That chain is one sequence of `if (…) return …` — an early-return chain,
|
|
216
|
+
* not independent checks — so only the FIRST key here found "declared" on a
|
|
217
|
+
* node is ever the one that decides, and every lower-priority key is never
|
|
218
|
+
* even asked. `SHOW` keys ask "is it `!== undefined`?" (their `true` means
|
|
219
|
+
* *shown*, so "absent" already means shown and needs no narrower test);
|
|
220
|
+
* `HIDE` keys ask {@link hasDeclaredPredicate} instead, because their `true`
|
|
221
|
+
* means *hidden* and the wider `!== undefined` test would vanish a node for
|
|
222
|
+
* `hidden: ''` (objectui#3955). See {@link winningVisibilityKey}.
|
|
223
|
+
*/
|
|
224
|
+
const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'];
|
|
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';
|
|
583
|
+
/**
|
|
584
|
+
* Which ONE visibility key actually decides a node's fate, mirroring both the
|
|
585
|
+
* `properties` hoist's precedence (same-named key: `properties.<key>`
|
|
586
|
+
* overwrites a node-level `<key>`) and `shouldHide`'s early-return chain
|
|
587
|
+
* (first-declared-wins across {@link VISIBILITY_SHOW_KEYS} then
|
|
588
|
+
* {@link VISIBILITY_HIDE_KEYS}) — without waiting for either to run.
|
|
589
|
+
*
|
|
590
|
+
* ## Why this exists (objectui#5756)
|
|
591
|
+
*
|
|
592
|
+
* A `${…}` template written as `properties.visible` is evaluated — and
|
|
593
|
+
* collapsed to a plain boolean — by the loop that runs BEFORE the hoist, in
|
|
594
|
+
* the SAME render pass. By the time `shouldHide` runs, the predicate TEXT is
|
|
595
|
+
* gone; there is nothing left to diagnose. Reaching it means asking "does
|
|
596
|
+
* THIS properties key actually decide?" at the one point in the render where
|
|
597
|
+
* the raw text still exists — which means asking the precedence question
|
|
598
|
+
* EARLY, on values the hoist has not yet merged and `shouldHide` has not yet
|
|
599
|
+
* consulted.
|
|
600
|
+
*
|
|
601
|
+
* ## Why the precedence question, and not just "is it in `properties`?"
|
|
602
|
+
*
|
|
603
|
+
* Without it, a `properties.visible` template that a co-declared `visibleWhen`
|
|
604
|
+
* OUTRANKS (exactly `shouldHide`'s own early-return: `visibleWhen` short-
|
|
605
|
+
* circuits before `visible` is ever reached) would be reported even though it
|
|
606
|
+
* decides nothing — a false positive for a leg that never runs. Restricting
|
|
607
|
+
* the report to the WINNING key mirrors what `shouldHide` already does for
|
|
608
|
+
* every OTHER key spelling (objectui#5454's leg-3 reporter is only ever
|
|
609
|
+
* invoked on the leg that is actually consulted, by construction of the same
|
|
610
|
+
* early-return chain) rather than inventing a different posture for this one.
|
|
611
|
+
*
|
|
612
|
+
* ## Why `node.properties` must already be POST-evaluation here
|
|
613
|
+
*
|
|
614
|
+
* The caller runs this AFTER the `properties.*` evaluation loop (so
|
|
615
|
+
* `node.properties.visible` may already be the collapsed boolean, not the raw
|
|
616
|
+
* template) and BEFORE the hoist (so a plain node-level key, untouched by that
|
|
617
|
+
* loop, is still exactly what `shouldHide` will see). That ordering is what
|
|
618
|
+
* makes "declared" answered here agree bit-for-bit with what `shouldHide`
|
|
619
|
+
* answers later — including the corner a naive PRE-evaluation read would get
|
|
620
|
+
* wrong: a template that evaluates to a literal `undefined` (e.g.
|
|
621
|
+
* `${data.missing}` read alone) is `!== undefined` on its RAW text but not on
|
|
622
|
+
* its EVALUATED value, and `shouldHide` falls through to the next key for
|
|
623
|
+
* exactly that reason. Reading pre-eval text here would call `visible` the
|
|
624
|
+
* winner when the real chain does not.
|
|
625
|
+
*
|
|
626
|
+
* Read-only: decides nothing about visibility itself, only which key a
|
|
627
|
+
* DIAGNOSTIC should look at.
|
|
628
|
+
*/
|
|
629
|
+
function winningVisibilityKey(node) {
|
|
630
|
+
const propertiesBag = node.properties;
|
|
631
|
+
const hasPropertiesBag = isConfigBag(propertiesBag);
|
|
632
|
+
const effective = (key) => hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)
|
|
633
|
+
? propertiesBag[key]
|
|
634
|
+
: node[key];
|
|
635
|
+
for (const key of VISIBILITY_SHOW_KEYS) {
|
|
636
|
+
if (effective(key) !== undefined)
|
|
637
|
+
return key;
|
|
638
|
+
}
|
|
639
|
+
for (const key of VISIBILITY_HIDE_KEYS) {
|
|
640
|
+
if (hasDeclaredPredicate(effective(key)))
|
|
641
|
+
return key;
|
|
642
|
+
}
|
|
643
|
+
return undefined;
|
|
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
|
+
}
|
|
97
685
|
export class SchemaErrorBoundary extends Component {
|
|
98
686
|
constructor() {
|
|
99
687
|
super(...arguments);
|
|
@@ -146,6 +734,64 @@ export class SchemaErrorBoundary extends Component {
|
|
|
146
734
|
* `useState` wiped (objectui#2954's latent hazard, made real by this line).
|
|
147
735
|
*/
|
|
148
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
|
+
}
|
|
149
795
|
/**
|
|
150
796
|
* The renderer loop.
|
|
151
797
|
*
|
|
@@ -190,10 +836,35 @@ const NO_DATA_SOURCE = {};
|
|
|
190
836
|
export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
191
837
|
const context = useContext(SchemaRendererContext);
|
|
192
838
|
const dataSource = context?.dataSource || NO_DATA_SOURCE;
|
|
193
|
-
// Ambient host scope (user /
|
|
839
|
+
// Ambient host scope (user / features), fed by app-shell's
|
|
194
840
|
// ExpressionProvider. Threaded into `visible`/expression evaluation so
|
|
195
841
|
// component predicates can gate on the signed-in user & deployment flags.
|
|
196
842
|
const predicateScope = usePredicateScope();
|
|
843
|
+
// The row the page is about, from `RecordContextProvider` (objectui#5454).
|
|
844
|
+
//
|
|
845
|
+
// `@objectstack/spec` has always DECLARED that a component node's
|
|
846
|
+
// `visibleWhen` binds it — `page.zod.ts`: *"Binds `record`, `current_user`,
|
|
847
|
+
// `page.<var>`"* — and until this change the evaluator below bound no
|
|
848
|
+
// `record` at all, on any block, on any record page. A `record.*` predicate
|
|
849
|
+
// therefore could not resolve; this surface is fail-soft, so it resolved to
|
|
850
|
+
// SHOWN. That is not a gate that misfires, it is a gate that never gates:
|
|
851
|
+
// both polarities of the same predicate returned the same verdict, measured
|
|
852
|
+
// on `record:alert`, `record:path`, `page:card` and `element:text`.
|
|
853
|
+
//
|
|
854
|
+
// Bound as the `record` ROOT ONLY — the exact three roots the describe
|
|
855
|
+
// promises, and nothing else:
|
|
856
|
+
//
|
|
857
|
+
// * NOT as bare fields. `page:tabs`' item-level predicate spreads the row
|
|
858
|
+
// flat as well (`containers.tsx`), but that breadth is undeclared on
|
|
859
|
+
// both surfaces, and contract-first (AGENTS.md #0.1) says bind what the
|
|
860
|
+
// spec states rather than accrete a second de-facto spelling here.
|
|
861
|
+
// * NOT over `data`. `data` on this evaluator is the data-source ADAPTER
|
|
862
|
+
// from `SchemaRendererContext`, which is what `${data.total}` in a
|
|
863
|
+
// `properties` / `props` / `content` value resolves against — a
|
|
864
|
+
// documented, pinned binding. Overwriting it with the row would be a
|
|
865
|
+
// silent interpolation change nobody asked for.
|
|
866
|
+
const recordContext = useRecordContext();
|
|
867
|
+
const boundRecord = recordContext?.data;
|
|
197
868
|
// Page-local state (PageSchema.variables), provided by PageVariablesProvider.
|
|
198
869
|
// Exposed to predicates/bindings under `page.<var>` so an interactive element
|
|
199
870
|
// (e.g. element:record_picker) writing a variable can drive another
|
|
@@ -231,19 +902,446 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
231
902
|
// an object spread.
|
|
232
903
|
if (!schema || typeof schema !== 'object')
|
|
233
904
|
return schema;
|
|
234
|
-
//
|
|
235
|
-
// is aliased to `user` so both `user.email` and
|
|
236
|
-
// resolve in component `visible`/`visibleOn`
|
|
237
|
-
// page-local state so predicates can gate on
|
|
238
|
-
// picker's selection toggling another
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
//
|
|
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.
|
|
932
|
+
//
|
|
933
|
+
// `record` is written AFTER the ambient spread so a page's own row wins
|
|
934
|
+
// over anything a host put in the scope — the same precedence
|
|
935
|
+
// `usePredicateRecordContext` states for the `useCondition` tier. And it is
|
|
936
|
+
// written CONDITIONALLY, for the other half of that same rule: no row binds
|
|
937
|
+
// NOTHING rather than an empty object. `{ record: undefined }` would SHADOW
|
|
938
|
+
// a `record` a host had legitimately supplied through the ambient scope
|
|
939
|
+
// (how `action:group`'s dropdown leaf is driven), turning "this surface has
|
|
940
|
+
// no row of its own" into "this surface's row is empty" — only the latter
|
|
941
|
+
// is entitled to shadow.
|
|
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.
|
|
246
950
|
const newSchema = { ...schema };
|
|
951
|
+
/**
|
|
952
|
+
* Evaluate ONE visibility predicate, and make an unresolvable one LOUD
|
|
953
|
+
* (objectui#5454, leg 3 of the 2026-08-21 ruling).
|
|
954
|
+
*
|
|
955
|
+
* ## The verdict is byte-for-byte what it was — only the silence moved
|
|
956
|
+
*
|
|
957
|
+
* `evaluateCondition` answers an unresolvable predicate with `true`, on
|
|
958
|
+
* every one of its three internal paths: the CEL envelope fails soft to its
|
|
959
|
+
* `true` fallback, a bare expression that throws is caught and returns
|
|
960
|
+
* `true`, and a `${…}` template that throws returns its own SOURCE TEXT,
|
|
961
|
+
* which is a non-empty string and therefore truthy. Passing
|
|
962
|
+
* `throwOnError: true` changes none of those verdicts — it only converts
|
|
963
|
+
* "could not evaluate" from a value into a throw — so this helper returns
|
|
964
|
+
* `true` from the catch and reproduces the old answer exactly. That is why
|
|
965
|
+
* it is safe on the two NON-negated legs (`hidden` / `hiddenOn`) as well,
|
|
966
|
+
* where the same `true` means HIDE rather than SHOW.
|
|
967
|
+
*
|
|
968
|
+
* ## Why it needed saying at all
|
|
969
|
+
*
|
|
970
|
+
* A fail-soft surface answers "this predicate is broken" and "this
|
|
971
|
+
* predicate said yes" with the same word. On the negated legs that word is
|
|
972
|
+
* SHOWN, so a `record.*` gate written before objectui#5454 bound the row
|
|
973
|
+
* rendered its block unconditionally and looked exactly like a gate the
|
|
974
|
+
* author had got right. One of the three paths was already loud — the CEL
|
|
975
|
+
* envelope's `evalFieldPredicate` warns (objectstack#5149) — and the other
|
|
976
|
+
* two were mute, so whether an author heard about their own typo depended
|
|
977
|
+
* on which dialect they happened to write it in.
|
|
978
|
+
*
|
|
979
|
+
* Deduped per (node type, key, predicate source): a broken predicate is
|
|
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.
|
|
994
|
+
*
|
|
995
|
+
* ## Defined HERE, ahead of the `properties` evaluation loop below
|
|
996
|
+
*
|
|
997
|
+
* It used to sit just above `shouldHide`, its only caller. objectui#5756
|
|
998
|
+
* added a second call site — inside the `properties` loop below, on the
|
|
999
|
+
* raw (pre-evaluation) text of whichever key that loop is about to collapse
|
|
1000
|
+
* — so the definition moved up to be in scope for both. Nothing about the
|
|
1001
|
+
* function changed; `shouldHide` below still calls it exactly as before,
|
|
1002
|
+
* on the POST-evaluation, POST-hoist schema, for the real verdict.
|
|
1003
|
+
*/
|
|
1004
|
+
const evaluateVisibilityPredicate = (raw, key) => {
|
|
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
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
const verdict = evaluator.evaluateCondition(raw, { throwOnError: true });
|
|
1042
|
+
// objectui#5687 — the NON-throwing half of the same silence, and it is
|
|
1043
|
+
// reachable ONLY here, on the branch where the predicate evaluated
|
|
1044
|
+
// cleanly. A `data.*` read the adapter cannot answer is not a fault the
|
|
1045
|
+
// evaluator can raise: `undefined == 'draft'` is a perfectly good
|
|
1046
|
+
// `false`. Verdict untouched — `verdict` is returned exactly as
|
|
1047
|
+
// computed, which is what keeps the ruling's "no verdict changes" true
|
|
1048
|
+
// by construction rather than by review.
|
|
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);
|
|
1056
|
+
return verdict;
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err, 'page-component', gate);
|
|
1060
|
+
// The historical fail-soft answer, unchanged — and identical to what
|
|
1061
|
+
// the production branch above returns for the same input, which is what
|
|
1062
|
+
// keeps the two branches one behaviour rather than two. See the
|
|
1063
|
+
// docblock: `true` is the value EVERY path of `evaluateCondition`
|
|
1064
|
+
// already returned for an unevaluable predicate.
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
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
|
+
}
|
|
1216
|
+
// Evaluate 'properties' — the SPEC spelling of a node's config bag, of
|
|
1217
|
+
// which `props` (evaluated below) is the legacy alias.
|
|
1218
|
+
//
|
|
1219
|
+
// objectui#4799: only `props` used to be evaluated here. Renderers in the
|
|
1220
|
+
// `element:*` namespace read their config out of `schema.properties` FIRST
|
|
1221
|
+
// (`readProps()` merges `{ ...schema.props, ...schema.properties }`), so a
|
|
1222
|
+
// node written the canonical way handed the renderer the RAW `${…}` source
|
|
1223
|
+
// and put it on screen verbatim, while the same node written with the
|
|
1224
|
+
// "legacy alias" evaluated correctly. Measured through a real render with
|
|
1225
|
+
// `dataSource: { total: 99 }`: `props: { content: '${data.total}' }` → `99`,
|
|
1226
|
+
// `properties: { content: '${data.total}' }` → `${data.total}`. That is the
|
|
1227
|
+
// inverse of contract-first (AGENTS.md #0.1) — the tolerant spelling was
|
|
1228
|
+
// fed and the canonical one starved — so the fix belongs HERE, at the one
|
|
1229
|
+
// producer of evaluated schema, not as a second read in each consumer.
|
|
1230
|
+
//
|
|
1231
|
+
// Order matters: this runs BEFORE the hoist block below, because that block
|
|
1232
|
+
// copies every `properties.*` value onto the node's top level (and those
|
|
1233
|
+
// copies are spread as React props at render). Evaluating first is what
|
|
1234
|
+
// makes one key mean one thing whether it is read as `schema.properties.x`,
|
|
1235
|
+
// as `schema.x`, or as the `x` prop. It also leaves the `content` leg below
|
|
1236
|
+
// idempotent: a value evaluated here no longer carries a `${…}`.
|
|
1237
|
+
//
|
|
1238
|
+
// Per-value and SHALLOW, matching the `props` branch exactly:
|
|
1239
|
+
// `evaluator.evaluate` returns every non-string untouched, so a nested
|
|
1240
|
+
// object/array value is passed through rather than walked (measured: an
|
|
1241
|
+
// `aria: { label: '${data.total}' }` nested under EITHER key renders the raw
|
|
1242
|
+
// source today). Deepening that is a separate decision and would have to be
|
|
1243
|
+
// taken for both spellings at once — not smuggled in on one side here.
|
|
1244
|
+
//
|
|
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.
|
|
1282
|
+
// Snapshotted BEFORE evaluation — objectui#5756's diagnostic below reads
|
|
1283
|
+
// the RAW (pre-collapse) text from this reference, since `newSchema.properties`
|
|
1284
|
+
// is about to be replaced with the evaluated copy.
|
|
1285
|
+
const rawPropertiesBag = isConfigBag(newSchema.properties)
|
|
1286
|
+
? newSchema.properties
|
|
1287
|
+
: undefined;
|
|
1288
|
+
if (rawPropertiesBag) {
|
|
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);
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* objectui#5756 — the `${…}` HALF of the #5687/#5454 silence, reached at
|
|
1299
|
+
* the one point in this render where it still can be: BEFORE the hoist
|
|
1300
|
+
* (which would bury it under the node-level keys) and immediately AFTER
|
|
1301
|
+
* the loop above (so `newSchema.properties` already reflects which key
|
|
1302
|
+
* would win the SAME precedence `shouldHide` applies later — see
|
|
1303
|
+
* {@link winningVisibilityKey}).
|
|
1304
|
+
*
|
|
1305
|
+
* A `${…}`-spelled visibility predicate written inside `properties` is
|
|
1306
|
+
* evaluated — and collapsed to a plain boolean — by the loop directly
|
|
1307
|
+
* above, before `shouldHide` (and its #5454/#5687 reporter) ever sees it.
|
|
1308
|
+
* The bare-string spelling of the SAME gate (`properties: { visible:
|
|
1309
|
+
* "data.status == 'draft'" }`) is untouched by that loop (no `${` to
|
|
1310
|
+
* interpolate) and reaches `shouldHide` intact, where the existing
|
|
1311
|
+
* reporter already catches it — this block exists only to give the
|
|
1312
|
+
* template spelling the same fate, not a different one.
|
|
1313
|
+
*
|
|
1314
|
+
* ## What this does NOT do
|
|
1315
|
+
*
|
|
1316
|
+
* - **No verdict change.** This call's return value is discarded; the
|
|
1317
|
+
* REAL verdict is still decided by `shouldHide` below, off the
|
|
1318
|
+
* POST-evaluation, POST-hoist schema, exactly as before this card.
|
|
1319
|
+
* - **No new report shape.** It is the SAME `evaluateVisibilityPredicate`
|
|
1320
|
+
* `shouldHide` calls — same two reporters, same dedupe `Set`, same
|
|
1321
|
+
* `__DEV__` gate — called one render-step earlier, on the text this
|
|
1322
|
+
* step is about to erase. There is nothing here to duplicate the LATER
|
|
1323
|
+
* call: by the time `shouldHide` runs, this key's value is already the
|
|
1324
|
+
* collapsed boolean, which carries no `data.` text for either reporter
|
|
1325
|
+
* to match — so at most one of the two calls ever fires per predicate.
|
|
1326
|
+
* - **Only the WINNING key.** Gated on {@link winningVisibilityKey} so a
|
|
1327
|
+
* `properties.visible` template that a co-declared `visibleWhen`
|
|
1328
|
+
* outranks is not reported for deciding nothing (mirrors #5454's own
|
|
1329
|
+
* leg semantics: its reporter is likewise only ever invoked on the leg
|
|
1330
|
+
* `shouldHide` actually consults).
|
|
1331
|
+
* - **Only what would actually collapse.** A bare string (no `${`) is
|
|
1332
|
+
* untouched by the loop above and is left for the existing gate-time
|
|
1333
|
+
* report to catch, unchanged.
|
|
1334
|
+
*/
|
|
1335
|
+
if (__DEV__) {
|
|
1336
|
+
const winningKey = winningVisibilityKey(newSchema);
|
|
1337
|
+
if (winningKey && rawPropertiesBag && Object.prototype.hasOwnProperty.call(rawPropertiesBag, winningKey)) {
|
|
1338
|
+
const rawWinningValue = rawPropertiesBag[winningKey];
|
|
1339
|
+
if (typeof rawWinningValue === 'string' && rawWinningValue.includes('${')) {
|
|
1340
|
+
// Diagnostic only — the boolean this returns is thrown away.
|
|
1341
|
+
evaluateVisibilityPredicate(rawWinningValue, winningKey);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
247
1345
|
// COMPAT: Hoist 'properties' up to schema level
|
|
248
1346
|
// This allows support for strict configs that wrap all props in 'properties'.
|
|
249
1347
|
// IMPORTANT: never let inner `properties.type` / `properties.id` shadow the
|
|
@@ -252,7 +1350,59 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
252
1350
|
// (e.g. tab visual style: 'line' | 'card' | 'pill'). Keep `properties`
|
|
253
1351
|
// intact on the schema so renderers can still read these collision-prone
|
|
254
1352
|
// keys via `schema.properties.<key>`.
|
|
255
|
-
|
|
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)) {
|
|
256
1406
|
const outerType = newSchema.type;
|
|
257
1407
|
const outerId = newSchema.id;
|
|
258
1408
|
const props = newSchema.properties;
|
|
@@ -271,35 +1421,186 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
271
1421
|
if (typeof newSchema.content === 'string') {
|
|
272
1422
|
newSchema.content = evaluator.evaluate(newSchema.content);
|
|
273
1423
|
}
|
|
274
|
-
|
|
275
|
-
|
|
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)) {
|
|
276
1521
|
const newProps = { ...newSchema.props };
|
|
277
1522
|
for (const [key, val] of Object.entries(newProps)) {
|
|
278
|
-
|
|
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);
|
|
279
1527
|
}
|
|
280
1528
|
newSchema.props = newProps;
|
|
281
1529
|
}
|
|
282
|
-
|
|
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));
|
|
1563
|
+
// Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn
|
|
283
1564
|
const shouldHide = (() => {
|
|
284
|
-
if (newSchema.visible !== undefined) {
|
|
285
|
-
return !evaluator.evaluateCondition(newSchema.visible);
|
|
286
|
-
}
|
|
287
1565
|
// `visibleWhen` is the single canonical conditional-visibility predicate
|
|
288
|
-
// across every layer since ADR-0089 (show-when-truthy)
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
1566
|
+
// across every layer since ADR-0089 (show-when-truthy), and it is the one
|
|
1567
|
+
// this shape DECLARES: `PageComponentSchema` is a closed object that
|
|
1568
|
+
// declares `visibleWhen` (+ the deprecated `visibility`) and REFUSES a
|
|
1569
|
+
// node-level `visible` outright, with the prescription "move it up one
|
|
1570
|
+
// level to the component node's own `visibleWhen` … inside `properties`
|
|
1571
|
+
// it is hoisted onto the node by the renderer but evaluated by nothing"
|
|
1572
|
+
// (`component.zod.ts`, COMPONENT_NODE_VISIBILITY_KEYS).
|
|
1573
|
+
//
|
|
1574
|
+
// It is tested FIRST — ahead of `visible` — since objectui#5454. The
|
|
1575
|
+
// hoist block above copies every `properties.*` value onto the node, so a
|
|
1576
|
+
// node carrying `properties.visible` arrived here with `schema.visible`
|
|
1577
|
+
// set, and the `visible` leg used to short-circuit before the DECLARED
|
|
1578
|
+
// node predicate was ever consulted. Measured on `record:alert`,
|
|
1579
|
+
// `record:path`, `page:card` and `element:text`: `properties.visible`
|
|
1580
|
+
// present and truthy made a co-declared `visibleWhen: false` a no-op, so
|
|
1581
|
+
// the one key the spec tells authors to write was the one key that could
|
|
1582
|
+
// be silently ignored. A declared node predicate now outranks a hoisted
|
|
1583
|
+
// renderer prop; when both resolve to "show", both still have to.
|
|
292
1584
|
if (newSchema.visibleWhen !== undefined) {
|
|
293
|
-
return !
|
|
1585
|
+
return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen');
|
|
294
1586
|
}
|
|
295
|
-
//
|
|
1587
|
+
// `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and
|
|
1588
|
+
// the landing spot of a hoisted `properties.visible`. Kept ABOVE the two
|
|
1589
|
+
// deprecated aliases: they normalize into `visibleWhen` at parse, so a
|
|
1590
|
+
// spec-parsed page never reaches them, and re-ranking them would move
|
|
1591
|
+
// verdicts for raw metadata that objectui#5454 did not rule on.
|
|
1592
|
+
if (newSchema.visible !== undefined) {
|
|
1593
|
+
return !evaluateVisibilityPredicate(newSchema.visible, 'visible');
|
|
1594
|
+
}
|
|
1595
|
+
// @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw /
|
|
1596
|
+
// un-normalized metadata reaching the renderer.
|
|
296
1597
|
if (newSchema.visibleOn !== undefined) {
|
|
297
|
-
return !
|
|
1598
|
+
return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn');
|
|
298
1599
|
}
|
|
299
1600
|
// @deprecated ADR-0089 → `visibleWhen` (was PageNodeSchema.visibility,
|
|
300
1601
|
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
|
|
301
1602
|
if (newSchema.visibility !== undefined) {
|
|
302
|
-
return !
|
|
1603
|
+
return !evaluateVisibilityPredicate(newSchema.visibility, 'visibility');
|
|
303
1604
|
}
|
|
304
1605
|
// Ask "is a `hidden` gate DECLARED?" — not "is the key present?"
|
|
305
1606
|
// (objectui#3955). These two legs are the only ones in this chain whose
|
|
@@ -320,10 +1621,10 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
320
1621
|
// equivalence, and pinned as a behaviour change: an UNDECLARED `hidden` no
|
|
321
1622
|
// longer short-circuits, so a declared `hiddenOn` is finally consulted.
|
|
322
1623
|
if (hasDeclaredPredicate(newSchema.hidden)) {
|
|
323
|
-
return
|
|
1624
|
+
return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
|
|
324
1625
|
}
|
|
325
1626
|
if (hasDeclaredPredicate(newSchema.hiddenOn)) {
|
|
326
|
-
return
|
|
1627
|
+
return evaluateVisibilityPredicate(newSchema.hiddenOn, 'hiddenOn');
|
|
327
1628
|
}
|
|
328
1629
|
return false;
|
|
329
1630
|
})();
|
|
@@ -361,12 +1662,20 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
361
1662
|
// value, exactly as before — only the gate in front of it narrowed. An
|
|
362
1663
|
// undeclared `disabled` now falls through to `disabledOn` instead of
|
|
363
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.
|
|
364
1673
|
const isDisabled = (() => {
|
|
365
1674
|
if (hasDeclaredPredicate(newSchema.disabled)) {
|
|
366
|
-
return
|
|
1675
|
+
return evaluateEnablementPredicate(newSchema.disabled, 'disabled');
|
|
367
1676
|
}
|
|
368
1677
|
if (hasDeclaredPredicate(newSchema.disabledOn)) {
|
|
369
|
-
return
|
|
1678
|
+
return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn');
|
|
370
1679
|
}
|
|
371
1680
|
return false;
|
|
372
1681
|
})();
|
|
@@ -374,7 +1683,73 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
374
1683
|
newSchema._disabled = true;
|
|
375
1684
|
}
|
|
376
1685
|
return newSchema;
|
|
377
|
-
}, [schema, dataSource, predicateScope, pageVariables]);
|
|
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]);
|
|
378
1753
|
if (!evaluatedSchema)
|
|
379
1754
|
return null;
|
|
380
1755
|
// If schema is just a string, render it as text
|
|
@@ -417,7 +1792,10 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
417
1792
|
}
|
|
418
1793
|
debugLog('schema', 'Component not found in registry', { type: evaluatedSchema.type });
|
|
419
1794
|
const errorInfo = ERROR_CODES['OBJUI-001'];
|
|
420
|
-
|
|
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) })] }));
|
|
421
1799
|
}
|
|
422
1800
|
// Note: We don't forward the ref to the Component because components in the registry
|
|
423
1801
|
// may not support refs. The SchemaRenderer itself can still receive refs for its own use.
|
|
@@ -434,25 +1812,167 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
434
1812
|
// block reported "Couldn't load records" — writing the binding the spec
|
|
435
1813
|
// documents BROKE the component (objectstack#5576). An explicit React
|
|
436
1814
|
// `dataSource` prop is unaffected: it arrives via `...props`, spread last.
|
|
437
|
-
dataSource: _dataSource,
|
|
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
|
|
438
1829
|
_disabled: __disabled, // stripped: internal disabled flag
|
|
439
1830
|
responsiveStyles: _responsiveStyles, // stripped: compiled to scoped CSS, not a DOM prop
|
|
440
1831
|
...componentProps } = evaluatedSchema;
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1832
|
+
// Dev-build loud diagnostic (objectui#4795, Direction 3): an unevaluated
|
|
1833
|
+
// `${…}` about to be placed verbatim in front of a user.
|
|
1834
|
+
//
|
|
1835
|
+
// Sited HERE, after the metadata destructure, on purpose. `componentProps` is
|
|
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:
|
|
1839
|
+
// `visible` / `visibleWhen` / `hidden` / `disabled` / … hold raw predicate
|
|
1840
|
+
// SOURCE by design (they are evaluated as conditions, never placed), so
|
|
1841
|
+
// scanning before the strip would report every correctly-authored predicate
|
|
1842
|
+
// in the repo. The strip list is therefore the diagnostic's exclusion list,
|
|
1843
|
+
// for free and without a second copy of it to drift.
|
|
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
|
+
//
|
|
1856
|
+
// Read-only: it reports what evaluation already produced and changes nothing
|
|
1857
|
+
// about what is rendered — no DOM attribute either, so no snapshot moves.
|
|
1858
|
+
if (__DEV__) {
|
|
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);
|
|
1972
|
+
}
|
|
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;
|
|
456
1976
|
// Extract AriaPropsSchema properties for accessibility
|
|
457
1977
|
const ariaProps = resolveAriaProps(evaluatedSchema);
|
|
458
1978
|
// Debug-mode enhancements: extra data attributes + perf tracking
|
|
@@ -468,14 +1988,37 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
468
1988
|
const renderStart = isDebug ? performance.now() : 0;
|
|
469
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, {
|
|
470
1990
|
schema: schemaForComponent,
|
|
471
|
-
|
|
472
|
-
|
|
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,
|
|
1994
|
+
// The legacy `props` alias still overrides plain top-level keys, but no
|
|
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,
|
|
473
2001
|
...ariaProps, // Inject ARIA attributes from AriaPropsSchema
|
|
474
2002
|
...debugAttrs, // Debug-mode data attributes
|
|
475
2003
|
disabled: __disabled || undefined,
|
|
476
2004
|
className: mergedClassName,
|
|
477
2005
|
'data-obj-id': evaluatedSchema.id,
|
|
478
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 }),
|
|
479
2022
|
...(__DEV__ && !_validation.valid ? { 'data-obj-schema-invalid': 'true' } : {}),
|
|
480
2023
|
...props
|
|
481
2024
|
})] }));
|