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
Open
Conversation
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.
khairul-syazwan
requested review from
LSriragavan,
SantanM,
brandantck,
csafreen,
hengxian-jiang,
jerome-ng,
maggie-li-yd,
p-hoffmann and
suwarnoong
as code owners
September 9, 2026 09:22
jerome-ng
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twelfth and last in the Data Exploration redesign stack. Based on
khairul-syazwan/atlas-native-mount(#3318), notdevelop— 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.
perf(vue-mri): keep only the woff2 source in each @font-faceassetsInlineLimitand inlines every asset as base64.@mdi/fontuses bulletproof@font-facesyntax, so 4.69 MiB of icon binary became 6.25 MiB of the entry, for a font where browsers only read the 403 KiB woff2perf(vue-mri): load the chart libraries on demandfix(atlas): stop registering the Atlas researcher pluginperf(portal): preload the active plugin first, queue the restMeasured
A/B on identical bases: this branch against its own parent, same machine, same build commands. Gzipped, kB as Vite reports them.
resources/mri/lifecycles.jsdist-atlas-native/index.system.jsChunk 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
developbefore 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.
app.config.errorHandler = () => nullin every non-debug build, in bothmain.tsandlifecycles.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 alazyComponenthelper that retries once, then renders an error with a reload and logs throughconsole.error, which the suppressed handler cannot swallow.generateAppIdderives 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.foregroundInFlightwas 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-
asyncdrilldownaction is safe at its one non-awaited call site, because every synchronousdispatchruns before the firstawait; the hand-resolvedPatientAnalytics.vuerebase 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 singleupdate()call in drilldown. This one was the load-bearing case: the store is built bylifecycles.ts, so it alone pinned the chunk to the entry. The action is nowasync, and its callers were checked.PatientAnalytics.vue,CohortComparisonDialog.vue,ChartToolbar.vue,query-filter/components/ExecuteSidePanel.vue— now declare their chart-bearing children withdefineAsyncComponent.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 firsttouches 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_CODErace the eager preload was added for.Validation
Reported honestly; this is not a claim of full end-to-end coverage.
vite build(portal)vite build --config vite.config.atlas.tsvite build --config vite.config.atlas-app.tsvite build --config vite.config.atlas-native.tsprettier --checkon the files this branch addsplugins/uihoists eslint 7.32, which cannot read the app's flat configno-mistakesgatedevelop, not this stack's parent — the same path every pull request in this stack has takenPortal tests run separately, through
react-scripts test: 8 passed inpreloadScheduler.test.ts, 3 new.Every new test was checked against the previous behaviour before being trusted: the two lazy-load tests fail with
RETRY_LIMITat 0 and noerrorComponent, 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.tsandlib/i18n.ts— already failprettier --checkon the parent branch. Left alone rather than reformatted, to keep the diff readable. The two files the branch adds were formatted.