Skip to content

perf: split panel chunks by domain#4382

Merged
koala73 merged 3 commits into
mainfrom
codex/issue-4371-panel-clusters
Jun 23, 2026
Merged

perf: split panel chunks by domain#4382
koala73 merged 3 commits into
mainfrom
codex/issue-4371-panel-clusters

Conversation

@koala73

@koala73 koala73 commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #4371.

  • Enables the existing PANEL_CLUSTER split so panel modules build into per-domain chunks instead of one eager panels-* bundle.
  • Converts top-level generated service-client construction in the affected panel modules to lazy first-use clients.
  • Removes startup value imports of clustered panels and replaces them with type-only imports, direct non-panel imports, or dynamic panel factories.
  • Adds lazy panel replay/guardrails so data that arrives before a deferred panel mounts is applied when the panel chunk loads.
  • Adds source and build-output guards for panel chunk assignment, lazy service clients, startup imports, and entry HTML modulepreloads.

Intent

#4381 made the shell contentful before hydration, but left the first-wave panel payload intact. This PR focuses on the next bottleneck: splitting the previous ~2.54 MB panels-* eager chunk into domain chunks without reintroducing the TDZ crashes that blocked the earlier split.

I reviewed draft PR #3946 as a required decision point. I did not reuse it because it was an older draft for #3944 with conflicts/failing checks and stale assumptions against current origin/main after #4381. This branch starts fresh from current main while adapting the durable approach: lazy service clients plus documented panel domain chunks.

Validation Matrix

Gate Result
npm run worktree:bootstrap Passed
Current-main baseline npm run build:full Passed; baseline panels-DXgUQe1q.js was 2,541,859 decoded / 671,155 gzip and modulepreloaded by dashboard.html
node --test tests/panel-cluster-chunks.test.mjs tests/feed-resolution.test.mts Passed, 19 tests
npx tsx --test --test-concurrency=16 tests/mission-presets.test.mts tests/product-catalog-freshness.test.mjs tests/panel-config-guardrails.test.mjs tests/panel-layout-dynamic-import-guard.test.mts tests/threat-timeline-panel.test.mts Passed, 57 tests
npx tsx --test --test-concurrency=16 tests/frontend-cii-source-of-truth.test.mts tests/live-news-panel-guard.test.mts tests/load-all-data-scheduling.test.mts Passed, 24 tests
npm run typecheck Passed
npm run build:full Passed
npm run test:data Passed, 11,526 passing / 0 failing / 6 skipped
git diff --check Passed
Browser smoke via npm run preview -- --host 127.0.0.1 --port 4172 Passed; 234 panel elements, all seven panel clusters dynamically imported, no TDZ/ReferenceError/page errors
Pre-push hook Passed: typecheck, API typecheck, Convex audit, boundaries, safe HTML, rate-limit policy, premium-fetch parity, edge bundle/tests, changed tests, version check

Final built chunks:

Asset Decoded Gzip
main-Bhhdo0cL.js 1,210,181 346,931
Panel-4kdyZ6ZX.js 31,721 7,914
panel-support-BwGUOgTA.js 2,789 1,261
panels-defense-DTj2y-BO.js 73,741 18,017
panels-economy-DL53tGCX.js 133,053 33,720
panels-energy-B4mBai0f.js 94,724 23,239
panels-intel-DzJbWByQ.js 241,525 65,980
panels-markets-DsIk7GaP.js 139,130 33,549
panels-news-qEVHNlJe.js 103,143 27,833
panels-risk-DbmiqzOf.js 111,710 28,232

Entry HTML checks:

  • dashboard.html panel/support modulepreloads: 0
  • main-Bhhdo0cL.js static panel/support imports: 0

Review Gates

  • Bundle graph adversary: passed. Panel/support chunks are not entry HTML modulepreloaded, and main has no static panel/support imports.
  • TDZ/runtime adversary: passed. Generated service clients are lazy, Sentry internals stay grouped with Sentry, and all panel clusters booted in browser smoke without TDZ/ReferenceError.
  • Panel behavior adversary: passed. Deferred panels replay queued data calls, CII/live-news/custom-news paths have focused guards, and premium fetch behavior is preserved.
  • Test-quality adversary: passed. Guardrails parse TypeScript/Vite source with AST checks and inspect built output when dist/ exists.
  • Scope/maintainability adversary: passed. Scope stays on perf: 2.5 MB eager panels chunk — unblock the per-domain cluster split (TDZ refactor) #4371 panel splitting, TDZ lazy clients, preload guardrails, and runtime replay.

Documentation

No user-facing docs changed. The durable contract is encoded in vite.config.ts comments and tests/panel-cluster-chunks.test.mjs.

Screenshots / UI Evidence

No intentional UI change. I captured a local browser smoke screenshot while verifying startup, but the evidence for this PR is bundle/runtime: all panel domain chunks dynamically import successfully, no TDZ/ReferenceError/page errors, and the entry HTML no longer modulepreloads panel/support chunks.

Residual Findings

@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 23, 2026 6:03pm

Request Review

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Splits the previous ~2.54 MB eager panels-* bundle into seven per-domain chunks (panels-markets, panels-energy, panels-defense, panels-news, panels-economy, panels-intel, panels-risk) plus a tiny panel-support chunk by enabling the existing PANEL_CLUSTER Rollup split, converting all top-level new XxxServiceClient(...) singletons to createLazyClient factories, and replacing every eager panel construction with a two-phase lazy registration system (lazyPanelloadRegisteredPanel).

  • Lazy panel registration: PanelLayoutManager.lazyPanel() now registers a LazyPanelRegistration instead of immediately calling the loader; panels load on demand via loadRegisteredPanel when an intersection observer fires or a deferred mount is triggered, with replayPendingCalls replaying any data that arrived before the chunk landed.
  • TDZ fix: All ~20 panels with top-level generated service-client singletons are converted to createLazyClient(() => new XxxServiceClient(...)), deferring construction to first call and eliminating the temporal dead-zone crashes that blocked the earlier split attempt.
  • Entry-HTML guardrail: onlyExplicitManualChunks: true is enabled and panelChunkForComponentId throws at build time if a new panel file is added without a PANEL_CLUSTER entry, preventing silent fallback into an eager catch-all chunk; panel chunks are also excluded from modulepreload in the entry HTML.

Confidence Score: 4/5

Safe to merge with one behavioural regression to address: news feeds are over-fetched for colliding data-panel keys on non-full variants.

The lazy-registration architecture, TDZ fix, and build guardrails are solid and well-tested. The condition panelSettings[key]?.enabled === true in the new configuredCategoryKeys loop of enabledNewsCategoryKeys conflates the data panel's settings key (e.g. markets) with the news category key, causing news feeds for markets/crypto/economic to be fetched on every loadNews() call for users on non-full variants who have the data panel enabled but the news panel disabled. This is a direct regression from the old behaviour. All other panel files, the vite split configuration, the lazy-client pattern, and the deferred-mount replay logic look correct and match the browser-smoke evidence in the PR description.

src/config/feed-resolution.ts — the second loop in enabledNewsCategoryKeys; tests/feed-resolution.test.mts — the new lazy-path test case needs a variant covering data-panel-enabled but news-panel-disabled for colliding keys.

Important Files Changed

Filename Overview
src/app/panel-layout.ts Major refactor converting all eager panel instantiation to a two-phase lazy registration system (lazyPanel/loadRegisteredPanel); introduces DeferredPanelMount with null-panel support, LazyPanelRegistration, and afterPanelMounted — core of the chunk-split work.
src/config/feed-resolution.ts Adds configuredCategoryKeys parameter to enabledNewsCategoryKeys so news categories can be detected before lazy news panels register; the second loop's condition is overly permissive for colliding data-panel keys (markets/crypto/economic), causing over-fetching on non-full variants.
vite.config.ts Enables PANEL_CLUSTER split with onlyExplicitManualChunks:true, adds panelChunkForComponentId guard that throws on unassigned panel files, moves Status to panel-support chunk, and excludes all panel chunks from entry HTML modulepreload.
src/services/rpc-client.ts Adds createLazyClient helper (once-on-first-call singleton) and rpcFetch wrapper to replace banned fetch.bind pattern; clean and correct implementation.
src/app/country-intel.ts CountryDeepDivePanel loading deferred to first country click via ensureCountryBriefPage/createCountryBriefPage; concurrent-call dedup via countryBriefPageLoading promise and isDestroyed guards are correct.
src/app/event-handlers.ts setupStatusPanel made async and awaited in App.ts to lazy-load the panel-support chunk; the await blocks phase-3 startup on a network fetch even though StatusPanel is not needed by subsequent phase-3 steps.
src/app/data-loader.ts Converts eager panel imports to type-only imports and routes CII panel calls through callPanel/enqueuePanelCall for lazy-panel compatibility; also passes Object.keys(CANONICAL_FEEDS) as configuredCategoryKeys.
tests/panel-cluster-chunks.test.mjs New 337-line AST-based guardrail test verifying panel chunk assignments, lazy service clients, startup-module import contracts, and built-output absence of panel modulepreloads in entry HTML.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App startup\nPhase 3/4] -->|await| B[panel-support chunk\nStatusPanel]
    A -->|eager static import| C[main chunk\nShell + orchestration]

    C -->|dynamic import on\nIntersectionObserver| D[panels-markets\nMarket/Crypto/ETF/etc.]
    C -->|dynamic import on\nIntersectionObserver| E[panels-energy\nEnergy/Pipeline/Storage/etc.]
    C -->|dynamic import on\nIntersectionObserver| F[panels-defense\nDefense/Hormuz/etc.]
    C -->|dynamic import on\nIntersectionObserver| G[panels-news\nNews/GDELT/LiveNews/etc.]
    C -->|dynamic import on\nIntersectionObserver| H[panels-economy\nEconomic/Consumer/Trade/etc.]
    C -->|dynamic import on\nIntersectionObserver| I[panels-intel\nCII/Cascade/CountryDeepDive/etc.]
    C -->|dynamic import on\nIntersectionObserver| J[panels-risk\nRisk/Climate/TechEvents/etc.]

    D -->|replayPendingCalls| K[(enqueuePanelCall\nbuffer)]
    E --> K
    H --> K
    I --> K

    subgraph LazyPanelRegistration
        L[lazyPanel registers\nload factory] --> M{Panel needed?}
        M -->|Yes - deferred mount\nor eager| N[loadRegisteredPanel\ncalls load]
        N --> O[Dynamic import\nchunk fetch]
        O --> P[Panel instantiated\nreplayPendingCalls\nafterpanelMounted]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[App startup\nPhase 3/4] -->|await| B[panel-support chunk\nStatusPanel]
    A -->|eager static import| C[main chunk\nShell + orchestration]

    C -->|dynamic import on\nIntersectionObserver| D[panels-markets\nMarket/Crypto/ETF/etc.]
    C -->|dynamic import on\nIntersectionObserver| E[panels-energy\nEnergy/Pipeline/Storage/etc.]
    C -->|dynamic import on\nIntersectionObserver| F[panels-defense\nDefense/Hormuz/etc.]
    C -->|dynamic import on\nIntersectionObserver| G[panels-news\nNews/GDELT/LiveNews/etc.]
    C -->|dynamic import on\nIntersectionObserver| H[panels-economy\nEconomic/Consumer/Trade/etc.]
    C -->|dynamic import on\nIntersectionObserver| I[panels-intel\nCII/Cascade/CountryDeepDive/etc.]
    C -->|dynamic import on\nIntersectionObserver| J[panels-risk\nRisk/Climate/TechEvents/etc.]

    D -->|replayPendingCalls| K[(enqueuePanelCall\nbuffer)]
    E --> K
    H --> K
    I --> K

    subgraph LazyPanelRegistration
        L[lazyPanel registers\nload factory] --> M{Panel needed?}
        M -->|Yes - deferred mount\nor eager| N[loadRegisteredPanel\ncalls load]
        N --> O[Dynamic import\nchunk fetch]
        O --> P[Panel instantiated\nreplayPendingCalls\nafterpanelMounted]
    end
Loading

Reviews (1): Last reviewed commit: "perf: split panel chunks by domain" | Re-trigger Greptile

Comment thread src/config/feed-resolution.ts
Comment thread src/app/event-handlers.ts Outdated
Comment thread vite.config.ts
@koala73
koala73 merged commit 648d109 into main Jun 23, 2026
24 checks passed
@koala73
koala73 deleted the codex/issue-4371-panel-clusters branch June 23, 2026 22:38
koala73 added a commit that referenced this pull request Jun 23, 2026
…p crash (#4384)

#4382 added output.onlyExplicitManualChunks (load-bearing for the panel
cluster split) but its side effect pulled deck.gl's transitive deps back
across the DeckGLMap boundary, forming a circular
DeckGLMap -> deck-stack -> DeckGLMap chunk. At runtime that is a TDZ
("Cannot access 'X' before initialization" in deck-stack-*.js) which throws
on deck.gl init, so MapContainer logs "DeckGL initialization failed,
falling back to SVG map" and every user gets the old d3/SVG flat map
instead of the WebGL map. The vite build already warns about the circular
chunk.

Fix: co-locate the DeckGLMap renderer into the deck vendor chunk so deck's
deps can never split across the DeckGLMap boundary. Keeps the flag (needed
to stop the panel clusters forming cycles).

Verified: build has zero circular chunks (deck and panel), DeckGLMap merges
into deck-stack (no separate chunk), 7 panel cluster chunks stay lazy and
out of the eager modulepreload. Runtime (vite preview + Chrome): map is
deckgl-mode, deck initializes, maplibre canvas renders, no deck error in
console.

Claude-Session: https://claude.ai/code/session_015Ae5Xavw1ZV4GMCWEsgKwe
koala73 added a commit that referenced this pull request Jun 24, 2026
… split

Follow-up hardening from the PR #4386 review (all non-blocking; the split
itself is verified correct — main.js still −108 KB, chunks lazy + excluded
from the dashboard modulepreload, no circular chunks):

- Rename initCheckoutOverlay -> registerCheckoutSuccessCallback: post-split
  it only registers the success callback (the SDK init moved to
  ensureCheckoutOverlayInitialized), so the old name misled callers.
- De-duplicate the gear button + GEAR_SVG: extract createSettingsButton()
  into a tiny shared src/components/settings-button.ts used by both the
  eager LazyUnifiedSettings proxy and UnifiedSettings.getButton(), so the
  413-char SVG and button markup/ARIA live in one place. The helper has no
  dependency on UnifiedSettings, so it stays in main without re-eagerizing
  the lazy chunk.
- Unify the tab-id type: the canonical UnifiedSettingsTabId now lives in a
  pure components-layer leaf (src/components/settings-types.ts); app-context
  re-exports it and UnifiedSettings imports it, so a new tab can't drift
  between two declarations — and there is no components -> app backward
  import (passes lint:boundaries).
- Suppress the spurious settings error toast when the controller is torn
  down mid-load (a deliberate unmount is not a user-facing failure).
- Strengthen the chunk guardrail test: when a build is present, assert each
  secondary flow (UnifiedSettings/settings-window/checkout) actually emits
  its own lazy chunk — otherwise the "not preloaded" check passes vacuously
  if the split regresses and a module folds back into the eager entry (the
  #4382 onlyExplicitManualChunks failure mode).

Verified: tsc clean, vite build clean with zero circular chunks, all three
lazy chunks emitted and absent from dashboard.html modulepreload,
lint:boundaries + lint:safe-html pass, and
tests/panel-cluster-chunks.test.mjs passes 6/6 against a fresh build.

Claude-Session: https://claude.ai/code/session_015Ae5Xavw1ZV4GMCWEsgKwe
koala73 added a commit that referenced this pull request Jun 24, 2026
* perf(startup): lazy-load settings and checkout SDK

* fix(checkout): preserve active overlay init promise

* refactor(checkout,settings): address review findings on the lazy-load split

Follow-up hardening from the PR #4386 review (all non-blocking; the split
itself is verified correct — main.js still −108 KB, chunks lazy + excluded
from the dashboard modulepreload, no circular chunks):

- Rename initCheckoutOverlay -> registerCheckoutSuccessCallback: post-split
  it only registers the success callback (the SDK init moved to
  ensureCheckoutOverlayInitialized), so the old name misled callers.
- De-duplicate the gear button + GEAR_SVG: extract createSettingsButton()
  into a tiny shared src/components/settings-button.ts used by both the
  eager LazyUnifiedSettings proxy and UnifiedSettings.getButton(), so the
  413-char SVG and button markup/ARIA live in one place. The helper has no
  dependency on UnifiedSettings, so it stays in main without re-eagerizing
  the lazy chunk.
- Unify the tab-id type: the canonical UnifiedSettingsTabId now lives in a
  pure components-layer leaf (src/components/settings-types.ts); app-context
  re-exports it and UnifiedSettings imports it, so a new tab can't drift
  between two declarations — and there is no components -> app backward
  import (passes lint:boundaries).
- Suppress the spurious settings error toast when the controller is torn
  down mid-load (a deliberate unmount is not a user-facing failure).
- Strengthen the chunk guardrail test: when a build is present, assert each
  secondary flow (UnifiedSettings/settings-window/checkout) actually emits
  its own lazy chunk — otherwise the "not preloaded" check passes vacuously
  if the split regresses and a module folds back into the eager entry (the
  #4382 onlyExplicitManualChunks failure mode).

Verified: tsc clean, vite build clean with zero circular chunks, all three
lazy chunks emitted and absent from dashboard.html modulepreload,
lint:boundaries + lint:safe-html pass, and
tests/panel-cluster-chunks.test.mjs passes 6/6 against a fresh build.

Claude-Session: https://claude.ai/code/session_015Ae5Xavw1ZV4GMCWEsgKwe

* chore(docs): refresh generated stats

* docs: sync component count references
koala73 added a commit that referenced this pull request Jun 24, 2026
…S loop (#4391)

#4382 flipped the `live-news` video panel from an eager assignment set
BEFORE the CANONICAL_FEEDS news-panel loop to a lazy registration AFTER
it, and swapped the collision guard from the dynamic `this.ctx.panels[key]`
to a static `COLLIDING_NEWS_PANEL_KEYS` set that omits `live-news`.

Net effect: the loop now creates a generic NewsPanel for the `live-news`
key (fed by `CANONICAL_FEEDS['live-news']`, the energy-headlines RSS block).
It registers first, and `lazyPanel()`'s dedup guard then blocks the real
`LiveNewsPanel` (24/7 video). On the live dashboard the flagship "LIVE NEWS"
panel rendered the empty news state — "No items in the last 7 days" — instead
of the video streams.

Fix: skip `live-news` in the CANONICAL_FEEDS loop so the dedicated video
panel owns the key on every variant. `happy` has no `live-news` panel at
all, and every other variant has non-empty default channels, so no variant
legitimately wants a generic RSS panel here — this restores pre-#4382
behavior exactly.

Adds a source-level regression guard in live-news-panel-guard.test.mts.

Claude-Session: https://claude.ai/code/session_01EdPTDeKmCTLD2mqWrxB8kc
koala73 added a commit that referenced this pull request Jun 24, 2026
…S loop

#4382 flipped the `live-news` video panel from an eager assignment set
BEFORE the CANONICAL_FEEDS news-panel loop to a lazy registration AFTER
it, and swapped the collision guard from the dynamic `this.ctx.panels[key]`
to a static `COLLIDING_NEWS_PANEL_KEYS` set that omits `live-news`.

Net effect: the loop now creates a generic NewsPanel for the `live-news`
key (fed by `CANONICAL_FEEDS['live-news']`, the energy-headlines RSS block).
It registers first, and `lazyPanel()`'s dedup guard then blocks the real
`LiveNewsPanel` (24/7 video). On the live dashboard the flagship "LIVE NEWS"
panel rendered the empty news state — "No items in the last 7 days" — instead
of the video streams.

Fix: skip `live-news` in the CANONICAL_FEEDS loop so the dedicated video
panel owns the key on every variant. `happy` has no `live-news` panel at
all, and every other variant has non-empty default channels, so no variant
legitimately wants a generic RSS panel here — this restores pre-#4382
behavior exactly.

Adds a source-level regression guard in live-news-panel-guard.test.mts.

Claude-Session: https://claude.ai/code/session_01EdPTDeKmCTLD2mqWrxB8kc
koala73 added a commit that referenced this pull request Jun 28, 2026
…(U2, #4478) (#4483)

* perf(main.js): defer bases-expanded (~48KB) off the eager boot graph

MILITARY_BASES (merged from the ~48KB bases-expanded table) was held on the
eager boot graph by FOUR static import edges: geo.ts and military-surge (both
eager via data-loader), plus the eager country-intel and related-assets
consumers. A manualChunks rule alone can't defer it — a static import evaluates
the module regardless of chunk placement (cf. #4382).

- Relocate MILITARY_BASES + mergeAndDeduplicateBases + US_DOMESTIC_BASES out of
  the eager geo.ts into a dedicated src/config/military-bases.ts (sync export).
- Break the eager edges: data-loader dynamic-imports military-surge at its two
  surge call sites; country-intel and related-assets lazy-cache the bases via
  dynamic import (mirroring the existing UNDERSEA_CABLES/datacenter pattern).
- Repoint lazy consumers (Map, DeckGLMap, GlobeMap, search-manager, e2e harness)
  to @/config/military-bases; drop MILITARY_BASES from the @/config barrel.
- manualChunks isolates military-bases-data (43.5KB / 9.3KB gzip); the
  dashboard-eager-chunks guard pins it off the entry chunk + modulepreload.

Verified: tsc clean; build emits no circular-chunk warning; eager-chunk guard
30/30 (chunk only dynamically referenced); 54 behavior tests pass; runtime boot
smoke renders the dashboard + map with no TDZ/chunk-load error.

U2 of the main.js eager diet (#4476). Closes #4478.

Claude-Session: https://claude.ai/code/session_017L8nACBdvQ5Sj7mTBfEBhy

* refactor(country-intel): dedup in-flight military-bases preload promise

Match the established lazy-loader pattern (related-assets preloadBaseIndex /
preloadCableIndex): share a single in-flight import() promise and reset it on
error so concurrent preloads don't each fire an import and a failed load can
retry. Harmless at runtime (dynamic import is idempotent) but aligns with the
peer loaders — addresses the Greptile review note on #4483.

Claude-Session: https://claude.ai/code/session_017L8nACBdvQ5Sj7mTBfEBhy

* fix(main): harden lazy military base loading

* chore(docs): refresh generated stats

* test(main): allow combined country brief lazy preload
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: 2.5 MB eager panels chunk — unblock the per-domain cluster split (TDZ refactor)

1 participant