Skip to content

Commit 1a32ecf

Browse files
committed
fix(example-showcase): project-detail 的 tab token 改用声明的 value (#5776)
`page:tabs` 的 `items[]` 上,稳定的 `?tab=` URL token 只有一个拼法:`value` —— 既是 `PageTabsProps.items[]` 声明的键(#5775 补上),也是 objectui tabs renderer 唯一读的键(`containers.tsx`:非空字符串 `it.value` 才作数,否则按 下标派生)。这两个 tab 写的是 `key`,两侧都不认识:schema 是 strip 模式,parse 把它剥掉;renderer 回落到 `tab-<i>`。页面看起来正常,深链回不到原来那个 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
1 parent a36db28 commit 1a32ecf

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

examples/app-showcase/src/ui/pages/project-detail.page.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ export const ProjectDetailPage = definePage({
4040
{
4141
// Explicit details sections — each section's `fields` is a
4242
// field-list bound to showcase_project in the page editor.
43-
key: 'details',
43+
//
44+
// `value` is the tab's stable `?tab=` URL token (#5776): the key
45+
// `PageTabsProps.items[]` declares and objectui's tabs renderer
46+
// reads. `key` was neither — an unknown prop nothing verifies and
47+
// nothing reads, which left both tabs on the index-derived
48+
// `tab-<i>` fallback and their deep links non-durable.
49+
value: 'details',
4450
label: 'Details',
4551
children: [
4652
{
@@ -56,7 +62,7 @@ export const ProjectDetailPage = definePage({
5662
],
5763
},
5864
{
59-
key: 'tasks',
65+
value: 'tasks',
6066
label: 'Tasks',
6167
children: [
6268
{
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { PageTabsProps } from '@objectstack/spec/ui';
5+
6+
import * as pages from '../src/ui/pages/index.js';
7+
import { ProjectDetailPage } from '../src/ui/pages/index.js';
8+
9+
/**
10+
* Dogfood gate for the project-detail page's tab strip (objectstack#5776).
11+
*
12+
* `page:tabs` items carry a **stable `?tab=` URL token**, and its one spelling
13+
* is `value`: that is the key `PageTabsProps.items[]` declares (#5775) and the
14+
* only one objectui's tabs renderer reads — `containers.tsx` takes a non-empty
15+
* string `it.value` and otherwise derives the token from the item's index.
16+
* This page authored `key` instead — a spelling neither side knows — so both
17+
* tabs silently fell back to the index-derived `tab-<i>`: the page renders,
18+
* and a deep link comes back to whichever tab happens to sit at that index.
19+
* #5068's `component-props-unknown-key` gate reported it as exactly that, twice.
20+
*
21+
* The assertions pin both halves, so neither can regress in silence:
22+
* - the tokens are authored under the declared key and survive the schema's
23+
* own parse (an undeclared key would be stripped, not carried);
24+
* - `key` — and the other near-miss spellings — appear on no tab item in the
25+
* whole showcase corpus, so a later page cannot re-introduce the shape;
26+
* - the tokens are semantic and distinct, which is the property the
27+
* index-derived fallback does not have.
28+
*/
29+
30+
type TabItem = Record<string, unknown> & { label?: unknown; value?: unknown };
31+
type AnyComponent = {
32+
type?: unknown;
33+
properties?: Record<string, unknown>;
34+
[k: string]: unknown;
35+
};
36+
37+
/** Every component on a page — regions and slots alike, nested tabs included. */
38+
function allComponents(page: Record<string, unknown>): AnyComponent[] {
39+
const out: AnyComponent[] = [];
40+
const visit = (node: unknown): void => {
41+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
42+
const component = node as AnyComponent;
43+
out.push(component);
44+
const props = component.properties;
45+
if (!props) return;
46+
for (const item of Array.isArray(props.items) ? props.items : []) {
47+
const children = (item as TabItem)?.children;
48+
for (const child of Array.isArray(children) ? children : []) visit(child);
49+
}
50+
for (const child of Array.isArray(props.children) ? props.children : []) visit(child);
51+
};
52+
53+
for (const region of (page.regions as { components?: unknown[] }[] | undefined) ?? []) {
54+
for (const c of region.components ?? []) visit(c);
55+
}
56+
for (const slot of Object.values((page.slots as Record<string, unknown>) ?? {})) {
57+
for (const c of Array.isArray(slot) ? slot : [slot]) visit(c);
58+
}
59+
return out;
60+
}
61+
62+
const tabsComponents = (page: Record<string, unknown>): AnyComponent[] =>
63+
allComponents(page).filter((c) => c.type === 'page:tabs');
64+
65+
const tabItems = (component: AnyComponent): TabItem[] =>
66+
(Array.isArray(component.properties?.items) ? component.properties!.items : []) as TabItem[];
67+
68+
/** Every page the showcase exports (the corpus the #5068 gate runs on). */
69+
const allPages = (Object.values(pages) as unknown[]).filter(
70+
(p) =>
71+
!!p && typeof p === 'object' && !Array.isArray(p) && typeof (p as { name?: unknown }).name === 'string',
72+
) as Record<string, unknown>[];
73+
74+
describe('Project detail — tab tokens are authored under the declared `value` (#5776)', () => {
75+
it('gives both tabs a stable `?tab=` token under `value`', () => {
76+
const [tabs, ...rest] = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>);
77+
expect(tabs, 'the project-detail page must carry a page:tabs component').toBeTruthy();
78+
expect(rest, 'exactly one tab strip on this page').toHaveLength(0);
79+
80+
const items = tabItems(tabs);
81+
expect(items).toHaveLength(2);
82+
expect(items.map((it) => it.value)).toEqual(['details', 'tasks']);
83+
for (const item of items) {
84+
expect(item, `tab "${String(item.label)}" must not carry the undeclared \`key\``).not.toHaveProperty('key');
85+
}
86+
});
87+
88+
it('survives `PageTabsProps` parse with the tokens intact — a declared key is CARRIED', () => {
89+
// The point of the rename: an undeclared key is dropped by the parse (strip
90+
// mode) while looking authored in the source. Parsing here proves the token
91+
// reaches a consumer, which is the whole difference between `key` and
92+
// `value`.
93+
const tabs = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>)[0]!;
94+
const parsed = PageTabsProps.parse(tabs.properties);
95+
expect(parsed.items.map((it) => it.value)).toEqual(['details', 'tasks']);
96+
});
97+
98+
it('leaves no near-miss token spelling on any tab item in the showcase corpus', () => {
99+
// The regression this pins is a new page (or a rewrite of this one) reaching
100+
// for `key` / `id` / `name` again — every one of them renders fine and
101+
// sets no token.
102+
for (const page of allPages) {
103+
for (const tabs of tabsComponents(page)) {
104+
for (const item of tabItems(tabs)) {
105+
for (const spelling of ['key', 'id', 'name', 'tabKey', 'slug'] as const) {
106+
expect(
107+
item,
108+
`page "${String(page.name)}" tab "${String(item.label)}" must carry its token as \`value\`, not \`${spelling}\``,
109+
).not.toHaveProperty(spelling);
110+
}
111+
}
112+
}
113+
}
114+
});
115+
116+
it('uses semantic, distinct tokens — the property the index fallback lacks', () => {
117+
const values = tabItems(
118+
tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>)[0]!,
119+
).map((it) => it.value as string);
120+
expect(new Set(values).size).toBe(values.length);
121+
for (const value of values) {
122+
expect(value).toMatch(/^[a-z][a-z0-9_:-]*$/);
123+
// `tab-<i>` is exactly the derived fallback; authoring it back would make
124+
// the token as fragile as having none.
125+
expect(value).not.toMatch(/^tab-\d+$/);
126+
}
127+
});
128+
});

0 commit comments

Comments
 (0)