diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx new file mode 100644 index 00000000..3c3129e8 --- /dev/null +++ b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, type ComponentProps, type ReactElement, type ReactNode } from 'react' +import { flushSync } from 'react-dom' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { BlueprintPublicInfo, PublicApi } from '@gadgets/workshop-shared/api' + +const route = vi.hoisted(() => ({ id: 'blueprint-a' })) + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ id: route.id }), + useNavigate: () => vi.fn<() => void>(), + useRouter: () => ({ history: { canGoBack: () => false, back: vi.fn<() => void>() } }), +})) + +vi.mock('./useAuth', () => ({ + useAuth: () => ({ + isAuthenticated: false, + authenticatedApi: null, + isLoading: false, + login: vi.fn<() => void>(), + }), +})) + +vi.mock('@cloudflare/kumo', () => { + // oxlint-disable-next-line unicorn/consistent-function-scoping + const PassThrough = ({ children }: { children?: ReactNode }) => <>{children} + const Dialog = Object.assign( + ({ children }: { children?: ReactNode }) =>
{children}
, + { + Root: ({ children, open }: { children?: ReactNode; open?: boolean }) => + open === false ? null : <>{children}, + Trigger: ({ render }: { render: ReactElement }) => render, + Title: PassThrough, + Description: PassThrough, + Close: ({ render }: { render: (props: object) => ReactElement }) => render({}), + }, + ) + const DropdownMenu = Object.assign(PassThrough, { + Trigger: ({ render }: { render: ReactElement }) => render, + Content: PassThrough, + Item: PassThrough, + Separator: () => null, + }) + const Select = Object.assign(PassThrough, { Option: PassThrough }) + + return { + Button: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), + Dialog, + DropdownMenu, + Select, + Tooltip: PassThrough, + useKumoToastManager: () => ({ add: vi.fn<(toast: unknown) => void>() }), + } +}) + +vi.mock('./components/WorkshopControls', () => ({ + WorkshopButton: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), + WorkshopIconButton: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), +})) + +vi.mock('./gatekeeper-modal/AccountChooser', () => ({ AccountChooser: () => null })) +vi.mock('./ResourceConfiguratorHost', () => ({ default: () => null })) + +import BlueprintLandingPage from './BlueprintLandingPage' + +const testGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const previousActEnvironment = testGlobal.IS_REACT_ACT_ENVIRONMENT +testGlobal.IS_REACT_ACT_ENVIRONMENT = true +afterAll(() => { + if (previousActEnvironment === undefined) delete testGlobal.IS_REACT_ACT_ENVIRONMENT + else testGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function blueprint(id: string, title: string): BlueprintPublicInfo { + return { + id, + metadata: { + title, + description: '', + author: { type: 'user', id: 'author', name: 'Author' }, + created: new Date('2026-08-01T00:00:00Z'), + version: 1, + lastUpdated: new Date('2026-08-01T00:00:00Z'), + bindings: {}, + }, + } +} + +describe('BlueprintLandingPage route changes', () => { + let container: HTMLDivElement | undefined + let root: Root | undefined + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = undefined + container = undefined + route.id = 'blueprint-a' + }) + + async function render(rpcStub: RpcStub) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root!.render()) + } + + async function navigateTo(id: string, rpcStub: RpcStub) { + route.id = id + await act(async () => root!.render()) + } + + it('ignores an earlier route response that finishes after the current route response', async () => { + const requestA = deferred() + const requestB = deferred() + const rpcStub = { + getBlueprint: vi.fn<(id: string) => Promise>((id: string) => + id === 'blueprint-a' ? requestA.promise : requestB.promise), + } as unknown as RpcStub + + await render(rpcStub) + await navigateTo('blueprint-b', rpcStub) + + await act(async () => { + requestB.resolve(blueprint('blueprint-b', 'CURRENT BLUEPRINT')) + await requestB.promise + }) + expect(container!.textContent).toContain('CURRENT BLUEPRINT') + + await act(async () => { + requestA.resolve(blueprint('blueprint-a', 'STALE BLUEPRINT')) + await requestA.promise + }) + expect(container!.textContent).toContain('CURRENT BLUEPRINT') + expect(container!.textContent).not.toContain('STALE BLUEPRINT') + }) + + it('hides the previous blueprint in the first render of a new route', async () => { + const requestA = deferred() + const requestB = deferred() + const rpcStub = { + getBlueprint: vi.fn<(id: string) => Promise>((id: string) => + id === 'blueprint-a' ? requestA.promise : requestB.promise), + } as unknown as RpcStub + + await render(rpcStub) + await act(async () => { + requestA.resolve(blueprint('blueprint-a', 'BLUEPRINT A')) + await requestA.promise + }) + expect(container!.textContent).toContain('BLUEPRINT A') + + let firstRenderText = '' + act(() => { + route.id = 'blueprint-b' + flushSync(() => root!.render()) + firstRenderText = container!.textContent ?? '' + }) + + expect(firstRenderText).toContain('Loading blueprint...') + expect(firstRenderText).not.toContain('BLUEPRINT A') + }) + + it('clears the loaded blueprint when the next route is not found', async () => { + const requestA = deferred() + const requestB = deferred() + const rpcStub = { + getBlueprint: vi.fn<(id: string) => Promise>((id: string) => + id === 'blueprint-a' ? requestA.promise : requestB.promise), + } as unknown as RpcStub + + await render(rpcStub) + await act(async () => { + requestA.resolve(blueprint('blueprint-a', 'BLUEPRINT A')) + await requestA.promise + }) + expect(container!.textContent).toContain('BLUEPRINT A') + + await navigateTo('blueprint-b', rpcStub) + expect(container!.textContent).toContain('Loading blueprint...') + expect(container!.textContent).not.toContain('BLUEPRINT A') + + await act(async () => { + requestB.resolve(null) + await requestB.promise + }) + expect(container!.textContent).toContain('Blueprint not found') + expect(container!.textContent).not.toContain('BLUEPRINT A') + }) + + it('clears the loaded blueprint when loading the next route fails', async () => { + const requestA = deferred() + const requestB = deferred() + const rpcStub = { + getBlueprint: vi.fn<(id: string) => Promise>((id: string) => + id === 'blueprint-a' ? requestA.promise : requestB.promise), + } as unknown as RpcStub + + await render(rpcStub) + await act(async () => { + requestA.resolve(blueprint('blueprint-a', 'BLUEPRINT A')) + await requestA.promise + }) + expect(container!.textContent).toContain('BLUEPRINT A') + + await navigateTo('blueprint-b', rpcStub) + expect(container!.textContent).toContain('Loading blueprint...') + expect(container!.textContent).not.toContain('BLUEPRINT A') + + await act(async () => { + requestB.reject(new Error('Failed to load B')) + await requestB.promise.catch(() => {}) + }) + expect(container!.textContent).toContain('Couldn’t load blueprint') + expect(container!.textContent).toContain('Failed to load B') + expect(container!.textContent).not.toContain('BLUEPRINT A') + }) +}) diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.tsx index 646dc048..28927199 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.tsx @@ -38,7 +38,13 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { const { isAuthenticated, authenticatedApi, isLoading: authLoading, login } = useAuth(rpcStub) const toasts = useKumoToastManager() - const [blueprint, setBlueprint] = useState(null) + const [loadedBlueprint, setBlueprint] = useState(null) + const [blueprintLoadKey, setBlueprintLoadKey] = useState<{ + id: string, + rpcStub: RpcStub, + } | null>(null) + const isCurrentBlueprintLoad = blueprintLoadKey?.id === id && blueprintLoadKey.rpcStub === rpcStub + const blueprint = isCurrentBlueprintLoad ? loadedBlueprint : null useDocumentTitle(blueprint?.metadata.title) const [loading, setLoading] = useState(true) const [notFound, setNotFound] = useState(false) @@ -89,6 +95,11 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { ) // Fetch blueprint metadata. useEffect(() => { + let cancelled = false + + setBlueprintLoadKey({ id, rpcStub }) + setBlueprint(null) + setError(null) if (!id) { setLoading(false) setNotFound(true) @@ -96,19 +107,25 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { } setLoading(true) setNotFound(false) - setError(null) rpcStub.getBlueprint(id).then(result => { + if (cancelled) return if (result) { setBlueprint(result) } else { setNotFound(true) } }).catch(err => { + if (cancelled) return setError(err.message || 'Failed to load blueprint.') }).finally(() => { + if (cancelled) return setLoading(false) }) + + return () => { + cancelled = true + } }, [id, rpcStub]) useEffect(() => { @@ -732,7 +749,7 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { return } - if (loading || authLoading) { + if (!isCurrentBlueprintLoad || loading || authLoading) { return }