Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/4795-bindable-text-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/react': minor
---

Expression-bindable text keys: `statistic.value`, `card.title`, `button.label`
and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1,
maintainer ruling 2026-08-25).

**What changes for you.** Four text keys — `title`, `label`, `value`,
`description` — can now carry an expression written directly on the component
node, on the component types that declare them:

| Component | Bindable node keys |
|---|---|
| `statistic` | `label`, `value`, `description` |
| `card` | `title`, `description` |
| `button` | `label` |

```json
{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" }
```

That node used to render the literal text `${data.metrics.active}`. A dashboard
`statistic` previously had no way at all to bind a dynamic number — the
documented workaround (moving the key under `props`) evaluated the value and
then discarded it, painting a blank card instead. Both shapes are fixed by the
same change: the value is evaluated once, at the single place that produces
evaluated schema, and lands where the renderers already read.

**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx`
are untouched — they always read these keys off the node; nothing was writing an
evaluated value there.

**Scope, and how it grows.** The list is closed and lives in
`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer
reads that declaration rather than keeping a copy. On any other component type
these four keys are still read raw, so an expression reaches the screen as
literal text — notably `text`, whose `value` is read but has no declaration.
Adding a type or a key is a change to the spec, never something the renderer
infers.

**Nothing is newly rejected.** This release only widens what evaluates; no
metadata that used to render now fails to. The build-time rejection of `${...}`
in undeclared keys — the second half of the same ruling — is not in this release
and is still open.

Published authoring guidance updated to match: `skills/objectui/rules/protocol.md`
(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions`
and `data-integration` guides, which taught the now-retired "never evaluated"
statement and its host-pre-resolution workaround.
105 changes: 105 additions & 0 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4795 Direction 1 — the same contract as
* `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but
* driven through the REAL production renderers with no stand-in for the
* read-back half.
*
* That companion file pins the memo against probes, because `@object-ui/react`
* by design does not depend on `@object-ui/components`. It can therefore prove
* the value was EVALUATED, and only assert the read-back against a mirror of
* the real read points. This file closes that gap: `statistic`, `card` and
* `button` here are the shipped renderers, so a passing assertion below means
* the evaluated value actually reached the DOM — which is the whole of what
* objectui#4795 measured as missing ("evaluated AND read back").
*
* ## Why this file contains no renderer-specific fix to guard
*
* It guards the OPPOSITE. The ruling's implementation caution was that these
* read-back sites must be "converged on evaluated values, not patched per
* component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or
* `form/button.tsx` is touched by this card. They already read the right place;
* the single memo leg upstream now writes an evaluated value there. So these
* assertions passing while those three files are untouched IS the convergence
* claim, stated as a measurement rather than as a promise.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
it('`statistic` binds label / value / description', () => {
renderNode({
type: 'statistic',
label: '${data.caption}',
value: '${data.total}',
description: '${data.note}',
});
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('99')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
// The defect, stated in the negative: the literal source must be gone.
expect(screen.queryByText('${data.total}')).toBeNull();
});

it('`statistic` interpolates inside surrounding text', () => {
renderNode({ type: 'statistic', value: 'Total: ${data.total}' });
expect(screen.getByText('Total: 99')).toBeTruthy();
});

it('`card` binds title / description', () => {
renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' });
expect(screen.getByText('Active users')).toBeTruthy();
expect(screen.getByText('+20.1% from last month')).toBeTruthy();
});

it('`button` binds label', () => {
renderNode({ type: 'button', label: 'Refresh ${data.total}' });
expect(screen.getByText('Refresh 99')).toBeTruthy();
});
});

describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
* `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
* IS a top-level read-back site — and `text` has no row in the spec's
* carriage map, so the memo must not evaluate it. This assertion therefore
* pins a KNOWN, reported gap rather than a desired behaviour: the literal on
* screen is what an author writing the form the expressions guide teaches
* gets today, and closing it is a spec-side row (objectstack), not a
* renderer-side inference here. If a row is ever added upstream, this is the
* test that will go red and say so.
*/
it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
renderNode({ type: 'text', value: '${data.total}' });
expect(screen.getByText('${data.total}')).toBeTruthy();
});

it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' });
// `card` declares title/description only; `value` is neither evaluated nor
// read back, so nothing from it reaches the DOM text.
expect(container.textContent).toContain('Fixed');
expect(container.textContent).not.toContain('99');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,20 @@
* - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT
* hoist (`type` / `id` excepted). It therefore lands
* exactly where every renderer reads, in every namespace.
* - a node key — read, but never expression-evaluated.
* - a node key — read, and expression-evaluated only if
* `@objectstack/spec` DECLARES it bindable for that
* component type (objectui#4795 Direction 1, ruled
* 2026-08-25). Every other node key is read raw.
*
* ⚠️ That third line said a flat "read, but never expression-evaluated" when
* this file was written, and the reading below said so too. objectui#4795
* closed the gap for the closed set `title` / `label` / `value` /
* `description`, per the carriage map in
* `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and
* `description`. The rest of this file's measurement is untouched: the two
* envelope fates above are exactly what #5372 measured, and the card's
* question ② (whether `properties` is an official `ui:*` authoring channel)
* is still open and still not answered here.
*
* So the one spelling the rules told an author not to reach for was the only
* one that reaches a provider's data, and the two the rules endorsed both fail
Expand DownExpand Up@@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => {
expect(header()).toBe('Customer Summary');
});

it('a node-level `title` is read but never evaluated', () => {
// objectui#4795 Direction 1 flipped this reading. It used to assert the
// literal `${data.label}` on screen — a node key was read but never
// evaluated — and that is the defect the 2026-08-25 ruling retired for the
// spec-DECLARED keys. Kept as a pair so the file still states both halves:
// a declared key now evaluates, an undeclared one on the same node does not.
it('a node-level `title` is read AND evaluated — `card` declares it', () => {
renderNode({ type: 'card', title: '${data.label}' });
expect(header()).toBe('${data.label}');
expect(header()).toBe('Evaluated Title');
});

it('a node key `card` does NOT declare is still read raw (`value`)', () => {
renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' });
expect(header()).toBe('Fixed');
});

it('the same expression under `properties` is evaluated AND read', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.1.0",
"react-hook-form": "^7.85.0"
},
"peerDependencies": {
Expand Down
75 changes: 74 additions & 1 deletion packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import { expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent<
if (typeof newSchema.content === 'string') {
newSchema.content = evaluator.evaluate(newSchema.content);
}


/**
* Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys
* (objectui#4795 Direction 1, maintainer ruling 2026-08-25).
*
* ## The hole this closes
*
* A value reaches the screen only if it is BOTH evaluated here AND read
* back by the renderer off the node. `content` above satisfies both; the
* other text keys satisfied neither at once, so
* `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads
* `schema.value` (`data-display/statistic.tsx`) — put the literal
* `${data.total}` on screen, and the `props`-envelope workaround the
* objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated,
* then spread as a React prop nobody reads). `statistic` is the dashboard
* workhorse and had no way at all to bind a dynamic value.
*
* ## Why this is ONE leg here and not a patch in each renderer
*
* The ruling's own implementation caution: the read-back sites must be
* "converged on evaluated values, not patched per component". They already
* agree on WHERE to read (the node's top level) — what they lacked was
* anything writing an evaluated value there. Writing it at the single
* producer of evaluated schema means `statistic.tsx`, `card.tsx` and
* `button.tsx` are UNCHANGED by this card and every future top-level reader
* is covered by construction. Four fixed components and no contract was the
* failure mode named at dispatch.
*
* ## The vocabulary is the spec's, and is consumed rather than copied
*
* `@objectstack/spec` declares it (objectstack#9599): the closed set
* `title` / `label` / `value` / `description`, plus the per-component
* carriage map saying which of them each component type actually reads
* back. The 2026-08-18 ruling is explicit that this memo CONSUMES the
* declaration "rather than hard-coding a twin list" — so the only thing
* this file knows is the name of the lookup. A row added upstream starts
* working here with no edit; a row this file invented would be the second
* dialect the declaration exists to prevent.
*
* ## The type string is passed VERBATIM
*
* `expressionBindableTextKeysFor` keys on the bare registry name, and the
* spec states the answer for an unlisted type is the empty set — "closed
* and mechanically answerable in both directions, never inferred from what
* a renderer happens to read". So no prefix-stripping normalization: it
* would look harmless (`ui:statistic` → `statistic`) and would in the same
* motion grant rows to `element:button` and `page:card`, whose renderers
* read their config out of the bag via `readProps()` and never touch these
* keys on the node — re-manufacturing the evaluated-but-not-read-back half
* of the very table this card exists to close. Measured on this tree: the
* authored corpus spells these types bare (`statistic` 42, `card` 135,
* `button` 158 nodes) and `ui:*` zero times, so verbatim is also the
* spelling authors actually use. `action:button` (5 nodes) has no row and
* is reported upstream rather than inferred here.
*
* ## Ordering and idempotence
*
* After the `properties` hoist deliberately, exactly like `content`: a
* value arriving through that channel was already evaluated by the
* `properties` leg, so it no longer carries a `${…}` and
* `evaluator.evaluate` returns it unchanged. The `typeof === 'string'`
* guard is doing real work rather than mirroring the line above — it stops
* this loop CREATING an absent key as `undefined`, which would change what
* `{ ...schema }` spreads and what `key in schema` answers downstream.
*/
for (const key of expressionBindableTextKeysFor(
typeof newSchema.type === 'string' ? newSchema.type : '',
)) {
if (typeof newSchema[key] === 'string') {
newSchema[key] = evaluator.evaluate(newSchema[key]);
}
}

// Evaluate 'props' — the legacy alias of the config bag.
//
// The guard MIRRORS the `properties` branch above rather than testing bare
Expand Down
Loading
Loading