Promote shell context to the root route

- Wait for the legacy shell bridge/profile before React routes render
- Expose the shell bridge and profile through root TanStack context
- Update issue routes and shell helpers to consume the shared context
- Remove the redundant issues search normalization on read
- Refresh the affected tests around shell bootstrap and routing
This commit is contained in:
Antti Kettunen 2026-05-03 11:54:03 +03:00
parent b34cea3388
commit 39f56fe63f
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
11 changed files with 194 additions and 89 deletions

View file

@ -7,7 +7,7 @@ import { ROUTER_ROOT_ID } from '@/platform/shell/route-controllers';
import { createAppQueryClient } from './query-client'; import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router'; import { AppRouterProvider, createAppRouter } from './router';
export function bootstrapApp() { export async function bootstrapApp() {
const container = document.getElementById(ROUTER_ROOT_ID); const container = document.getElementById(ROUTER_ROOT_ID);
if (!container) return null; if (!container) return null;
@ -20,4 +20,4 @@ export function bootstrapApp() {
return { queryClient, router }; return { queryClient, router };
} }
bootstrapApp(); void bootstrapApp();

View file

@ -2,7 +2,11 @@ import { createMemoryHistory } from '@tanstack/react-router';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; import {
SHELL_PROFILE_CONTEXT_CHANGED_EVENT,
type ShellBridge,
type ShellPageId,
} from '@/platform/shell/bridge';
import { createAppQueryClient } from './query-client'; import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router'; import { AppRouterProvider, createAppRouter } from './router';
@ -129,10 +133,30 @@ describe('createAppRouter', () => {
render(<AppRouterProvider router={router} queryClient={queryClient} />); render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => { await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/discover'); expect(history.location.pathname).toBe('/discover');
});
});
it('waits for profile context before rendering React routes', async () => {
const getCurrentProfileContext = vi.fn(() => null);
window.SoulSyncWebShellBridge = createShellBridge({
getCurrentProfileContext,
}); });
expect(history.location.pathname).toBe('/discover'); const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
expect(screen.queryByTestId('issues-board')).not.toBeInTheDocument();
getCurrentProfileContext.mockReturnValue({ profileId: 1, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await waitFor(() => {
expect(screen.getByTestId('issues-board')).toBeInTheDocument();
});
}); });
it('redirects the root route to the profile home page', async () => { it('redirects the root route to the profile home page', async () => {

View file

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellProfileContext } from './bridge';
import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, waitForShellContext } from './bridge';
describe('waitForShellContext', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = undefined;
});
it('resolves immediately when the shell already has a profile', async () => {
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentPageId: vi.fn(() => 'issues'),
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
await expect(waitForShellContext()).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 2,
isAdmin: true,
},
});
});
it('waits for the legacy shell to publish profile context', async () => {
const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null);
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentPageId: vi.fn(() => 'issues'),
getCurrentProfileContext,
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
const contextPromise = waitForShellContext();
getCurrentProfileContext.mockReturnValue({ profileId: 5, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await expect(contextPromise).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 5,
isAdmin: false,
},
});
});
});

View file

@ -14,8 +14,16 @@ export interface ShellProfileContext {
isAdmin: boolean; isAdmin: boolean;
} }
export interface ShellContext {
bridge: ShellBridge;
profile: ShellProfileContext;
}
export type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>; export type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>;
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export const SHELL_PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
export function getShellBridge(): ShellBridge | null { export function getShellBridge(): ShellBridge | null {
return window.SoulSyncWebShellBridge ?? null; return window.SoulSyncWebShellBridge ?? null;
} }
@ -24,11 +32,50 @@ export function getShellProfileContext(bridge = getShellBridge()): ShellProfileC
return bridge?.getCurrentProfileContext() ?? null; return bridge?.getCurrentProfileContext() ?? null;
} }
export function getShellContext(bridge = getShellBridge()): ShellContext | null {
const profile = getShellProfileContext(bridge);
if (!bridge || !profile) return null;
return { bridge, profile };
}
export function getProfileHomePath(bridge = getShellBridge()): `/${string}` { export function getProfileHomePath(bridge = getShellBridge()): `/${string}` {
const pageId = bridge?.getProfileHomePage() ?? 'discover'; const pageId = bridge?.getProfileHomePage() ?? 'discover';
return getShellRouteByPageId(pageId)?.path ?? '/discover'; return getShellRouteByPageId(pageId)?.path ?? '/discover';
} }
export async function waitForShellContext(): Promise<ShellContext> {
const currentContext = getShellContext();
if (currentContext) return currentContext;
return await new Promise<ShellContext>((resolve) => {
const cleanup = () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.removeEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
};
const settleIfReady = () => {
const shell = getShellContext();
if (!shell) return;
cleanup();
resolve(shell);
};
const handleReady = () => {
settleIfReady();
};
const handleProfileChange = () => {
settleIfReady();
};
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.addEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
settleIfReady();
});
}
export function bindWindowWebRouter(router: AnyRouter) { export function bindWindowWebRouter(router: AnyRouter) {
window.SoulSyncWebRouter = { window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest], routeManifest: [...shellRouteManifest],

View file

@ -1,30 +1,24 @@
import { useRouter } from '@tanstack/react-router'; import { useRouteContext, useRouter } from '@tanstack/react-router';
import { useEffect, useLayoutEffect, useState } from 'react'; import { useEffect, useLayoutEffect } from 'react';
import { getProfileHomePath, getShellBridge, type ShellPageId } from './bridge'; import { getProfileHomePath, type ShellContext, type ShellPageId } from './bridge';
export const ROUTER_ROOT_ID = 'webui-react-root'; export const ROUTER_ROOT_ID = 'webui-react-root';
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export const SHELL_PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed'; export function useShellContext(): ShellContext {
const context = useRouteContext({
from: '__root__',
select: (routeContext) => routeContext.shell,
});
return context;
}
export function useShellBridge() { export function useShellBridge() {
const [, setRevision] = useState(0); return useShellContext().bridge;
}
useEffect(() => { export function useProfile() {
const handleContextChange = () => { return useShellContext().profile;
setRevision((value) => value + 1);
};
handleContextChange();
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleContextChange);
window.addEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleContextChange);
return () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleContextChange);
window.removeEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleContextChange);
};
}, []);
return getShellBridge();
} }
export function LegacyRouteController({ pathname }: { pathname: string }) { export function LegacyRouteController({ pathname }: { pathname: string }) {

View file

@ -2,9 +2,15 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { AppRouterContext } from '@/app/router'; import type { AppRouterContext } from '@/app/router';
import { waitForShellContext } from '@/platform/shell/bridge';
import { IssueDomainHost } from './issues/-ui/issue-domain-host'; import { IssueDomainHost } from './issues/-ui/issue-domain-host';
export const Route = createRootRouteWithContext<AppRouterContext>()({ export const Route = createRootRouteWithContext<AppRouterContext>()({
beforeLoad: async () => {
const shell = await waitForShellContext();
return { shell };
},
component: () => ( component: () => (
<> <>
<Outlet /> <Outlet />

View file

@ -7,8 +7,7 @@ export const Route = createFileRoute('/')({
beforeLoad: ({ context, location }) => { beforeLoad: ({ context, location }) => {
if (location.pathname !== '/') return; if (location.pathname !== '/') return;
const bridge = context.platform.getShellBridge(); const { bridge } = context.shell;
if (!bridge) return;
throw redirect({ href: getProfileHomePath(bridge), replace: true }); throw redirect({ href: getProfileHomePath(bridge), replace: true });
}, },

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog'; import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { Button } from '@/components/form'; import { Button } from '@/components/form';
import { useProfile } from '@/platform/shell/route-controllers';
import { import {
launchAlbumDownloadWorkflow, launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow, launchAlbumWishlistWorkflow,
@ -22,21 +23,18 @@ import {
import styles from './issue-detail-modal.module.css'; import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({ export function IssueDetailModal({
isAdmin,
issueId, issueId,
onClose, onClose,
onMutationSuccess, onMutationSuccess,
profileId,
}: { }: {
isAdmin: boolean; issueId?: number;
issueId: number | null;
onClose: () => void; onClose: () => void;
onMutationSuccess: () => void; onMutationSuccess: () => void;
profileId: number;
}) { }) {
const { isAdmin, profileId } = useProfile();
const selectedIssueQuery = useQuery({ const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, issueId ?? 0), ...issueDetailQueryOptions(profileId, issueId ?? 0),
enabled: profileId > 0 && issueId !== null, enabled: issueId != null,
}); });
const issue = selectedIssueQuery.data ?? null; const issue = selectedIssueQuery.data ?? null;
const queryError = selectedIssueQuery.error; const queryError = selectedIssueQuery.error;

View file

@ -14,8 +14,7 @@ import {
TextArea, TextArea,
TextInput, TextInput,
} from '@/components/form'; } from '@/components/form';
import { getShellProfileContext } from '@/platform/shell/bridge'; import { useProfile } from '@/platform/shell/route-controllers';
import { useShellBridge } from '@/platform/shell/route-controllers';
import type { IssuePriority, IssueReportPayload } from '../-issues.types'; import type { IssuePriority, IssueReportPayload } from '../-issues.types';
@ -44,15 +43,13 @@ const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
}; };
export function IssueDomainHost() { export function IssueDomainHost() {
const bridge = useShellBridge();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profile = getShellProfileContext(bridge); const profile = useProfile();
const [reportPayload, setReportPayload] = useState<IssueReportPayload | null>(null); const [reportPayload, setReportPayload] = useState<IssueReportPayload | null>(null);
const profileId = profile?.profileId ?? 0; const profileId = profile.profileId;
const countsQuery = useQuery({ const countsQuery = useQuery({
...issueCountsQueryOptions(profileId), ...issueCountsQueryOptions(profileId),
enabled: profileId > 0,
}); });
useEffect(() => { useEffect(() => {

View file

@ -3,8 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Select } from '@/components/form'; import { Select } from '@/components/form';
import { getShellProfileContext } from '@/platform/shell/bridge'; import { useProfile, useReactPageShell } from '@/platform/shell/route-controllers';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types'; import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
@ -28,75 +27,59 @@ import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal'; import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css'; import styles from './issues-page.module.css';
type NavigateFunction = ReturnType<typeof useNavigate>;
function clearIssueSelection(navigate: NavigateFunction) {
void navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true,
});
}
export function IssuesPage() { export function IssuesPage() {
const bridge = useReactPageShell('issues'); useReactPageShell('issues');
const { isAdmin, profileId } = useProfile();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch(); const params = Route.useSearch();
const normalizedSearch = normalizeIssuesSearch(search);
const selectedIssueId = normalizedSearch.issueId ? Number(normalizedSearch.issueId) : null;
const profile = getShellProfileContext(bridge);
const profileId = profile?.profileId ?? 0;
const openIssue = (issueId: number) => { const openIssue = (issueId: number) => {
void navigate({ navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId }), search: (prev) => normalizeIssuesSearch({ ...prev, issueId }),
}); });
}; };
const clearIssueSelection = () => {
navigate({
to: Route.fullPath,
search: (prev) => normalizeIssuesSearch({ ...prev, issueId: undefined }),
replace: true,
});
};
useEffect(() => { useEffect(() => {
const handleRefresh = () => { const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ['issues'] }); queryClient.invalidateQueries({ queryKey: ['issues'] });
};
const handleClose = () => {
clearIssueSelection(navigate);
}; };
window.addEventListener(REFRESH_EVENT, handleRefresh); window.addEventListener(REFRESH_EVENT, handleRefresh);
window.addEventListener(CLOSE_EVENT, handleClose); window.addEventListener(CLOSE_EVENT, clearIssueSelection);
return () => { return () => {
window.removeEventListener(REFRESH_EVENT, handleRefresh); window.removeEventListener(REFRESH_EVENT, handleRefresh);
window.removeEventListener(CLOSE_EVENT, handleClose); window.removeEventListener(CLOSE_EVENT, clearIssueSelection);
}; };
}, [navigate, queryClient]); }, [navigate, queryClient]);
const countsQuery = useQuery({ const countsQuery = useQuery({
...issueCountsQueryOptions(profileId), ...issueCountsQueryOptions(profileId),
enabled: profileId > 0,
}); });
const issuesQuery = useQuery({ const issuesQuery = useQuery({
...issueListQueryOptions(profileId, normalizedSearch), ...issueListQueryOptions(profileId, params),
enabled: profileId > 0,
}); });
if (!bridge || !profile || !bridge.isPageAllowed('issues')) {
return null;
}
return ( return (
<> <>
<IssueBoard <IssueBoard
categoryFilter={normalizedSearch.category} categoryFilter={params.category}
counts={countsQuery.data} counts={countsQuery.data}
isAdmin={profile.isAdmin} isAdmin={isAdmin}
issues={issuesQuery.data?.issues ?? []} issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error} issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading} issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) => onCategoryChange={(category) =>
void navigate({ navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => search: (prev) =>
normalizeIssuesSearch({ normalizeIssuesSearch({
@ -108,7 +91,7 @@ export function IssuesPage() {
} }
onIssueSelect={openIssue} onIssueSelect={openIssue}
onStatusChange={(status) => onStatusChange={(status) =>
void navigate({ navigate({
to: Route.fullPath, to: Route.fullPath,
search: (prev) => search: (prev) =>
normalizeIssuesSearch({ normalizeIssuesSearch({
@ -118,17 +101,15 @@ export function IssuesPage() {
replace: true, replace: true,
}) })
} }
statusFilter={normalizedSearch.status} statusFilter={params.status}
/> />
<IssueDetailModal <IssueDetailModal
isAdmin={profile.isAdmin} issueId={params.issueId}
issueId={selectedIssueId} onClose={clearIssueSelection}
onClose={() => clearIssueSelection(navigate)}
onMutationSuccess={() => { onMutationSuccess={() => {
clearIssueSelection(navigate); clearIssueSelection();
dispatchIssuesRefreshEvent(); dispatchIssuesRefreshEvent();
}} }}
profileId={profile.profileId}
/> />
</> </>
); );

View file

@ -1,6 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath, getShellProfileContext } from '@/platform/shell/bridge'; import { getProfileHomePath } from '@/platform/shell/bridge';
import { import {
issueCountsQueryOptions, issueCountsQueryOptions,
@ -13,8 +13,9 @@ import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({ export const Route = createFileRoute('/issues')({
validateSearch: normalizeIssuesSearch, validateSearch: normalizeIssuesSearch,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const bridge = context.platform.getShellBridge(); const { bridge } = context.shell;
if (bridge && !bridge.isPageAllowed('issues')) {
if (!bridge.isPageAllowed('issues')) {
throw redirect({ href: getProfileHomePath(bridge), replace: true }); throw redirect({ href: getProfileHomePath(bridge), replace: true });
} }
}, },
@ -24,8 +25,7 @@ export const Route = createFileRoute('/issues')({
issueId: search.issueId ?? null, issueId: search.issueId ?? null,
}), }),
loader: async ({ context, deps }) => { loader: async ({ context, deps }) => {
const profile = getShellProfileContext(context.platform.getShellBridge()); const { profile } = context.shell;
if (!profile) return;
await Promise.all([ await Promise.all([
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)), context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),