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
12 changes: 12 additions & 0 deletions .changeset/div-deprecation-provenance-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@object-ui/sdui-parser": patch
"@object-ui/components": patch
---

`div` 的废弃提示按 provenance 收窄:只对 **JSON 作者面**的节点报,不再对 `kind:'html'` tier 自己解析出的节点开火。

html tier 的页面是一段受限 JSX/Tailwind 文本,由引擎自己的解析器编译(只解析、不执行),标签名原样映射成节点 —— 作者在那一层写下的盒子标签,是该 tier 词表里的一等成员,**没有别的拼法可迁移**。提示照旧对他们开火,给的还是 JSON 作者面的替代建议:一条谁都无法执行的提示不是废弃,是噪声;它同时意味着这个类型永远退不掉,因为引擎自己的编译器一直在产出它。

判据是**来源**,由生产者确立:解析器给它产出的每个节点打一个 symbol 标记(`Symbol.for` 注册键),渲染器读这个标记。symbol 对 `JSON.stringify` / `Object.keys` / DOM 全部不可见 —— 所以它既不会落进被持久化的文档,也就无法被一份(手写或 AI 生成的)JSON 元数据复制回来给自己买到豁免;通过花括号属性夹带进来的 JSON **不打标记**,那部分本来就是手写的,建议对它成立。

迁移建议一字未改,JSON 作者面照旧每次模块加载报一次;提示文案现在写明它针对哪一个作者面。
7 changes: 7 additions & 0 deletions content/docs/components/basic/div.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ description: "Generic container element - use Shadcn components instead"

The Div component is a generic container element. **It is now deprecated in favor of semantic Shadcn-based components** that provide better accessibility, consistency, and design system integration.

**Scope of this deprecation: JSON-authored pages.** A `kind:'html'` page is written as
constrained JSX/Tailwind text that the engine compiles (parses, never executes) into
nodes, tag name straight through — there, the plain box tag is part of that tier's own
vocabulary and stays fully supported, because no other spelling of it exists for an
author to migrate to. The dev-build deprecation notice is therefore reported for
JSON-authored nodes only, and never for what the html tier compiled from its own source.

## Migration Guide

Instead of using `div`, use these Shadcn alternatives:
Expand Down
134 changes: 134 additions & 0 deletions packages/components/src/__tests__/div-deprecation-provenance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* 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.
*/

/**
* The `div` deprecation notice is scoped BY PROVENANCE (objectui#4000).
*
* A `kind:'html'` page is authored as constrained JSX/Tailwind text that our own
* parser compiles (never executes) into SDUI nodes, tag name straight through.
* So an author writing the plain box tag in that tier gets a node the DEPRECATED
* renderer serves — and the notice fired at them, recommending replacements that
* belong to the JSON authoring surface. Nothing the author could write made it
* stop: in that tier the tag IS the tier's own vocabulary. A notice nobody can
* act on is not a deprecation, it is noise, and it also meant the type could
* never be retired — the engine's own compiler keeps emitting it.
*
* Maintainer ruling (2026-08-10, on the issue): split the notice by provenance.
* Nodes the html tier's parser emitted are exempt; JSON-authored nodes keep
* being reported, unchanged.
*
* NOTE ON ORDER — the warn-once guard from objectui#3965 is a module-level Set
* that latches for the lifetime of this module instance, so the cases below are
* ordered deliberately and each depends on the one before:
*
* 1. the html-tier case runs FIRST, against a virgin Set. "No notice" here
* therefore cannot be explained away by an earlier render having latched
* the guard — there was no earlier render.
* 2. the authored case then observes exactly one notice, which is only
* possible if case 1 left the Set virgin. The two cases pin each other:
* an exemption that silently marked the guard would show up as a ZERO in
* case 2, not as a pass.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

const DEPRECATION_RE = /The "div" component is deprecated/;

function deprecationCalls(spy: ReturnType<typeof vi.spyOn>): unknown[][] {
return spy.mock.calls.filter((args: unknown[]) => DEPRECATION_RE.test(String(args[0])));
}

/** Renders a `kind:'html'` page — source compiled by the parser, then rendered. */
function renderHtmlPage(source: string) {
return render(<SchemaRenderer schema={{ type: 'home', kind: 'html', name: 'test_page', source } as never} />);
}

describe('div deprecation notice — scoped by provenance (#4000)', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

// MUST run first: see the ordering note above.
it('stays silent for nodes the html tier compiled from its own source', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const { container } = renderHtmlPage(
'<div className="outer"><div className="inner">hello html tier</div></div>',
);

// Control FIRST: silence proves nothing if the page never rendered. A
// compile error replaces the whole page with an error panel, which would
// produce zero notices for entirely the wrong reason.
expect(container.textContent).not.toContain('failed to compile');
expect(container.textContent).toContain('hello html tier');
expect(container.querySelector('.outer')).toBeTruthy();
expect(container.querySelector('.inner')).toBeTruthy();

expect(deprecationCalls(warn)).toHaveLength(0);
});

it('still reports a JSON-authored node, exactly once, and says which surface it means', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const { container } = render(
<SchemaRenderer
schema={{
type: 'div',
className: 'authored',
children: [{ type: 'div', className: 'authored-inner' }],
} as never}
/>,
);

// Same control on this side: the nodes have to have actually rendered.
expect(container.querySelector('.authored')).toBeTruthy();
expect(container.querySelector('.authored-inner')).toBeTruthy();

const calls = deprecationCalls(warn);
expect(calls).toHaveLength(1);
const notice = String(calls[0][0]);
// The migration guidance is untouched — this issue narrows WHO is told, it
// does not water down WHAT they are told.
expect(notice).toContain('"card", "flex", or semantic layout components');
expect(notice).toContain('"container", "stack", or "grid"');
// …and the notice now names the surface it applies to. A notice that says
// the type is deprecated FULL STOP is false the moment another tier keeps
// it as permanent vocabulary; whoever reads the console has to be able to
// tell which of their pages it is about.
expect(notice).toMatch(/JSON-authored/);
expect(notice).toMatch(/html/);
});

it('does not re-report an authored node on a later render', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

render(<SchemaRenderer schema={{ type: 'div', className: 'later' } as never} />);

expect(deprecationCalls(warn)).toHaveLength(0);
});

it('keeps provenance off the DOM and out of the serialized node', () => {
// The marker rides on the node object, so it must not reach the element or
// survive serialization. A string-keyed marker would have been spread onto
// the host element as an unknown attribute, and would have been copied into
// any persisted form of the tree — where an authored page could replay it
// and silence the notice for itself.
const { container } = renderHtmlPage('<div className="probe">x</div>');
const el = container.querySelector('.probe') as HTMLElement | null;
expect(el).toBeTruthy();
const attrs = Array.from(el!.attributes).map((a) => a.name);
expect(attrs.filter((n) => n.includes('provenance') || n.includes('tier'))).toHaveLength(0);
});
});
45 changes: 37 additions & 8 deletions packages/components/src/renderers/basic/div.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { ComponentRegistry } from '@object-ui/core';
import { isHtmlTierNode } from '@object-ui/sdui-parser';
import type { DivSchema } from '@object-ui/types';
import { renderChildren } from '../../lib/utils';
import { forwardRef } from 'react';
Expand Down Expand Up @@ -38,19 +39,47 @@ function warnDeprecatedOnce(type: string, message: string): void {
console.warn(message);
}

/**
* The notice, including WHICH AUTHORING SURFACE it is about.
*
* Scope is part of the message, not decoration. This type is deprecated on the
* JSON surface and simultaneously a permanent, first-class tag of the
* `kind:'html'` tier — an author there writes the plain box tag and our own
* parser maps it straight through, and no other spelling exists for them to
* migrate to. A notice that says the type is deprecated FULL STOP is therefore
* false for one of its two readers, and it was the reader who could do nothing
* about it who kept receiving it (objectui#4000).
*
* The migration guidance below is byte-for-byte what it was: this issue narrows
* WHO is told, it does not water down WHAT they are told.
*/
const DIV_DEPRECATION_NOTICE =
'[ObjectUI] The "div" component is deprecated for JSON-authored pages. Please use Shadcn components instead:\n' +
' - For containers: use "card", "flex", or semantic layout components\n' +
' - For simple wrappers: use layout components like "container", "stack", or "grid"\n' +
' This applies to JSON-authored nodes. In a kind:\'html\' page the tag is part of that tier\'s own\n' +
' vocabulary, is compiled straight through, and is not reported here.\n' +
'See documentation at https://www.objectui.org/docs/components for alternatives.';

// Index signature on the parameter annotation, not on the `forwardRef` type
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
// `__tests__/forwardref-props-annotation.guard.test.ts`.
const DivRenderer = forwardRef<HTMLDivElement, { schema: DivSchema; className?: string }>(
({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: any }, ref) => {
// Deprecation warning (once per module load — see warnDeprecatedOnce)
warnDeprecatedOnce(
'div',
'[ObjectUI] The "div" component is deprecated. Please use Shadcn components instead:\n' +
' - For containers: use "card", "flex", or semantic layout components\n' +
' - For simple wrappers: use layout components like "container", "stack", or "grid"\n' +
'See documentation at https://www.objectui.org/docs/components for alternatives.'
);
// Deprecation notice — JSON-authored nodes only (objectui#4000), once per
// module load (objectui#3965, see warnDeprecatedOnce).
//
// ORDER, same discipline as the production early-return inside
// warnDeprecatedOnce: the exemption is checked BEFORE the seen-set is
// marked. An html-tier node rendering first must not latch the guard, or it
// would swallow the notice a JSON-authored node earns later on the same
// page — silencing exactly the reader this notice is for.
//
// The test is provenance, established by the producer (the parser stamps
// what it emits), not a guess about the node's shape here.
if (!isHtmlTierNode(schema)) {
warnDeprecatedOnce('div', DIV_DEPRECATION_NOTICE);
}

// Extract designer-related props
const {
Expand Down
97 changes: 97 additions & 0 deletions packages/sdui-parser/src/__tests__/provenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* html-tier provenance marking (objectui#4000).
*
* The parser stamps every element it emits, so a renderer can tell an html-tier
* node from a JSON-authored one and scope authoring advice to the surface the
* advice is actually about. What is pinned here is the marker's four load-
* bearing properties, each of which has a distinct failure mode:
*
* - it is SET on what the parser produces, at every depth;
* - it SURVIVES an object spread — `SchemaRenderer` hands renderers a shallow
* copy, so a marker that did not survive would restore the original bug in
* the render path only, silently;
* - it is INVISIBLE to JSON — so it cannot be persisted, and therefore cannot
* be replayed by an authored document that wants the exemption;
* - it is NOT set on JSON reached through a braced attribute — that JSON was
* hand-written, so the JSON surface's advice does apply to it.
*/

import { describe, expect, it } from 'vitest';
import { parseJsx } from '../parse.js';
import { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from '../provenance.js';
import type { SchemaElement } from '../types.js';

describe('html-tier provenance (#4000)', () => {
it('marks every element the parser emits, at every depth', () => {
const { tree } = parseJsx('<div className="outer"><span><em>deep</em></span></div>');
const root = tree as SchemaElement;
expect(isHtmlTierNode(root)).toBe(true);

const child = root.children![0] as SchemaElement;
expect(isHtmlTierNode(child)).toBe(true);
const grandchild = child.children![0] as SchemaElement;
expect(isHtmlTierNode(grandchild)).toBe(true);
});

it('survives the shallow copy a renderer receives', () => {
// `SchemaRenderer` evaluates expressions against `{ ...schema }` and passes
// the COPY down. Object spread carries own enumerable symbol keys and drops
// non-enumerable ones — so this assertion, not the definition site, is what
// actually holds the marker's descriptor in place.
const { tree } = parseJsx('<div className="a" />');
const copy = { ...(tree as SchemaElement) };
expect(isHtmlTierNode(copy)).toBe(true);

// …and through a second hop, which is what nesting plus scoped styling does.
expect(isHtmlTierNode({ ...copy, className: 'a scoped' })).toBe(true);
});

it('is invisible to JSON, to Object.keys and to for...in', () => {
const { tree } = parseJsx('<div className="a" />');
const root = tree as SchemaElement;

expect(JSON.parse(JSON.stringify(root))).toEqual({ type: 'div', className: 'a' });
expect(Object.keys(root)).toEqual(['type', 'className']);
const seen: string[] = [];
for (const k in root) seen.push(k);
expect(seen).toEqual(['type', 'className']);

// The round-trip is the anti-forgery pin: a persisted tree comes back
// unmarked, so a saved (or AI-generated) document cannot carry the
// exemption back in with it.
expect(isHtmlTierNode(JSON.parse(JSON.stringify(root)))).toBe(false);
});

it('does not mark JSON smuggled in through a braced attribute', () => {
// The parser produces the element; the object inside the braces is
// hand-written JSON that merely rode along. Marking it would hand the
// exemption to authored metadata — the precise over-reach this mechanism
// exists to avoid.
const { tree } = parseJsx('<page body={[{"type":"div","className":"inner"}]} />');
const root = tree as SchemaElement;
expect(isHtmlTierNode(root)).toBe(true);

const smuggled = (root.body as SchemaElement[])[0];
expect(smuggled.type).toBe('div');
expect(isHtmlTierNode(smuggled)).toBe(false);
});

it('reads false for anything unmarked, and never throws on non-objects', () => {
expect(isHtmlTierNode({ type: 'div' })).toBe(false);
expect(isHtmlTierNode(null)).toBe(false);
expect(isHtmlTierNode(undefined)).toBe(false);
expect(isHtmlTierNode('div')).toBe(false);
expect(isHtmlTierNode(7)).toBe(false);
// A string-keyed lookalike is not provenance — there is no fallback read.
expect(isHtmlTierNode({ type: 'div', _provenance: 'html' })).toBe(false);
});

it('keys the marker in the global registry, so duplicate instances agree', () => {
// This package ships ESM and CJS and is consumed from several packages. Two
// module instances holding two local symbols would not compare equal, and
// the failure mode of that mismatch is the original bug returning with no
// error anywhere. A registry key cannot drift.
expect(HTML_TIER_NODE).toBe(Symbol.for('@object-ui/sdui-parser.html-tier-node'));
expect(isHtmlTierNode(markHtmlTierNode({ type: 'div' }))).toBe(true);
});
});
1 change: 1 addition & 0 deletions packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

export * from './types.js';
export { parseJsx, interpretBrace } from './parse.js';
export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
export { validateTree } from './validate.js';
export { generateDts, propsName, generateBlockList } from './codegen.js';
export type { CodegenOptions } from './codegen.js';
Expand Down
11 changes: 10 additions & 1 deletion packages/sdui-parser/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode } from './types.js';
import { markHtmlTierNode } from './provenance.js';

/** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */
const EVENT_ATTR = /^on[A-Z]/;
Expand Down Expand Up @@ -82,7 +83,15 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// Stamp html-tier provenance at the point of production (objectui#4000).
// Renderers whose type carries authoring advice aimed at the JSON surface
// read this to tell "the author wrote a tag in OUR tier, and there is no
// other spelling available to them" apart from "the author wrote this type
// in JSON, where the advice applies". Symbol-keyed, so it is invisible to
// JSON, to the DOM, and to anything an authored document could forge — see
// provenance.ts. Values reached through a braced attribute are NOT marked:
// that JSON was written by hand, and the JSON surface's advice does apply.
const node: SchemaElement = markHtmlTierNode({ type: tag, ...props });
if (children && children.length) node.children = children;
return node;
}
Expand Down
Loading
Loading