diff --git a/src/features/tech-log/presentation/public/components/home-focus.tsx b/src/features/tech-log/presentation/public/components/home-focus.tsx index 50e7e34..ce81ab4 100644 --- a/src/features/tech-log/presentation/public/components/home-focus.tsx +++ b/src/features/tech-log/presentation/public/components/home-focus.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import type { @@ -28,23 +28,30 @@ export function HomeFocus({ const keys = items.map((item) => item.key); const activeItem = items.find((item) => item.key === activeKey) ?? items[0]; - function replaceFocus(key: FocusKey) { - const search = new URLSearchParams(location.search); - search.set("focus", key); - void navigate( - { - pathname: location.pathname, - search: `?${search.toString()}`, - hash: location.hash, - }, - { replace: true }, - ); - } + const replaceFocus = useCallback( + (key: FocusKey) => { + const search = new URLSearchParams(location.search); + search.set("focus", key); + void navigate( + { + pathname: location.pathname, + search: `?${search.toString()}`, + hash: location.hash, + }, + { replace: true }, + ); + }, + [location.hash, location.pathname, location.search, navigate], + ); useEffect(() => { - if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return; - replaceFocus(activeKey); - }); + setActiveKey(initialKey); + }, [initialKey]); + + useEffect(() => { + if (!shouldNormalizeFocusUrl(requestedKey, initialKey)) return; + replaceFocus(initialKey); + }, [initialKey, replaceFocus, requestedKey]); function select(key: FocusKey, moveKeyboardFocus = false) { setActiveKey(key); diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index a0c854e..01873b0 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -50,6 +50,7 @@ import { import type { ParsedRouteInput } from "./route-contract.ts"; import { RegisteredNotFoundProvider, + type RegisteredNotFoundDescriptor, RouteInputProvider, } from "./route-input.tsx"; @@ -259,14 +260,14 @@ function RegisteredRoute({ definition, runtime, codecs, - NotFoundComponent, + registeredNotFound, }: { routeId: RouteIdValue; buildId: string; definition: RouteDefinition; runtime: GroupedRouteRuntimeDefinition; codecs: RouteCodecRegistry; - NotFoundComponent?: ComponentType; + registeredNotFound?: RegisteredNotFoundRegistration; }) { const params = useParams(); const [search] = useSearchParams(); @@ -282,10 +283,35 @@ function RegisteredRoute({ if (!parsed.success) return ; const routeInput = parsed.data; const RuntimeComponent = runtime.Component; + const notFoundDescriptor: RegisteredNotFoundDescriptor | undefined = + registeredNotFound + ? { + ...registeredNotFound, + render: () => { + const NotFoundRuntime = registeredNotFound.runtime.Component; + return ( + + } + > + + + + + ); + }, + } + : undefined; const content = ( - + ; +type RegisteredNotFoundRegistration = Readonly<{ + definition: RouteDefinition; + runtime: GroupedRouteRuntimeDefinition; + buildId: string; +}>; + type GroupedRouteRegistry = Readonly>; type GroupedRouteRuntime = Readonly< Record @@ -352,7 +384,16 @@ export function createGroupedRouteObjects( PUBLIC: [], STUDIO: [], }; - const NotFoundComponent = runtime.NOT_FOUND?.Component; + const notFoundDefinition = registry.NOT_FOUND; + const notFoundRuntime = runtime.NOT_FOUND; + const registeredNotFound = + notFoundDefinition && notFoundRuntime + ? { + definition: notFoundDefinition, + runtime: notFoundRuntime, + buildId, + } + : undefined; for (const definition of Object.values(registry)) { const routeId = definition.routeId; const routeRuntime = runtime[definition.routeId]; @@ -366,7 +407,11 @@ export function createGroupedRouteObjects( definition={definition} runtime={routeRuntime} codecs={codecs} - NotFoundComponent={NotFoundComponent} + registeredNotFound={ + definition.layoutGroup === notFoundDefinition?.layoutGroup + ? registeredNotFound + : undefined + } /> ); if (definition.path === "/") { diff --git a/src/presentation/routes/route-input.tsx b/src/presentation/routes/route-input.tsx index a14d240..7bd0b07 100644 --- a/src/presentation/routes/route-input.tsx +++ b/src/presentation/routes/route-input.tsx @@ -5,10 +5,23 @@ import { useContext, } from "react"; +import type { RouteDefinition } from "../../contracts/routes.ts"; import type { ParsedRouteInput, RouteId } from "./route-contract.ts"; const RouteInputContext = createContext | null>(null); -const RegisteredNotFoundContext = createContext(null); + +export type RegisteredNotFoundDescriptor = Readonly<{ + definition: RouteDefinition; + runtime: Readonly<{ + moduleId: string; + Component: ComponentType; + }>; + buildId: string; + render(): ReactNode; +}>; + +const RegisteredNotFoundContext = + createContext(null); export function RouteInputProvider({ input, @@ -33,21 +46,23 @@ export function useRouteInput< } export function RegisteredNotFoundProvider({ - Component, + descriptor, children, }: Readonly<{ - Component?: ComponentType; + descriptor?: RegisteredNotFoundDescriptor; children: ReactNode; }>) { return ( - + {children} ); } export function RegisteredNotFoundRoute() { - const Component = useContext(RegisteredNotFoundContext); - if (!Component) throw new Error("Registered not-found runtime is required"); - return ; + const descriptor = useContext(RegisteredNotFoundContext); + if (!descriptor) { + throw new Error("Registered not-found runtime is required"); + } + return descriptor.render(); } diff --git a/tests/features/tech-log/public-discovery-screens.test.tsx b/tests/features/tech-log/public-discovery-screens.test.tsx index acb704e..25f39e0 100644 --- a/tests/features/tech-log/public-discovery-screens.test.tsx +++ b/tests/features/tech-log/public-discovery-screens.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { lazy, type ComponentType } from "react"; import { createMemoryRouter, Outlet, @@ -47,9 +48,15 @@ const routeComponents = { type DiscoveryRouteId = keyof typeof routeComponents; +type DiscoveryRenderOptions = Readonly<{ + NotFoundComponent?: ComponentType; + application?: ReturnType; +}>; + function renderDiscoveryRoute( routeId: RouteId, initialEntry: string, + options: DiscoveryRenderOptions = {}, ) { const definition = TECH_LOG_ROUTE_REGISTRY[routeId]; const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId]; @@ -67,7 +74,7 @@ function renderDiscoveryRoute( }, NOT_FOUND: { moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId, - Component: NotFoundPage, + Component: options.NotFoundComponent ?? NotFoundPage, }, }, { @@ -86,9 +93,12 @@ function renderDiscoveryRoute( const techLog = createTechLogFeatureInstalledInput().input; const view = render( , @@ -183,6 +193,62 @@ describe("TechLog home discovery", () => { expect(screen.getAllByRole("tabpanel")).toHaveLength(1); }); + it("treats external focus URL changes as authoritative without stealing focus", async () => { + const { router } = renderDiscoveryRoute( + "TECH_LOG_HOME", + "/?focus=current&state=latest-empty#latest", + ); + const current = screen.getByRole("tab", { name: "현재 작업" }); + current.focus(); + + await router.navigate("/?focus=question&state=latest-empty#latest"); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + expect(router.state.location).toMatchObject({ + pathname: "/", + search: "?focus=question&state=latest-empty", + hash: "#latest", + }); + expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(current).toHaveAttribute("aria-selected", "false"); + expect(current).toHaveAttribute("tabindex", "-1"); + expect(current).toHaveFocus(); + expect( + screen.getByRole("tabpanel", { name: "열린 질문" }), + ).toBeVisible(); + expect(document.getElementById("focus-panel-current")).toHaveAttribute( + "hidden", + ); + + await router.navigate("/?focus=decision&state=latest-empty#latest"); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + expect(router.state.location).toMatchObject({ + pathname: "/", + search: "?focus=decision&state=latest-empty", + hash: "#latest", + }); + expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(screen.getByRole("tabpanel", { name: "최근 결정" })).toBeVisible(); + expect(screen.getAllByRole("tabpanel")).toHaveLength(1); + }); + it("preserves the source home empty and error states", () => { const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty"); expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass( @@ -321,6 +387,86 @@ describe("TechLog explore discovery", () => { ).not.toBeInTheDocument(); expect(container.querySelector(".site-frame")).not.toBeNull(); }); + + it("recovers a rejecting registered not-found runtime under its own chunk contract", async () => { + let rejectNotFound: ((reason?: unknown) => void) | undefined; + const LazyNotFound = lazy( + () => + new Promise<{ default: typeof NotFoundPage }>((_resolve, reject) => { + rejectNotFound = reject; + }), + ); + const notFoundChunkRead = vi.fn(() => "assets/not-found.js"); + const exploreKindChunkRead = vi.fn(() => "assets/explore-kind.js"); + const routeChunks = { + get "route-not-found"() { + return notFoundChunkRead(); + }, + get "route-tech-log-explore-kind"() { + return exploreKindChunkRead(); + }, + }; + const release = { + buildId: "task-7-test-build", + releaseId: "task-7-test-release", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "task-7-test-hash", + routeChunks, + }; + const record = vi.fn(); + const techLog = createTechLogFeatureInstalledInput().input; + const application = createTestApplication({ + diagnostics: { record }, + releaseInfo: { + getCurrent: async () => release, + refresh: async () => release, + }, + featureInputs: { "tech-log": techLog }, + }); + const { router, container } = renderDiscoveryRoute( + "TECH_LOG_EXPLORE_KIND", + "/explore/unknown", + { NotFoundComponent: LazyNotFound, application }, + ); + + expect( + screen.getByText("화면을 준비하고 있습니다.").closest("section"), + ).toHaveAttribute("data-surface", "none"); + await waitFor(() => expect(rejectNotFound).toBeTypeOf("function")); + await act(async () => { + rejectNotFound?.( + new TypeError("Failed to fetch dynamically imported module"), + ); + }); + + expect( + await screen.findByRole("heading", { + name: "화면 자산을 복구하지 못했습니다.", + }), + ).toBeVisible(); + expect(notFoundChunkRead).toHaveBeenCalledOnce(); + expect(exploreKindChunkRead).not.toHaveBeenCalled(); + expect(router.state.location.pathname).toBe("/explore/unknown"); + expect(container.querySelector(".site-frame")).not.toBeNull(); + expect( + screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }), + ).not.toBeInTheDocument(); + + const routeChangedRecords = record.mock.calls + .map(([entry]) => entry) + .filter((entry) => entry.eventId === "route.changed"); + expect(routeChangedRecords).toEqual([ + expect.objectContaining({ + context: expect.objectContaining({ + route_id: "TECH_LOG_EXPLORE_KIND", + }), + }), + ]); + expect( + record.mock.calls.some(([entry]) => entry.eventId === "ui.render.failed"), + ).toBe(false); + }); }); describe("TechLog search discovery", () => {