@object-ui/react 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +9 -8
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.js +434 -22
  6. package/dist/context/HostNavigationContext.d.ts +82 -0
  7. package/dist/context/HostNavigationContext.js +65 -0
  8. package/dist/context/RelatedRecordActionsContext.d.ts +15 -0
  9. package/dist/context/index.d.ts +13 -12
  10. package/dist/context/index.js +13 -12
  11. package/dist/element-data-source/ElementDataSourceGate.d.ts +75 -2
  12. package/dist/element-data-source/ElementDataSourceGate.js +81 -2
  13. package/dist/hooks/index.d.ts +35 -35
  14. package/dist/hooks/index.js +35 -35
  15. package/dist/hooks/useActionEngine.js +1 -1
  16. package/dist/hooks/useCapabilityGate.js +6 -5
  17. package/dist/hooks/useClientNotifications.js +2 -2
  18. package/dist/hooks/useCrudShortcuts.js +1 -1
  19. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  20. package/dist/hooks/useDiscovery.js +1 -1
  21. package/dist/hooks/useElementDataSource.js +5 -2
  22. package/dist/hooks/useExpression.d.ts +24 -3
  23. package/dist/hooks/useExpression.js +56 -4
  24. package/dist/hooks/useNavigationOverlay.d.ts +1 -1
  25. package/dist/hooks/usePageAssignment.js +1 -1
  26. package/dist/hooks/usePageTransition.js +1 -1
  27. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  28. package/dist/hooks/useViewData.js +1 -1
  29. package/dist/hooks/useViewTransition.js +1 -1
  30. package/dist/index.d.ts +11 -11
  31. package/dist/index.js +10 -10
  32. package/dist/schema-input.d.ts +16 -5
  33. package/dist/schema-input.js +15 -4
  34. package/dist/spec-bridge/SpecBridge.d.ts +1 -1
  35. package/dist/spec-bridge/SpecBridge.js +2 -2
  36. package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
  37. package/dist/spec-bridge/bridges/form-view.js +51 -7
  38. package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
  39. package/dist/spec-bridge/bridges/list-view.js +37 -10
  40. package/dist/spec-bridge/index.d.ts +4 -4
  41. package/dist/spec-bridge/index.js +3 -3
  42. package/dist/utils/error-message.d.ts +59 -0
  43. package/dist/utils/error-message.js +116 -1
  44. package/dist/utils/unevaluatedExpression.d.ts +69 -0
  45. package/dist/utils/unevaluatedExpression.js +194 -0
  46. package/dist/utils/visibilityDiagnostic.d.ts +119 -0
  47. package/dist/utils/visibilityDiagnostic.js +284 -0
  48. package/package.json +7 -7
@@ -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 { usePredicateScope } from './hooks/useExpression';
13
- import { usePageVariables } from './hooks/usePageVariables';
14
- import { resolveKeyedI18nLabel } from './utils/i18n';
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: visible / visibleWhen / visibleOn / visibility / hidden / hiddenOn
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). The spec folds the
289
- // deprecated `visibleOn` (view) / `visibility` (page) aliases into it at
290
- // parse, so it is checked FIRST; the aliases below remain as a defensive
291
- // read for any raw / un-normalized metadata reaching the renderer.
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 !evaluator.evaluateCondition(newSchema.visibleWhen);
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 !evaluator.evaluateCondition(newSchema.visibleOn);
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 !evaluator.evaluateCondition(newSchema.visibility);
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 evaluator.evaluateCondition(newSchema.hidden);
712
+ return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
324
713
  }
325
714
  if (hasDeclaredPredicate(newSchema.hiddenOn)) {
326
- return evaluator.evaluateCondition(newSchema.hiddenOn);
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
- ...(evaluatedSchema.props || {}), // Override with explicit props if provided
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;