perf(client): lazy-load every routed module #508

Merged
allan merged 3 commits from perf/lazy-routes into release/7.23 2026-11-07 16:53:08 +00:00
Collaborator

The routes already declared loadChildren, but in the eager form - a function returning the module class - so Angular bundled every screen into the initial chunk and loaded it before the first paint. The only chunk that was ever split was exceljs, because it uses a dynamic import.

Each route now returns a dynamic import, which is the whole change.

Measured

Production build, before and after:

before after
main 8.96 MB 3.50 MB
initial total (raw) 10.29 MB 4.83 MB
initial total (transfer) 2.29 MB 849.30 kB

And the lazy side, which is what a route now costs when it opens:

home-home-module         1.98 MB   (the landing route: `/` redirects to home)
viewer-viewer-module     1.06 MB + 809 kB + 731 kB + 111 kB
editor-editor-module    687 kB
licensing               61 kB     review 51 kB     deploy 41 kB
system                  16 kB     stage  11 kB

So the login screen no longer downloads the grid, the Excel stack or the viewer, and a user who never opens the editor never fetches it.

Why it is safe

  • No route module declares providers, so a lazy module cannot introduce a second instance of a service the shell already holds - the classic way this change breaks an app.
  • ReviewRouteComponent and NotFoundComponent stay eager: the first is a route's own shell component, the second is the wildcard.
  • client/src/app/app.routing.ts is the only file that changes; no component, service or template is touched.

Verification

  • Production build compiles clean, with the chunk table above.
  • Karma, locally: 896 tests, all passing.
  • The Cypress suite runs on this pull request and navigates every route, which is the assertion that lazy loading did not leave a screen unreachable; the published Lighthouse table in the pull request will show what it does to the score.

Note on where this leaves the bundle

home-home-module is 1.98 MB and home is what / redirects to, so the landing route still pays most of a bundle's worth of cost - the grid and the viewer are out of it now, but the home screen itself is the next thing to look at once this lands and the audit number is read. The smaller items (501 icons, 503 @sheet/crypto, 505 CSS) are separate issues and mostly land inside these chunks now, so their effect on the initial load is smaller than the sizes suggest - except 501 and 505, which move bytes out of the initial stylesheet and the eager icon registry.

The routes already declared `loadChildren`, but in the eager form - a function returning the module class - so Angular bundled every screen into the initial chunk and loaded it before the first paint. The only chunk that was ever split was `exceljs`, because it uses a dynamic import. Each route now returns a dynamic import, which is the whole change. ## Measured Production build, before and after: | | before | after | |---|---|---| | main | 8.96 MB | 3.50 MB | | initial total (raw) | 10.29 MB | 4.83 MB | | initial total (transfer) | 2.29 MB | 849.30 kB | And the lazy side, which is what a route now costs when it opens: ``` home-home-module 1.98 MB (the landing route: `/` redirects to home) viewer-viewer-module 1.06 MB + 809 kB + 731 kB + 111 kB editor-editor-module 687 kB licensing 61 kB review 51 kB deploy 41 kB system 16 kB stage 11 kB ``` So the login screen no longer downloads the grid, the Excel stack or the viewer, and a user who never opens the editor never fetches it. ## Why it is safe - No route module declares `providers`, so a lazy module cannot introduce a second instance of a service the shell already holds - the classic way this change breaks an app. - `ReviewRouteComponent` and `NotFoundComponent` stay eager: the first is a route's own shell component, the second is the wildcard. - `client/src/app/app.routing.ts` is the only file that changes; no component, service or template is touched. ## Verification - Production build compiles clean, with the chunk table above. - Karma, locally: 896 tests, all passing. - The Cypress suite runs on this pull request and navigates every route, which is the assertion that lazy loading did not leave a screen unreachable; the published Lighthouse table in the pull request will show what it does to the score. ## Note on where this leaves the bundle `home-home-module` is 1.98 MB and `home` is what `/` redirects to, so the landing route still pays most of a bundle's worth of cost - the grid and the viewer are out of it now, but the home screen itself is the next thing to look at once this lands and the audit number is read. The smaller items (501 icons, 503 `@sheet/crypto`, 505 CSS) are separate issues and mostly land inside these chunks now, so their effect on the initial load is smaller than the sizes suggest - except 501 and 505, which move bytes out of the *initial* stylesheet and the eager icon registry.
hermes added 1 commit 2026-11-07 16:17:06 +00:00
perf(client): lazy-load every routed module
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / lighthouse (pull_request) Canceled after 0s
f45dc92027
app.routing.ts already declared loadChildren, but as the eager form - a
function returning the module class - so Angular bundled every screen into
the initial chunk and loaded it before the first paint. Only exceljs, which
uses a dynamic import, was ever split.

Each route now returns a dynamic import, so webpack emits a chunk per screen:

                        before          after
  main                  8.96 MB         3.50 MB
  initial total        10.29 MB         4.83 MB
  initial transfer      2.29 MB       849.30 kB

The modules declare no providers, so a lazy module cannot introduce a second
instance of a service the shell already holds - the failure this change would
otherwise risk.
hermes added 1 commit 2026-11-07 16:18:37 +00:00
style(client): prettier the lazy route declarations
Build / Build-and-ng-test (pull_request) Successful in 3m36s
Build / Build-and-test-development (pull_request) Canceled after 4m5s
Build / lighthouse (pull_request) Canceled after 23s
e309cc17e7
hermes left a comment
Author
Collaborator

Reviewed client/src/app/app.routing.ts at e309cc17 (both commits: the lazy conversion and the prettier pass). Angular is 20.3, every dynamic import names a class the module exports, and nothing else under client/src imports these modules, so the split itself is clean. Build / Build-and-ng-test is green at this head; Build / Build-and-test-development and lighthouse were still running, so the emitted chunk layout is not verified from here.

  • client/src/app/home/home-routing.module.ts:15 - { path: 'excel-maps', loadChildren: () => XLMapModule }, and :16 { path: 'multi-load', loadChildren: () => MultiDatasetModule } keep the type form with the static imports at :5-6, so both modules stay in the home chunk. "every routed module" is not reached: convert them to () => import('./xlmap/xlmap.module').then((m) => m.XLMapModule) and the same for multi-dataset.

1 finding above for review.

Reviewed client/src/app/app.routing.ts at e309cc17 (both commits: the lazy conversion and the prettier pass). Angular is 20.3, every dynamic import names a class the module exports, and nothing else under client/src imports these modules, so the split itself is clean. Build / Build-and-ng-test is green at this head; Build / Build-and-test-development and lighthouse were still running, so the emitted chunk layout is not verified from here. - client/src/app/home/home-routing.module.ts:15 - `{ path: 'excel-maps', loadChildren: () => XLMapModule },` and :16 `{ path: 'multi-load', loadChildren: () => MultiDatasetModule }` keep the type form with the static imports at :5-6, so both modules stay in the home chunk. "every routed module" is not reached: convert them to `() => import('./xlmap/xlmap.module').then((m) => m.XLMapModule)` and the same for multi-dataset. 1 finding above for review.
hermes added 1 commit 2026-11-07 16:34:20 +00:00
perf(client): lazy-load the home children too
Build / Build-and-ng-test (pull_request) Successful in 3m30s
Build / lighthouse (pull_request) Successful in 6m27s
Build / Build-and-test-development (pull_request) Failing after 46m20s
cc43512e8f
Review found two more loadChildren in the eager form, in
home-routing.module.ts: excel-maps (XLMapModule) and multi-load
(MultiDatasetModule). They were inside the home chunk, so opening the app's
default screen /home/tables pulled both.

Both now return dynamic imports, so each gets its own chunk (47 kB apiece) and
the tables screen no longer pays for them. The initial total is unchanged -
home was already lazy - which is why the earlier measurement did not show
them.
Author
Collaborator

Right, and the claim in the title was too strong. Fixed at cc43512e: home-routing.module.ts now returns dynamic imports for excel-maps (XLMapModule) and multi-load (MultiDatasetModule), and the static imports are gone.

grep -rn "loadChildren" client/src | grep -v "import(" now returns only the wrapped loadChildren: () => lines, so "every routed module" is true at this head.

Measured after the change:

editor-editor-module                1.46 MB + 687 kB + 512 kB
viewer-viewer-module                1.06 MB + 809 kB + 720 kB + 123 kB
multi-dataset-multi-dataset-module  47 kB
xlmap (own chunk)                   47 kB
initial total                       4.83 MB  (unchanged)

The initial total does not move, which is why my earlier measurement missed them: home was already lazy, so those two only ever cost the landing route. What changes is that /home/tables - the redirect target for / - no longer pulls the Excel-map and multi-load screens.

Right, and the claim in the title was too strong. Fixed at cc43512e: `home-routing.module.ts` now returns dynamic imports for `excel-maps` (`XLMapModule`) and `multi-load` (`MultiDatasetModule`), and the static imports are gone. `grep -rn "loadChildren" client/src | grep -v "import("` now returns only the wrapped `loadChildren: () =>` lines, so "every routed module" is true at this head. Measured after the change: ``` editor-editor-module 1.46 MB + 687 kB + 512 kB viewer-viewer-module 1.06 MB + 809 kB + 720 kB + 123 kB multi-dataset-multi-dataset-module 47 kB xlmap (own chunk) 47 kB initial total 4.83 MB (unchanged) ``` The initial total does not move, which is why my earlier measurement missed them: home was already lazy, so those two only ever cost the landing route. What changes is that `/home/tables` - the redirect target for `/` - no longer pulls the Excel-map and multi-load screens.

Lighthouse

Route Performance Accessibility Best practices SEO
#/home/tables 0.59 0.93 1.00 1.00
#/editor/DC996664.MPE_X_TEST 0.35 0.96 1.00 1.00
#/view/data 0.56 0.92 1.00 1.00
#/view/data/DC996664 0.56 0.92 1.00 1.00
#/view/data/DC996664.MPE_X_TEST 0.52 0.92 1.00 1.00
#/view/usernav/groups 0.56 0.92 1.00 1.00
#/view/usernav/groups/1 0.56 0.92 1.00 1.00
#/view/usernav/users/1 0.56 0.92 1.00 1.00
#/home/excel-maps 0.57 0.92 1.00 1.00
#/home/excel-maps/BASEL-CR2 0.55 0.87 1.00 1.00
#/home/multi-load 0.57 0.90 1.00 1.00
#/review/submitted 0.57 0.91 1.00 1.00
#/review/approve 0.57 0.91 1.00 1.00
#/review/history 0.57 0.91 1.00 1.00
#/stage/DC20221006T142649516_059582_7169 0.57 0.96 1.00 0.92
#/review/submitted/DC20221006T142649516_059582_7169 0.57 0.91 1.00 1.00
#/system 0.59 0.93 1.00 1.00

Lowest accessibility score: 0.87 (the gate is a median of 0.85 while issue 497 is open).

Run: https://git.datacontroller.io/dc/dc/actions/runs/3441

<!-- lighthouse-report --> ### Lighthouse | Route | Performance | Accessibility | Best practices | SEO | |---|---|---|---|---| | `#/home/tables` | 0.59 | 0.93 | 1.00 | 1.00 | | `#/editor/DC996664.MPE_X_TEST` | 0.35 | 0.96 | 1.00 | 1.00 | | `#/view/data` | 0.56 | 0.92 | 1.00 | 1.00 | | `#/view/data/DC996664` | 0.56 | 0.92 | 1.00 | 1.00 | | `#/view/data/DC996664.MPE_X_TEST` | 0.52 | 0.92 | 1.00 | 1.00 | | `#/view/usernav/groups` | 0.56 | 0.92 | 1.00 | 1.00 | | `#/view/usernav/groups/1` | 0.56 | 0.92 | 1.00 | 1.00 | | `#/view/usernav/users/1` | 0.56 | 0.92 | 1.00 | 1.00 | | `#/home/excel-maps` | 0.57 | 0.92 | 1.00 | 1.00 | | `#/home/excel-maps/BASEL-CR2` | 0.55 | 0.87 | 1.00 | 1.00 | | `#/home/multi-load` | 0.57 | 0.90 | 1.00 | 1.00 | | `#/review/submitted` | 0.57 | 0.91 | 1.00 | 1.00 | | `#/review/approve` | 0.57 | 0.91 | 1.00 | 1.00 | | `#/review/history` | 0.57 | 0.91 | 1.00 | 1.00 | | `#/stage/DC20221006T142649516_059582_7169` | 0.57 | 0.96 | 1.00 | 0.92 | | `#/review/submitted/DC20221006T142649516_059582_7169` | 0.57 | 0.91 | 1.00 | 1.00 | | `#/system` | 0.59 | 0.93 | 1.00 | 1.00 | Lowest accessibility score: **0.87** (the gate is a median of 0.85 while issue 497 is open). Run: https://git.datacontroller.io/dc/dc/actions/runs/3441
hermes left a comment
Author
Collaborator

Re-review at cc43512e of the full base...head diff. Delta since e309cc17 is one commit ("lazy-load the home children too"): client/src/app/home/home-routing.module.ts now imports xlmap.module and multi-dataset.module dynamically and drops both static imports, so the earlier finding at that file no longer applies. No routed module is statically imported anywhere under client/src/app now - app.routing.ts and home-routing.module.ts are the only referrers and all ten are dynamic. The remaining cross-folder imports are type/util files (client/src/app/viewer/viewer.component.ts:42, client/src/app/models/CellValidationSource.ts:4, client/src/app/services/app.service.ts:8) and pull no module into the initial chunk. CI at this head: Build / Build-and-ng-test success, Build-and-test-development and Build / lighthouse still in progress - code review only.

No blocking issues.

Re-review at cc43512e of the full base...head diff. Delta since e309cc17 is one commit ("lazy-load the home children too"): client/src/app/home/home-routing.module.ts now imports xlmap.module and multi-dataset.module dynamically and drops both static imports, so the earlier finding at that file no longer applies. No routed module is statically imported anywhere under client/src/app now - app.routing.ts and home-routing.module.ts are the only referrers and all ten are dynamic. The remaining cross-folder imports are type/util files (client/src/app/viewer/viewer.component.ts:42, client/src/app/models/CellValidationSource.ts:4, client/src/app/services/app.service.ts:8) and pull no module into the initial chunk. CI at this head: Build / Build-and-ng-test success, Build-and-test-development and Build / lighthouse still in progress - code review only. No blocking issues.
allan merged commit 7d5f1e8505 into release/7.23 2026-11-07 16:53:08 +00:00
allan deleted branch perf/lazy-routes 2026-11-07 16:53:09 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dc/dc#508