Skip to content

Commit e95cc25

Browse files
os-zhuangclaude
andauthored
fix(app-shell): NavigationSyncEffect baseline race — readiness gate, sys_ filter, ADR-0010 lock filter (#1668)
* fix(app-shell): NavigationSyncEffect baseline race — gate on metadata readiness, exclude sys_ artifacts and ADR-0010 locked apps The page/dashboard metadata types load lazily, so the effect's first baseline could be seeded from a partial list — and invalidate() empties the cache mid-session. When the full list landed, platform pages (sys_organization_detail, sys_user_detail, …) diffed as 'user added': the effect PUT them into every app's navigation, 403ing on write- protected system apps (red 'Failed to update navigation' toasts) while polluting writable apps' nav (green toast for a sys_ page). - MetadataContextValue grows optional getTypeStatus(type); the app-shell provider exposes the cache entry status. Absent (hand-rolled test contexts) means 'always ready'. - NavigationSyncEffect only seeds its baseline and diffs while both 'page' and 'dashboard' are status==='ready' — the invalidate dip and the initial partial load no longer enter the diff. - sys_-prefixed pages/dashboards never participate in the diff (platform-shipped, never user creations). - Apps whose ADR-0010 _lock (or protection.lock) is 'full'/'no-overlay' are skipped by the effect and the *AllApps sync methods — a PUT there can only 403. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: changeset for navigation-sync baseline race fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(app-shell): treat package-provenance pages/dashboards as platform artifacts, not just sys_ names Package-registered pages are not guaranteed to be sys_-prefixed, and third-party plugins ship code-defined metadata too — a mid-session package install adds its pages between two ready snapshots, which a name-set diff reads exactly like user CRUD. Filter on the ADR-0010 provenance stamps (_packageId / _provenance === 'package') first, with the sys_ namespace prefix kept only as a fallback for registration paths that don't stamp provenance yet (the metadata-service path registers without package coords). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 22316fb commit e95cc25

5 files changed

Lines changed: 421 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/react": patch
4+
---
5+
6+
Fix the NavigationSyncEffect baseline race: lazily-loaded `page`/`dashboard` metadata (and the empty cache during `invalidate()` refetch) could seed a partial diff baseline, making platform `sys_` pages look "user added" — the effect then wrote them into every app's navigation, 403ing on ADR-0010 locked apps (red "Failed to update navigation" toasts) and polluting writable apps. The effect now diffs only while both types are `status === 'ready'` (new optional `MetadataContextValue.getTypeStatus`), never treats `sys_`-prefixed artifacts as user creations, and skips apps whose `_lock`/`protection.lock` is `full`/`no-overlay`.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* NavigationSyncEffect baseline race — the regression that motivated this
11+
* suite: `page` / `dashboard` metadata is lazily loaded, so the effect's
12+
* first baseline could be seeded from a partial (or, after `invalidate()`,
13+
* emptied) list. When the full list landed, platform pages
14+
* (sys_organization_detail, sys_user_detail, …) diffed as "user added" and
15+
* the effect PUT them into every app's navigation — including write-protected
16+
* system apps (ADR-0010 `_lock`), which 403'd into red failure toasts while
17+
* writable apps got polluted with sys_ nav entries (and a green toast).
18+
*/
19+
20+
import { describe, it, expect, vi, beforeEach } from 'vitest';
21+
import { render, waitFor } from '@testing-library/react';
22+
import { MetadataCtx, AdapterCtx, type MetadataContextValue, type MetadataTypeStatus } from '@object-ui/react';
23+
import {
24+
NavigationSyncEffect,
25+
isSystemArtifactName,
26+
isPlatformArtifact,
27+
isNavigationSyncableApp,
28+
} from '../useNavigationSync';
29+
30+
vi.mock('sonner', () => ({
31+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
32+
}));
33+
34+
// ---------------------------------------------------------------------------
35+
// Pure helpers
36+
// ---------------------------------------------------------------------------
37+
38+
describe('isSystemArtifactName', () => {
39+
it('flags sys_-prefixed names and nothing else', () => {
40+
expect(isSystemArtifactName('sys_organization_detail')).toBe(true);
41+
expect(isSystemArtifactName('sys_user_detail')).toBe(true);
42+
expect(isSystemArtifactName('my_page')).toBe(false);
43+
expect(isSystemArtifactName('system_overview')).toBe(false); // not the sys_ prefix
44+
expect(isSystemArtifactName('')).toBe(false);
45+
expect(isSystemArtifactName(undefined)).toBe(false);
46+
expect(isSystemArtifactName(42)).toBe(false);
47+
});
48+
});
49+
50+
describe('isPlatformArtifact', () => {
51+
it('flags package-provenance items regardless of name (ADR-0010 stamps)', () => {
52+
// Third-party plugin pages are NOT sys_-prefixed — provenance is the
53+
// primary signal, the name prefix only a fallback.
54+
expect(isPlatformArtifact({ name: 'crm_dashboard', _packageId: 'com.acme.crm' })).toBe(true);
55+
expect(isPlatformArtifact({ name: 'billing_home', _provenance: 'package' })).toBe(true);
56+
expect(isPlatformArtifact({ name: 'sys_user_detail' })).toBe(true); // prefix fallback
57+
});
58+
59+
it('accepts user-authored items', () => {
60+
expect(isPlatformArtifact({ name: 'my_page' })).toBe(false);
61+
expect(isPlatformArtifact({ name: 'my_page', _provenance: 'org' })).toBe(false);
62+
expect(isPlatformArtifact(null)).toBe(false);
63+
expect(isPlatformArtifact('my_page')).toBe(false);
64+
});
65+
});
66+
67+
describe('isNavigationSyncableApp', () => {
68+
it('rejects apps whose lock forbids PUT (ADR-0010)', () => {
69+
expect(isNavigationSyncableApp({ name: 'setup', _lock: 'full' })).toBe(false);
70+
expect(isNavigationSyncableApp({ name: 'studio', _lock: 'no-overlay' })).toBe(false);
71+
expect(isNavigationSyncableApp({ name: 'setup', protection: { lock: 'full' } })).toBe(false);
72+
});
73+
74+
it('accepts writable apps (no lock, or locks that still allow PUT)', () => {
75+
expect(isNavigationSyncableApp({ name: 'crm' })).toBe(true);
76+
expect(isNavigationSyncableApp({ name: 'crm', _lock: 'none' })).toBe(true);
77+
expect(isNavigationSyncableApp({ name: 'crm', _lock: 'no-delete' })).toBe(true);
78+
});
79+
80+
it('rejects non-objects', () => {
81+
expect(isNavigationSyncableApp(null)).toBe(false);
82+
expect(isNavigationSyncableApp('crm')).toBe(false);
83+
});
84+
});
85+
86+
// ---------------------------------------------------------------------------
87+
// NavigationSyncEffect
88+
// ---------------------------------------------------------------------------
89+
90+
interface World {
91+
apps: any[];
92+
pages: any[];
93+
dashboards: any[];
94+
status: Partial<Record<string, MetadataTypeStatus>>;
95+
}
96+
97+
function metaValue(world: World): MetadataContextValue {
98+
return {
99+
apps: world.apps,
100+
objects: [],
101+
dashboards: world.dashboards,
102+
reports: [],
103+
pages: world.pages,
104+
loading: false,
105+
error: null,
106+
refresh: async () => {},
107+
invalidate: () => {},
108+
ensureType: async () => [],
109+
getItem: async () => null,
110+
getItemsByType: () => [],
111+
getTypeStatus: (type: string) => world.status[type] ?? 'ready',
112+
};
113+
}
114+
115+
function makeAdapter(saveItem: ReturnType<typeof vi.fn>) {
116+
return { getClient: () => ({ meta: { saveItem } }) } as any;
117+
}
118+
119+
function renderEffect(saveItem: ReturnType<typeof vi.fn>, world: World) {
120+
const utils = render(
121+
<AdapterCtx.Provider value={makeAdapter(saveItem)}>
122+
<MetadataCtx.Provider value={metaValue(world)}>
123+
<NavigationSyncEffect />
124+
</MetadataCtx.Provider>
125+
</AdapterCtx.Provider>,
126+
);
127+
const setWorld = (next: World) =>
128+
utils.rerender(
129+
<AdapterCtx.Provider value={makeAdapter(saveItem)}>
130+
<MetadataCtx.Provider value={metaValue(next)}>
131+
<NavigationSyncEffect />
132+
</MetadataCtx.Provider>
133+
</AdapterCtx.Provider>,
134+
);
135+
return { ...utils, setWorld };
136+
}
137+
138+
/** Flush the effect's fire-and-forget async sync loop. */
139+
const flush = () => new Promise((r) => setTimeout(r, 0));
140+
141+
const crm = { name: 'crm', label: 'CRM', navigation: [] };
142+
143+
describe('NavigationSyncEffect', () => {
144+
beforeEach(() => {
145+
vi.clearAllMocks();
146+
});
147+
148+
it('does not seed the baseline until page AND dashboard types are ready', async () => {
149+
const saveItem = vi.fn().mockResolvedValue({});
150+
// Lazy types still loading — pages reads as [] even though the server
151+
// has sys_ pages plus a user page.
152+
const { setWorld } = renderEffect(saveItem, {
153+
apps: [crm],
154+
pages: [],
155+
dashboards: [],
156+
status: { page: 'loading', dashboard: 'loading' },
157+
});
158+
159+
// Full load lands. If the empty not-ready snapshot had been used as the
160+
// baseline, every page here would now diff as "user added".
161+
setWorld({
162+
apps: [crm],
163+
pages: [{ name: 'sys_organization_detail' }, { name: 'sys_user_detail' }, { name: 'home' }],
164+
dashboards: [{ name: 'sales' }],
165+
status: {},
166+
});
167+
await flush();
168+
expect(saveItem).not.toHaveBeenCalled();
169+
170+
// A page created AFTER the ready baseline does sync.
171+
setWorld({
172+
apps: [crm],
173+
pages: [{ name: 'sys_organization_detail' }, { name: 'sys_user_detail' }, { name: 'home' }, { name: 'my_page' }],
174+
dashboards: [{ name: 'sales' }],
175+
status: {},
176+
});
177+
await waitFor(() => expect(saveItem).toHaveBeenCalledTimes(1));
178+
const [type, name, schema] = saveItem.mock.calls[0];
179+
expect(type).toBe('app');
180+
expect(name).toBe('crm');
181+
expect(schema.navigation.map((i: any) => i.pageName)).toEqual(['my_page']);
182+
});
183+
184+
it('ignores the invalidate dip (status leaves ready, items empty out)', async () => {
185+
const saveItem = vi.fn().mockResolvedValue({});
186+
const ready: World = {
187+
apps: [crm],
188+
pages: [{ name: 'home' }],
189+
dashboards: [],
190+
status: {},
191+
};
192+
const { setWorld } = renderEffect(saveItem, ready);
193+
await flush();
194+
195+
// invalidate('page') → status 'idle'/'loading', items []. Without the
196+
// ready gate this read as "home deleted" …
197+
setWorld({ apps: [crm], pages: [], dashboards: [], status: { page: 'loading' } });
198+
await flush();
199+
// … and the refetch landing (now including a late sys_ page) as
200+
// "home + sys_* created".
201+
setWorld({
202+
apps: [crm],
203+
pages: [{ name: 'home' }, { name: 'sys_organization_detail' }],
204+
dashboards: [],
205+
status: {},
206+
});
207+
await flush();
208+
209+
expect(saveItem).not.toHaveBeenCalled();
210+
});
211+
212+
it('never treats sys_ pages/dashboards as user creations or deletions', async () => {
213+
const saveItem = vi.fn().mockResolvedValue({});
214+
const { setWorld } = renderEffect(saveItem, {
215+
apps: [crm],
216+
pages: [{ name: 'home' }],
217+
dashboards: [{ name: 'sys_metrics' }],
218+
status: {},
219+
});
220+
await flush();
221+
222+
// sys_ artifacts appear and disappear (package install/uninstall) —
223+
// neither direction may touch app navigation.
224+
setWorld({
225+
apps: [crm],
226+
pages: [{ name: 'home' }, { name: 'sys_user_detail' }],
227+
dashboards: [],
228+
status: {},
229+
});
230+
await flush();
231+
expect(saveItem).not.toHaveBeenCalled();
232+
});
233+
234+
it('ignores package-provenance pages from a mid-session install (not sys_-prefixed)', async () => {
235+
const saveItem = vi.fn().mockResolvedValue({});
236+
const { setWorld } = renderEffect(saveItem, {
237+
apps: [crm],
238+
pages: [{ name: 'home' }],
239+
dashboards: [],
240+
status: {},
241+
});
242+
await flush();
243+
244+
// Installing a package adds its pages to the metadata list between two
245+
// ready snapshots — a name-set diff reads that exactly like user CRUD.
246+
// The package ships its own navigation; auto-sync must stay out.
247+
setWorld({
248+
apps: [crm],
249+
pages: [{ name: 'home' }, { name: 'crm_dashboard', _packageId: 'com.acme.crm' }],
250+
dashboards: [],
251+
status: {},
252+
});
253+
await flush();
254+
expect(saveItem).not.toHaveBeenCalled();
255+
});
256+
257+
it('skips write-protected apps (ADR-0010 _lock) when syncing', async () => {
258+
const saveItem = vi.fn().mockResolvedValue({});
259+
const apps = [
260+
crm,
261+
{ name: 'setup', label: 'Setup', navigation: [], _lock: 'full' },
262+
{ name: 'studio', label: 'Studio', navigation: [], protection: { lock: 'no-overlay' } },
263+
];
264+
const { setWorld } = renderEffect(saveItem, {
265+
apps,
266+
pages: [],
267+
dashboards: [],
268+
status: {},
269+
});
270+
await flush();
271+
272+
setWorld({ apps, pages: [{ name: 'my_page' }], dashboards: [], status: {} });
273+
await waitFor(() => expect(saveItem).toHaveBeenCalledTimes(1));
274+
await flush();
275+
276+
// Exactly one write — to the writable app; no 403-bound PUTs attempted.
277+
expect(saveItem).toHaveBeenCalledTimes(1);
278+
expect(saveItem.mock.calls[0][1]).toBe('crm');
279+
const { toast } = await import('sonner');
280+
expect(toast.error).not.toHaveBeenCalled();
281+
});
282+
283+
it('treats a context without getTypeStatus as always ready (back-compat)', async () => {
284+
const saveItem = vi.fn().mockResolvedValue({});
285+
const base = metaValue({ apps: [crm], pages: [{ name: 'home' }], dashboards: [], status: {} });
286+
delete (base as any).getTypeStatus;
287+
const next = metaValue({
288+
apps: [crm],
289+
pages: [{ name: 'home' }, { name: 'p2' }],
290+
dashboards: [],
291+
status: {},
292+
});
293+
delete (next as any).getTypeStatus;
294+
295+
const adapter = makeAdapter(saveItem);
296+
const utils = render(
297+
<AdapterCtx.Provider value={adapter}>
298+
<MetadataCtx.Provider value={base}>
299+
<NavigationSyncEffect />
300+
</MetadataCtx.Provider>
301+
</AdapterCtx.Provider>,
302+
);
303+
utils.rerender(
304+
<AdapterCtx.Provider value={adapter}>
305+
<MetadataCtx.Provider value={next}>
306+
<NavigationSyncEffect />
307+
</MetadataCtx.Provider>
308+
</AdapterCtx.Provider>,
309+
);
310+
await waitFor(() => expect(saveItem).toHaveBeenCalledTimes(1));
311+
expect(saveItem.mock.calls[0][1]).toBe('crm');
312+
});
313+
});

0 commit comments

Comments
 (0)