fix: synchronize TechLog focus and not-found runtime
This commit is contained in:
@@ -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,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 === "/") {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user