From 38d2912eb1170ca7fc75df5913d81efb4a6b757d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 05:05:01 +0000 Subject: [PATCH] =?UTF-8?q?fix(components):=20div=20=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=8C=89=20provenance=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E5=88=B0=20JSON=20=E4=BD=9C=E8=80=85=E9=9D=A2=20(#4000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:'html' tier 的页面由引擎自己的解析器编译,标签名原样映射成节点 —— 作者在那一层写下的盒子标签是该 tier 词表的一等成员,没有别的拼法可迁移, 提示却照旧对他们开火并给出 JSON 作者面的替代建议。一条谁都无法执行的提示 不是废弃而是噪声,同时意味着这个类型永远退不掉。 判据是来源、由生产者确立:解析器给它产出的每个节点打一个 symbol 标记 (Symbol.for 注册键,跨模块实例不漂移),渲染器读这个标记。symbol 对 JSON.stringify / Object.keys / DOM 均不可见 —— 不落进持久化文档,也就无法 被一份 JSON 元数据复制回来给自己买到豁免;花括号属性夹带的 JSON 不打标记。 豁免判断放在 warn-once 的 Set 标记之前,html-tier 节点先渲染不会吞掉之后 JSON 作者节点应得的那一条。迁移建议一字未改,文案补上它针对哪个作者面。 Co-authored-by: Claude --- .../div-deprecation-provenance-scope.md | 12 ++ content/docs/components/basic/div.mdx | 7 + .../div-deprecation-provenance.test.tsx | 134 ++++++++++++++++++ .../components/src/renderers/basic/div.tsx | 45 ++++-- .../src/__tests__/provenance.test.ts | 97 +++++++++++++ packages/sdui-parser/src/index.ts | 1 + packages/sdui-parser/src/parse.ts | 11 +- packages/sdui-parser/src/provenance.ts | 80 +++++++++++ 8 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 .changeset/div-deprecation-provenance-scope.md create mode 100644 packages/components/src/__tests__/div-deprecation-provenance.test.tsx create mode 100644 packages/sdui-parser/src/__tests__/provenance.test.ts create mode 100644 packages/sdui-parser/src/provenance.ts diff --git a/.changeset/div-deprecation-provenance-scope.md b/.changeset/div-deprecation-provenance-scope.md new file mode 100644 index 0000000000..4e8cbbb0c1 --- /dev/null +++ b/.changeset/div-deprecation-provenance-scope.md @@ -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 作者面照旧每次模块加载报一次;提示文案现在写明它针对哪一个作者面。 diff --git a/content/docs/components/basic/div.mdx b/content/docs/components/basic/div.mdx index 72a60a4294..6b177b59b4 100644 --- a/content/docs/components/basic/div.mdx +++ b/content/docs/components/basic/div.mdx @@ -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: diff --git a/packages/components/src/__tests__/div-deprecation-provenance.test.tsx b/packages/components/src/__tests__/div-deprecation-provenance.test.tsx new file mode 100644 index 0000000000..09cfe964fb --- /dev/null +++ b/packages/components/src/__tests__/div-deprecation-provenance.test.tsx @@ -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): 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(); +} + +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( + '
hello html tier
', + ); + + // 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( + , + ); + + // 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(); + + 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('
x
'); + 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); + }); +}); diff --git a/packages/components/src/renderers/basic/div.tsx b/packages/components/src/renderers/basic/div.tsx index eeeefe7606..c3ca5fba39 100644 --- a/packages/components/src/renderers/basic/div.tsx +++ b/packages/components/src/renderers/basic/div.tsx @@ -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'; @@ -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( ({ 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 { diff --git a/packages/sdui-parser/src/__tests__/provenance.test.ts b/packages/sdui-parser/src/__tests__/provenance.test.ts new file mode 100644 index 0000000000..ae44a1ce5a --- /dev/null +++ b/packages/sdui-parser/src/__tests__/provenance.test.ts @@ -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('
deep
'); + 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('
'); + 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('
'); + 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(''); + 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); + }); +}); diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index fe49054d7d..fe314acc20 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -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'; diff --git a/packages/sdui-parser/src/parse.ts b/packages/sdui-parser/src/parse.ts index d2b21b3d6c..6469997429 100644 --- a/packages/sdui-parser/src/parse.ts +++ b/packages/sdui-parser/src/parse.ts @@ -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]/; @@ -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; } diff --git a/packages/sdui-parser/src/provenance.ts b/packages/sdui-parser/src/provenance.ts new file mode 100644 index 0000000000..e581faefeb --- /dev/null +++ b/packages/sdui-parser/src/provenance.ts @@ -0,0 +1,80 @@ +/** + * ObjectUI — html-tier provenance (ADR-0080, objectui#4000) + * + * Records the one fact a renderer cannot otherwise recover: whether the node it + * is about to render was EMITTED BY THIS PARSER (an author's `kind:'html'` + * source, compiled tag-name-straight-through) or AUTHORED AS JSON metadata. + * + * Why a renderer needs it: the two surfaces have different vocabularies. A type + * the JSON surface is steering authors away from can still be a first-class, + * permanently-supported tag in the html tier — the plain box element is exactly + * that. Advice aimed at the JSON surface, delivered to an html-tier author, is + * advice they cannot act on: in that tier there is no other spelling. The + * deprecation notice fired at them anyway (objectui#4000), which is also why the + * type could never be retired — the engine's own compiler keeps emitting it. + * + * The judgement therefore has to be PROVENANCE, established by the producer, + * not a guess made at the consumer from the node's shape. + * + * ## Why a symbol, and why `Symbol.for` + * + * The marker is deliberately NOT a JSON-visible key: + * + * - `JSON.stringify` skips symbol keys, so provenance never lands in a + * persisted tree. That is the point, not a limitation: "our parser produced + * this node in this process" is not a fact about the saved document, and a + * saved document must not be able to REPLAY it. A string key like + * `_provenance: 'html'` would be copyable — an authored (or AI-generated) + * JSON page could paste it in and buy itself silence from a notice that is + * meant for it. Provenance that authored metadata can forge is not + * provenance. + * - `Object.keys` / `for...in` skip symbol keys, so the marker cannot leak out + * of a renderer onto a host element as a stray DOM attribute. + * + * It IS enumerable, which matters more than it looks: `SchemaRenderer` hands + * renderers a shallow copy of the node (`{ ...schema }`), and object spread + * copies own ENUMERABLE symbol keys while dropping non-enumerable ones. A + * non-enumerable marker would be lost in that copy and the misfire would come + * back — silently, and only in the render path. `render.test.tsx` and the + * components-side pin both go through the real `SchemaRenderer` for this reason. + * + * `Symbol.for` (the cross-realm registry) rather than a module-local `Symbol()`: + * this package ships as both ESM and CJS and is consumed from several packages, + * so two module instances are possible. 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. + */ + +/** Registry key for the marker. Stable — it is a cross-instance contract. */ +const HTML_TIER_NODE_KEY = '@object-ui/sdui-parser.html-tier-node'; + +/** + * The provenance marker itself. Exported for tests and for any consumer that + * needs to reason about the key; prefer {@link isHtmlTierNode} for reading. + */ +export const HTML_TIER_NODE: symbol = Symbol.for(HTML_TIER_NODE_KEY); + +/** + * Stamp a node as html-tier output. Called by the parser as it builds each + * element — the producer is the only place that knows this for certain. + */ +export function markHtmlTierNode(node: T): T { + (node as Record)[HTML_TIER_NODE] = true; + return node; +} + +/** + * True when this node came out of the html tier's parser. + * + * Deliberately a plain boolean read of the marker — no fallback to a + * string-keyed spelling, no shape heuristic ("looks like a raw tag"). Either + * the producer marked it or it did not; a consumer-side guess is precisely what + * this replaces. + */ +export function isHtmlTierNode(node: unknown): boolean { + return ( + typeof node === 'object' && + node !== null && + (node as Record)[HTML_TIER_NODE] === true + ); +}