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
pull/388/head
Antti Kettunen 1 month ago
parent b34cea3388
commit 39f56fe63f
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

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

@ -2,7 +2,11 @@ import { createMemoryHistory } from '@tanstack/react-router';
import { render, screen, waitFor } from '@testing-library/react';
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 { AppRouterProvider, createAppRouter } from './router';
@ -129,10 +133,30 @@ describe('createAppRouter', () => {
render(<AppRouterProvider router={router} queryClient={queryClient} />);
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,
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
expect(history.location.pathname).toBe('/discover');
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 () => {

@ -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,
},
});
});
});

@ -14,8 +14,16 @@ export interface ShellProfileContext {
isAdmin: boolean;
}
export interface ShellContext {
bridge: ShellBridge;
profile: ShellProfileContext;
}
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 {
return window.SoulSyncWebShellBridge ?? null;
}
@ -24,11 +32,50 @@ export function getShellProfileContext(bridge = getShellBridge()): ShellProfileC
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}` {
const pageId = bridge?.getProfileHomePage() ?? '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) {
window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest],

@ -1,30 +1,24 @@
import { useRouter } from '@tanstack/react-router';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useRouteContext, useRouter } from '@tanstack/react-router';
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 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() {
const [, setRevision] = useState(0);
return useShellContext().bridge;
}
useEffect(() => {
const handleContextChange = () => {
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 useProfile() {
return useShellContext().profile;
}
export function LegacyRouteController({ pathname }: { pathname: string }) {

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

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

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

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

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

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

Loading…
Cancel
Save