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 ? (