fix: synchronize TechLog focus and not-found runtime

This commit is contained in:
DongHyeonka
2026-08-15 22:52:42 +09:00
parent ef1d5cc548
commit 512aa4a1e9
4 changed files with 246 additions and 33 deletions
@@ -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 { Link, useLocation, useNavigate } from "react-router-dom";
import type { import type {
@@ -28,23 +28,30 @@ export function HomeFocus({
const keys = items.map((item) => item.key); const keys = items.map((item) => item.key);
const activeItem = items.find((item) => item.key === activeKey) ?? items[0]; const activeItem = items.find((item) => item.key === activeKey) ?? items[0];
function replaceFocus(key: FocusKey) { const replaceFocus = useCallback(
const search = new URLSearchParams(location.search); (key: FocusKey) => {
search.set("focus", key); const search = new URLSearchParams(location.search);
void navigate( search.set("focus", key);
{ void navigate(
pathname: location.pathname, {
search: `?${search.toString()}`, pathname: location.pathname,
hash: location.hash, search: `?${search.toString()}`,
}, hash: location.hash,
{ replace: true }, },
); { replace: true },
} );
},
[location.hash, location.pathname, location.search, navigate],
);
useEffect(() => { useEffect(() => {
if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return; setActiveKey(initialKey);
replaceFocus(activeKey); }, [initialKey]);
});
useEffect(() => {
if (!shouldNormalizeFocusUrl(requestedKey, initialKey)) return;
replaceFocus(initialKey);
}, [initialKey, replaceFocus, requestedKey]);
function select(key: FocusKey, moveKeyboardFocus = false) { function select(key: FocusKey, moveKeyboardFocus = false) {
setActiveKey(key); setActiveKey(key);
+50 -5
View File
@@ -50,6 +50,7 @@ import {
import type { ParsedRouteInput } from "./route-contract.ts"; import type { ParsedRouteInput } from "./route-contract.ts";
import { import {
RegisteredNotFoundProvider, RegisteredNotFoundProvider,
type RegisteredNotFoundDescriptor,
RouteInputProvider, RouteInputProvider,
} from "./route-input.tsx"; } from "./route-input.tsx";
@@ -259,14 +260,14 @@ function RegisteredRoute<RouteIdValue extends string>({
definition, definition,
runtime, runtime,
codecs, codecs,
NotFoundComponent, registeredNotFound,
}: { }: {
routeId: RouteIdValue; routeId: RouteIdValue;
buildId: string; buildId: string;
definition: RouteDefinition; definition: RouteDefinition;
runtime: GroupedRouteRuntimeDefinition; runtime: GroupedRouteRuntimeDefinition;
codecs: RouteCodecRegistry; codecs: RouteCodecRegistry;
NotFoundComponent?: ComponentType; registeredNotFound?: RegisteredNotFoundRegistration;
}) { }) {
const params = useParams(); const params = useParams();
const [search] = useSearchParams(); const [search] = useSearchParams();
@@ -282,10 +283,35 @@ function RegisteredRoute<RouteIdValue extends string>({
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />; if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const routeInput = parsed.data; const routeInput = parsed.data;
const RuntimeComponent = runtime.Component; const RuntimeComponent = runtime.Component;
const notFoundDescriptor: RegisteredNotFoundDescriptor | undefined =
registeredNotFound
? {
...registeredNotFound,
render: () => {
const NotFoundRuntime = registeredNotFound.runtime.Component;
return (
<Suspense
fallback={
<RouteLoadingSurface
definition={registeredNotFound.definition}
/>
}
>
<ChunkRecoveryBoundary
chunkId={registeredNotFound.definition.chunkId}
recover={recovery.recoverChunk}
>
<NotFoundRuntime />
</ChunkRecoveryBoundary>
</Suspense>
);
},
}
: undefined;
const content = ( const content = (
<RouteInputProvider input={routeInput}> <RouteInputProvider input={routeInput}>
<RegisteredNotFoundProvider Component={NotFoundComponent}> <RegisteredNotFoundProvider descriptor={notFoundDescriptor}>
<CanonicalRouteRedirect <CanonicalRouteRedirect
input={routeInput} input={routeInput}
definition={definition} definition={definition}
@@ -329,6 +355,12 @@ type GroupedRouteRuntimeDefinition = Readonly<{
Component: ComponentType; Component: ComponentType;
}>; }>;
type RegisteredNotFoundRegistration = Readonly<{
definition: RouteDefinition;
runtime: GroupedRouteRuntimeDefinition;
buildId: string;
}>;
type GroupedRouteRegistry = Readonly<Record<string, RouteDefinition>>; type GroupedRouteRegistry = Readonly<Record<string, RouteDefinition>>;
type GroupedRouteRuntime = Readonly< type GroupedRouteRuntime = Readonly<
Record<string, GroupedRouteRuntimeDefinition> Record<string, GroupedRouteRuntimeDefinition>
@@ -352,7 +384,16 @@ export function createGroupedRouteObjects(
PUBLIC: [], PUBLIC: [],
STUDIO: [], 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)) { for (const definition of Object.values(registry)) {
const routeId = definition.routeId; const routeId = definition.routeId;
const routeRuntime = runtime[definition.routeId]; const routeRuntime = runtime[definition.routeId];
@@ -366,7 +407,11 @@ export function createGroupedRouteObjects(
definition={definition} definition={definition}
runtime={routeRuntime} runtime={routeRuntime}
codecs={codecs} codecs={codecs}
NotFoundComponent={NotFoundComponent} registeredNotFound={
definition.layoutGroup === notFoundDefinition?.layoutGroup
? registeredNotFound
: undefined
}
/> />
); );
if (definition.path === "/") { if (definition.path === "/") {
+22 -7
View File
@@ -5,10 +5,23 @@ import {
useContext, useContext,
} from "react"; } from "react";
import type { RouteDefinition } from "../../contracts/routes.ts";
import type { ParsedRouteInput, RouteId } from "./route-contract.ts"; import type { ParsedRouteInput, RouteId } from "./route-contract.ts";
const RouteInputContext = createContext<ParsedRouteInput<string> | null>(null); const RouteInputContext = createContext<ParsedRouteInput<string> | null>(null);
const RegisteredNotFoundContext = createContext<ComponentType | null>(null);
export type RegisteredNotFoundDescriptor = Readonly<{
definition: RouteDefinition;
runtime: Readonly<{
moduleId: string;
Component: ComponentType;
}>;
buildId: string;
render(): ReactNode;
}>;
const RegisteredNotFoundContext =
createContext<RegisteredNotFoundDescriptor | null>(null);
export function RouteInputProvider<RouteIdValue extends string>({ export function RouteInputProvider<RouteIdValue extends string>({
input, input,
@@ -33,21 +46,23 @@ export function useRouteInput<
} }
export function RegisteredNotFoundProvider({ export function RegisteredNotFoundProvider({
Component, descriptor,
children, children,
}: Readonly<{ }: Readonly<{
Component?: ComponentType; descriptor?: RegisteredNotFoundDescriptor;
children: ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
<RegisteredNotFoundContext.Provider value={Component ?? null}> <RegisteredNotFoundContext.Provider value={descriptor ?? null}>
{children} {children}
</RegisteredNotFoundContext.Provider> </RegisteredNotFoundContext.Provider>
); );
} }
export function RegisteredNotFoundRoute() { export function RegisteredNotFoundRoute() {
const Component = useContext(RegisteredNotFoundContext); const descriptor = useContext(RegisteredNotFoundContext);
if (!Component) throw new Error("Registered not-found runtime is required"); if (!descriptor) {
return <Component />; throw new Error("Registered not-found runtime is required");
}
return descriptor.render();
} }
@@ -1,8 +1,9 @@
// @vitest-environment jsdom // @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 userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lazy, type ComponentType } from "react";
import { import {
createMemoryRouter, createMemoryRouter,
Outlet, Outlet,
@@ -47,9 +48,15 @@ const routeComponents = {
type DiscoveryRouteId = keyof typeof routeComponents; type DiscoveryRouteId = keyof typeof routeComponents;
type DiscoveryRenderOptions = Readonly<{
NotFoundComponent?: ComponentType;
application?: ReturnType<typeof createTestApplication>;
}>;
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>( function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
routeId: RouteId, routeId: RouteId,
initialEntry: string, initialEntry: string,
options: DiscoveryRenderOptions = {},
) { ) {
const definition = TECH_LOG_ROUTE_REGISTRY[routeId]; const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId]; const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
@@ -67,7 +74,7 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
}, },
NOT_FOUND: { NOT_FOUND: {
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId, moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
Component: NotFoundPage, Component: options.NotFoundComponent ?? NotFoundPage,
}, },
}, },
{ {
@@ -86,9 +93,12 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
const techLog = createTechLogFeatureInstalledInput().input; const techLog = createTechLogFeatureInstalledInput().input;
const view = render( const view = render(
<ApplicationProvider <ApplicationProvider
application={createTestApplication({ application={
featureInputs: { "tech-log": techLog }, options.application ??
})} createTestApplication({
featureInputs: { "tech-log": techLog },
})
}
> >
<RouterProvider router={router} /> <RouterProvider router={router} />
</ApplicationProvider>, </ApplicationProvider>,
@@ -183,6 +193,62 @@ describe("TechLog home discovery", () => {
expect(screen.getAllByRole("tabpanel")).toHaveLength(1); 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", () => { it("preserves the source home empty and error states", () => {
const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty"); const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass( expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass(
@@ -321,6 +387,86 @@ describe("TechLog explore discovery", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(container.querySelector(".site-frame")).not.toBeNull(); 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", () => { describe("TechLog search discovery", () => {