From b8ca223c1ea472a59c31afb11afd7fec4ffe53af Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 01:30:05 +0900 Subject: [PATCH] perf(frontend): split conditional workspace surfaces Rebase-free graft of PR #644's native surface split onto current main: the 9 conditionally rendered workspace components (AdminPanel, AskEvidenceLayerPopup, LeftoverPairList, LineageDag, OntologyExplorer, OperationsDashboard, PublicClaimVerification, SimilarVocPanel, WorkspaceCalendar) move to lazy() native dynamic imports with a SurfaceBoundary error boundary (fallback spinner, alert + admin-guidance recovery with Refresh action). PublicClaimVerification stays eager because Ask renders it synchronously in the answer popup. Build now emits 9 separate chunks (1.5-37 kB each) instead of one 577 kB bundle; 454 frontend tests, tsc, and oxlint green. --- .../frontend-native-surface-code-splitting.md | 5 + frontend/.storybook/preview.ts | 7 + frontend/src/App.test.tsx | 30 ++- frontend/src/App.tsx | 217 +++++++++++------- frontend/src/SurfaceBoundary.stories.tsx | 37 +++ frontend/src/i18n.ts | 4 + 6 files changed, 214 insertions(+), 86 deletions(-) create mode 100644 CHANGELOG.d/frontend-native-surface-code-splitting.md create mode 100644 frontend/src/SurfaceBoundary.stories.tsx diff --git a/CHANGELOG.d/frontend-native-surface-code-splitting.md b/CHANGELOG.d/frontend-native-surface-code-splitting.md new file mode 100644 index 000000000..260767232 --- /dev/null +++ b/CHANGELOG.d/frontend-native-surface-code-splitting.md @@ -0,0 +1,5 @@ +### Changed + +- Defer conditionally rendered workspace surfaces with native imports, while + announcing module loading and load or render failure to assistive technology; + offer one refresh action and administrator guidance if failure persists. diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts index cec7405d2..236402e5f 100644 --- a/frontend/.storybook/preview.ts +++ b/frontend/.storybook/preview.ts @@ -1,9 +1,16 @@ import type { Preview } from "@storybook/react-vite"; import { MINIMAL_VIEWPORTS } from "storybook/viewport"; +import { setLocale } from "../src/i18n"; import "../src/index.css"; import "../src/App.css"; const preview: Preview = { + decorators: [ + (Story) => { + setLocale("en"); + return Story(); + }, + ], parameters: { controls: { matchers: { color: /(background|color)$/i } }, viewport: { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 3d5a3d572..ed32b41ce 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, 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 App from "./App"; +import App, { SurfaceBoundary } from "./App"; import { optionalKnowledgeCutoffIso } from "./api"; import { setLocale } from "./i18n"; import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; @@ -43,8 +43,36 @@ afterEach(() => { window.history.replaceState({}, "", "/"); window.sessionStorage.clear(); window.localStorage.clear(); + vi.restoreAllMocks(); }); + +it("announces a lazy surface load failure with a recovery action", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const BrokenSurface = () => { + throw new Error("synthetic chunk failure"); + }; + + const { rerender } = render( + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "This view is unavailable. Refresh once; if it fails again, contact your administrator.", + ); + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + + rerender( + + Recovered surface + , + ); + expect(screen.getByText("Recovered surface")).toBeInTheDocument(); +}); + + describe("App, unauthenticated", () => { it("shows a login button that starts the real OIDC redirect", async () => { window.history.replaceState({}, "", "/?post=abc#evidence"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3194ef14b..e3fd0d8d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,7 @@ -import { AdminPanel } from "./components/AdminPanel"; -import { LeftoverPairList } from "./components/LeftoverPairList"; -import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { focusedGraphMustReset } from "./focusedGraphSelection"; import { canAuthorVoice, postPrimaryVoiceLabel } from "./voicePerspective"; -import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; +import { Component, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -92,22 +89,17 @@ import { fetchTenantConfig, } from "./api"; import { CitationChip } from "./components/CitationChip"; +import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { OrganizationAliasChip } from "./components/OrganizationAliasChip"; import { organizationAliasCaption } from "./components/organizationAliasCaption"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; -import { OntologyExplorer } from "./components/OntologyExplorer"; -import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; -import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { PopupCloseButton } from "./components/PopupCloseButton"; -import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { TeppAcceptedReceipt } from "./components/TeppAcceptedReceipt"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; -import { OperationsDashboard } from "./components/OperationsDashboard"; import { OccupationRatingProfile } from "./components/OccupationRatingProfile"; import { initialWorkspaceDestination } from "./gnbChrome"; -import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; @@ -129,6 +121,41 @@ import { } from "./i18n"; import "./App.css"; +const AdminPanel = lazy(() => import("./components/AdminPanel").then((module) => ({ default: module.AdminPanel }))); +const AskEvidenceLayerPopup = lazy(() => import("./components/AskEvidenceLayerPopup").then((module) => ({ default: module.AskEvidenceLayerPopup }))); +const LeftoverPairList = lazy(() => import("./components/LeftoverPairList").then((module) => ({ default: module.LeftoverPairList }))); +const LineageDag = lazy(() => import("./LineageDag").then((module) => ({ default: module.LineageDag }))); +const OntologyExplorer = lazy(() => import("./components/OntologyExplorer").then((module) => ({ default: module.OntologyExplorer }))); +const OperationsDashboard = lazy(() => import("./components/OperationsDashboard").then((module) => ({ default: module.OperationsDashboard }))); +const SimilarVocPanel = lazy(() => import("./components/SimilarVocPanel").then((module) => ({ default: module.SimilarVocPanel }))); +const WorkspaceCalendar = lazy(() => import("./components/WorkspaceCalendar").then((module) => ({ default: module.WorkspaceCalendar }))); + +function SurfaceFallback() { + return

{t("Loading...")}

; +} + +export class SurfaceBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + if (this.state.failed) { + return ( +
+

{t("This view is unavailable. Refresh once; if it fails again, contact your administrator.")}

+ +
+ ); + } + return }>{this.props.children}; + } +} + function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; @@ -518,7 +545,9 @@ function EventLineageSection({ return ( <> {scoped.nodes.length > 0 && onSelectPost && ( - + + + )} {scoped.nodes.length > 0 && currentNextAction ? (

@@ -1360,13 +1389,15 @@ function KeymanPanel({ ) : null} {ontologyOpen ? ( - + + + ) : null} ); @@ -2657,35 +2688,37 @@ function PostDetailPopup({ }} /> - onSelectPost?.(candidatePostId)} - loadingMore={similarVocLoadingMore} - onLoadMore={similarVocNextOffset === null ? null : () => { - if (similarVocLoadingMoreRef.current) return; - const requestScope = similarVocScopeRef.current; - similarVocLoadingMoreRef.current = true; - setSimilarVocLoadingMore(true); - setSimilarVocError(null); - fetchSimilarVoc(accessToken, postId, similarVocNextOffset) - .then((result) => { - if (similarVocScopeRef.current !== requestScope) return; - setSimilarVoc((current) => [...(current ?? []), ...result.items]); - setSimilarVocNextOffset(result.next_offset); - }) - .catch(() => { - if (similarVocScopeRef.current === requestScope) { - setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요."); - } - }) - .finally(() => { - if (similarVocScopeRef.current !== requestScope) return; - similarVocLoadingMoreRef.current = false; - setSimilarVocLoadingMore(false); - }); - }} - /> + + onSelectPost?.(candidatePostId)} + loadingMore={similarVocLoadingMore} + onLoadMore={similarVocNextOffset === null ? null : () => { + if (similarVocLoadingMoreRef.current) return; + const requestScope = similarVocScopeRef.current; + similarVocLoadingMoreRef.current = true; + setSimilarVocLoadingMore(true); + setSimilarVocError(null); + fetchSimilarVoc(accessToken, postId, similarVocNextOffset) + .then((result) => { + if (similarVocScopeRef.current !== requestScope) return; + setSimilarVoc((current) => [...(current ?? []), ...result.items]); + setSimilarVocNextOffset(result.next_offset); + }) + .catch(() => { + if (similarVocScopeRef.current === requestScope) { + setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요."); + } + }) + .finally(() => { + if (similarVocScopeRef.current !== requestScope) return; + similarVocLoadingMoreRef.current = false; + setSimilarVocLoadingMore(false); + }); + }} + /> + @@ -3604,12 +3637,14 @@ function CalendarPanel({ if (calendar === null) return

{t("Loading calendar...")}

; return ( - + + + ); } @@ -3830,18 +3865,20 @@ function ReportsPanel({

)} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( - { - onSelectPost(pair.post_id, { - fromLeftoverPair: { - pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", - criterionCode: pair.criterion_code, - }, - }); - }} - /> + + { + onSelectPost(pair.post_id, { + fromLeftoverPair: { + pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", + criterionCode: pair.criterion_code, + }, + }); + }} + /> + )} {report.members.length > 0 && (
    @@ -5109,7 +5146,9 @@ export function AskAgentPanel({ ) : null} {answer.next_action ?

    {t(answer.next_action)}

    : null} - + + + {answer.delivery ? (