diff --git a/apps/desktop/src/app/contrib/surfaces-reactivity.test.tsx b/apps/desktop/src/app/contrib/surfaces.late-routes.test.tsx similarity index 50% rename from apps/desktop/src/app/contrib/surfaces-reactivity.test.tsx rename to apps/desktop/src/app/contrib/surfaces.late-routes.test.tsx index b3ae15977e..f7b0abe26a 100644 --- a/apps/desktop/src/app/contrib/surfaces-reactivity.test.tsx +++ b/apps/desktop/src/app/contrib/surfaces.late-routes.test.tsx @@ -1,3 +1,12 @@ +/** + * A plugin page route registered AFTER the workspace surface mounts must + * become navigable. Regression for late-loaded desktop plugins (disk plugins + * load async): the route table was compiled into a memo slot keyed on + * unrelated props, so a late `routes`-area registration never entered the + * table — the sidebar row rendered but navigating to the path fell through + * to the `:sessionId` chat route. Uses the REAL useContributions + registry + * (unlike surfaces.test.tsx) because the reactive flow is the subject. + */ import { act, cleanup, render, screen } from '@testing-library/react' import { atom } from 'nanostores' import { MemoryRouter } from 'react-router' @@ -8,13 +17,6 @@ import { registry } from '@/contrib/registry' import { ChatRoutesSurface } from './surfaces' import type { WiringActions } from './types' -// #109063: the workspace surface subscribes to ROUTES_AREA but used to derive -// routes via an independently-called `contributedRoutes()`, which React -// Compiler can keep memoized across the delivered subscription update. The -// registry, `useContributions` and the routes module all stay REAL here — only -// unrelated stores and heavy sibling views are stubbed, because mocking the -// route list is exactly what hid the defect. - vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') })) vi.mock('@/store/gateway', () => ({ $gateway: atom(null) })) vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') })) @@ -30,61 +32,44 @@ vi.mock('../shell/hooks/use-statusbar-items', () => ({ useStatusbarItems: () => ({ leftStatusbarItems: [], statusbarItems: [] }) })) vi.mock('../shell/statusbar-controls', () => ({ StatusbarControls: () => null })) -vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null })) vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) })) vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] })) +vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null })) +vi.mock('../shell/reasoning-menu-panel', () => ({ ReasoningMenuPanel: () => null })) -afterEach(cleanup) +afterEach(() => { + cleanup() +}) -/** Register (or hot-replace) the plugin route page mounted at `/resetwatch`. */ -function registerResetwatch(label: string) { - return registry.register({ - id: 'resetwatch', - area: 'routes', - title: `Resetwatch ${label}`, - data: { path: '/resetwatch' }, - render: () =>
{`PLUGIN-PAGE-${label}`}
- }) -} - -describe('ChatRoutesSurface route-contribution reactivity (#109063)', () => { - it('mounts a plugin page registered after the surface, tracks replacement, and unmounts on dispose', () => { - const actions = { getGateway: () => null } as unknown as WiringActions +describe('ChatRoutesSurface late-registered plugin routes', () => { + it('renders a page whose route registers after mount', () => { + const actions = {} as unknown as WiringActions render( - + ) - // Unregistered path falls through the catch-all redirect to the chat view. + + // Before registration the path falls through to the chat catch-all. + expect(screen.queryByTestId('late-page')).toBeNull() expect(screen.getByTestId('chat-view')).toBeTruthy() let dispose = () => {} act(() => { - // Late registration after the surface has mounted and settled. - dispose = registerResetwatch('A') + dispose = registry.register({ + area: 'routes', + id: 'late-plugin:page', + data: { path: '/late-plugin' }, + render: () =>
+ }) }) - expect(screen.getByText('PLUGIN-PAGE-A')).toBeTruthy() + + // The late registration must reach the route table and win over the + // `:sessionId` dynamic route. + expect(screen.getByTestId('late-page')).toBeTruthy() expect(screen.queryByTestId('chat-view')).toBeNull() - act(() => { - dispose() - dispose = registerResetwatch('B') - }) - expect(screen.getByText('PLUGIN-PAGE-B')).toBeTruthy() - - act(() => { - dispose() - }) - expect(screen.getByTestId('chat-view')).toBeTruthy() - - act(() => { - dispose = registerResetwatch('A') - }) - expect(screen.getByText('PLUGIN-PAGE-A')).toBeTruthy() - - act(() => { - dispose() - }) + act(() => dispose()) }) })