Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/9673-pageview-drop-context-spread.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 5 additions & 1 deletion packages/app-shell/src/views/PageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}}
/>
)}
Expand Down
126 changes: 126 additions & 0 deletions packages/app-shell/src/views/__tests__/PageView.context-9673.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
useObjectTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? o?.name ?? k }),
}));

/** The stored page document the next `render()` will resolve. */
let storedPage: Record<string, unknown> | 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<string, any>[] = [];

vi.mock('@object-ui/react', async (orig) => {
const actual = await (orig as any)();
return {
...actual,
useAdapter: () => ({}),
SchemaRenderer: ({ schema }: { schema: Record<string, any> }) => {
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<string, unknown>, query: string): Record<string, any> | undefined {
cleanup();
written.length = 0;
search = query;
storedPage = doc;
render(<PageView />);
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<string, unknown>, '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 });
});
});
Loading