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
25 changes: 25 additions & 0 deletions .changeset/7849-unevaluated-diagnostic-lists-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@object-ui/react": patch
---

The unevaluated-expression diagnostic now lists `properties` among the channels
that evaluate and read back, matching the sibling `props`-bag diagnostic that
tells authors to write their keys there.

Both messages are dev-build diagnostics in `SchemaRenderer`, and one node can
trip both. They disagreed: `propsBagDiagnostic` said *"`props` is NOT hoisted
onto the node — only `properties.*` is … Write them under `properties`
instead"*, while `unevaluatedExpression` enumerated *"channels that do evaluate
and read back today"* as `content` or host-side resolution — omitting
`properties`. An author who hit both was told to use a channel the other message
said did not work.

The enumeration was the wrong half, established by measurement rather than by
reading the `COMPAT` label on the hoist. `properties` has no retirement on
record: `@objectstack/spec@17.2.0` calls the vocabulary carried there *"ALIVE —
this is not dead surface to retire under ADR-0049"*, keeps
`PageComponentSchema.properties` as the open carrier on purpose, gates it at the
authoring door, and tombstones no part of it via `retiredKey()`. In this repo
`props`, not `properties`, is the spelling annotated as the legacy alias.

Diagnostic text only — no evaluation, hoist or schema behaviour changed.
125 changes: 125 additions & 0 deletions packages/react/src/__tests__/diagnosticChannelConsistency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* 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#7849 — the two dev diagnostics that talk to an author about WHERE to
* write an expression must not contradict each other.
*
* ## The defect this pins
*
* Both messages can fire on the SAME node. `propsBagDiagnostic` told the author
* to move the key under `properties`; `unevaluatedExpression` enumerated "the
* channels that do evaluate and read back today" and left `properties` OUT of
* that list, naming only `content` and host-side resolution. So the runtime
* answered one question two ways, and the half that was wrong was the
* enumeration: `properties` demonstrably evaluates, hoists and reads back.
*
* ## Why this is a DERIVED assertion and not two hard-coded strings
*
* Pinning both sentences literally would pin today's wording, and the next
* rewrite of either message would be free to reintroduce the contradiction as
* long as it also updated its own pin. So the channel the `props` diagnostic
* RECOMMENDS is parsed back out of the message it actually emits, and the
* enumeration is parsed out of the other; the assertion is that the first
* appears in the second. Reword either message and this test still asks the
* question that matters — it only goes red when they disagree.
*
* Both extractions are asserted to have MATCHED before they are compared. A
* regex that silently misses is the failure mode this shape would otherwise
* introduce: `null` vs `null` would compare equal and the pin would pass
* without reading a single word either developer wrote.
*/

import { describe, it, expect } from 'vitest';
import {
formatDroppedPropsBagMessage,
DROPPED_PROPS_BAG_PREFIX,
} from '../utils/propsBagDiagnostic';
import {
collectUnevaluatedExpressions,
formatUnevaluatedExpressionMessage,
UNEVALUATED_EXPRESSION_PREFIX,
} from '../utils/unevaluatedExpression';

/**
* One node, authored the way that trips BOTH diagnostics: an expression written
* into the `props` envelope on a `schema`-reading renderer.
*/
const NODE_TYPE = 'badge';
const NODE_ID = 'status_badge';
const AUTHORED_KEY = 'label';
const AUTHORED_VALUE = '${data.status}';

/** The message `propsBagDiagnostic` really emits for that node. */
const propsBagMessage = (): string =>
formatDroppedPropsBagMessage(NODE_TYPE, NODE_ID, [AUTHORED_KEY]);

/** The message `unevaluatedExpression` really emits for that node. */
const unevaluatedMessage = (): string => {
const findings = collectUnevaluatedExpressions(
undefined,
undefined,
{ [AUTHORED_KEY]: AUTHORED_VALUE },
);
expect(findings.length).toBeGreaterThan(0);
return formatUnevaluatedExpressionMessage(NODE_TYPE, NODE_ID, findings);
};

/**
* The channel the `props` diagnostic tells the author to write under, read out
* of its own sentence ("Write it/them under `X` instead").
*/
const recommendedChannel = (message: string): string | null => {
const m = /Write (?:it|them) under `([^`]+)` instead/.exec(message);
return m ? m[1] : null;
};

/**
* The enumeration sentence of the unevaluated-expression diagnostic — from
* "Channels that do evaluate and read back today:" to the end of the message.
*/
const channelEnumeration = (message: string): string | null => {
const m = /Channels that do evaluate and read back today:([\s\S]+)$/.exec(message);
return m ? m[1] : null;
};

describe('objectui#7849 — the two authoring diagnostics agree on the channels', () => {
it('both messages are the ones the author actually reads', () => {
expect(propsBagMessage()).toContain(DROPPED_PROPS_BAG_PREFIX);
expect(unevaluatedMessage()).toContain(UNEVALUATED_EXPRESSION_PREFIX);
});

it('the props diagnostic still recommends a channel, in a sentence this test can read', () => {
expect(recommendedChannel(propsBagMessage())).not.toBeNull();
});

it('the unevaluated diagnostic still enumerates the working channels', () => {
expect(channelEnumeration(unevaluatedMessage())).not.toBeNull();
});

it('the channel the props diagnostic RECOMMENDS is listed by the unevaluated diagnostic as one that works', () => {
const recommended = recommendedChannel(propsBagMessage());
const enumeration = channelEnumeration(unevaluatedMessage());

// Guarded above, and re-guarded here: a missed match must not compare equal.
expect(recommended).not.toBeNull();
expect(enumeration).not.toBeNull();

expect(
enumeration as string,
'objectui#7849: `propsBagDiagnostic` tells the author to write the key under ' +
'`' + recommended + '`, but `unevaluatedExpression`\'s list of channels that ' +
'evaluate and read back does not mention it. One node can trip both messages; ' +
'they must not answer the same question two ways.',
).toContain(recommended as string);
});

it('`content` — the channel that was already listed — stays listed', () => {
expect(channelEnumeration(unevaluatedMessage()) as string).toContain('content');
});
});
35 changes: 33 additions & 2 deletions packages/react/src/utils/unevaluatedExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,35 @@ function locate(finding: UnevaluatedExpressionFinding): string {
/**
* Build the message. Separate from the emit so a test can assert the words a
* developer is going to read, not merely that something was logged.
*
* ## Why `properties.*` is in the channel list (objectui#7849)
*
* It was missing, and the omission CONTRADICTED the sibling diagnostic in
* `propsBagDiagnostic.ts`, which tells an author who wrote the same key one
* envelope out: *"`props` is NOT hoisted onto the node — only `properties.*`
* is … Write them under `properties` instead."* An author whose node trips
* both messages was told to use `properties` by one and, by the other, that
* the only channels that work are `content` or host-side resolution.
*
* The enumeration was the wrong half. `properties` is not a channel on its way
* out and there is no retirement on record for it: `@objectstack/spec@17.2.0`
* calls the vocabulary carried there *"ALIVE — this is not dead surface to
* retire under ADR-0049"*, keeps `PageComponentSchema.properties` as the open
* carrier ON PURPOSE, gates it at the authoring door
* (`validate-component-props.ts`), and tombstones no part of it via
* `retiredKey()`. In THIS repo `props` — not `properties` — is the spelling
* annotated as the legacy alias (`SchemaRenderer.tsx`: "the legacy `props`
* bag, minus every key the canonical `properties` bag also …").
*
* Measured on this branch's base through the real renderers, not inferred:
* `badge` with `properties.label = '${data.status}'` renders `completed` and
* applies the variant classes; `card` with a conditional
* `properties.className` renders `class="… border-red-500"`; the same keys
* under `props` render an EMPTY badge body and no `border-red-500`.
*
* The words are borrowed from the sibling message on purpose — one hoist, one
* vocabulary, so the two diagnostics cannot drift apart again. Pinned by
* `__tests__/diagnosticChannelConsistency.test.ts`.
*/
export function formatUnevaluatedExpressionMessage(
type: unknown,
Expand All @@ -195,8 +224,10 @@ export function formatUnevaluatedExpressionMessage(
'to the DOM, with its source text intact. Either SchemaRenderer does not\n' +
'evaluate this key, or the expression threw and the evaluator returned the\n' +
'source unchanged.\n' +
'Channels that do evaluate and read back today: `content`, or resolve the\n' +
'value in the host before handing the schema to SchemaRenderer.'
'Channels that do evaluate and read back today: `content`; `properties.*`,\n' +
'which is evaluated and then HOISTED onto the node, so a renderer declared\n' +
'as `({ schema })` reads it back as `schema.<key>`; or resolve the value in\n' +
'the host before handing the schema to SchemaRenderer.'
);
}

Expand Down
Loading