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:
parent
b34cea3388
commit
39f56fe63f
11 changed files with 194 additions and 89 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
59
webui/src/platform/shell/bridge.test.ts
Normal file
59
webui/src/platform/shell/bridge.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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…
Reference in a new issue