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
51 changes: 51 additions & 0 deletions .changeset/6614-brace-literal-subset.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@object-ui/sdui-parser': minor
---

html tier: braced attribute values now materialize the JS literal subset — single-quoted strings and unquoted identifier keys work (objectui#6614)

The html tier is the untrusted-safe DATA tier: source is parsed, never executed
(ADR-0080), which makes it the only safe carrier for runtime AI- or
tenant-authored pages. But `interpretBrace` accepted only strict JSON inside
braces while the surface called itself JSX, so `columns={['name','amount']}` —
the spelling every JSX author and every AI author writes by habit — compiled to
the deferred `{ $expr }` marker that nothing downstream evaluates, and the
author's whole data binding vanished at render. A production page's `list-view`
rendered its row count and toolbar with zero data columns through eight
`columns` spellings before the author gave up (objectui#6598, moved from
objectstack#12649). That was a trap, not a contract.

`interpretBrace` now materializes the JS **literal subset**. Exactly two
widenings over JSON, and nothing else:

1. **single-quoted strings**, in value position and in key position —
`title={'Accounts'}`, `columns={['name','amount']}`, `{{'pageSize': 25}}`;
2. **unquoted identifier object keys** — `options={{pageSize: 25}}`,
`columns={[{field:'name',label:'Full Name'}]}`.

Everything else JSON refuses is still refused, still compiles to `{ $expr }`,
and still draws the warning-severity `inert-expression` diagnostic: trailing
commas, comments, array holes, spreads, `undefined`/`NaN`/`Infinity`,
`+1`/`.5`/`1.`/`0x1f`, template literals, and every genuine expression —
identifiers, member access, calls, operators, ternaries. The subset contains no
identifier lookup and no operator, so there is nothing in it to execute: this
moves habitual spellings onto the materialized side, it does not move the
boundary between data and code. An authored `__proto__` key becomes an ordinary
own property, as `JSON.parse` gives it — never the prototype setter.

Strict JSON is unchanged, structurally: `JSON.parse` still runs first and
untouched, so any value it accepts takes byte-identically the path it always
did, and the literal reader only ever sees input `JSON.parse` has already
thrown on.

The `inert-expression` message changed with the grammar. It used to advise
"write it as JSON (double-quoted strings and keys)" and named
`columns={['name','amount']}` as the wrong form — advice that would now send an
author to edit working source. It names the accepted literal grammar instead.

Maintainer ruling of 2026-08-28 (objectui#6614 Q1-A). ⛔ Two ruled items are
deliberately NOT in this change: escalating `inert-expression` from warning to
error (Q2 — it belongs at the save gate, once the framework wires the registry
manifest into `validate-jsx-pages`), and base-prop (`style`) `$expr` inertness
(Q3 — sequenced after this, so no warning is added for spellings this change
legalises).
66 changes: 39 additions & 27 deletions packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
/**
* objectui#6598 — the html tier's silent-vanish hole for braced non-JSON values.
* objectui#6598 — the html tier's silent-vanish hole for braced values this
* tier cannot materialize.
*
* `interpretBrace` materializes strict-JSON values only; anything else becomes
* the deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker
* `interpretBrace` materializes strict JSON plus the JS literal subset
* (objectui#6614 Q1-A, ruled 2026-08-28); a GENUINE EXPRESSION still becomes the
* deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker
* (this tier parses, never executes — ADR-0080; a repo-wide grep finds zero
* `$expr` consumers outside this package). So `columns={['name','amount']}` —
* the universal JSX spelling, single quotes — used to compile with ZERO
* diagnostics into a value every renderer's defensive non-array read degrades
* to "no columns declared": rows render, the author's whole data binding is
* eaten, and no surface ever says why. That is ADR-0078's prohibited
* parsed-but-silently-inert state, reported from production as objectui#6598
* (moved from objectstack#12649).
* `$expr` consumers outside this package). Such a value reaches the renderer as
* an opaque object, every defensive non-array read degrades it to "not
* declared", and the author's binding is eaten in silence. That is ADR-0078's
* prohibited parsed-but-silently-inert state, reported from production as
* objectui#6598 (moved from objectstack#12649).
*
* These cases pin the correction: a `$expr` value on a DECLARED input now draws
* the warning-severity `inert-expression` diagnostic, message carrying the fix.
* Severity is pinned as WARNING deliberately (the objectui#5709 precedent for
* inert authored keys): escalating to error, widening the accepted literal
* grammar (single quotes / unquoted keys), and base-prop (`style`) coverage are
* open contract decisions on the issue — a change to any of those should move
* these pins consciously, not by accident.
* These cases pin the correction: a `$expr` value on a DECLARED input draws the
* warning-severity `inert-expression` diagnostic, message carrying the fix.
* Severity stays WARNING deliberately (the objectui#5709 precedent for inert
* authored keys); ⛔ escalation to error is objectui#6614 **Q2**, which lands at
* the SAVE GATE once the framework wires the registry manifest into
* `validate-jsx-pages` — not here, and not at render.
*
* ⭐ WHAT MOVED IN #6614 Q1-A, AND WHY IT IS NOT AN ACCIDENT. This file
* originally pinned `columns={['name','amount']}`, `columns={[{field:"name"}]}`
* and `options={{pageSize: 25}}` as WARNING cases, and said in so many words
* that widening the literal grammar "should move these pins consciously, not by
* accident". Q1-A widened it, so those three spellings now MATERIALIZE and are
* correct — the whole point of the ruling. They moved to
* `literal-subset-6614.test.ts`, which pins their values; each was replaced here
* by a genuine expression, so this file still pins the same FACT (an inert
* braced value is never silent) on the same side of the new boundary.
*
* The fixture manifest mirrors the LIVE `list-view` registration's relevant
* inputs (packages/plugin-list/src/index.tsx) but is deliberately synthetic —
Expand All@@ -45,9 +54,12 @@ const manifest: Manifest = {
},
};

describe('inert-expression: braced non-JSON on a declared input warns instead of vanishing', () => {
it("single-quoted array — the JSX habit — draws the warning and stays in the tree as $expr", () => {
const r = compile(`<list-view objectName="account" columns={['name','amount']} />`, manifest);
describe('inert-expression: a braced EXPRESSION on a declared input warns instead of vanishing', () => {
it('a method call — the shape #6598 could not materialize — warns and stays as $expr', () => {
const r = compile(
`<list-view objectName="account" columns={rows.map((r) => r.name)} />`,
manifest,
);
expect(r.diagnostics).toEqual([
expect.objectContaining({
severity: 'warning',
Expand All@@ -57,22 +69,22 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of
}),
]);
// The marker itself is unchanged — the tree still carries the deferred
// value; only the silence is gone. The message names the fix.
expect(r.tree?.columns).toEqual({ $expr: "['name','amount']" });
expect(r.diagnostics[0].message).toMatch(/JSON/);
// value; only the silence is gone. The message names what IS accepted.
expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' });
expect(r.diagnostics[0].message).toMatch(/LITERALS only/);
// Warning, not error: the page still compiles (the objectui#5709 posture).
expect(r.ok).toBe(true);
});

it('unquoted object keys draw the same warning', () => {
const r = compile(`<list-view objectName="account" columns={[{field:"name"}]} />`, manifest);
it('a bare identifier draws the same warning', () => {
const r = compile(`<list-view objectName="account" columns={savedColumns} />`, manifest);
expect(r.diagnostics).toEqual([
expect.objectContaining({ severity: 'warning', code: 'inert-expression' }),
]);
});

it('an $expr on an object-typed input is covered too', () => {
const r = compile(`<list-view objectName="account" options={{pageSize: 25}} />`, manifest);
const r = compile(`<list-view objectName="account" options={{...defaults}} />`, manifest);
expect(r.diagnostics).toEqual([
expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }),
]);
Expand All@@ -92,7 +104,7 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of
});

it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => {
const r = compile(`<list-view objectName="account" aggregate={{field:'amount'}} />`, manifest);
const r = compile(`<list-view objectName="account" aggregate={someTotal(amount)} />`, manifest);
expect(r.diagnostics).toEqual([
expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }),
]);
Expand Down
Loading
Loading