From 39f56fe63f15fecc2e2c51db30f9f85f22dee9d7 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 3 May 2026 11:54:03 +0300 Subject: [PATCH] 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 --- webui/src/app/main.tsx | 4 +- webui/src/app/router.test.tsx | 30 +++++++- webui/src/platform/shell/bridge.test.ts | 59 ++++++++++++++++ webui/src/platform/shell/bridge.ts | 47 +++++++++++++ .../src/platform/shell/route-controllers.tsx | 36 ++++------ webui/src/routes/__root.tsx | 6 ++ webui/src/routes/index.tsx | 3 +- .../routes/issues/-ui/issue-detail-modal.tsx | 10 ++- .../routes/issues/-ui/issue-domain-host.tsx | 9 +-- webui/src/routes/issues/-ui/issues-page.tsx | 69 +++++++------------ webui/src/routes/issues/route.tsx | 10 +-- 11 files changed, 194 insertions(+), 89 deletions(-) create mode 100644 webui/src/platform/shell/bridge.test.ts 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)),