From 1a32ecf474edeef5f242deb76bc258e000a41dad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 06:30:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(example-showcase):=20project-detail=20?= =?UTF-8?q?=E7=9A=84=20tab=20token=20=E6=94=B9=E7=94=A8=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E7=9A=84=20`value`=20(#5776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `page:tabs` 的 `items[]` 上,稳定的 `?tab=` URL token 只有一个拼法:`value` —— 既是 `PageTabsProps.items[]` 声明的键(#5775 补上),也是 objectui tabs renderer 唯一读的键(`containers.tsx`:非空字符串 `it.value` 才作数,否则按 下标派生)。这两个 tab 写的是 `key`,两侧都不认识:schema 是 strip 模式,parse 把它剥掉;renderer 回落到 `tab-`。页面看起来正常,深链回不到原来那个 tab, 而作者拿到的是成功回执 —— ADR-0078 的教科书形状。#5068 的 `component-props-unknown-key` 闸门对这一处正好报了 2 条。 只改两行键名,零 spec / 零 objectui 改动:声明与 renderer 早已在 `value` 上一致, 错的是语料。 新增 `test/project-detail-tabs.test.ts` 同时钉形状与语义:token 写在声明的 `value` 下、能过 `PageTabsProps` 的 parse 且不被剥掉(这正是 `key` 与 `value` 的全部差别)、showcase 语料里任何 tab item 都不再出现 `key`/`id`/`name`/ `tabKey`/`slug` 这些近似拼法、token 语义且互不相同(下标派生值不具备的性质)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../src/ui/pages/project-detail.page.ts | 10 +- .../test/project-detail-tabs.test.ts | 128 ++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 examples/app-showcase/test/project-detail-tabs.test.ts diff --git a/examples/app-showcase/src/ui/pages/project-detail.page.ts b/examples/app-showcase/src/ui/pages/project-detail.page.ts index 94837b93c3..75994af4d3 100644 --- a/examples/app-showcase/src/ui/pages/project-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/project-detail.page.ts @@ -40,7 +40,13 @@ export const ProjectDetailPage = definePage({ { // Explicit details sections — each section's `fields` is a // field-list bound to showcase_project in the page editor. - key: 'details', + // + // `value` is the tab's stable `?tab=` URL token (#5776): the key + // `PageTabsProps.items[]` declares and objectui's tabs renderer + // reads. `key` was neither — an unknown prop nothing verifies and + // nothing reads, which left both tabs on the index-derived + // `tab-` fallback and their deep links non-durable. + value: 'details', label: 'Details', children: [ { @@ -56,7 +62,7 @@ export const ProjectDetailPage = definePage({ ], }, { - key: 'tasks', + value: 'tasks', label: 'Tasks', children: [ { diff --git a/examples/app-showcase/test/project-detail-tabs.test.ts b/examples/app-showcase/test/project-detail-tabs.test.ts new file mode 100644 index 0000000000..337e8dd506 --- /dev/null +++ b/examples/app-showcase/test/project-detail-tabs.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { PageTabsProps } from '@objectstack/spec/ui'; + +import * as pages from '../src/ui/pages/index.js'; +import { ProjectDetailPage } from '../src/ui/pages/index.js'; + +/** + * Dogfood gate for the project-detail page's tab strip (objectstack#5776). + * + * `page:tabs` items carry a **stable `?tab=` URL token**, and its one spelling + * is `value`: that is the key `PageTabsProps.items[]` declares (#5775) and the + * only one objectui's tabs renderer reads — `containers.tsx` takes a non-empty + * string `it.value` and otherwise derives the token from the item's index. + * This page authored `key` instead — a spelling neither side knows — so both + * tabs silently fell back to the index-derived `tab-`: the page renders, + * and a deep link comes back to whichever tab happens to sit at that index. + * #5068's `component-props-unknown-key` gate reported it as exactly that, twice. + * + * The assertions pin both halves, so neither can regress in silence: + * - the tokens are authored under the declared key and survive the schema's + * own parse (an undeclared key would be stripped, not carried); + * - `key` — and the other near-miss spellings — appear on no tab item in the + * whole showcase corpus, so a later page cannot re-introduce the shape; + * - the tokens are semantic and distinct, which is the property the + * index-derived fallback does not have. + */ + +type TabItem = Record & { label?: unknown; value?: unknown }; +type AnyComponent = { + type?: unknown; + properties?: Record; + [k: string]: unknown; +}; + +/** Every component on a page — regions and slots alike, nested tabs included. */ +function allComponents(page: Record): AnyComponent[] { + const out: AnyComponent[] = []; + const visit = (node: unknown): void => { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + const component = node as AnyComponent; + out.push(component); + const props = component.properties; + if (!props) return; + for (const item of Array.isArray(props.items) ? props.items : []) { + const children = (item as TabItem)?.children; + for (const child of Array.isArray(children) ? children : []) visit(child); + } + for (const child of Array.isArray(props.children) ? props.children : []) visit(child); + }; + + for (const region of (page.regions as { components?: unknown[] }[] | undefined) ?? []) { + for (const c of region.components ?? []) visit(c); + } + for (const slot of Object.values((page.slots as Record) ?? {})) { + for (const c of Array.isArray(slot) ? slot : [slot]) visit(c); + } + return out; +} + +const tabsComponents = (page: Record): AnyComponent[] => + allComponents(page).filter((c) => c.type === 'page:tabs'); + +const tabItems = (component: AnyComponent): TabItem[] => + (Array.isArray(component.properties?.items) ? component.properties!.items : []) as TabItem[]; + +/** Every page the showcase exports (the corpus the #5068 gate runs on). */ +const allPages = (Object.values(pages) as unknown[]).filter( + (p) => + !!p && typeof p === 'object' && !Array.isArray(p) && typeof (p as { name?: unknown }).name === 'string', +) as Record[]; + +describe('Project detail — tab tokens are authored under the declared `value` (#5776)', () => { + it('gives both tabs a stable `?tab=` token under `value`', () => { + const [tabs, ...rest] = tabsComponents(ProjectDetailPage as unknown as Record); + expect(tabs, 'the project-detail page must carry a page:tabs component').toBeTruthy(); + expect(rest, 'exactly one tab strip on this page').toHaveLength(0); + + const items = tabItems(tabs); + expect(items).toHaveLength(2); + expect(items.map((it) => it.value)).toEqual(['details', 'tasks']); + for (const item of items) { + expect(item, `tab "${String(item.label)}" must not carry the undeclared \`key\``).not.toHaveProperty('key'); + } + }); + + it('survives `PageTabsProps` parse with the tokens intact — a declared key is CARRIED', () => { + // The point of the rename: an undeclared key is dropped by the parse (strip + // mode) while looking authored in the source. Parsing here proves the token + // reaches a consumer, which is the whole difference between `key` and + // `value`. + const tabs = tabsComponents(ProjectDetailPage as unknown as Record)[0]!; + const parsed = PageTabsProps.parse(tabs.properties); + expect(parsed.items.map((it) => it.value)).toEqual(['details', 'tasks']); + }); + + it('leaves no near-miss token spelling on any tab item in the showcase corpus', () => { + // The regression this pins is a new page (or a rewrite of this one) reaching + // for `key` / `id` / `name` again — every one of them renders fine and + // sets no token. + for (const page of allPages) { + for (const tabs of tabsComponents(page)) { + for (const item of tabItems(tabs)) { + for (const spelling of ['key', 'id', 'name', 'tabKey', 'slug'] as const) { + expect( + item, + `page "${String(page.name)}" tab "${String(item.label)}" must carry its token as \`value\`, not \`${spelling}\``, + ).not.toHaveProperty(spelling); + } + } + } + } + }); + + it('uses semantic, distinct tokens — the property the index fallback lacks', () => { + const values = tabItems( + tabsComponents(ProjectDetailPage as unknown as Record)[0]!, + ).map((it) => it.value as string); + expect(new Set(values).size).toBe(values.length); + for (const value of values) { + expect(value).toMatch(/^[a-z][a-z0-9_:-]*$/); + // `tab-` is exactly the derived fallback; authoring it back would make + // the token as fragile as having none. + expect(value).not.toMatch(/^tab-\d+$/); + } + }); +});