Skip to content

perf(vue-mri): cut the eager bundle 77% in the portal and 40% in Atlas - #3319

Open
khairul-syazwan wants to merge 7 commits into
khairul-syazwan/atlas-native-mountfrom
khairul-syazwan/refactor-vue-mri-bundle-size
Open

perf(vue-mri): cut the eager bundle 77% in the portal and 40% in Atlas#3319
khairul-syazwan wants to merge 7 commits into
khairul-syazwan/atlas-native-mountfrom
khairul-syazwan/refactor-vue-mri-bundle-size

Conversation

@khairul-syazwan

Copy link
Copy Markdown
Collaborator

Twelfth and last in the Data Exploration redesign stack. Based on khairul-syazwan/atlas-native-mount (#3318), not develop — review that one first.

No ticket. This finishes #3116 by making the redesign cheap to load.

Why this is last, and not first

It was going to land before the native Atlas mount, because Atlas3 imports every plugin eagerly at boot with a 30 second timeout, so a large entry is a real risk there. The order was reversed on purpose: the ticket owner wants Atlas load benchmarks taken before and after this change, and that needs the mount landed first. The two compose cleanly, so this is sequencing, not dependency.

What it does

Four performance commits, each one independently measured, plus three from review. Read the commit messages — they carry the reasoning and the numbers, and are the better record than this summary.

Commit Change
perf(vue-mri): keep only the woff2 source in each @font-face Vite library mode ignores assetsInlineLimit and inlines every asset as base64. @mdi/font uses bulletproof @font-face syntax, so 4.69 MiB of icon binary became 6.25 MiB of the entry, for a font where browsers only read the 403 KiB woff2
perf(vue-mri): load the chart libraries on demand Four static paths reached plotly.js, echarts and d3. All four broken, so Rollup can split them
fix(atlas): stop registering the Atlas researcher plugin Removes a manifest entry that produced two 404s on every portal load and never worked
perf(portal): preload the active plugin first, queue the rest Six plugin bundles went on the wire together and starved the one the user opened

Measured

A/B on identical bases: this branch against its own parent, same machine, same build commands. Gzipped, kB as Vite reports them.

Entry Without With Change
portal, resources/mri/lifecycles.js 4,075 kB 938 kB −77%
native Atlas, dist-atlas-native/index.system.js 6,701 kB 4,047 kB −40%

Chunk count 47 → 54. plotly.js becomes its own 1,430 kB gzipped chunk that the Cohorts page never requests.

The Atlas number is the one that matters most. The host fetches only the entry, eagerly, for every installed plugin at boot.

The absolute byte counts in the commit messages were measured against develop before the rebase and drift slightly from the table above; the percentages hold.

Three defects found in review, fixed here

The four performance commits were written in an earlier session and had never been reviewed. Review found three, all of them created by this diff rather than pre-existing.

  • A chart that failed to load showed nothing at all, and said nothing. This is the significant one. Making the four chart components lazy turned static imports into runtime network dependencies — a static import cannot fail once the entry has run, a chunk fetch can. And the app sets app.config.errorHandler = () => null in every non-debug build, in both main.ts and lifecycles.ts, so the rejection was discarded: an empty pane, nothing in the console, and no reason for the user to try a reload. A stale document asking for a chunk hash that a new deployment has replaced produces exactly that, and is the most likely cause. There is now a lazyComponent helper that retries once, then renders an error with a reload and logs through console.error, which the suppressed handler cannot swallow.
  • A queued preload was never cancelled when its plugin unregistered, so a plugin the user had navigated away from was still downloaded, competing with whatever they opened instead. A dataset switch remounts the whole researcher container, so this is ordinary, not an edge case. Worse, generateAppId derives the id from the path alone, so a re-register reused the id and left two entries queued for one plugin — two background downloads, which is the exact thing the scheduler was written to prevent.
  • foregroundInFlight was a boolean. Two plugins can both match the current location when their base paths nest; the first to settle resumed background draining while the second was still on the wire. Latent today, since no two current manifests overlap. It is a counter now.

Review also confirmed, by tracing: no static path to plotly, echarts or d3 survives; the now-async drilldown action is safe at its one non-awaited call site, because every synchronous dispatch runs before the first await; the hand-resolved PatientAnalytics.vue rebase conflict is correct; and the postcss plugin no-ops correctly on a rule with no woff2 source.

The chart split only works if every static path is broken

One surviving static import pins the chunk back into the entry, and nothing in the build fails when that happens — the bundle just gets big again quietly. The four paths were:

  • store/modules/query.ts — imported plotly for a single update() call in drilldown. This one was the load-bearing case: the store is built by lifecycles.ts, so it alone pinned the chunk to the entry. The action is now async, and its callers were checked.
  • PatientAnalytics.vue, CohortComparisonDialog.vue, ChartToolbar.vue, query-filter/components/ExecuteSidePanel.vue — now declare their chart-bearing children with defineAsyncComponent.

The chart components themselves are untouched.

Section 4.4 of the project roadmap carries a follow-up: add a build-time check that keeps plotly off the entry. Nothing today prevents a future static import from silently undoing this.

Blast radius

perf(portal): preload the active plugin first touches the React portal's single-spa registry — plugins/ui/apps/portal/src/singleSpa/. That is wider than vue-mri, and it changes the load behaviour of every plugin the portal hosts, not just this one.

Deferring a preload is only an optimisation: if a plugin is opened before its queued preload runs, single-spa calls its load function on activation and the module cache dedupes the two callers. The plugin whose route is already active still preloads immediately, which is what closes the LOADING_SOURCE_CODE race the eager preload was added for.

Validation

Reported honestly; this is not a claim of full end-to-end coverage.

Check Result
Unit tests, Node 20, the command CI runs 1083 passed, 3 skipped. 15 new
vite build (portal) pass
vite build --config vite.config.atlas.ts pass
vite build --config vite.config.atlas-app.ts pass
vite build --config vite.config.atlas-native.ts pass
prettier --check on the files this branch adds pass
eslint not run. plugins/ui hoists eslint 7.32, which cannot read the app's flat config
Bundle A/B measurement done, table above
no-mistakes gate not run. It does not follow a treehouse worktree and targets develop, not this stack's parent — the same path every pull request in this stack has taken

Portal tests run separately, through react-scripts test: 8 passed in preloadScheduler.test.ts, 3 new.

Every new test was checked against the previous behaviour before being trusted: the two lazy-load tests fail with RETRY_LIMIT at 0 and no errorComponent, and the three scheduler tests fail with the cancel, the dedupe and the counter reverted.

Three files this branch touches — CohortComparisonDialog.vue, store/modules/query.ts and lib/i18n.ts — already fail prettier --check on the parent branch. Left alone rather than reformatted, to keep the diff readable. The two files the branch adds were formatted.

Khairul Syazwan and others added 7 commits September 9, 2026 17:00
The build runs in Vite library mode. In library mode Vite ignores
build.assetsInlineLimit and inlines every asset as a base64 data URI,
whatever its size, so each font format named in a @font-face rule ends
up inside lifecycles.js.

@mdi/font uses the bulletproof @font-face syntax. That syntax names the
EOT file two times, then names woff2, woff and ttf. The result was
4.69 MiB of icon font binary, which base64 expands to 6.25 MiB of the
bundle, for a font where browsers only ever read the 403 KiB woff2.

Add a PostCSS plugin that keeps only the woff2 source, and register it
beside the existing remove-color-adjust plugin. A rule is rewritten only
when it already offers a woff2 source, and local() sources are kept, so
app-FFH-icons and app-MRI-icons, which ship no woff2 file, are left
alone.

lifecycles.js goes from 8,619,436 to 2,427,268 bytes, which is 72%
smaller. Gzipped on the wire it goes from 4.69 MB to 1.06 MB, which is
77% smaller. Throttled to 4 Mbit/s the wait from clicking Cohorts to
seeing the page goes from 23.8 s to 16.3 s.

@mdi/font is not vendored. The upstream CSS is used as it is.
lifecycles.js opened with
System.register(["./js/lifecycles-<hash>.js"]), and that chunk carried
plotly.js, echarts and d3. SystemJS must fetch and run a static
dependency before the entry executes, so 3.44 MB on the wire blocked
mount even though no chart is on screen when the Cohorts page opens.

Four static paths reached the chart libraries. Break all of them,
because one surviving path keeps the chunk static:

- store/modules/query.ts imported plotly for a single update() call in
  drilldown. The store is built by lifecycles.ts, so this alone pinned
  the chunk to the entry. Load it with a dynamic import instead and make
  the action async.
- PatientAnalytics.vue, CohortComparisonDialog.vue, ChartToolbar.vue and
  ExecuteSidePanel.vue now declare their chart-bearing children with
  defineAsyncComponent.

The chart components themselves are untouched. Rollup moves them and
their libraries into chunks that load when a chart is actually needed.

The entry's static dependency drops from 8.66 MB to 2.64 MB, which is
2,653 kB to 808 kB after gzip. plotly.js becomes its own 1.43 MB
gzipped chunk that the Cohorts page never requests.

Throttled to 4 Mbit/s, the wait from clicking Cohorts to seeing the page
goes from 15.7 s to 10.6 s, and total bytes for the flow go from
10.9 MB to 8.5 MB.

Cover the branch that changed: the drilldown tests now await the action,
and a new test asserts update() is called through the lazy import.
Every portal load produced two 404s, for /d2e/vue and
/d2e/single-spa-vue. Users read them as a fault.

The cause is this manifest. It registered a researcher plugin pointing
at /atlas-portal/index.js, and that module is
System.register(["vue","single-spa-vue"]). It expects an import map to
supply both. The portal builds its import map from plugin paths only, so
SystemJS cannot resolve either specifier, falls back to URL resolution
against the document base, and requests /d2e/vue and
/d2e/single-spa-vue.

The plugin never worked. The module load never completes, so the app
never mounts, and "Atlas" does not appear in the researcher navigation.
In this stack plugins/atlas ships only the login and portal resources,
so the shell pointed at an Atlas application that is not installed.

Remove the uiplugins block. The routes block is untouched, so /atlas,
/atlas-portal and /atlas-login still resolve exactly as before.

The Atlas JSON cohort definition import in vue-mri is a separate feature
and is not affected.
The researcher container pre-renders a container for every "app" plugin,
so registerSingleSpaApp ran for all of them on the same tick and the
eager preload put six bundles on the wire together.

On a constrained link that starves the plugin the user opened. Measured
at 4 Mbit/s with six bundles in flight, vue-mri's 1,036 KB entry took
8,935 ms, an effective 116 KB/s out of the 500 KB/s the link allows. Its
own payload was 2.06 MB of the 7.88 MB the page downloaded.

Add preloadScheduler. The plugin whose route is already active preloads
straight away, which is what closes the LOADING_SOURCE_CODE race the
eager preload was added for. Every other plugin is queued and drained
one at a time while the browser is idle, and the drain waits for any
foreground preload to settle first, so at most one background download
competes with the one the user is waiting on.

Deferring is only an optimisation. If a plugin is opened before its
queued preload runs, single-spa calls its load function on activation
and the module cache dedupes the two callers.

Extract matchesBasePath so the preload decision and createActivityFunction
share one path test. It deliberately ignores autoMount, because an
autoMount plugin is always active for single-spa but that says nothing
about what the user is looking at.
Both files are new in this branch and did not pass prettier --check.

Other files this branch touches also fail that check, but they failed
before the branch too. Reformatting those would bury the diff, so they
are left as they are.
Loading the chart components on demand turned four static imports into
runtime network dependencies. A static import cannot fail after the
entry has run; a chunk fetch can.

The app sets app.config.errorHandler = () => null in every non-debug
build, in both main.ts and lifecycles.ts. So a rejected chunk fetch was
discarded: the pane rendered empty, nothing reached the console, and
nothing told the user to reload. A stale document asking for a chunk
hash that a new deployment has replaced produces exactly that, and it is
the most likely cause.

Add lazyComponent(name, loader). It retries once, because the common
causes are transient, then renders LazyLoadError and logs through
console.error, which the suppressed Vue handler cannot swallow. The
error component offers a reload, which is the only fix for a stale
chunk hash once the retry has failed.

Use it at all four call sites in place of a bare defineAsyncComponent.

The retry policy is tested by mocking defineAsyncComponent and calling
onError directly, so the tests cover the policy rather than rendering.
Each one was checked against the previous behaviour: two fail with
RETRY_LIMIT at 0 and no errorComponent.
The scheduler had no way to drop a queued task, and unloadSingleSpaApp
never told it anything. Three consequences, all of them working against
the reason the scheduler exists:

- A plugin that unmounted before its turn came up was still downloaded,
  competing with whatever the user moved on to. A dataset switch
  remounts the whole researcher container, so this is a normal event.
- generateAppId derives the id from the path alone, so a plugin that is
  unregistered and registered again reuses it. With the stale entry
  still queued, that put two background downloads on the wire for one
  plugin.
- foregroundInFlight was a boolean. Two plugins can both match the
  current location when their base paths nest, and the first to settle
  resumed background draining while the second was still in flight.

Add cancelPreload(id) and call it from unloadSingleSpaApp. Refuse a
duplicate id on enqueue. Make foregroundInFlight a counter.

A preload already in flight is left alone: the bytes are spent, and the
module cache makes them harmless.

Three tests, each checked against the previous behaviour.
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.

2 participants