diff --git a/webui/src/app/main.tsx b/webui/src/app/main.tsx
index 553924f0..1754e3b0 100644
--- a/webui/src/app/main.tsx
+++ b/webui/src/app/main.tsx
@@ -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();
diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx
index 72652b19..8eb9b0f0 100644
--- a/webui/src/app/router.test.tsx
+++ b/webui/src/app/router.test.tsx
@@ -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();
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();
+
+ 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 () => {
diff --git a/webui/src/platform/shell/bridge.test.ts b/webui/src/platform/shell/bridge.test.ts
new file mode 100644
index 00000000..3d6ec03f
--- /dev/null
+++ b/webui/src/platform/shell/bridge.test.ts
@@ -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;
+
+ 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;
+
+ 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,
+ },
+ });
+ });
+});
diff --git a/webui/src/platform/shell/bridge.ts b/webui/src/platform/shell/bridge.ts
index 13290341..253ff3a9 100644
--- a/webui/src/platform/shell/bridge.ts
+++ b/webui/src/platform/shell/bridge.ts
@@ -14,8 +14,16 @@ export interface ShellProfileContext {
isAdmin: boolean;
}
+export interface ShellContext {
+ bridge: ShellBridge;
+ profile: ShellProfileContext;
+}
+
export type ShellBridge = NonNullable;
+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 {
+ const currentContext = getShellContext();
+ if (currentContext) return currentContext;
+
+ return await new Promise((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],
diff --git a/webui/src/platform/shell/route-controllers.tsx b/webui/src/platform/shell/route-controllers.tsx
index e6a77dd6..a08d3c66 100644
--- a/webui/src/platform/shell/route-controllers.tsx
+++ b/webui/src/platform/shell/route-controllers.tsx
@@ -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 }) {
diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx
index 0f57d9c4..d46454e1 100644
--- a/webui/src/routes/__root.tsx
+++ b/webui/src/routes/__root.tsx
@@ -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()({
+ beforeLoad: async () => {
+ const shell = await waitForShellContext();
+ return { shell };
+ },
component: () => (
<>
diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx
index 4962ae6a..1d7a303d 100644
--- a/webui/src/routes/index.tsx
+++ b/webui/src/routes/index.tsx
@@ -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 });
},
diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx
index c723c040..8cdf0452 100644
--- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx
+++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx
@@ -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;
diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx
index 8b5fc609..8aac7d05 100644
--- a/webui/src/routes/issues/-ui/issue-domain-host.tsx
+++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx
@@ -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(null);
- const profileId = profile?.profileId ?? 0;
+ const profileId = profile.profileId;
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
- enabled: profileId > 0,
});
useEffect(() => {
diff --git a/webui/src/routes/issues/-ui/issues-page.tsx b/webui/src/routes/issues/-ui/issues-page.tsx
index 5c1cce58..7d5b45e2 100644
--- a/webui/src/routes/issues/-ui/issues-page.tsx
+++ b/webui/src/routes/issues/-ui/issues-page.tsx
@@ -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;
-
-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 (
<>
- 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}
/>
clearIssueSelection(navigate)}
+ issueId={params.issueId}
+ onClose={clearIssueSelection}
onMutationSuccess={() => {
- clearIssueSelection(navigate);
+ clearIssueSelection();
dispatchIssuesRefreshEvent();
}}
- profileId={profile.profileId}
/>
>
);
diff --git a/webui/src/routes/issues/route.tsx b/webui/src/routes/issues/route.tsx
index cd7bc3fb..1de18b39 100644
--- a/webui/src/routes/issues/route.tsx
+++ b/webui/src/routes/issues/route.tsx
@@ -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)),