deer-flow/docs/superpowers/specs/2026-07-31-frontend-performance-remediation-design.md
DanielWalnut 459dd78707
perf(frontend): bound delivery, bundles, and long-running UI work (#4622)
* docs: design frontend performance remediation

* docs: plan frontend performance remediation

* test(frontend): add route asset performance budgets

* perf(nginx): compress textual responses safely

* perf(frontend): lazy load case study media

* perf(frontend): bound static demo file tracing

* perf(frontend): restore static locale boundaries

* perf(frontend): defer closed workspace panels

* perf(frontend): split editors and deduplicate highlighting

* perf(frontend): index incremental message derivation

* perf(frontend): stabilize paged history cache policy

* perf(frontend): bound streaming markdown renders

* perf(frontend): virtualize message history

* perf(frontend): bound and virtualize chat lists

* perf(frontend): suspend inactive decorative animation

* perf(browser): stream latest frames as binary

* perf(artifacts): stream bounded text previews

* docs: finalize performance runtime boundaries

* style(backend): apply test formatting

* fix(frontend): keep translation functions client-side

* perf(frontend): defer decorative animation bundles

* test(frontend): lock optimized route budgets

* fix: harden frontend performance boundaries

* test(frontend): update i18n provider fixture

* fix(frontend): preserve sidebar pagination position

* style(backend): format artifact range test
2026-08-01 22:19:59 +08:00

318 lines
18 KiB
Markdown

# Frontend performance remediation — design
## Background
A production-oriented audit identified seventeen performance risks across the
default DeerFlow web deployment. The audit was originally performed against an
older `main`, so this design starts from a source re-audit of
`origin/main@17461ee5` rather than treating the old findings as immutable.
Upstream has already improved three parts of the original report:
- render-facing stream messages are coalesced to an 80 ms budget and the main
history/live/optimistic merge is memoized;
- processing groups build tool-result and browser-view indexes once instead of
scanning the group for every tool call;
- binary artifacts are served with `FileResponse`, including byte-range support.
Those changes are preserved. The remaining work is one comprehensive PR made
of independently reviewable commits, with a measurement and regression gate
after each slice.
## Goals
1. Fix every still-reproducible item from the seventeen-item audit rather than
only the easiest P0 subset.
2. Bound network bytes, client bundle bytes, retained DOM, repeated stream
computation, animation work, browser-frame copies, artifact preview memory,
and mock-route filesystem work.
3. Preserve chat ordering, streaming semantics, history pagination, anchored
scrolling, locale behavior, artifact downloads, Browser Live control, and
the default Docker/local deployment topology.
4. Add deterministic tests or executable measurements for every fix so later
changes cannot silently restore the same cost.
5. Deliver the work as one PR whose commits can be reviewed and reverted by
subsystem.
## Non-goals
- Replacing LangGraph, Streamdown, Nextra, CodeMirror, or the existing query
layer.
- Changing model, sandbox, agent, or scheduler behavior.
- Optimizing third-party hosted deployments that bypass DeerFlow's bundled
nginx; the default nginx/Next deployment is the delivery contract here.
- Claiming a performance win from source shape alone. Production build output
and runtime measurements are required.
## Current finding matrix
| # | Audit topic | Current status on `17461ee5` | Planned disposition |
|---|---|---|---|
| 1 | No explicit gzip/Brotli in bundled nginx | Reproduced | Add portable gzip for compressible static/document responses while excluding SSE and already-compressed media; test the generated nginx config and live headers. |
| 2 | Full-history work on every stream chunk | Partially fixed upstream | Keep the 80 ms render coalescer, then make grouping, usage, and human-input derivation reuse an immutable prefix and recompute only the active tail. Compare incremental results with the existing full derivation in tests. |
| 3 | Loaded message history is not virtualized | Reproduced | Add turn-level dynamic-height windowing with anchored prepend and overscan; keep the active streaming turn mounted. |
| 4 | No component-level code splitting | Reproduced | Lazy-load settings, inactive settings sections, Browser Live, artifact preview/editor, and other interaction-only heavy surfaces. |
| 5 | Docs/blog carry excessive shared dependencies | Needs current production measurement; source risk remains | Establish a fresh route asset baseline, remove workspace-only providers/styles from the root layout, and enforce route budgets. |
| 6 | Root locale cookie makes the whole site dynamic | Reproduced | Make the root layout static; move cookie-aware locale providers to auth/workspace and keep public locale resolution route-owned. |
| 7 | Streaming Markdown repeatedly parses growing text | Reproduced | Align reveal updates to the stream render budget, eliminate the per-animation-frame parse loop, and keep cheap streaming code rendering until the block settles. |
| 8 | Focus can refetch every loaded history page | Reproduced | Disable focus refetch for immutable paged history and explicitly invalidate/refetch on run lifecycle events. |
| 9 | Tool lookup and `steps.indexOf` produce quadratic work | Partially fixed upstream | Preserve the new lookup maps; replace remaining repeated `steps.indexOf(...)` calls with indexed conversion metadata. |
| 10 | Code blocks run and retain two Shiki renders | Reproduced | Lazy-load Shiki and produce one dual-theme CSS-variable rendering, with request/result caching and stale-effect protection. |
| 11 | Below-fold landing case-study images load eagerly | Reproduced | Replace CSS backgrounds with optimized lazy images, correct responsive sizes, and retain the card overlay/hover behavior. |
| 12 | Galaxy and Magic Bento do offscreen/unthrottled work | Reproduced | Pause on invisibility/document hide, honor reduced motion, and coalesce pointer work to one animation frame. |
| 13 | Browser Live uses JSON + base64 + React state per frame | Reproduced | Send JPEGs as binary WebSocket messages, use revocable object URLs, and present only the latest frame per paint. Keep JSON for control metadata. |
| 14 | Text artifact preview reads/renders the full file | Partially fixed upstream | Serve text through range-capable responses, request a bounded preview first, show truncation/load-full affordances, and avoid mounting CodeMirror for oversized previews. |
| 15 | Chat lists render every loaded row in two surfaces | Reproduced | Share normalized query data, virtualize the full chats page, and bound the sidebar to a recent/pinned window with explicit older-chat navigation. |
| 16 | Global styles/translations/background queries are too broad | Reproduced | Scope Markdown/KaTeX/Nextra styles by route, avoid shipping both locale payloads where possible, and mount queries only with their visible feature surface. |
| 17 | Mock API uses synchronous IO and dynamic project tracing | Reproduced | Use async cached reads and a deterministic demo-thread manifest so request handling and output tracing do not scan the project tree. |
## Design
### 1. Reproducible performance baseline and budgets
Add a repository script that builds the production frontend and reports, per
representative route, the transitive first-load JavaScript and CSS from Next's
build manifests. The representative routes are:
- `/`
- `/login`
- `/workspace/chats`
- `/workspace/chats/<fixture-id>`
- `/en/docs`
- `/blog/posts`
The script writes no tracked build artifact. It prints stable JSON plus a human
summary and can compare against a checked-in budget file. Budgets are set only
after the fresh `origin/main` baseline is captured; the PR must improve the
audited routes and may not regress an unrelated route beyond a small explicit
tolerance. A second smoke check starts the production stack and records
`Content-Encoding`, `Cache-Control`, and transferred bytes for HTML, JS, CSS,
JSON, and SSE samples.
Long-thread and large-artifact fixtures provide runtime gates:
- a synthetic history with hundreds of heterogeneous turns;
- a continuously growing Markdown answer with code, math, and citations;
- a multi-megabyte UTF-8 artifact;
- a burst of Browser Live JPEG frames.
Unit tests pin bounded derivation and protocol behavior; browser measurements
record DOM node count, rendered turn count, and frame URL cleanup.
### 2. Delivery, static rendering, and route ownership
The bundled nginx enables gzip for HTML, JavaScript, CSS, JSON, XML, and SVG.
It does not gzip `text/event-stream`, fonts, images, video, archives, or other
already-compressed payloads. Proxy buffering remains disabled for streaming
routes. A config test and a live header smoke test prevent an apparently valid
directive from being placed in the wrong nginx context.
The top-level Next layout becomes request-invariant:
- it owns only document structure, the theme provider, and truly global CSS;
- it does not call `cookies()`;
- Streamdown and KaTeX styles move to the layouts that render rich content;
- landing navigation uses the public default locale, docs derive locale from
their explicit `[lang]` segment, and blog keeps its own locale selection;
- auth and workspace layouts read the locale cookie and mount `I18nProvider`.
The client provider receives only the selected locale from its server layout
and synchronizes `document.documentElement.lang` when the workspace/auth locale
changes. Translation dictionaries include formatter functions and therefore
cannot cross the React Server Component serialization boundary. The
interactive auth/workspace provider deliberately owns both small dictionaries
so switching language remains immediate, while public landing, docs, and blog
routes resolve one route-owned locale without mounting that provider. This
retains the existing language switch behavior without making public routes
dynamically rendered or putting both dictionaries in every route bundle.
### 3. Bundle boundaries
Interaction-only code must not be reachable from the initial workspace chunk.
The workspace root keeps only a small settings-store listener. The settings
dialog is imported after it opens, and each settings page is imported only when
selected. Browser Live, artifact code/preview tooling, and CodeMirror language
packages are loaded only when their panels and modes are used.
Shiki is loaded through an async highlighter boundary instead of a static
top-level import. One Shiki call emits light and dark token variables in one DOM
tree; theme changes are CSS-only. A bounded cache keys on code, language, line
number mode, and highlighter configuration. Effects ignore stale resolutions
when code changes or the component unmounts.
The baseline script verifies that docs/blog no longer inherit workspace-only
chunks and that a closed settings/artifact/browser surface contributes no
interaction-only chunk to initial workspace loading.
### 4. Bounded chat rendering and derivation
Message virtualization happens at the existing `ThreadMessageGroup` boundary,
not individual tool steps. A small headless `@tanstack/react-virtual` adapter
owns dynamic measurements, overscan, and stable identity keys while the
existing conversation component remains the scroll owner. The newest streaming
group is always in the render window. Prepending an older history page
preserves the visual anchor; bottom-follow behavior continues only when the
user was already pinned to the bottom. Selection, edit/regenerate controls,
human-input cards, artifact links, and run-duration anchors remain inside their
current group. A dependency-size comparison is part of the bundle gate; if the
adapter would break the workspace route budget, the same interface is
implemented locally rather than accepting an initial-load regression.
Full derivation remains the reference algorithm. A new incremental adapter
stores the settled group prefix and recomputes only from the earliest message
whose identity/content/run metadata changed. Tests feed append, in-place stream
mutation, tool result, reasoning, history prepend, checkpoint replacement,
summarization bridge, edit/regenerate, and hidden human-input sequences through
both algorithms and require identical groups.
Turn usage, run-duration anchors, workspace-change anchors, and human-input
state use the same stable-prefix boundary or keyed indexes. The active tail may
change every 80 ms, but historical turns are not rescanned. Remaining
`steps.indexOf(...)` calls are removed by attaching the index while steps are
created.
Paged history uses `refetchOnWindowFocus: false` and a nonzero stale window.
Run finish, stop, regenerate, edit-and-regenerate, and explicit refresh remain
authoritative invalidation points.
### 5. Streaming Markdown
The SDK/coalescer's 80 ms snapshot is the upper-frequency source of renderable
content. The current smooth-reveal hook must not turn one snapshot into a new
full Markdown parse on every animation frame. It will reveal at the same bounded
cadence (or render the coalesced snapshot directly when the delta is small),
while Streamdown's visual animation handles appearance without creating extra
source strings.
Incomplete code blocks continue to use the cheap streaming `<pre>/<code>`
components. Shiki, Mermaid, and other expensive settled rendering activates
only after the relevant block/turn is stable. DOM tests cover cancellation,
rapid target replacement, incomplete fences/lists, reduced motion, and the
final exact content.
### 6. Landing visuals
Case-study cards use `next/image` with `fill`, responsive `sizes`, and lazy
loading. Only genuinely above-the-fold media can be priority-loaded. Source
images may be re-encoded if doing so materially lowers bytes without visible
quality loss; generated files remain deterministic and are compared in the
route transfer report.
Galaxy owns a visibility state derived from `IntersectionObserver`, document
visibility, and `prefers-reduced-motion`. Its RAF exists only while rendering is
allowed. Magic Bento listens for pointer movement only while its section is
active and coalesces geometry reads/writes to one RAF. Both components cancel
pending work and animations on cleanup. Tests use mocked RAF/observers to prove
that offscreen, hidden, reduced-motion, and unmounted states perform no frames.
### 7. Browser Live binary frame path
Browser session capture returns JPEG bytes rather than base64 text. The updated
client requests `frame_format=binary` in the WebSocket URL. The gateway keeps
its bounded lossy frame queue and sends frame entries with
`WebSocket.send_bytes`; URL, tab, rejection, and input messages remain JSON
text. Connections without the capability retain the legacy base64 JSON frame
for rolling-deployment compatibility. The new protocol is self-demultiplexing
by WebSocket message type and does not add an extra binary header.
The client treats binary messages as blobs, keeps only the newest pending frame,
and publishes at most one object URL per animation frame. Replaced and unmounted
URLs are revoked. JSON parsing is performed only for text messages. The static
artifact screenshot fallback and all input/control behavior stay unchanged.
Backend tests cover capability negotiation, byte delivery, the legacy fallback,
queue dropping, metadata text frames, auth, and disconnect cleanup; frontend
tests cover coalescing and URL revocation.
### 8. Bounded artifact previews
Active content remains download-only and binary media remains range-streamed.
Regular text artifacts move to a range-capable inline `FileResponse` path. The
frontend initially asks for a fixed byte range and reads response metadata to
distinguish complete from truncated content. It shows file size and a clear
"Load full file" action when truncated; download always returns the original
file.
The 1 MiB preview limit is owned by the frontend, while the shared HTTP Range
contract lets both regular files and bounded archive members honor it. The
preview handles an incomplete UTF-8 tail safely, and tests cover ASCII,
multibyte boundary, empty, exact-limit, oversized, skill-archive, active, and
binary files. Large text opens in the lightweight preview first; CodeMirror is
not instantiated until content is within its safe budget or the user explicitly
loads the full file.
### 9. Conversation lists, queries, and mock data
`useInfiniteThreads` remains the single cache. A shared selector deduplicates
and sorts pages once so the sidebar and chats page do not repeat normalization.
The full chats page uses fixed/dynamic row virtualization. The sidebar renders a
bounded recent window plus all pinned entries, and routes users to the full
page for older conversations instead of silently auto-loading/rendering an
unbounded list.
Channel/provider, scheduler, and other feature queries mount only with their
visible page/panel. Locale payloads are route-scoped: public routes own one
selected locale, while the interactive auth/workspace boundary owns both for
instant switching. Route-scoped CSS is verified in the build manifest rather
than inferred from import location.
Mock route handlers use promise-based filesystem APIs and a cached,
deterministically generated demo-thread manifest. The workspace redirect and
thread search do not call `readdirSync` or dynamically construct project-wide
paths at request time. A manifest consistency test fails if demo fixtures are
added or removed without regeneration.
## Error handling and compatibility
- Performance fallbacks preserve content: a failed lazy import shows the
existing surface error boundary; a failed bounded preview offers download;
unsupported binary Browser Live frames fall back to the latest static
screenshot.
- No user-authored content is moved into `dangerouslySetInnerHTML` beyond the
existing Shiki output boundary.
- WebSocket auth, origin, and exact thread-owner checks remain before session
acquisition.
- Range requests do not weaken the active-content download policy or path
traversal validation.
- Reduced work must not hide persisted history, discard a Browser Live control
event, or change the final streamed Markdown text.
## Implementation and commit slices
The single PR is implemented in this order:
1. Baseline/budget tooling and failing regression tests.
2. Nginx compression, static root ownership, route CSS/locale split, landing
image delivery.
3. Lazy bundle boundaries and single-pass Shiki.
4. Incremental chat derivation, history query policy, message/chat
virtualization, and bounded Markdown cadence.
5. Visibility-aware landing effects and binary Browser Live frames.
6. Range-bounded text artifact previews and async/cached mock data.
7. Documentation, production build comparison, full frontend/backend checks,
and review fixes.
Each behavior change follows red-green-refactor. Commits remain independently
testable, but the PR description reports the aggregate before/after result and
maps every audit item to its evidence.
## Verification and completion gate
The PR is not complete until all of the following are true:
- every row in the seventeen-item matrix has a code change or current evidence
proving that no change is required;
- focused unit/DOM/backend tests pass and demonstrate their initial failure;
- `cd frontend && pnpm check`, `pnpm test`, and `pnpm build` pass;
- relevant Playwright suites pass for streaming, history prepend, settings,
artifacts, and Browser Live;
- backend artifact/browser tests, the blocking-IO gate for changed async code,
Ruff lint, and format checks pass;
- nginx configuration validation and live compression/SSE smoke checks pass;
- production route asset/transfer budgets pass and the before/after report is
included in the PR;
- source review, automated review, PR review threads, and CI contain no
unresolved actionable finding.
An absence of review comments is not sufficient by itself: the final audit must
walk this matrix and attach authoritative evidence for each row.