Skip to content

Commit 40a35de

Browse files
fix(app-shell): a dashboard draft the server refuses to read stays unopened in Studio Interfaces, whatever order the designers load in (#29922)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb53xYKskegHeLV9gUxkwMmb)_ Fixes #29884 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6600806534). ## 维护者速读 - 改了什么:Studio Interfaces 打开一个服务器拒绝读取(403)的仪表盘草稿时,不论设计器先加载还是后加载,都只显示拒绝说明,不再打开空白的属性表单、画布和项目工具(Source),自动保存也没有任何可发送的内容。 - 为什么改:#29015 的修复只在设计器先注册时成立。内置设计器是动态加载的,多数新打开的页面(以及"所有元数据"里的项目链接)首屏没有设计器,于是空草稿被当成已加载;之后在 Source 里改一下,就会无版本前置条件地覆盖草稿(实测 200),草稿的权限门和所有仅草稿的改动丢失,发布后仪表盘对所有人可见。18.3.0 挡版项的修复不完整。 - 风险与回滚:只改拒绝读取(403)那一个分支,让它像对象、流程、应用三处已有的拒绝分支一样收回草稿归属。可读草稿、无草稿、其他读取失败的路径不变。回滚即撤销本 PR。 - 先看:`StudioDesignSurface.tsx` 叶子加载里 `'refused' in draftRead` 分支新增的五行;新测试 describe(`StudioDesignSurface.draftUnreadable-29015.test.tsx` 末尾)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.draftUnreadable-29015.test.tsx`,预期 6 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes - **Where the hole was.** The built-in designers register through a dynamic import, so the Interfaces pillar's first render can find no dashboard preview (`isEditable = !!Preview && !StudioCanvas` is false). That render takes the no-designer arm of the leaf load, which installs the leaf's clean `{}` buffer and stamps it as the leaf's (`setDraftFor(leafKey)`, objectui#11272). When the preview registers, the load runs again and reads the draft. On a 403 its refused branch (added by #29132) only set the strip's error and returned, so the stamp stayed. To `useDraftAutoSave` a stamped buffer is a loaded one, so the canvas, the Properties inspector and the item tools (Source) opened on `{}`, and the autosave was live with no version pin. A reload of a leaf whose earlier read was answered (a publish, a rollback) kept its loaded buffer the same way. - **The fix.** The refused branch now takes the leaf's buffer back: `setDraft({})`, `setDraftFor('')`, `forgetLeafVersion()`, the read address cleared, `setIfDirty(false)`. The object (`setObjDraftFor('')`), flow (`setDraftFor('')`) and app nav (`setAppDraftFor('')`) refused branches already do this. - **The no-designer arm is kept as it is.** objectui#11272 needs that leaf's clean `{}` buffer. While `!isEditable`, the autosave is blocked (`blocked: !current || !isEditable || …`) and the inspector needs `isEditable`, so the arm sends nothing by itself. The hole was only the refusal keeping its stamp. - **Census of the other editors this surface hosts.** The object and flow loads have no pre-registration arm that stamps ownership (both effects start at `if (!current) return`), and their refused branches, like the app nav's, already clear. Only the Interfaces leaf had this ordering. - **The "All metadata" deep link is the same load.** `/studio/PKG/metadata/dashboard/NAME` navigates a dashboard to the Interfaces pillar (`StudioMetadataArea` → `areaItemPillarPath`), so it is covered by the same fix, not a second editor. ## Tests A new top-level describe in the #29015 shared harness, `StudioDesignSurface.draftUnreadable-29015.test.tsx`. The designer registries are module state with no unregister, and the file's `beforeEach` registers the dashboard preview, so each late-registration case loads the pillar and its registries afresh (`vi.resetModules`), with nothing registered. Each case first asserts the stamped state (the Source tool is shown and the draft has not been read), then registers the preview and a default inspector inside `act`. - **Interfaces route, late registration:** the strip with the server sentence shows; no canvas, no inspector edit, no Source tool; no save after the autosave window; the stored draft still carries `requiredPermissions`. - **The area's deep link:** the same, mounted at the URL `areaItemPillarPath` gives for `/studio/PKG/metadata/dashboard/NAME`. This mounts the landing the area navigates to, not the area route itself. - **Same-leaf reload:** a readable draft opens, then a `publishNonce` reload is refused; the loaded buffer is taken back and nothing is sent. - **CONTROL, readable draft after late registration:** it opens editable, and an inspector edit saves the served draft (its `requiredPermissions` included) pinned `ifMatch: 'v1'`. - **CONTROL, early registration:** the existing #29015 case (preview registered in `beforeEach`) still refuses, unchanged. <details><summary>Red at main, green with the fix, ablation</summary> - At main `00f72f94` with only the test commit: `Tests 3 failed | 3 passed (6)`. All three new cases red on `expected <pre data-testid="surface-draft"></pre> to be null`; both controls and the #29015 case green. - With the fix: `Test Files 1 passed (1)`, `Tests 6 passed (6)`. - Ablation through `scripts/ablation-replace.mjs`: the refused branch's `setDraftFor('')` replaced by `setDraftFor((prev) => prev)` (anchor 1 → 0, blob `a60c33a4f242` → `ec0372a0a886`). Result: `Tests 3 failed | 3 passed (6)`, the same three red. Restored: blob == HEAD (`a60c33a4f242`), `git diff HEAD` empty. No build is involved: ui vitest aliases app-shell to `src`. </details> <details><summary>Neighbour sweep and type-check (head 1a9786f, after merging main)</summary> - Every ui test file naming `InterfacesPillar` (57 files, the harness included), two locked batches at `--maxWorkers=2`: 29 files / 155 tests passed; 28 files / 283 tests passed. - Narrowing, declared: the 223 files naming `StudioDesignSurface` are not all run locally; the touched code is the Interfaces pillar's leaf load, and CI's Test UI runs the rest. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell` (65 tasks, test files included): exit 0. - `eslint` on the two touched ui files: 0 errors; the 36 warnings all predate this PR (none is on a line it adds). </details> ## Acceptance notes - **Live, card steps 1–7** on a fresh showcase backend and the Console Vite dev server: a writable package, a published dashboard with one metric widget, an app whose nav names it, and a draft with `requiredPermissions: ['showcase.export_data']` (admin's draft read: 403). - Before (main's `StudioDesignSurface.tsx` served): 7 of 7 fresh loads (5 on Interfaces, 2 by the deep link) show the strip AND the empty Properties form (5 form controls) and the item tools. A Source edit to the published body sent `PUT …?mode=draft&package=…&surface=studio` with no If-Match, answered **200**, and admin's draft read flipped 403 → 200 (the gate was gone). That happened on both routes. - After (this branch): 9 of 9 fresh loads (5 + 2 loads, plus the two edit attempts) show only the strip, with 0 form controls, no item tools and no PUT. The draft read stays 403. - The swap was restored (blob == HEAD), and the after leg was re-run once Vite served the restored file again. - Probe script, JSON readings and screenshots: `/mnt/project-files/issue-29884/shots/`. - `Fixes #29884` is on the third line, after the project attribution block, which the session requires as the body's first two lines. ## Verification Local gate union on the final head `58c3a3f7` (main merged twice; the second merge brought 5 backend-only commits, none touching `ui/`): - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 35 families for the 3 changed paths (182 changed lines). - The 34 non-vitest families: all `exit 0` on `58c3a3f7`, among them `check-sdui-manifest.mjs` (exit 0, the record does not move for a logic-only edit), `check-changeset-no-major`, `check-empty-changeset`, `check-adr-0087-registration`, `check:query-options-erasure`, `check:type-check-debt`, `check:cross-package-test-inputs`, `check:test-source-alias`. - `pnpm --dir ui exec vitest run scripts/`: on `1a9786f9` (the first merge), `Test Files 170 passed | 2 skipped (172)`, `Tests 4979 passed | 2 skipped (4981)`, exit 0. The re-run on `58c3a3f7` finished green after this PR opened (4979 passed; card report 6604287278). The second merge touched no `ui/` file. - `dispatch-gates --ran` on `1a9786f9`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - CI runs the rest: Type Check UI, Test UI, Lint UI, Build & E2E UI and the required set. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQx7EwgLR5PQRL3zQp)_ <!-- signed: domain:ui#3 · zhuangjianguo · session_01QsR2jV4dWohojjbTJTQq5m --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a04e9d7 commit 40a35de

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a dashboard draft the server refuses to read stays unopened in Studio Interfaces, whatever order the designers load in
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Studio Interfaces already opened no buffer over a pending draft whose read the server refuses (403: its `requiredPermissions` name a capability the author does not hold). That held only when the dashboard designer was registered before the pillar first rendered. The built-in designers load through a dynamic import, so on most fresh loads (and on the "All metadata" item link, which lands on the same pillar) the first render found no designer and stamped an empty buffer as the leaf's. When the designer arrived, the refusal kept that stamp: the canvas, the Properties inspector and the item tools opened on an empty body, and a Source edit was autosaved over the draft with no version pin, dropping its audience gate and every draft-only change. A reload of a leaf whose earlier read was answered kept its loaded buffer the same way.
11+
12+
A refused read now takes the leaf's buffer back, as the object, flow and app loads already do: nothing is shown to edit, and the autosave has nothing to send.

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftUnreadable-29015.test.tsx‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
106106
import { InterfacesPillar } from './StudioDesignSurface';
107107
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
108108
import { registerMetadataPreview, getMetadataPreview } from '../metadata-admin/preview-registry';
109+
import { areaItemPillarPath } from './metadataArea';
109110

110111
const dataSource = createEmptyDataSource();
111112
failOnAbsorbedFetchError();
@@ -136,6 +137,15 @@ function renderPillar() {
136137
);
137138
}
138139

140+
/** Default-inspector stand-in: one edit through `onPatch`, as the Properties form offers. */
141+
function StubInspector({ onPatch }: { onPatch: (patch: Record<string, unknown>) => void }) {
142+
return (
143+
<button type="button" data-testid="stub-inspector-edit" onClick={() => onPatch({ label: 'Sales (inspector)' })}>
144+
inspect
145+
</button>
146+
);
147+
}
148+
139149
/** Longer than the autosave's debounce, so a save that was going to leave has left. */
140150
async function pastTheAutosave() {
141151
await act(async () => {
@@ -209,3 +219,153 @@ describe('StudioDesignSurface — a dashboard draft the server refuses to read i
209219
expect(opts).not.toHaveProperty('ifMatch');
210220
});
211221
});
222+
223+
/**
224+
* objectstack#29884 — the same contract whatever order the designers arrive
225+
* in. The package entry registers the built-in designers through a dynamic
226+
* import, so the pillar's first render can find no dashboard preview. That
227+
* render installs the leaf's clean `{}` buffer (objectui#11272) and stamps it
228+
* as the leaf's; the read of the draft starts only once the preview
229+
* registers. A refusal then must take that stamp back, as it must on a reload
230+
* of a leaf whose earlier read was answered: a stamped buffer is a loaded
231+
* buffer to the autosave, the canvas, the inspector and the item tools.
232+
*
233+
* The designer registries are module state with no unregister, and every
234+
* test above registers the dashboard preview. So each late-registration case
235+
* below loads the pillar and its registries afresh (`vi.resetModules`), with
236+
* nothing registered, and registers the designers itself inside `act`.
237+
*/
238+
describe('StudioDesignSurface — a refused dashboard draft stays unopened when the designer registers late or the leaf reloads (objectstack#29884)', () => {
239+
async function freshPillarWithNoDesigners() {
240+
vi.resetModules();
241+
const previews = await import('../metadata-admin/preview-registry');
242+
const inspectors = await import('../metadata-admin/default-inspector-registry');
243+
const { InterfacesPillar: FreshPillar } = await import('./StudioDesignSurface');
244+
expect(previews.getMetadataPreview('dashboard')).toBeUndefined();
245+
expect(inspectors.getMetadataDefaultInspector('dashboard')).toBeUndefined();
246+
return {
247+
FreshPillar,
248+
registerDesigners: () =>
249+
act(() => {
250+
previews.registerMetadataPreview('dashboard', StubCanvas as never);
251+
inspectors.registerMetadataDefaultInspector('dashboard', StubInspector as never);
252+
}),
253+
};
254+
}
255+
256+
/** Before registration: the leaf's `{}` buffer is stamped as loaded, and its draft is not read yet. */
257+
async function expectStampedBeforeRegistration() {
258+
expect(await screen.findByTestId('studio-item-tool-source', undefined, { timeout: 8000 })).toBeInTheDocument();
259+
expect(mockClient.getDraft).not.toHaveBeenCalledWith('dashboard', 'sales_board', expect.anything());
260+
}
261+
262+
async function expectRefusedAndUnopened() {
263+
await waitFor(
264+
() => expect(mockClient.getDraft).toHaveBeenCalledWith('dashboard', 'sales_board', { packageId: PKG }),
265+
{ timeout: 8000 },
266+
);
267+
expect(
268+
await screen.findByText(t('engine.studio.refusal.draftUnreadable', 'en-US'), undefined, { timeout: 8000 }),
269+
).toBeInTheDocument();
270+
expect(screen.getByTestId('studio-refusal')).toHaveTextContent(GATE_REFUSAL);
271+
// Red on base: the stamped `{}` buffer stayed the leaf's, so the canvas,
272+
// the Properties inspector and the item tools (Source) opened on it.
273+
expect(screen.queryByTestId('surface-draft')).toBeNull();
274+
expect(screen.queryByTestId('stub-edit')).toBeNull();
275+
expect(screen.queryByTestId('stub-inspector-edit')).toBeNull();
276+
expect(screen.queryByTestId('studio-item-tool-source')).toBeNull();
277+
await pastTheAutosave();
278+
expect(server.saves).toEqual([]);
279+
expect(server.drafts.get(key('dashboard', 'sales_board'))).toStrictEqual(wire(GATED_DRAFT));
280+
}
281+
282+
it('the Interfaces route: a preview registered after the first render opens no buffer over the refused draft', async () => {
283+
server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT));
284+
server.gated.add(key('dashboard', 'sales_board'));
285+
const { FreshPillar, registerDesigners } = await freshPillarWithNoDesigners();
286+
287+
render(
288+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
289+
<FreshPillar packageId={PKG} />
290+
</MemoryRouter>,
291+
);
292+
await expectStampedBeforeRegistration();
293+
registerDesigners();
294+
await expectRefusedAndUnopened();
295+
}, 30000);
296+
297+
it("the area's deep link: the item URL lands on the same leaf load, and opens no buffer over the refused draft", async () => {
298+
server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT));
299+
server.gated.add(key('dashboard', 'sales_board'));
300+
const { FreshPillar, registerDesigners } = await freshPillarWithNoDesigners();
301+
// Where `/studio/PKG/metadata/dashboard/sales_board` sends a dashboard (`StudioMetadataArea`).
302+
const landing = areaItemPillarPath({
303+
packageId: PKG,
304+
type: 'dashboard',
305+
name: 'sales_board',
306+
search: new URLSearchParams(),
307+
});
308+
expect(landing?.startsWith(`/studio/${PKG}/interfaces?`)).toBe(true);
309+
310+
render(
311+
<MemoryRouter initialEntries={[landing!]}>
312+
<FreshPillar packageId={PKG} />
313+
</MemoryRouter>,
314+
);
315+
await expectStampedBeforeRegistration();
316+
registerDesigners();
317+
await expectRefusedAndUnopened();
318+
}, 30000);
319+
320+
it('a reload of a leaf whose earlier read was answered: the refusal takes the loaded buffer back', async () => {
321+
// Readable at first: the pending draft opens as the buffer.
322+
server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT));
323+
const { rerender } = render(
324+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
325+
<InterfacesPillar packageId={PKG} publishNonce={0} />
326+
</MemoryRouter>,
327+
);
328+
await waitFor(
329+
() =>
330+
expect(JSON.parse(screen.getByTestId('surface-draft').textContent ?? '{}')).toStrictEqual(wire(GATED_DRAFT)),
331+
{ timeout: 8000 },
332+
);
333+
// The same leaf read again (a publish) is refused now.
334+
server.gated.add(key('dashboard', 'sales_board'));
335+
mockClient.getDraft.mockClear();
336+
rerender(
337+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
338+
<InterfacesPillar packageId={PKG} publishNonce={1} />
339+
</MemoryRouter>,
340+
);
341+
await expectRefusedAndUnopened();
342+
}, 30000);
343+
344+
it('CONTROL: a readable pending draft still opens editable after a late registration, and its save is pinned to the version read', async () => {
345+
server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT));
346+
const { FreshPillar, registerDesigners } = await freshPillarWithNoDesigners();
347+
348+
render(
349+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
350+
<FreshPillar packageId={PKG} />
351+
</MemoryRouter>,
352+
);
353+
await expectStampedBeforeRegistration();
354+
registerDesigners();
355+
await waitFor(
356+
() =>
357+
expect(JSON.parse(screen.getByTestId('surface-draft').textContent ?? '{}')).toStrictEqual(wire(GATED_DRAFT)),
358+
{ timeout: 8000 },
359+
);
360+
expect(screen.queryByText(t('engine.studio.refusal.draftUnreadable', 'en-US'))).toBeNull();
361+
expect(screen.getByTestId('studio-item-tool-source')).toBeInTheDocument();
362+
363+
await userEvent.click(screen.getByTestId('stub-inspector-edit'));
364+
await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 });
365+
const { body, opts } = server.saves[0]!;
366+
expect(body.label).toBe('Sales (inspector)');
367+
// The draft's gate rides the save: the buffer is the served draft.
368+
expect(body.requiredPermissions).toEqual(['showcase.export_data']);
369+
expect(opts).toMatchObject({ mode: 'draft', packageId: PKG, ifMatch: 'v1' });
370+
}, 30000);
371+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5280,7 +5280,17 @@ export function InterfacesPillar({
52805280
// buffer is installed over it, so nothing is shown to edit and the
52815281
// autosave has nothing to send: a buffer seeded from the published
52825282
// layer would be saved over the draft, dropping its keys and its gate.
5283+
// objectstack#29884 — and a buffer already stamped as this leaf's is
5284+
// taken back: the `{}` the no-designer arm above installs while the
5285+
// built-in designers are still loading, or the body an earlier read of
5286+
// this leaf installed before a reload. A stamped buffer is a loaded one
5287+
// to the autosave, the canvas, the inspector and the item tools.
52835288
if ('refused' in draftRead) {
5289+
setDraft({});
5290+
setDraftFor('');
5291+
forgetLeafVersion();
5292+
leafReadAtRef.current = null;
5293+
setIfDirty(false);
52845294
setError({
52855295
message: t('engine.studio.refusal.draftUnreadable', locale),
52865296
detail: formatMetadataError(draftRead.refused),

0 commit comments

Comments
 (0)