From b669fb25ffd660210e4d1b9be1fe071fe3cfa7e6 Mon Sep 17 00:00:00 2001 From: song Date: Thu, 15 Oct 2026 22:56:57 +0800 Subject: [PATCH] perf(workspace): load settings and secondary routes on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every visit parsed one 1.59 MB chunk that included the settings editors, the contributor projection page, the benchmark study page and the answer report page, which most visits never open. Those become dynamic imports fetched the first time they are shown, each behind a visible loading status; the workspace route stays eager. First visit, as index.html loads it: JavaScript 1,592,902 → 1,364,480 bytes (gzip 456,653 → 396,717), CSS 374,647 → 360,276 bytes. Deferred: workspace-settings-page with its editors (204.8 kB), benchmark-study-page, frontstage-developer-page and answer-report-page. Codex team results stay eager: the loopx-mode scenario pins that their list loads exactly once when a configured team appears, and a lazy mount shifted that timing. The settings ternary keeps its structure, so the contract that the workspace stays mounted behind settings still holds; the contract test pins the boundaries and the loading state. The bundle manifest records every chunk and the one-upgrade asset retention already covers an old tab importing a deferred chunk; the upgrade smoke exercises it. Validation: dev scenarios capability-scope, steward-journey, goal-showcase, workspace-locale, loopx-mode; packaged build plus packaged capability-scope and goal-workspace-create; smoke:chat-upgrade; tests/presentation/test_chat_bundle.py; contract and theme tests; tsc; CSS token check; semantic inventory advisory. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../personal-workspace-contract.test.mjs | 14 ++++++++++++ .../personal-workspace-page.tsx | 8 +++++-- .../personal-workspace/personal-workspace.css | 2 ++ apps/presentation/dashboard/src/router.tsx | 22 +++++++++++++------ docs/development/frontend-delivery.md | 12 ++++++++++ 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 2efa09d6c0..88560f1ae8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -600,4 +600,18 @@ for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoa assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } +// Load boundaries: settings are a separate chunk that arrives on demand and +// the secondary routes are not part of the workspace's first load. Each lazy +// site keeps a visible loading state. Team results stay eager: their list +// must load exactly once when a configured team appears, and a lazy mount +// shifted that timing (see the loopx-mode scenario). +const router = source("../../router.tsx"); +assert.match(page, /const WorkspaceSettingsPage = lazy\(\(\) => import\("\.\/workspace-settings-page"\)/, "Settings load on demand"); +assert.doesNotMatch(page, /^import \{ WorkspaceSettingsPage \}/m, "No eager import undoes the boundary"); +assert.match(page, /^import \{ GoalTeamResults \} from "\.\/goal-team-results";/m, "Team results load eagerly so their single list request keeps its timing"); +for (const route of ["./views/frontstage-developer-page", "./views/benchmark-study-page", "./features/personal-workspace/answer-report-page"]) { + assert.match(router, new RegExp(`lazy\\(\\(\\) => import\\("${route.replace(/[./]/g, (ch) => `\\${ch}`)}"\\)`), `${route} loads on demand`); +} +assert.equal((page.match(//g) ?? []).length, 1, "The lazy settings site shows a loading status"); + console.log("personal workspace drawer contract smoke passed"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index d982251735..2fba78b8dd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -14,7 +14,7 @@ import { } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import { refreshAttention } from "./attention-details"; import { teamPlanAssignments, teamPlanAppliedLine, teamPlanAppliedOutcome, teamPlanFields, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes, teamPlanTodoIds } from "./team-plan-preview"; -import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type ReactNode } from "react"; +import { Suspense, lazy, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type ReactNode } from "react"; import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react"; import { @@ -84,7 +84,9 @@ import { goalWorkKind } from "./goal-activity"; import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-action-form"; import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view"; import { ManagerBrief } from "./manager-brief"; -import { WorkspaceSettingsPage } from "./workspace-settings-page"; +// Loaded on demand: settings and their editors are a separate chunk that +// arrives when the owner first opens Settings, not on every workspace visit. +const WorkspaceSettingsPage = lazy(() => import("./workspace-settings-page").then((module) => ({ default: module.WorkspaceSettingsPage }))); import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; import { compareProposalRecency } from "./proposal-recency"; @@ -2091,6 +2093,7 @@ export function PersonalWorkspacePage({ } const settingsPage = settingsOpen ? ( + {t("common.loading")}

}> +
) : null; const composerSuggestions = !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index d4172394a9..5b6d45a5d4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -2073,3 +2073,5 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-conversation-latest { display: block; margin: 0 auto 10px; padding: 7px 14px; border: 1px solid var(--pw-line); border-radius: 20px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); cursor: pointer; } .personal-composer-hint { padding-top: 7px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); text-align: center; } + +.personal-lazy-loading { margin: 0; padding: 24px; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 1b1cb9282c..6afc09644f 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -8,11 +8,19 @@ import { import { z } from "zod"; import { DashboardPage } from "./views/dashboard-page"; -import { FrontstageDeveloperPage } from "./views/frontstage-developer-page"; -import { useEffect } from "react"; +import { Suspense, lazy, useEffect } from "react"; import { resolveLocalStatusUrl } from "./data/local-status-query"; -import { BenchmarkStudyPage } from "./views/benchmark-study-page"; -import { AnswerReportPage } from "./features/personal-workspace/answer-report-page"; + +// Secondary routes load on demand. The workspace is what every visit opens; +// contributor projections, benchmark studies and answer reports arrive as +// their own chunks only when such a route is entered. +const FrontstageDeveloperPage = lazy(() => import("./views/frontstage-developer-page").then((module) => ({ default: module.FrontstageDeveloperPage }))); +const BenchmarkStudyPage = lazy(() => import("./views/benchmark-study-page").then((module) => ({ default: module.BenchmarkStudyPage }))); +const AnswerReportPage = lazy(() => import("./features/personal-workspace/answer-report-page").then((module) => ({ default: module.AnswerReportPage }))); + +function RouteLoading() { + return
加载中… / Loading…
; +} const searchSchema = z.object({ goalId: z.string().optional().default(""), @@ -84,7 +92,7 @@ export const dashboardRoute = createRoute({ component: () => { const search = dashboardRoute.useSearch(); return search.reportSessionId && search.reportMessageId - ? + ? }> : ; }, }); @@ -112,14 +120,14 @@ export const frontstageDeveloperRoute = createRoute({ export const projectionDeveloperRoute = createRoute({ getParentRoute: () => rootRoute, path: "/developers/projections", - component: FrontstageDeveloperPage, + component: () => }>, }); export const benchmarkStudyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/benchmarks/study", validateSearch: (search) => benchmarkStudySearchSchema.parse(search), - component: BenchmarkStudyPage, + component: () => }>, }); const routeTree = rootRoute.addChildren([ diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 8d4792db8e..e5ce7329f4 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -148,6 +148,18 @@ can require a rebuild. Text inputs normalize CRLF so a CI-built bundle remains valid on Windows; delivered bytes are always hashed exactly. This is integrity and freshness metadata, not a signature or a replacement for release attestations. +### Load boundaries + +The workspace route is what every visit opens, so it loads eagerly. Settings +and its editors, the contributor projection page, the benchmark study page and +the answer report page are dynamic imports: each is its own chunk fetched the +first time it is shown, behind a visible loading status. Keep new secondary +surfaces behind the same kind of boundary rather than adding them to the entry +chunk, and keep a surface eager when a scenario pins the timing of its first +read (team results must list exactly once when a configured team appears). The bundle manifest records every chunk, +and the one-upgrade asset retention already covers an old tab importing a +deferred chunk after a delivery. + ### Goal sidebar order Goal dragging and keyboard move controls save presentation order through