Skip to content

Commit 2f7977c

Browse files
fix(app-shell): record pages and sealed managed apps get no navigation writes (#29294)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbM4E3JW8kr9W6U2A9dehyRU)_ Fixes #29182 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:新建页面首次发布时,只有「不需要记录就能打开」的页面才写进各应用导航;记录页(`type: 'record'`)不再写。由托管包提供、被服务端封存的应用(如 showcase 的 `showcase_app`)不再被写导航,也就不再弹红色「Failed to update navigation」。 - **为什么改**:管理员每建一个记录页,控制台就往每个应用的导航里加一个打不开的入口(没有记录 id),并且对封存应用的写入被 403 拒绝、每次都弹错误提示。 - **风险与回滚**:只改 `useNavigationSync.ts` 的两处判断;「全部发布」路径无页面正文时按名读一次页面类型,读失败则不写导航并提示。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/hooks/useNavigationSync.ts`(`isNavigationSyncableApp` 的 `_provenance` 行、`useNavigationWrites().created` 的页面分支),`ui/packages/app-shell/src/hooks/__tests__/useNavigationSync.test.tsx` 末尾的新 describe。 - **两分钟验证**:showcase 上在 Studio 新建 `record` 类型页面并发布:网络面板里发布后没有任何 `PUT /api/v1/meta/app/*`,也没有红色提示;改建 `home` 类型页面,只写运行时创建的应用。 - **需要你做的**:无,席位落地。 ## What changes `ui/packages/app-shell/src/hooks/useNavigationSync.ts`, per the seat's rulings on #29182: 1. **A page that needs a record is never synced into navigation.** `useNavigationWrites().created` now asks `isNavigablePage(body)` before `syncPageCreatedAllApps`. The page type comes from the body the create already holds (`ResourceEditPage` passes the published `lay.effective`). A body that leaves `type` out counts as a record page, because `PageSchema` declares `type` with the default `PAGE_TYPE_DEFAULT = 'record'`. - **Publish all** (`usePublishAllDrafts`) holds only the `_drafts` header rows, so no body reaches `created()`. For that caller only, the door reads the page by name once (`client.meta.getItem('page', name)`, one read per created page). If that read fails, the navigation write for that page is **skipped, not written blind**, and the existing "Failed to update navigation" toast says navigation was not updated. The seat amended the claim to add the Publish-all test file to the surface for this. 2. **A sealed app is not syncable.** `isNavigationSyncableApp` also returns `false` for an app whose read carries `_provenance: 'package'`. `app` has no per-org overlay channel (`allowEnvironmentOverlay: false`), so the server refuses every package-less write onto an artifact-backed app with `403 NOT_OVERRIDABLE` whatever its lock. The marker was measured on the stock showcase (below). Because the read carries a marker, the `403 NOT_OVERRIDABLE` fallback branch of the ruling is not needed and not added. Any other app-write failure still shows today's error toast. 3. Ruling A of #28680 stands: a navigable page is still written into every app that can take it, at create time. No metadata key, no server change, no new gate. `isNavigationSyncableApp` is not exported from `@object-ui/app-shell`'s entry, and its signature is unchanged. ### Non-navigable page types found `PageTypeSchema` (`packages/spec/src/ui/page.zod.ts`, the enum at `PageTypeSchema`) has five members: `record`, `home`, `app`, `utility`, `list`. Only `record` cannot render without a record: - `record`: `ui/packages/app-shell/src/views/metadata-admin/inspectors/nav-target.ts`, `isStaticPageOption` docblock: "Record-detail pages (`type: 'record'`) require a specific record id to render, but a `type: 'page'` nav item resolves to a static `/apps/{app}/page/{name}` URL with no mechanism to pass one". Also `packages/spec/src/ui/page.zod.ts`, `checkPageRecordBindingPlacement` docblock: "A record page already has its row — the record it opens". - A body with no `type`: `packages/spec/src/ui/default-record-page.ts`, `PAGE_TYPE_DEFAULT` docblock: "a page authored without `type` IS a record page, and an unparsed body … that leaves the key out is judged as one". - `home`, `list`, `utility` render without one (same `checkPageRecordBindingPlacement` docblock: they are "not about a row at all"). `app` resolves its own row from its optional `record` query binding, so it does not need one from the route either. ### The sealed-app marker, measured Stock showcase boot at this branch's head (`objectstack dev --seed-admin --fresh`), `GET /api/v1/meta/app`, private keys per app: | app | served keys | package-less `PUT` | |:--|:--|:--| | `showcase_app` | `_packageId: com.example.showcase`, `_provenance: package` (no `_lock`) | `403 NOT_OVERRIDABLE` ("provided by a managed package and is sealed") | | `setup`, `account` | `_provenance: package`, `_lock: full` | (already skipped by the lock) | | `probe_app` (package-less, created by PUT) | none | 200 | | `rthree_app` (app of a package created at runtime, `POST /api/v1/packages`) | `_packageId: com.probe.rthree` only | 200 | So `_provenance` is the discriminator. `_packageId` is not, because a runtime package's app carries it and takes the write. ## Tests - `useNavigationSync.test.tsx`: new `isNavigablePage` units, two new `isNavigationSyncableApp` units, and a new `describe` on the door: a record page (and a type-less body) writes no app and shows no toast; `home` and `list` pages still write every syncable app; with no body held the page is read by name and its type decides; a failed read writes nothing and toasts once; a sealed app gets no write and no red toast on create, dashboard create and delete; another app's write failure (500) still toasts. Existing door cases now pass an `app` page body. - `usePublishAllDrafts.navigationWrites-28680.test.tsx` (surface amended by the seat): the adapter mock gains `getItem`. New case: Publish all with a new record page and a new `home` page writes only the `home` page, with exactly one by-name read per created page. - `useNavigationSync.labelInherits-9868.test.tsx`: its page body now states `type: 'app'`. A type-less body is a record page, so it no longer writes. That is the behaviour this PR asks for. <details><summary>Test runs (head 92a066d)</summary> - Touched files: `cd ui && pnpm exec vitest run useNavigationSync.test.tsx usePublishAllDrafts.navigationWrites-28680.test.tsx`: Test Files 2 passed, Tests 30 passed. - Every ui test naming `useNavigationSync`, `useNavigationWrites` or `isNavigationSyncableApp` (9 files): Test Files 9 passed, Tests 77 passed. - Every ui test naming `usePublishAllDrafts` or `publishAll`, plus every `ResourceEditPage*.test.tsx` (76 files): Test Files 76 passed, Tests 419 passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 tasks successful. - After merging `origin/main` (36 commits, none on the touched files), on head `3c9ee893`: every ui test naming `useNavigationSync`, `useNavigationWrites`, `isNavigationSyncableApp` or `usePublishAllDrafts` (33 files): Test Files 33 passed, Tests 277 passed. </details> <details><summary>Derived gates (dispatch-gates --commands, 34 families)</summary> All 34 exit 0 on head `92a066d9`, reconciled with `dispatch-gates --ran`: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped. Re-run on the merged head `3c9ee893`: `check-sdui-manifest`, `check-changeset-no-major`, `check-closing-keyword-parity`, `check-adr-0087-registration`, `check:nul-bytes`, all exit 0. </details> <details><summary>Ablations (each committed first, run through scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> - Record-page guard `if (!isNavigablePage(body)) return;` made always-pass: 3 cases red (record page writes no app; by-name read decides; Publish all record page). - Sealed-app line `if (a._provenance === 'package') return false;` neutralised: 2 cases red (the unit; the door's sealed-app case). - Failed-read branch `if (body === undefined) {` disabled: 1 case red (the failed read toasts). Each restore read back as the HEAD blob `06b76092f89a` with an empty `git diff HEAD`. </details> ## Acceptance notes - A failed by-name page read (Publish all only) skips that page's navigation write and shows "Failed to update navigation". It does not write blind. - Apps written by an earlier record-page create keep their stray entries. This PR stops new ones and does not clean up old ones. Deleting the page still removes its entries, as before. ## Verification Card steps on a stock showcase (`examples/app-showcase`, `objectstack dev --seed-admin --fresh`), Console from source (Vite, `@object-ui/*` src-aliased), Playwright. Studio `/studio/~all/metadata/page/new`, Object `showcase_task`, Save, then Publish. Apps present: `showcase_app`, `setup`, `account`, and the two runtime apps above. <details><summary>Before (useNavigationSync.ts restored from origin/main 14208f2), page type record</summary> ```text PUT /api/v1/meta/page/before_record_probe?mode=draft -> 200 POST /api/v1/meta/page/before_record_probe/publish -> 200 PUT /api/v1/meta/app/showcase_app -> 403 PUT /api/v1/meta/app/probe_app -> 200 PUT /api/v1/meta/app/rthree_app -> 200 toasts: success "Navigation updated: added page before_record_probe" · error "Failed to update navigation" ``` </details> <details><summary>After (this branch), page type record, run twice (before and after the before-run's restore)</summary> ```text PUT /api/v1/meta/page/after_record_probe_two?mode=draft -> 200 POST /api/v1/meta/page/after_record_probe_two/publish -> 200 toasts: none ``` </details> <details><summary>After (this branch), page type home</summary> ```text PUT /api/v1/meta/page/after_home_probe_two?mode=draft -> 200 POST /api/v1/meta/page/after_home_probe_two/publish -> 200 PUT /api/v1/meta/app/probe_app -> 200 PUT /api/v1/meta/app/rthree_app -> 200 toasts: success "Navigation updated: added page after_home_probe_two" ``` No write reaches `showcase_app`, `setup` or `account`, and no error toast shows. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUbgX5dtoeHsDmfa9Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d528bf commit 2f7977c

5 files changed

Lines changed: 273 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): creating a record page no longer adds it to every app's navigation, and no navigation write goes to a sealed managed app
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **A page that needs a record joins no app.** When a page is published for the first time, the Console adds it to app navigation only if it renders without a record. A `record` page (and a page body that leaves `type` out, which `PageSchema` defaults to `record`) gets no navigation entry, because a page entry opens the page with no record id. `home`, `app`, `utility` and `list` pages are still added to every app that can take them. When the publishing screen holds no page body (Publish all), the page is read by name once to learn its type; if that read fails, the page is left out of navigation and the error toast says navigation was not updated.
11+
- **A sealed managed app is not written.** An app a managed package ships (`_provenance: 'package'` on the app read, as the stock showcase serves `showcase_app`) is sealed: the server refuses a package-less write with `403 NOT_OVERRIDABLE`. Navigation writes now skip such an app, so publishing a page no longer shows a red "Failed to update navigation" toast for it. Apps stored at runtime, including apps of a package authored at runtime, are written as before. Any other failure of an app write still shows the error toast.

‎ui/packages/app-shell/src/hooks/__tests__/useNavigationSync.labelInherits-9868.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ describe('objectui#9868 — the create sites write unnamed entries with NO label
7676
const { created } = useNavigationWrites();
7777
useEffect(() => {
7878
void (async () => {
79-
await created('page', 'my_page', { name: 'my_page' });
79+
// A page kind that renders without a record: a record page joins no
80+
// app's navigation (objectstack#29182).
81+
await created('page', 'my_page', { name: 'my_page', type: 'app' });
8082
await created('dashboard', 'my_dash', { name: 'my_dash', label: 'My Dashboard' });
8183
})();
8284
// Once, on mount: the two creates are the case.

‎ui/packages/app-shell/src/hooks/__tests__/useNavigationSync.test.tsx‎

Lines changed: 144 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
isPlatformArtifact,
3131
isNavigationSyncableApp,
3232
isNavigationTargetType,
33+
isNavigablePage,
3334
navigationReferences,
3435
} from '../useNavigationSync';
3536
import { MemoryRouter } from 'react-router-dom';
@@ -85,6 +86,20 @@ describe('isNavigationSyncableApp', () => {
8586
expect(isNavigationSyncableApp({ name: 'crm', _lock: 'no-delete' })).toBe(true);
8687
});
8788

89+
it('rejects an app a managed package ships, which the server seals (objectstack#29182)', () => {
90+
// The artifact loader's ADR-0010 stamp, as the stock showcase serves `showcase_app`.
91+
expect(isNavigationSyncableApp({ name: 'showcase_app', _provenance: 'package', _packageId: 'com.example.showcase' })).toBe(false);
92+
expect(isNavigationSyncableApp({ name: 'showcase_app', _provenance: 'package' })).toBe(false);
93+
});
94+
95+
it('accepts a stored app, a package-bound one included: provenance decides, not _packageId', () => {
96+
// As the stock showcase serves them: an app of a package authored at
97+
// runtime carries `_packageId` alone, a package-less app no stamp at all.
98+
expect(isNavigationSyncableApp({ name: 'rthree_app', _packageId: 'com.probe.rthree' })).toBe(true);
99+
expect(isNavigationSyncableApp({ name: 'probe_app' })).toBe(true);
100+
expect(isNavigationSyncableApp({ name: 'mine', _provenance: 'org' })).toBe(true);
101+
});
102+
88103
it('rejects non-objects', () => {
89104
expect(isNavigationSyncableApp(null)).toBe(false);
90105
expect(isNavigationSyncableApp('crm')).toBe(false);
@@ -101,6 +116,25 @@ describe('isNavigationTargetType', () => {
101116
});
102117
});
103118

119+
describe('isNavigablePage (objectstack#29182)', () => {
120+
it('rejects a page that needs a record, and a body that leaves type out (PageSchema defaults it to record)', () => {
121+
expect(isNavigablePage({ name: 'p', type: 'record', object: 'task' })).toBe(false);
122+
expect(isNavigablePage({ name: 'p' })).toBe(false);
123+
});
124+
125+
it('accepts every page kind that renders without a record', () => {
126+
for (const type of ['home', 'app', 'utility', 'list']) {
127+
expect(isNavigablePage({ name: 'p', type })).toBe(true);
128+
}
129+
});
130+
131+
it('rejects a body it cannot read', () => {
132+
expect(isNavigablePage(undefined)).toBe(false);
133+
expect(isNavigablePage(null)).toBe(false);
134+
expect(isNavigablePage({ name: 'p', type: 7 })).toBe(false);
135+
});
136+
});
137+
104138
describe('navigationReferences', () => {
105139
const tree = [
106140
{ id: 'a', type: 'object', objectName: 'task' },
@@ -155,13 +189,15 @@ function metaValue(world: World, read: Set<string> = new Set()): MetadataContext
155189
} as unknown as MetadataContextValue;
156190
}
157191

158-
function makeAdapter(saveItem: ReturnType<typeof vi.fn>) {
159-
return { getClient: () => ({ meta: { saveItem } }) } as any;
192+
function makeAdapter(saveItem: ReturnType<typeof vi.fn>, getItem?: ReturnType<typeof vi.fn>) {
193+
return { getClient: () => ({ meta: { saveItem, ...(getItem ? { getItem } : {}) } }) } as any;
160194
}
161195

162196
const flush = () => new Promise((r) => setTimeout(r, 0));
163197

164198
const crm = { name: 'crm', label: 'CRM', navigation: [] };
199+
/** A published page body that renders without a record. */
200+
const appPage = { name: 'my_page', type: 'app' };
165201

166202
// ---------------------------------------------------------------------------
167203
// useNavigationWrites — the create / delete sites' door
@@ -176,9 +212,12 @@ type Writes = ReturnType<typeof useNavigationWrites>;
176212
async function withWrites(
177213
world: World,
178214
act: (writes: Writes) => Promise<void>,
179-
{ preview = false }: { preview?: boolean } = {},
215+
{
216+
preview = false,
217+
saveItem = vi.fn().mockResolvedValue({}),
218+
getItem,
219+
}: { preview?: boolean; saveItem?: ReturnType<typeof vi.fn>; getItem?: ReturnType<typeof vi.fn> } = {},
180220
) {
181-
const saveItem = vi.fn().mockResolvedValue({});
182221
const read = new Set<string>();
183222
let done = false;
184223
function Caller() {
@@ -195,7 +234,7 @@ async function withWrites(
195234
render(
196235
<MemoryRouter>
197236
<PreviewModeProvider force={preview}>
198-
<AdapterCtx.Provider value={makeAdapter(saveItem)}>
237+
<AdapterCtx.Provider value={makeAdapter(saveItem, getItem)}>
199238
<MetadataCtx.Provider value={metaValue(world, read)}>
200239
<Caller />
201240
</MetadataCtx.Provider>
@@ -216,7 +255,7 @@ describe('useNavigationWrites keeps the diff\'s rules at the site (objectstack#2
216255
it('a created page and dashboard land label-less in every writable app, and no list is read', async () => {
217256
const sales = { name: 'sales', label: 'Sales', navigation: [] };
218257
const { saveItem, read } = await withWrites({ apps: [crm, sales] }, async (w) => {
219-
await w.created('page', 'my_page', { name: 'my_page', label: 'My Page' });
258+
await w.created('page', 'my_page', { name: 'my_page', label: 'My Page', type: 'app' });
220259
await w.created('dashboard', 'my_dash');
221260
});
222261

@@ -279,7 +318,7 @@ describe('useNavigationWrites keeps the diff\'s rules at the site (objectstack#2
279318
{ name: 'setup', label: 'Setup', navigation: [], _lock: 'full' },
280319
{ name: 'studio', label: 'Studio', navigation: [], protection: { lock: 'no-overlay' } },
281320
];
282-
const { saveItem } = await withWrites({ apps }, (w) => w.created('page', 'my_page'));
321+
const { saveItem } = await withWrites({ apps }, (w) => w.created('page', 'my_page', appPage));
283322

284323
expect(saveItem).toHaveBeenCalledTimes(1);
285324
expect(saveItem.mock.calls[0][1]).toBe('crm');
@@ -294,7 +333,7 @@ describe('useNavigationWrites keeps the diff\'s rules at the site (objectstack#2
294333
};
295334
const withDash = { name: 'ops', navigation: [{ id: 'n2', type: 'dashboard', dashboardName: 'my_dash' }] };
296335
const { saveItem } = await withWrites({ apps: [withPage, withDash] }, async (w) => {
297-
await w.created('page', 'my_page');
336+
await w.created('page', 'my_page', appPage);
298337
await w.created('dashboard', 'my_dash');
299338
});
300339

@@ -306,3 +345,100 @@ describe('useNavigationWrites keeps the diff\'s rules at the site (objectstack#2
306345
]);
307346
});
308347
});
348+
349+
describe('useNavigationWrites writes no record page and no sealed app (objectstack#29182)', () => {
350+
beforeEach(() => {
351+
vi.clearAllMocks();
352+
});
353+
354+
const sales = { name: 'sales', label: 'Sales', navigation: [] };
355+
356+
it('creating a record page writes no app', async () => {
357+
const { saveItem } = await withWrites({ apps: [crm, sales] }, async (w) => {
358+
await w.created('page', 'task_record', { name: 'task_record', type: 'record', object: 'task' });
359+
await w.created('page', 'untyped', { name: 'untyped' });
360+
});
361+
expect(saveItem).not.toHaveBeenCalled();
362+
const { toast } = await import('sonner');
363+
expect(toast.error).not.toHaveBeenCalled();
364+
expect(toast.success).not.toHaveBeenCalled();
365+
});
366+
367+
it('creating a navigable page still writes each syncable app', async () => {
368+
const { saveItem } = await withWrites({ apps: [crm, sales] }, async (w) => {
369+
await w.created('page', 'landing', { name: 'landing', type: 'home' });
370+
await w.created('page', 'tasks', { name: 'tasks', type: 'list', object: 'task' });
371+
});
372+
expect(saveItem.mock.calls.map((c) => [c[1], c[2].navigation.at(-1).pageName])).toEqual([
373+
['crm', 'landing'],
374+
['sales', 'landing'],
375+
['crm', 'tasks'],
376+
['sales', 'tasks'],
377+
]);
378+
});
379+
380+
it('a caller holding no body has the page read by name, and its type decides', async () => {
381+
const getItem = vi.fn(async (_type: string, name: string) => ({
382+
type: 'page',
383+
name,
384+
item: { name, type: name === 'rec' ? 'record' : 'app' },
385+
}));
386+
const { saveItem } = await withWrites(
387+
{ apps: [crm] },
388+
async (w) => {
389+
await w.created('page', 'rec');
390+
await w.created('page', 'nav');
391+
},
392+
{ getItem },
393+
);
394+
expect(getItem.mock.calls).toEqual([
395+
['page', 'rec'],
396+
['page', 'nav'],
397+
]);
398+
expect(saveItem.mock.calls.map((c) => c[2].navigation.at(-1).pageName)).toEqual(['nav']);
399+
});
400+
401+
it('a page read that fails writes no app and says navigation was not updated', async () => {
402+
const getItem = vi.fn().mockRejectedValue(new Error('down'));
403+
const { saveItem } = await withWrites({ apps: [crm] }, (w) => w.created('page', 'nav'), { getItem });
404+
expect(saveItem).not.toHaveBeenCalled();
405+
const { toast } = await import('sonner');
406+
expect(toast.error).toHaveBeenCalledTimes(1);
407+
expect(toast.error).toHaveBeenCalledWith('navigationSync.updateFailed');
408+
});
409+
410+
it('a sealed managed app gets no write and no red toast, in every direction', async () => {
411+
const sealed = {
412+
name: 'showcase_app',
413+
_provenance: 'package',
414+
_packageId: 'com.example.showcase',
415+
navigation: [{ id: 'n1', type: 'page', pageName: 'gone' }],
416+
};
417+
const runtimePkgApp = { name: 'rthree_app', _packageId: 'com.probe.rthree', navigation: [] };
418+
const { saveItem } = await withWrites({ apps: [sealed, runtimePkgApp] }, async (w) => {
419+
await w.created('page', 'landing', { name: 'landing', type: 'app' });
420+
await w.created('dashboard', 'kpis');
421+
await w.deleted('page', 'gone');
422+
});
423+
expect(saveItem.mock.calls.map((c) => c[1])).toEqual(['rthree_app', 'rthree_app']);
424+
const { toast } = await import('sonner');
425+
expect(toast.error).not.toHaveBeenCalled();
426+
});
427+
428+
it('any other app write failure still shows the error toast', async () => {
429+
const saveItem = vi.fn(async (_t: string, app: string) => {
430+
if (app === 'sales') throw Object.assign(new Error('boom'), { status: 500 });
431+
return {};
432+
});
433+
await withWrites(
434+
{ apps: [crm, sales] },
435+
(w) => w.created('page', 'landing', { name: 'landing', type: 'app' }),
436+
{ saveItem },
437+
);
438+
expect(saveItem.mock.calls.map((c) => c[1])).toEqual(['crm', 'sales']);
439+
const { toast } = await import('sonner');
440+
expect(toast.success).toHaveBeenCalledTimes(1);
441+
expect(toast.error).toHaveBeenCalledTimes(1);
442+
expect(toast.error).toHaveBeenCalledWith('navigationSync.updateFailed');
443+
});
444+
});

‎ui/packages/app-shell/src/hooks/useNavigationSync.ts‎

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,22 @@ export function isPlatformArtifact(item: unknown): boolean {
201201
* 'no-overlay' reject PUT with 403 — a navigation write into such an app can
202202
* never succeed, so don't attempt it (each attempt surfaced as a red
203203
* "Failed to update navigation" toast).
204+
*
205+
* ADR-0131 D6: an app a managed package ships is sealed. `app` has no per-org
206+
* overlay channel (`allowEnvironmentOverlay: false`), so the server refuses
207+
* every package-less write onto a packaged app with `403 NOT_OVERRIDABLE`,
208+
* whatever its lock (objectstack#29182). The app read marks such an app by its
209+
* ADR-0010 provenance: the artifact loader stamps `_provenance: 'package'`
210+
* (the stock showcase serves `showcase_app` so, with no `_lock`). A stored row
211+
* carries no such stamp: a package-less app is served with no provenance keys,
212+
* and an app of a package authored at runtime with `_packageId` alone, and the
213+
* server takes a navigation write into either. So provenance decides, never
214+
* `_packageId`.
204215
*/
205216
export function isNavigationSyncableApp(app: unknown): boolean {
206217
if (!app || typeof app !== 'object') return false;
207-
const a = app as { _lock?: string; protection?: { lock?: string } };
218+
const a = app as { _lock?: string; protection?: { lock?: string }; _provenance?: unknown };
219+
if (a._provenance === 'package') return false;
208220
const lock = a._lock ?? a.protection?.lock;
209221
return lock !== 'full' && lock !== 'no-overlay';
210222
}
@@ -601,6 +613,38 @@ export function isNavigationTargetType(type: unknown): type is NavigationTargetT
601613
return type === 'page' || type === 'dashboard';
602614
}
603615

616+
/**
617+
* The page kinds (`PageSchema.type`) that cannot render without a record, so a
618+
* `type: 'page'` navigation entry, which opens `/apps/{app}/page/{name}` with
619+
* no record id, can never open one. Today that is `record` alone: the nav
620+
* target picker leaves it out for the same reason (`isStaticPageOption` in
621+
* `views/metadata-admin/inspectors/nav-target.ts`). `home`, `app`, `utility`
622+
* and `list` render without one.
623+
*/
624+
const RECORD_BOUND_PAGE_TYPES: ReadonlySet<string> = new Set(['record']);
625+
626+
/**
627+
* The page kind a page body states. A body that leaves `type` out is a record
628+
* page: `PageSchema` declares `type` with the default `PAGE_TYPE_DEFAULT`
629+
* (`'record'`, `@objectstack/spec` `ui/default-record-page.ts`), which judges
630+
* an unparsed body the same way.
631+
*/
632+
function pageTypeOf(body: Record<string, unknown>): unknown {
633+
return body.type === undefined ? 'record' : body.type;
634+
}
635+
636+
/**
637+
* Whether a page body may be written into app navigation: a page whose kind
638+
* renders without a record. A page that needs one (a `record` page) is never
639+
* navigable on its own, so a navigation entry for it opens a page with no
640+
* record (objectstack#29182).
641+
*/
642+
export function isNavigablePage(body: unknown): boolean {
643+
if (!body || typeof body !== 'object') return false;
644+
const type = pageTypeOf(body as Record<string, unknown>);
645+
return typeof type === 'string' && !RECORD_BOUND_PAGE_TYPES.has(type);
646+
}
647+
604648
export interface UseNavigationWritesReturn {
605649
/**
606650
* A page or dashboard was just published for the FIRST time (it held no
@@ -609,7 +653,9 @@ export interface UseNavigationWritesReturn {
609653
*
610654
* `item` is the published body when the caller holds one: its provenance
611655
* (`_packageId`, `_provenance`) decides, with the name, whether it is a
612-
* platform artifact, which never writes navigation.
656+
* platform artifact, which never writes navigation. A page's `type` decides
657+
* whether it is navigable at all; a caller that holds no body has the page
658+
* read by name.
613659
*/
614660
created: (type: string, name: string, item?: unknown) => Promise<void>;
615661
/** A page or dashboard was just deleted, leaving no baseline behind. Removes its entries from every app that may be written. */
@@ -626,6 +672,9 @@ export interface UseNavigationWritesReturn {
626672
* that diff's rules, so the same items reach navigation from the console:
627673
*
628674
* - only a `page` or a `dashboard`;
675+
* - on create, only a page that renders without a record
676+
* ({@link isNavigablePage}, objectstack#29182): a `record` page joins no
677+
* app's navigation;
629678
* - never a platform artifact ({@link isPlatformArtifact}): an item a package
630679
* install or a CLI deploy brings carries `_packageId`, and its package ships
631680
* the navigation that exposes it;
@@ -641,6 +690,10 @@ export interface UseNavigationWritesReturn {
641690
*/
642691
export function useNavigationWrites(): UseNavigationWritesReturn {
643692
const previewDrafts = usePreviewDrafts();
693+
const adapter = useAdapter();
694+
const adapterRef = useRef(adapter);
695+
adapterRef.current = adapter;
696+
const { t } = useObjectTranslation();
644697
const {
645698
syncPageCreatedAllApps,
646699
syncDashboardCreatedAllApps,
@@ -657,15 +710,42 @@ export function useNavigationWrites(): UseNavigationWritesReturn {
657710
[previewDrafts],
658711
);
659712

713+
/**
714+
* The body of a page just created: the one the caller holds, else the
715+
* published one, read by name (a publish of every pending draft holds only
716+
* the draft list's rows). `undefined` when that read fails.
717+
*/
718+
const createdPageBody = useCallback(
719+
async (name: string, item: unknown): Promise<unknown> => {
720+
if (item && typeof item === 'object') return item;
721+
try {
722+
const client = adapterRef.current?.getClient();
723+
const res = (await client?.meta.getItem('page', name)) as { item?: unknown } | undefined;
724+
return res?.item;
725+
} catch {
726+
return undefined;
727+
}
728+
},
729+
[],
730+
);
731+
660732
const created = useCallback(
661733
async (type: string, name: string, item?: unknown) => {
662734
if (!writes(type, name, item)) return;
663735
// No author label: the entry inherits its target's label at render time
664736
// (objectui#9868), as the diff's entries did.
665-
if (type === 'page') await syncPageCreatedAllApps(name);
666-
else await syncDashboardCreatedAllApps(name);
737+
if (type === 'page') {
738+
// objectstack#29182 — a page that needs a record joins no app.
739+
const body = await createdPageBody(name, item);
740+
if (body === undefined) {
741+
toast.error(t('navigationSync.updateFailed'));
742+
return;
743+
}
744+
if (!isNavigablePage(body)) return;
745+
await syncPageCreatedAllApps(name);
746+
} else await syncDashboardCreatedAllApps(name);
667747
},
668-
[writes, syncPageCreatedAllApps, syncDashboardCreatedAllApps],
748+
[writes, createdPageBody, t, syncPageCreatedAllApps, syncDashboardCreatedAllApps],
669749
);
670750

671751
const deleted = useCallback(

0 commit comments

Comments
 (0)