diff --git a/.changeset/9673-pageview-drop-context-spread.md b/.changeset/9673-pageview-drop-context-spread.md new file mode 100644 index 0000000000..b706192515 --- /dev/null +++ b/.changeset/9673-pageview-drop-context-spread.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): `PageView` builds the page node's `context`, it no longer reads one off the page + +`PageView` spread `(page as any).context` into the `context` it hands `SchemaRenderer`. +`PageSchema` refuses a page-level `context` key, so on every page that parses the +spread added nothing, and the code read as an author channel that no author could use. +The node's `context` is now exactly `{ params, refreshKey }`, built from the route and +the refresh counter, and the `as any` cast at that spot is gone. A stored document +that carries `context` without passing `PageSchema` no longer passes it through. +`context` stays undeclared on `PageSchema` (objectui#9673). diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx index 2a8eb31007..9de300582c 100644 --- a/packages/app-shell/src/views/PageView.tsx +++ b/packages/app-shell/src/views/PageView.tsx @@ -151,7 +151,11 @@ export function PageView() { // turns it red. Both directions were measured on objectui#9718. type: (page as any).type || 'page', pageType: (page as any).type, - context: { ...(page as any).context, params, refreshKey }, + // `context` is built here, never read off the page: `PageSchema` + // refuses a page-level `context` key, so no parsed page can + // carry one (objectui#9673). Written after `...page`, it also + // overrides whatever an unparsed document smuggled in. + context: { params, refreshKey }, }} /> )} diff --git a/packages/app-shell/src/views/__tests__/PageView.context-9673.test.tsx b/packages/app-shell/src/views/__tests__/PageView.context-9673.test.tsx new file mode 100644 index 0000000000..a94afacc17 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/PageView.context-9673.test.tsx @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9673 — the `context` PageView hands `SchemaRenderer` is BUILT, not read. + * + * `PageSchema` refuses a page-level `context` key, so a stored page that parses + * never carries one. PageView used to spread `(page as any).context` into the + * node anyway: a no-op on every parsed page, and a channel that read as + * author-supplied page context that no author could supply. Ruled "remove" + * (comment 5811058824): the node's `context` is exactly `{ params, refreshKey }`. + * + * The second case is the discriminating one: a document that never passed + * `PageSchema` and sneaks `context` in through a cast must not leak it into the + * node. With the spread restored, that case goes red. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; +import { PageSchema } from '@objectstack/spec/ui'; + +// Lowercase snake_case: PageSchema refuses any other `name`, and the control +// below needs a page that parses. +const PAGE_NAME = 'the_page'; + +/** Query string the next `render()` sees; PageView turns it into `params`. */ +let search = ''; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ pageName: PAGE_NAME }), + useSearchParams: () => [new URLSearchParams(search), vi.fn()], + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: `/apps/cloud/page/${PAGE_NAME}`, search: search ? `?${search}` : '' }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'User', role: 'user', image: null }, + activeOrganization: null, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? o?.name ?? k }), +})); + +/** The stored page document the next `render()` will resolve. */ +let storedPage: Record | undefined; + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ pages: storedPage ? [storedPage] : [], objects: [] }), +})); + +vi.mock('../MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false }), +})); + +/** Every schema node handed to `SchemaRenderer` since the last render. */ +const written: Record[] = []; + +vi.mock('@object-ui/react', async (orig) => { + const actual = await (orig as any)(); + return { + ...actual, + useAdapter: () => ({}), + SchemaRenderer: ({ schema }: { schema: Record }) => { + written.push(schema); + return null; + }, + }; +}); + +import { PageView } from '../PageView'; + +/** Mount `PageView` over one stored document and return the node it wrote. */ +function writeFor(doc: Record, query: string): Record | undefined { + cleanup(); + written.length = 0; + search = query; + storedPage = doc; + render(); + return written[0]; +} + +const PARSED_PAGE = { name: PAGE_NAME, label: 'A Page', type: 'app' }; + +describe('objectui#9673 — PageView builds the node context, it never reads one off the page', () => { + it('hands SchemaRenderer a context of exactly { params, refreshKey } for a page that parses', () => { + // Control: the fixture is a page PageSchema accepts, so this is the case + // every real author is in. + expect(PageSchema.safeParse(PARSED_PAGE).success).toBe(true); + + const schema = writeFor(PARSED_PAGE, 'account=42&tab=notes'); + + // Firing control: absence means the harness stopped reaching SchemaRenderer. + expect(schema, 'PageView rendered no schema at all; this probe measured nothing').toBeDefined(); + expect(schema!.context).toEqual({ params: { account: '42', tab: 'notes' }, refreshKey: 0 }); + }); + + it('a document that sneaks `context` in past PageSchema does not leak it into the node', () => { + const smuggled = { ...PARSED_PAGE, context: { leaked: 'author-value', params: { account: 'forged' } } }; + + // Why no author can reach this: PageSchema refuses the key outright. + expect(PageSchema.safeParse(smuggled).success).toBe(false); + + const schema = writeFor(smuggled as Record, 'account=42'); + + expect(schema, 'PageView rendered no schema at all; this probe measured nothing').toBeDefined(); + expect( + schema!.context, + 'the node context must be built from the route alone; a `context` key on the stored page ' + + 'is one PageSchema refuses and must not reach SchemaRenderer (objectui#9673).', + ).toEqual({ params: { account: '42' }, refreshKey: 0 }); + }); +});