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 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);
+50 -5
View File
@@ -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<RouteIdValue extends string>({
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<RouteIdValue extends string>({
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const routeInput = parsed.data;
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 = (
<RouteInputProvider input={routeInput}>
<RegisteredNotFoundProvider Component={NotFoundComponent}>
<RegisteredNotFoundProvider descriptor={notFoundDescriptor}>
<CanonicalRouteRedirect
input={routeInput}
definition={definition}
@@ -329,6 +355,12 @@ type GroupedRouteRuntimeDefinition = Readonly<{
Component: ComponentType;
}>;
type RegisteredNotFoundRegistration = Readonly<{
definition: RouteDefinition;
runtime: GroupedRouteRuntimeDefinition;
buildId: string;
}>;
type GroupedRouteRegistry = Readonly<Record<string, RouteDefinition>>;
type GroupedRouteRuntime = Readonly<
Record<string, GroupedRouteRuntimeDefinition>
@@ -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 === "/") {
+22 -7
View File
@@ -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<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>({
input,
@@ -33,21 +46,23 @@ export function useRouteInput<
}
export function RegisteredNotFoundProvider({
Component,
descriptor,
children,
}: Readonly<{
Component?: ComponentType;
descriptor?: RegisteredNotFoundDescriptor;
children: ReactNode;
}>) {
return (
<RegisteredNotFoundContext.Provider value={Component ?? null}>
<RegisteredNotFoundContext.Provider value={descriptor ?? null}>
{children}
</RegisteredNotFoundContext.Provider>
);
}
export function RegisteredNotFoundRoute() {
const Component = useContext(RegisteredNotFoundContext);
if (!Component) throw new Error("Registered not-found runtime is required");
return <Component />;
const descriptor = useContext(RegisteredNotFoundContext);
if (!descriptor) {
throw new Error("Registered not-found runtime is required");
}
return descriptor.render();
}
@@ -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<typeof createTestApplication>;
}>;
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
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<RouteId extends DiscoveryRouteId>(
},
NOT_FOUND: {
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 view = render(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
})}
application={
options.application ??
createTestApplication({
featureInputs: { "tech-log": techLog },
})
}
>
<RouterProvider router={router} />
</ApplicationProvider>,
@@ -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", () => {