@object-ui/react 17.5.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +334 -0
- package/README.md +9 -8
- package/dist/LazyPluginLoader.d.ts +14 -7
- package/dist/LazyPluginLoader.js +14 -7
- package/dist/SchemaRenderer.js +434 -22
- package/dist/context/HostNavigationContext.d.ts +82 -0
- package/dist/context/HostNavigationContext.js +65 -0
- package/dist/context/RelatedRecordActionsContext.d.ts +15 -0
- package/dist/context/index.d.ts +13 -12
- package/dist/context/index.js +13 -12
- package/dist/element-data-source/ElementDataSourceGate.d.ts +75 -2
- package/dist/element-data-source/ElementDataSourceGate.js +81 -2
- package/dist/hooks/index.d.ts +35 -35
- package/dist/hooks/index.js +35 -35
- package/dist/hooks/useActionEngine.js +1 -1
- package/dist/hooks/useCapabilityGate.js +6 -5
- package/dist/hooks/useClientNotifications.js +2 -2
- package/dist/hooks/useCrudShortcuts.js +1 -1
- package/dist/hooks/useDatasetDimensionLabels.js +1 -1
- package/dist/hooks/useDiscovery.js +1 -1
- package/dist/hooks/useElementDataSource.js +5 -2
- package/dist/hooks/useExpression.d.ts +24 -3
- package/dist/hooks/useExpression.js +56 -4
- package/dist/hooks/useNavigationOverlay.d.ts +1 -1
- package/dist/hooks/usePageAssignment.js +1 -1
- package/dist/hooks/usePageTransition.js +1 -1
- package/dist/hooks/usePageVariableActionBridge.js +2 -2
- package/dist/hooks/useViewData.js +1 -1
- package/dist/hooks/useViewTransition.js +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.js +10 -10
- package/dist/schema-input.d.ts +16 -5
- package/dist/schema-input.js +15 -4
- package/dist/spec-bridge/SpecBridge.d.ts +1 -1
- package/dist/spec-bridge/SpecBridge.js +2 -2
- package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
- package/dist/spec-bridge/bridges/form-view.js +51 -7
- package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
- package/dist/spec-bridge/bridges/list-view.js +37 -10
- package/dist/spec-bridge/index.d.ts +4 -4
- package/dist/spec-bridge/index.js +3 -3
- package/dist/utils/error-message.d.ts +59 -0
- package/dist/utils/error-message.js +116 -1
- package/dist/utils/unevaluatedExpression.d.ts +69 -0
- package/dist/utils/unevaluatedExpression.js +194 -0
- package/dist/utils/visibilityDiagnostic.d.ts +119 -0
- package/dist/utils/visibilityDiagnostic.js +284 -0
- package/package.json +7 -7
package/dist/SchemaRenderer.js
CHANGED
|
@@ -8,10 +8,13 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
8
8
|
*/
|
|
9
9
|
import React, { forwardRef, useContext, useMemo, useEffect, useReducer, useState, Component } from 'react';
|
|
10
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 {
|
|
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 { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
|
|
17
|
+
import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, } from './utils/visibilityDiagnostic.js';
|
|
15
18
|
/**
|
|
16
19
|
* Dev-mode schema validation.
|
|
17
20
|
*
|
|
@@ -56,7 +59,6 @@ function validateSchemaOnce(schema) {
|
|
|
56
59
|
entry = { valid: false, messages: msgs };
|
|
57
60
|
if (!_warnedSchemas.has(schema)) {
|
|
58
61
|
_warnedSchemas.add(schema);
|
|
59
|
-
// eslint-disable-next-line no-console
|
|
60
62
|
console.warn('[ObjectUI] Invalid schema detected:\n' + msgs.join('\n'), schema);
|
|
61
63
|
}
|
|
62
64
|
}
|
|
@@ -65,7 +67,6 @@ function validateSchemaOnce(schema) {
|
|
|
65
67
|
// Validator itself failed — surface but don't crash render.
|
|
66
68
|
if (!_warnedSchemas.has(schema)) {
|
|
67
69
|
_warnedSchemas.add(schema);
|
|
68
|
-
// eslint-disable-next-line no-console
|
|
69
70
|
console.warn('[ObjectUI] Schema validator threw:', err);
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -94,6 +95,167 @@ function resolveAriaProps(schema) {
|
|
|
94
95
|
}
|
|
95
96
|
return aria;
|
|
96
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Keys the `properties` hoist deliberately refuses to copy onto the node — they
|
|
100
|
+
* identify WHICH renderer to dispatch to, so an inner `properties.type` (e.g. a
|
|
101
|
+
* tab's visual style `line` | `card` | `pill`) must never shadow the outer
|
|
102
|
+
* component descriptor. See the hoist in the evaluation memo below.
|
|
103
|
+
*/
|
|
104
|
+
const HOIST_PROTECTED_KEYS = new Set(['type', 'id']);
|
|
105
|
+
/**
|
|
106
|
+
* The legacy `props` bag, minus every key the canonical `properties` bag also
|
|
107
|
+
* declares (objectui#5123).
|
|
108
|
+
*
|
|
109
|
+
* A node may spell its config bag `properties` (the spec spelling) or `props`
|
|
110
|
+
* (the annotated legacy alias). A node that writes BOTH used to get a DIFFERENT
|
|
111
|
+
* answer for the same key depending on which channel read it, and the two
|
|
112
|
+
* channels' precedence was exactly OPPOSITE:
|
|
113
|
+
*
|
|
114
|
+
* - config bag: `readProps()` in the `element:*` family merges
|
|
115
|
+
* `{ ...schema.props, ...schema.properties }` — `properties` wins.
|
|
116
|
+
* - React prop: `createElement` below spread `...componentProps` (which
|
|
117
|
+
* carries the hoisted `properties.*` values) and then
|
|
118
|
+
* `...(evaluatedSchema.props || {})` LAST, which overwrote them —
|
|
119
|
+
* `props` won.
|
|
120
|
+
*
|
|
121
|
+
* Measured on one render of one node carrying both:
|
|
122
|
+
* `bagRead(properties.content)="FROM_PROPERTIES"` while
|
|
123
|
+
* `reactPropRead(content)="FROM_PROPS"`. Which one reached the screen depended
|
|
124
|
+
* only on whether the renderer happened to be in the `readProps()` family.
|
|
125
|
+
*
|
|
126
|
+
* Maintainer ruling 2026-08-18: **`properties` wins on BOTH channels — one
|
|
127
|
+
* answer per key.** The config-bag order was already correct and is untouched;
|
|
128
|
+
* this narrowing is the React-prop half. It restores the declared posture
|
|
129
|
+
* (`props` is an annotated legacy alias) rather than changing it.
|
|
130
|
+
*
|
|
131
|
+
* Implemented by REMOVING the overlapping keys from the alias spread rather
|
|
132
|
+
* than by re-spreading `properties` after it. That matters: `properties.*` is
|
|
133
|
+
* already on the node via the hoist, so the values are in `componentProps`
|
|
134
|
+
* having passed the metadata strip. Re-spreading the raw bag would push
|
|
135
|
+
* stripped schema metadata back out as React props — exactly the regression
|
|
136
|
+
* that made a spec-documented `dataSource` binding shadow the injected adapter
|
|
137
|
+
* and break the component (objectstack#5576). Subtracting adds no key to the
|
|
138
|
+
* outgoing bag; it only decides which of two co-present values a key carries.
|
|
139
|
+
*
|
|
140
|
+
* Scope, deliberately narrow — this only moves a reading where BOTH bags
|
|
141
|
+
* declare the same key:
|
|
142
|
+
* - a key only `props` declares is untouched (the alias keeps working);
|
|
143
|
+
* - a key only `properties` declares is untouched (it already won);
|
|
144
|
+
* - `type`/`id` are skipped, because the hoist never copied a canonical value
|
|
145
|
+
* up for them, so dropping the alias would DELETE the prop rather than
|
|
146
|
+
* replace it;
|
|
147
|
+
* - a degenerate (non-object) `properties` is left alone — the hoist and
|
|
148
|
+
* `readProps()` both merely object-spread it, and there is no canonical bag
|
|
149
|
+
* to prefer.
|
|
150
|
+
*
|
|
151
|
+
* Adjacent but NOT decided here: objectui#4795's pending question ② (whether
|
|
152
|
+
* the `properties` envelope is an official `ui:*` authoring channel at all).
|
|
153
|
+
* This is a precedence rule between two co-present spellings and takes no
|
|
154
|
+
* position on whether either should exist.
|
|
155
|
+
*/
|
|
156
|
+
function propsWithoutCanonicalKeys(propsBag, propertiesBag) {
|
|
157
|
+
if (!propsBag)
|
|
158
|
+
return {};
|
|
159
|
+
// Only a real object bag can win a key. `typeof null === 'object'` is covered
|
|
160
|
+
// by the truthiness check; arrays are excluded for the same reason the
|
|
161
|
+
// evaluation guard excludes them — a degenerate `properties` must not have
|
|
162
|
+
// its shape reinterpreted here.
|
|
163
|
+
if (!propertiesBag ||
|
|
164
|
+
typeof propertiesBag !== 'object' ||
|
|
165
|
+
Array.isArray(propertiesBag)) {
|
|
166
|
+
return propsBag;
|
|
167
|
+
}
|
|
168
|
+
let narrowed = null;
|
|
169
|
+
for (const key of Object.keys(propertiesBag)) {
|
|
170
|
+
if (HOIST_PROTECTED_KEYS.has(key))
|
|
171
|
+
continue;
|
|
172
|
+
if (!Object.prototype.hasOwnProperty.call(propsBag, key))
|
|
173
|
+
continue;
|
|
174
|
+
// Copy lazily: the overwhelmingly common node declares one bag or neither,
|
|
175
|
+
// and this runs on every render of every node.
|
|
176
|
+
if (!narrowed)
|
|
177
|
+
narrowed = { ...propsBag };
|
|
178
|
+
delete narrowed[key];
|
|
179
|
+
}
|
|
180
|
+
return narrowed ?? propsBag;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The visibility-chain keys, in the EXACT precedence order the `shouldHide`
|
|
184
|
+
* IIFE in the evaluation memo consults them (objectui#5756).
|
|
185
|
+
*
|
|
186
|
+
* That chain is one sequence of `if (…) return …` — an early-return chain,
|
|
187
|
+
* not independent checks — so only the FIRST key here found "declared" on a
|
|
188
|
+
* node is ever the one that decides, and every lower-priority key is never
|
|
189
|
+
* even asked. `SHOW` keys ask "is it `!== undefined`?" (their `true` means
|
|
190
|
+
* *shown*, so "absent" already means shown and needs no narrower test);
|
|
191
|
+
* `HIDE` keys ask {@link hasDeclaredPredicate} instead, because their `true`
|
|
192
|
+
* means *hidden* and the wider `!== undefined` test would vanish a node for
|
|
193
|
+
* `hidden: ''` (objectui#3955). See {@link winningVisibilityKey}.
|
|
194
|
+
*/
|
|
195
|
+
const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'];
|
|
196
|
+
const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'];
|
|
197
|
+
/**
|
|
198
|
+
* Which ONE visibility key actually decides a node's fate, mirroring both the
|
|
199
|
+
* `properties` hoist's precedence (same-named key: `properties.<key>`
|
|
200
|
+
* overwrites a node-level `<key>`) and `shouldHide`'s early-return chain
|
|
201
|
+
* (first-declared-wins across {@link VISIBILITY_SHOW_KEYS} then
|
|
202
|
+
* {@link VISIBILITY_HIDE_KEYS}) — without waiting for either to run.
|
|
203
|
+
*
|
|
204
|
+
* ## Why this exists (objectui#5756)
|
|
205
|
+
*
|
|
206
|
+
* A `${…}` template written as `properties.visible` is evaluated — and
|
|
207
|
+
* collapsed to a plain boolean — by the loop that runs BEFORE the hoist, in
|
|
208
|
+
* the SAME render pass. By the time `shouldHide` runs, the predicate TEXT is
|
|
209
|
+
* gone; there is nothing left to diagnose. Reaching it means asking "does
|
|
210
|
+
* THIS properties key actually decide?" at the one point in the render where
|
|
211
|
+
* the raw text still exists — which means asking the precedence question
|
|
212
|
+
* EARLY, on values the hoist has not yet merged and `shouldHide` has not yet
|
|
213
|
+
* consulted.
|
|
214
|
+
*
|
|
215
|
+
* ## Why the precedence question, and not just "is it in `properties`?"
|
|
216
|
+
*
|
|
217
|
+
* Without it, a `properties.visible` template that a co-declared `visibleWhen`
|
|
218
|
+
* OUTRANKS (exactly `shouldHide`'s own early-return: `visibleWhen` short-
|
|
219
|
+
* circuits before `visible` is ever reached) would be reported even though it
|
|
220
|
+
* decides nothing — a false positive for a leg that never runs. Restricting
|
|
221
|
+
* the report to the WINNING key mirrors what `shouldHide` already does for
|
|
222
|
+
* every OTHER key spelling (objectui#5454's leg-3 reporter is only ever
|
|
223
|
+
* invoked on the leg that is actually consulted, by construction of the same
|
|
224
|
+
* early-return chain) rather than inventing a different posture for this one.
|
|
225
|
+
*
|
|
226
|
+
* ## Why `node.properties` must already be POST-evaluation here
|
|
227
|
+
*
|
|
228
|
+
* The caller runs this AFTER the `properties.*` evaluation loop (so
|
|
229
|
+
* `node.properties.visible` may already be the collapsed boolean, not the raw
|
|
230
|
+
* template) and BEFORE the hoist (so a plain node-level key, untouched by that
|
|
231
|
+
* loop, is still exactly what `shouldHide` will see). That ordering is what
|
|
232
|
+
* makes "declared" answered here agree bit-for-bit with what `shouldHide`
|
|
233
|
+
* answers later — including the corner a naive PRE-evaluation read would get
|
|
234
|
+
* wrong: a template that evaluates to a literal `undefined` (e.g.
|
|
235
|
+
* `${data.missing}` read alone) is `!== undefined` on its RAW text but not on
|
|
236
|
+
* its EVALUATED value, and `shouldHide` falls through to the next key for
|
|
237
|
+
* exactly that reason. Reading pre-eval text here would call `visible` the
|
|
238
|
+
* winner when the real chain does not.
|
|
239
|
+
*
|
|
240
|
+
* Read-only: decides nothing about visibility itself, only which key a
|
|
241
|
+
* DIAGNOSTIC should look at.
|
|
242
|
+
*/
|
|
243
|
+
function winningVisibilityKey(node) {
|
|
244
|
+
const propertiesBag = node.properties;
|
|
245
|
+
const hasPropertiesBag = propertiesBag != null && typeof propertiesBag === 'object' && !Array.isArray(propertiesBag);
|
|
246
|
+
const effective = (key) => hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)
|
|
247
|
+
? propertiesBag[key]
|
|
248
|
+
: node[key];
|
|
249
|
+
for (const key of VISIBILITY_SHOW_KEYS) {
|
|
250
|
+
if (effective(key) !== undefined)
|
|
251
|
+
return key;
|
|
252
|
+
}
|
|
253
|
+
for (const key of VISIBILITY_HIDE_KEYS) {
|
|
254
|
+
if (hasDeclaredPredicate(effective(key)))
|
|
255
|
+
return key;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
97
259
|
export class SchemaErrorBoundary extends Component {
|
|
98
260
|
constructor() {
|
|
99
261
|
super(...arguments);
|
|
@@ -194,6 +356,31 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
194
356
|
// ExpressionProvider. Threaded into `visible`/expression evaluation so
|
|
195
357
|
// component predicates can gate on the signed-in user & deployment flags.
|
|
196
358
|
const predicateScope = usePredicateScope();
|
|
359
|
+
// The row the page is about, from `RecordContextProvider` (objectui#5454).
|
|
360
|
+
//
|
|
361
|
+
// `@objectstack/spec` has always DECLARED that a component node's
|
|
362
|
+
// `visibleWhen` binds it — `page.zod.ts`: *"Binds `record`, `current_user`,
|
|
363
|
+
// `page.<var>`"* — and until this change the evaluator below bound no
|
|
364
|
+
// `record` at all, on any block, on any record page. A `record.*` predicate
|
|
365
|
+
// therefore could not resolve; this surface is fail-soft, so it resolved to
|
|
366
|
+
// SHOWN. That is not a gate that misfires, it is a gate that never gates:
|
|
367
|
+
// both polarities of the same predicate returned the same verdict, measured
|
|
368
|
+
// on `record:alert`, `record:path`, `page:card` and `element:text`.
|
|
369
|
+
//
|
|
370
|
+
// Bound as the `record` ROOT ONLY — the exact three roots the describe
|
|
371
|
+
// promises, and nothing else:
|
|
372
|
+
//
|
|
373
|
+
// * NOT as bare fields. `page:tabs`' item-level predicate spreads the row
|
|
374
|
+
// flat as well (`containers.tsx`), but that breadth is undeclared on
|
|
375
|
+
// both surfaces, and contract-first (AGENTS.md #0.1) says bind what the
|
|
376
|
+
// spec states rather than accrete a second de-facto spelling here.
|
|
377
|
+
// * NOT over `data`. `data` on this evaluator is the data-source ADAPTER
|
|
378
|
+
// from `SchemaRendererContext`, which is what `${data.total}` in a
|
|
379
|
+
// `properties` / `props` / `content` value resolves against — a
|
|
380
|
+
// documented, pinned binding. Overwriting it with the row would be a
|
|
381
|
+
// silent interpolation change nobody asked for.
|
|
382
|
+
const recordContext = useRecordContext();
|
|
383
|
+
const boundRecord = recordContext?.data;
|
|
197
384
|
// Page-local state (PageSchema.variables), provided by PageVariablesProvider.
|
|
198
385
|
// Exposed to predicates/bindings under `page.<var>` so an interactive element
|
|
199
386
|
// (e.g. element:record_picker) writing a variable can drive another
|
|
@@ -236,14 +423,196 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
236
423
|
// resolve in component `visible`/`visibleOn` expressions. `page` exposes
|
|
237
424
|
// page-local state so predicates can gate on `page.<var>` (e.g. a record
|
|
238
425
|
// picker's selection toggling another component's visibility).
|
|
426
|
+
//
|
|
427
|
+
// `record` is written AFTER the ambient spread so a page's own row wins
|
|
428
|
+
// over anything a host put in the scope — the same precedence
|
|
429
|
+
// `usePredicateRecordContext` states for the `useCondition` tier. And it is
|
|
430
|
+
// written CONDITIONALLY, for the other half of that same rule: no row binds
|
|
431
|
+
// NOTHING rather than an empty object. `{ record: undefined }` would SHADOW
|
|
432
|
+
// a `record` a host had legitimately supplied through the ambient scope
|
|
433
|
+
// (how `action:group`'s dropdown leaf is driven), turning "this surface has
|
|
434
|
+
// no row of its own" into "this surface's row is empty" — only the latter
|
|
435
|
+
// is entitled to shadow.
|
|
239
436
|
const evaluator = new ExpressionEvaluator({
|
|
240
437
|
...predicateScope,
|
|
241
438
|
current_user: predicateScope?.user,
|
|
439
|
+
...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord)
|
|
440
|
+
? { record: boundRecord }
|
|
441
|
+
: null),
|
|
242
442
|
data: dataSource,
|
|
243
443
|
page: pageVariables,
|
|
244
444
|
});
|
|
245
445
|
// Shallow copy
|
|
246
446
|
const newSchema = { ...schema };
|
|
447
|
+
/**
|
|
448
|
+
* Evaluate ONE visibility predicate, and make an unresolvable one LOUD
|
|
449
|
+
* (objectui#5454, leg 3 of the 2026-08-21 ruling).
|
|
450
|
+
*
|
|
451
|
+
* ## The verdict is byte-for-byte what it was — only the silence moved
|
|
452
|
+
*
|
|
453
|
+
* `evaluateCondition` answers an unresolvable predicate with `true`, on
|
|
454
|
+
* every one of its three internal paths: the CEL envelope fails soft to its
|
|
455
|
+
* `true` fallback, a bare expression that throws is caught and returns
|
|
456
|
+
* `true`, and a `${…}` template that throws returns its own SOURCE TEXT,
|
|
457
|
+
* which is a non-empty string and therefore truthy. Passing
|
|
458
|
+
* `throwOnError: true` changes none of those verdicts — it only converts
|
|
459
|
+
* "could not evaluate" from a value into a throw — so this helper returns
|
|
460
|
+
* `true` from the catch and reproduces the old answer exactly. That is why
|
|
461
|
+
* it is safe on the two NON-negated legs (`hidden` / `hiddenOn`) as well,
|
|
462
|
+
* where the same `true` means HIDE rather than SHOW.
|
|
463
|
+
*
|
|
464
|
+
* ## Why it needed saying at all
|
|
465
|
+
*
|
|
466
|
+
* A fail-soft surface answers "this predicate is broken" and "this
|
|
467
|
+
* predicate said yes" with the same word. On the negated legs that word is
|
|
468
|
+
* SHOWN, so a `record.*` gate written before objectui#5454 bound the row
|
|
469
|
+
* rendered its block unconditionally and looked exactly like a gate the
|
|
470
|
+
* author had got right. One of the three paths was already loud — the CEL
|
|
471
|
+
* envelope's `evalFieldPredicate` warns (objectstack#5149) — and the other
|
|
472
|
+
* two were mute, so whether an author heard about their own typo depended
|
|
473
|
+
* on which dialect they happened to write it in.
|
|
474
|
+
*
|
|
475
|
+
* Deduped per (node type, key, predicate source): a broken predicate is
|
|
476
|
+
* re-evaluated on every render, and the point is one line, not a wall.
|
|
477
|
+
*
|
|
478
|
+
* ## Defined HERE, ahead of the `properties` evaluation loop below
|
|
479
|
+
*
|
|
480
|
+
* It used to sit just above `shouldHide`, its only caller. objectui#5756
|
|
481
|
+
* added a second call site — inside the `properties` loop below, on the
|
|
482
|
+
* raw (pre-evaluation) text of whichever key that loop is about to collapse
|
|
483
|
+
* — so the definition moved up to be in scope for both. Nothing about the
|
|
484
|
+
* function changed; `shouldHide` below still calls it exactly as before,
|
|
485
|
+
* on the POST-evaluation, POST-hoist schema, for the real verdict.
|
|
486
|
+
*/
|
|
487
|
+
const evaluateVisibilityPredicate = (raw, key) => {
|
|
488
|
+
// PRODUCTION IS THE UNTOUCHED CALL. `throwOnError` is how the fault is
|
|
489
|
+
// detected, and on the CEL branch `evaluateCelCondition` implements it by
|
|
490
|
+
// evaluating TWICE (once with each fallback — a value that tracks the
|
|
491
|
+
// fallback both times is a fault). Spec-parsed metadata normalizes
|
|
492
|
+
// `visibleWhen` into a `{ dialect: 'cel' }` envelope, so that branch is
|
|
493
|
+
// the common one in production: paying for the probe unconditionally
|
|
494
|
+
// would double the engine calls for every predicate of every node, to
|
|
495
|
+
// build a message no production build ever prints.
|
|
496
|
+
if (!__DEV__)
|
|
497
|
+
return evaluator.evaluateCondition(raw);
|
|
498
|
+
try {
|
|
499
|
+
const verdict = evaluator.evaluateCondition(raw, { throwOnError: true });
|
|
500
|
+
// objectui#5687 — the NON-throwing half of the same silence, and it is
|
|
501
|
+
// reachable ONLY here, on the branch where the predicate evaluated
|
|
502
|
+
// cleanly. A `data.*` read the adapter cannot answer is not a fault the
|
|
503
|
+
// evaluator can raise: `undefined == 'draft'` is a perfectly good
|
|
504
|
+
// `false`. Verdict untouched — `verdict` is returned exactly as
|
|
505
|
+
// computed, which is what keeps the ruling's "no verdict changes" true
|
|
506
|
+
// by construction rather than by review.
|
|
507
|
+
reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource);
|
|
508
|
+
return verdict;
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
reportUnresolvableVisibilityPredicate(newSchema.type, newSchema.id, key, raw, err);
|
|
512
|
+
// The historical fail-soft answer, unchanged — and identical to what
|
|
513
|
+
// the production branch above returns for the same input, which is what
|
|
514
|
+
// keeps the two branches one behaviour rather than two. See the
|
|
515
|
+
// docblock: `true` is the value EVERY path of `evaluateCondition`
|
|
516
|
+
// already returned for an unevaluable predicate.
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
// Evaluate 'properties' — the SPEC spelling of a node's config bag, of
|
|
521
|
+
// which `props` (evaluated below) is the legacy alias.
|
|
522
|
+
//
|
|
523
|
+
// objectui#4799: only `props` used to be evaluated here. Renderers in the
|
|
524
|
+
// `element:*` namespace read their config out of `schema.properties` FIRST
|
|
525
|
+
// (`readProps()` merges `{ ...schema.props, ...schema.properties }`), so a
|
|
526
|
+
// node written the canonical way handed the renderer the RAW `${…}` source
|
|
527
|
+
// and put it on screen verbatim, while the same node written with the
|
|
528
|
+
// "legacy alias" evaluated correctly. Measured through a real render with
|
|
529
|
+
// `dataSource: { total: 99 }`: `props: { content: '${data.total}' }` → `99`,
|
|
530
|
+
// `properties: { content: '${data.total}' }` → `${data.total}`. That is the
|
|
531
|
+
// inverse of contract-first (AGENTS.md #0.1) — the tolerant spelling was
|
|
532
|
+
// fed and the canonical one starved — so the fix belongs HERE, at the one
|
|
533
|
+
// producer of evaluated schema, not as a second read in each consumer.
|
|
534
|
+
//
|
|
535
|
+
// Order matters: this runs BEFORE the hoist block below, because that block
|
|
536
|
+
// copies every `properties.*` value onto the node's top level (and those
|
|
537
|
+
// copies are spread as React props at render). Evaluating first is what
|
|
538
|
+
// makes one key mean one thing whether it is read as `schema.properties.x`,
|
|
539
|
+
// as `schema.x`, or as the `x` prop. It also leaves the `content` leg below
|
|
540
|
+
// idempotent: a value evaluated here no longer carries a `${…}`.
|
|
541
|
+
//
|
|
542
|
+
// Per-value and SHALLOW, matching the `props` branch exactly:
|
|
543
|
+
// `evaluator.evaluate` returns every non-string untouched, so a nested
|
|
544
|
+
// object/array value is passed through rather than walked (measured: an
|
|
545
|
+
// `aria: { label: '${data.total}' }` nested under EITHER key renders the raw
|
|
546
|
+
// source today). Deepening that is a separate decision and would have to be
|
|
547
|
+
// taken for both spellings at once — not smuggled in on one side here.
|
|
548
|
+
//
|
|
549
|
+
// The guard is wider than the `props` branch's bare truthiness on purpose:
|
|
550
|
+
// this value FEEDS the hoist, so re-shaping a degenerate `properties`
|
|
551
|
+
// (a string, an array) via the object spread would propagate. Non-objects
|
|
552
|
+
// skip evaluation and reach the hoist exactly as they do today.
|
|
553
|
+
// Snapshotted BEFORE evaluation — objectui#5756's diagnostic below reads
|
|
554
|
+
// the RAW (pre-collapse) text from this reference, since `newSchema.properties`
|
|
555
|
+
// is about to be replaced with the evaluated copy.
|
|
556
|
+
const rawPropertiesBag = newSchema.properties &&
|
|
557
|
+
typeof newSchema.properties === 'object' &&
|
|
558
|
+
!Array.isArray(newSchema.properties)
|
|
559
|
+
? newSchema.properties
|
|
560
|
+
: undefined;
|
|
561
|
+
if (rawPropertiesBag) {
|
|
562
|
+
const newProperties = { ...rawPropertiesBag };
|
|
563
|
+
for (const [key, val] of Object.entries(newProperties)) {
|
|
564
|
+
newProperties[key] = evaluator.evaluate(val);
|
|
565
|
+
}
|
|
566
|
+
newSchema.properties = newProperties;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* objectui#5756 — the `${…}` HALF of the #5687/#5454 silence, reached at
|
|
570
|
+
* the one point in this render where it still can be: BEFORE the hoist
|
|
571
|
+
* (which would bury it under the node-level keys) and immediately AFTER
|
|
572
|
+
* the loop above (so `newSchema.properties` already reflects which key
|
|
573
|
+
* would win the SAME precedence `shouldHide` applies later — see
|
|
574
|
+
* {@link winningVisibilityKey}).
|
|
575
|
+
*
|
|
576
|
+
* A `${…}`-spelled visibility predicate written inside `properties` is
|
|
577
|
+
* evaluated — and collapsed to a plain boolean — by the loop directly
|
|
578
|
+
* above, before `shouldHide` (and its #5454/#5687 reporter) ever sees it.
|
|
579
|
+
* The bare-string spelling of the SAME gate (`properties: { visible:
|
|
580
|
+
* "data.status == 'draft'" }`) is untouched by that loop (no `${` to
|
|
581
|
+
* interpolate) and reaches `shouldHide` intact, where the existing
|
|
582
|
+
* reporter already catches it — this block exists only to give the
|
|
583
|
+
* template spelling the same fate, not a different one.
|
|
584
|
+
*
|
|
585
|
+
* ## What this does NOT do
|
|
586
|
+
*
|
|
587
|
+
* - **No verdict change.** This call's return value is discarded; the
|
|
588
|
+
* REAL verdict is still decided by `shouldHide` below, off the
|
|
589
|
+
* POST-evaluation, POST-hoist schema, exactly as before this card.
|
|
590
|
+
* - **No new report shape.** It is the SAME `evaluateVisibilityPredicate`
|
|
591
|
+
* `shouldHide` calls — same two reporters, same dedupe `Set`, same
|
|
592
|
+
* `__DEV__` gate — called one render-step earlier, on the text this
|
|
593
|
+
* step is about to erase. There is nothing here to duplicate the LATER
|
|
594
|
+
* call: by the time `shouldHide` runs, this key's value is already the
|
|
595
|
+
* collapsed boolean, which carries no `data.` text for either reporter
|
|
596
|
+
* to match — so at most one of the two calls ever fires per predicate.
|
|
597
|
+
* - **Only the WINNING key.** Gated on {@link winningVisibilityKey} so a
|
|
598
|
+
* `properties.visible` template that a co-declared `visibleWhen`
|
|
599
|
+
* outranks is not reported for deciding nothing (mirrors #5454's own
|
|
600
|
+
* leg semantics: its reporter is likewise only ever invoked on the leg
|
|
601
|
+
* `shouldHide` actually consults).
|
|
602
|
+
* - **Only what would actually collapse.** A bare string (no `${`) is
|
|
603
|
+
* untouched by the loop above and is left for the existing gate-time
|
|
604
|
+
* report to catch, unchanged.
|
|
605
|
+
*/
|
|
606
|
+
if (__DEV__) {
|
|
607
|
+
const winningKey = winningVisibilityKey(newSchema);
|
|
608
|
+
if (winningKey && rawPropertiesBag && Object.prototype.hasOwnProperty.call(rawPropertiesBag, winningKey)) {
|
|
609
|
+
const rawWinningValue = rawPropertiesBag[winningKey];
|
|
610
|
+
if (typeof rawWinningValue === 'string' && rawWinningValue.includes('${')) {
|
|
611
|
+
// Diagnostic only — the boolean this returns is thrown away.
|
|
612
|
+
evaluateVisibilityPredicate(rawWinningValue, winningKey);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
247
616
|
// COMPAT: Hoist 'properties' up to schema level
|
|
248
617
|
// This allows support for strict configs that wrap all props in 'properties'.
|
|
249
618
|
// IMPORTANT: never let inner `properties.type` / `properties.id` shadow the
|
|
@@ -279,27 +648,47 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
279
648
|
}
|
|
280
649
|
newSchema.props = newProps;
|
|
281
650
|
}
|
|
282
|
-
// Evaluate visibility:
|
|
651
|
+
// Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn
|
|
283
652
|
const shouldHide = (() => {
|
|
284
|
-
if (newSchema.visible !== undefined) {
|
|
285
|
-
return !evaluator.evaluateCondition(newSchema.visible);
|
|
286
|
-
}
|
|
287
653
|
// `visibleWhen` is the single canonical conditional-visibility predicate
|
|
288
|
-
// across every layer since ADR-0089 (show-when-truthy)
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
654
|
+
// across every layer since ADR-0089 (show-when-truthy), and it is the one
|
|
655
|
+
// this shape DECLARES: `PageComponentSchema` is a closed object that
|
|
656
|
+
// declares `visibleWhen` (+ the deprecated `visibility`) and REFUSES a
|
|
657
|
+
// node-level `visible` outright, with the prescription "move it up one
|
|
658
|
+
// level to the component node's own `visibleWhen` … inside `properties`
|
|
659
|
+
// it is hoisted onto the node by the renderer but evaluated by nothing"
|
|
660
|
+
// (`component.zod.ts`, COMPONENT_NODE_VISIBILITY_KEYS).
|
|
661
|
+
//
|
|
662
|
+
// It is tested FIRST — ahead of `visible` — since objectui#5454. The
|
|
663
|
+
// hoist block above copies every `properties.*` value onto the node, so a
|
|
664
|
+
// node carrying `properties.visible` arrived here with `schema.visible`
|
|
665
|
+
// set, and the `visible` leg used to short-circuit before the DECLARED
|
|
666
|
+
// node predicate was ever consulted. Measured on `record:alert`,
|
|
667
|
+
// `record:path`, `page:card` and `element:text`: `properties.visible`
|
|
668
|
+
// present and truthy made a co-declared `visibleWhen: false` a no-op, so
|
|
669
|
+
// the one key the spec tells authors to write was the one key that could
|
|
670
|
+
// be silently ignored. A declared node predicate now outranks a hoisted
|
|
671
|
+
// renderer prop; when both resolve to "show", both still have to.
|
|
292
672
|
if (newSchema.visibleWhen !== undefined) {
|
|
293
|
-
return !
|
|
673
|
+
return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen');
|
|
674
|
+
}
|
|
675
|
+
// `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and
|
|
676
|
+
// the landing spot of a hoisted `properties.visible`. Kept ABOVE the two
|
|
677
|
+
// deprecated aliases: they normalize into `visibleWhen` at parse, so a
|
|
678
|
+
// spec-parsed page never reaches them, and re-ranking them would move
|
|
679
|
+
// verdicts for raw metadata that objectui#5454 did not rule on.
|
|
680
|
+
if (newSchema.visible !== undefined) {
|
|
681
|
+
return !evaluateVisibilityPredicate(newSchema.visible, 'visible');
|
|
294
682
|
}
|
|
295
|
-
// @deprecated ADR-0089 → `visibleWhen`.
|
|
683
|
+
// @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw /
|
|
684
|
+
// un-normalized metadata reaching the renderer.
|
|
296
685
|
if (newSchema.visibleOn !== undefined) {
|
|
297
|
-
return !
|
|
686
|
+
return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn');
|
|
298
687
|
}
|
|
299
688
|
// @deprecated ADR-0089 → `visibleWhen` (was PageNodeSchema.visibility,
|
|
300
689
|
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
|
|
301
690
|
if (newSchema.visibility !== undefined) {
|
|
302
|
-
return !
|
|
691
|
+
return !evaluateVisibilityPredicate(newSchema.visibility, 'visibility');
|
|
303
692
|
}
|
|
304
693
|
// Ask "is a `hidden` gate DECLARED?" — not "is the key present?"
|
|
305
694
|
// (objectui#3955). These two legs are the only ones in this chain whose
|
|
@@ -320,10 +709,10 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
320
709
|
// equivalence, and pinned as a behaviour change: an UNDECLARED `hidden` no
|
|
321
710
|
// longer short-circuits, so a declared `hiddenOn` is finally consulted.
|
|
322
711
|
if (hasDeclaredPredicate(newSchema.hidden)) {
|
|
323
|
-
return
|
|
712
|
+
return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
|
|
324
713
|
}
|
|
325
714
|
if (hasDeclaredPredicate(newSchema.hiddenOn)) {
|
|
326
|
-
return
|
|
715
|
+
return evaluateVisibilityPredicate(newSchema.hiddenOn, 'hiddenOn');
|
|
327
716
|
}
|
|
328
717
|
return false;
|
|
329
718
|
})();
|
|
@@ -374,7 +763,7 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
374
763
|
newSchema._disabled = true;
|
|
375
764
|
}
|
|
376
765
|
return newSchema;
|
|
377
|
-
}, [schema, dataSource, predicateScope, pageVariables]);
|
|
766
|
+
}, [schema, dataSource, predicateScope, pageVariables, boundRecord]);
|
|
378
767
|
if (!evaluatedSchema)
|
|
379
768
|
return null;
|
|
380
769
|
// If schema is just a string, render it as text
|
|
@@ -438,6 +827,24 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
438
827
|
_disabled: __disabled, // stripped: internal disabled flag
|
|
439
828
|
responsiveStyles: _responsiveStyles, // stripped: compiled to scoped CSS, not a DOM prop
|
|
440
829
|
...componentProps } = evaluatedSchema;
|
|
830
|
+
// Dev-build loud diagnostic (objectui#4795, Direction 3): an unevaluated
|
|
831
|
+
// `${…}` about to be placed verbatim in front of a user.
|
|
832
|
+
//
|
|
833
|
+
// Sited HERE, after the metadata destructure, on purpose. `componentProps` is
|
|
834
|
+
// precisely the set of values that leaves this component for the DOM — it is
|
|
835
|
+
// spread as React props below, and the same values are what renderers read as
|
|
836
|
+
// `schema.<key>`. Everything the destructure stripped is schema METADATA:
|
|
837
|
+
// `visible` / `visibleWhen` / `hidden` / `disabled` / … hold raw predicate
|
|
838
|
+
// SOURCE by design (they are evaluated as conditions, never placed), so
|
|
839
|
+
// scanning before the strip would report every correctly-authored predicate
|
|
840
|
+
// in the repo. The strip list is therefore the diagnostic's exclusion list,
|
|
841
|
+
// for free and without a second copy of it to drift.
|
|
842
|
+
//
|
|
843
|
+
// Read-only: it reports what evaluation already produced and changes nothing
|
|
844
|
+
// about what is rendered — no DOM attribute either, so no snapshot moves.
|
|
845
|
+
if (__DEV__) {
|
|
846
|
+
reportUnevaluatedExpressions(schema, evaluatedSchema.type, evaluatedSchema.id, componentProps, evaluatedSchema.properties, evaluatedSchema.props);
|
|
847
|
+
}
|
|
441
848
|
// SDUI scoped styling (ADR-0065): a node's `responsiveStyles` compiles to
|
|
442
849
|
// id-scoped CSS injected as a <style> tag, and a scope class is appended to
|
|
443
850
|
// the node's className. Build-independent, collision-free, responsive-correct.
|
|
@@ -469,7 +876,12 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
|
469
876
|
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
877
|
schema: schemaForComponent,
|
|
471
878
|
...componentProps, // Spread non-metadata schema properties as props
|
|
472
|
-
|
|
879
|
+
// The legacy `props` alias still overrides plain top-level keys, but no
|
|
880
|
+
// longer overrides the canonical `properties` bag: for a key BOTH bags
|
|
881
|
+
// declare, `properties` wins here exactly as it already wins in
|
|
882
|
+
// `readProps()`, so one key has one answer on both channels
|
|
883
|
+
// (objectui#5123, maintainer ruling 2026-08-18).
|
|
884
|
+
...propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties),
|
|
473
885
|
...ariaProps, // Inject ARIA attributes from AriaPropsSchema
|
|
474
886
|
...debugAttrs, // Debug-mode data attributes
|
|
475
887
|
disabled: __disabled || undefined,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* HostNavigationContext — the host's own in-app navigate, offered to renderers.
|
|
10
|
+
*
|
|
11
|
+
* A published renderer package cannot navigate correctly on its own. Its only
|
|
12
|
+
* dependency-free option is a browser-level navigation
|
|
13
|
+
* (`window.location.assign`), which resolves a rooted path such as `/thanks`
|
|
14
|
+
* against the ORIGIN. Under a host mounted at a sub-path — the framework CLI
|
|
15
|
+
* configures one for every embedded deployment — that leaves the application
|
|
16
|
+
* entirely. This is objectui#4989 defect 4, and the same class as
|
|
17
|
+
* objectui#4190 / #4181.
|
|
18
|
+
*
|
|
19
|
+
* The host is the only party that knows its mount, so the host supplies the
|
|
20
|
+
* navigate. This context is that seam: **optional injection**, ruled by the
|
|
21
|
+
* maintainer on 2026-08-17. Two mechanisms were rejected in that ruling and are
|
|
22
|
+
* named here so they are not re-proposed:
|
|
23
|
+
*
|
|
24
|
+
* - **router-presence sniffing** (a renderer reading React Router's context
|
|
25
|
+
* when it happens to be there) — implicit magic, and it forces every
|
|
26
|
+
* renderer that wants mount-awareness to import `react-router`;
|
|
27
|
+
* - **a `react-router` peer requirement** — it imposes module resolution on
|
|
28
|
+
* router-less consumers. Two real ones exist in this repo (`apps/site`,
|
|
29
|
+
* `packages/plugin-view`), and a published renderer's whole property is
|
|
30
|
+
* that it drops into ANY React application.
|
|
31
|
+
*
|
|
32
|
+
* Explicit injection keeps every renderer dependency-free and puts
|
|
33
|
+
* mount-awareness on the type face where a host can see it is missing.
|
|
34
|
+
*
|
|
35
|
+
* ## Absent is a supported state, not a degraded one
|
|
36
|
+
*
|
|
37
|
+
* The default value is an empty object, so `useHostNavigation()` outside a
|
|
38
|
+
* provider answers `{ navigate: undefined }` and never throws. A consumer reads
|
|
39
|
+
* it as "no host navigate — fall back to whatever this renderer did before",
|
|
40
|
+
* which for a router-less host is already the correct behaviour: with no
|
|
41
|
+
* router there is no basename, so origin-rooted resolution is right. The seam
|
|
42
|
+
* changes what a MOUNTED host gets, and changes nothing else.
|
|
43
|
+
*
|
|
44
|
+
* ## Relationship to the other navigation seams in this package
|
|
45
|
+
*
|
|
46
|
+
* `DrillNavigationContext.openRecordList` is a SEMANTIC navigation — "show me
|
|
47
|
+
* the records behind this aggregate" — that only the console can turn into a
|
|
48
|
+
* URL. `ActionProvider`'s `onNavigate` is the ActionRunner's handler for
|
|
49
|
+
* `type: 'navigate'` actions, reachable only through an action dispatch. This
|
|
50
|
+
* one is neither: it is the plain "go to this already-resolved in-app path"
|
|
51
|
+
* that a renderer holds a destination for and cannot perform correctly itself.
|
|
52
|
+
* All three are supplied by the same shell from the same router `navigate`.
|
|
53
|
+
*/
|
|
54
|
+
import React from 'react';
|
|
55
|
+
export interface HostNavigationValue {
|
|
56
|
+
/**
|
|
57
|
+
* Navigate to an in-app path, resolved by the HOST — so a mounted host's
|
|
58
|
+
* basename is applied and the transition stays inside the application.
|
|
59
|
+
*
|
|
60
|
+
* `to` is an already-resolved, application-relative path (e.g. `/thanks`,
|
|
61
|
+
* `/apps/crm/accounts/r1`), never an absolute URL: a renderer that holds an
|
|
62
|
+
* external destination must not launder it through the host's router. It is
|
|
63
|
+
* the CALLER's job to have judged the destination — this seam performs a
|
|
64
|
+
* navigation, it does not authorize one.
|
|
65
|
+
*
|
|
66
|
+
* Absent when no host wired it; consumers fall back to their own default.
|
|
67
|
+
*/
|
|
68
|
+
navigate?: (to: string, options?: {
|
|
69
|
+
replace?: boolean;
|
|
70
|
+
}) => void;
|
|
71
|
+
}
|
|
72
|
+
export declare const HostNavigationContext: React.Context<HostNavigationValue>;
|
|
73
|
+
export declare const HostNavigationProvider: React.FC<{
|
|
74
|
+
value: HostNavigationValue;
|
|
75
|
+
children: React.ReactNode;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Consume the host-provided in-app navigate. Answers `{}` (i.e. `navigate`
|
|
79
|
+
* undefined) when no host wired one — that is a supported state, see the module
|
|
80
|
+
* docblock.
|
|
81
|
+
*/
|
|
82
|
+
export declare function useHostNavigation(): HostNavigationValue;
|