PR 10832: SharedModule out of the initial bundle, reviewed

PR 10832 stops AppModule from importing SharedModule. Thirteen shell classes go standalone, AppModule imports the five its template uses, and shared-root.providers.ts keeps the root injector's provider list intact. One commit, 17 files, +212 / −62. The author's write-up is thorough, so I spent my time trying to break its claims: I built base and head from clean worktrees, audited the providers from compiled library code, diffed server output for 19 URLs, and drove both builds in headless Chrome.

Verdict

Merge it. Every number I reproduced landed within a percent of the write-up. Initial JavaScript drops 32%, the server sends identical HTML, the login dialog still opens on the new root injector, and the provider list matches what I found by reading every module SharedModule imports. It also clears a bundle budget warning the base branch had. My one addition is about what the shell still drags in: one @ContentChild query in the header layout keeps 1.2 MB of video player code on the critical path.

reviewed 2026-09-23 base e6d7afd43c head 4ea9a1b5ee Angular 21.2.23

What changed

AppComponent's template could only see app-header and app-sidebar through SharedModule, and that import put 147 declarations and 83 module imports in main's static graph. Now the header, sidebar, user menu, two modals, a directive, and four pipes are standalone. SharedModule imports and re-exports them, so the 41 lazy modules that use it don't change. MinimizedPlayerComponent loads the music player modal with a dynamic import, because that modal imports SharedModule.

Findings

correct

The import lists match the templates

I pulled every tag, pipe, and directive out of the eight converted templates and checked each against its component's imports. All resolve. Standalone components have no CUSTOM_ELEMENTS_SCHEMA to hide a missing element, and Angular warns on unused imports. The head build prints neither.

correct

The root providers are all there

This is the part that breaks at runtime without the compiler noticing, so I checked it myself. I read the compiled injector of every module SharedModule imports. Six carry providers: NgSelectModule, NgMultiSelectDropDownModule, CarouselModule, NgOtpInputModule, ColorPickerModule, and NgbModalModule (root-provided anyway). Two more provide through forRoot and withConfig, and ShareIconsModule's constructor registers icons. All are in provideSharedRootProviders(). The plain ngx-bootstrap 21 modules declare nothing. DynamicFormBuilderModule's other two providers are providedIn: 'root'. No token fell off.

correct

The music player dynamic import is safe

MusicPlayerModalComponent imports SharedModule, so any static reference would have pulled it back into main. It now lands in a 68 KB lazy chunk. The await runs before any state changes, so a failed load leaves the player as it was. Two fast taps before the chunk arrives would stack two modals; first open only, not worth holding the PR.

suggest

The header keeps 1.2 MB of video code in the initial bundle

The write-up says what remains is "mostly libraries" and names ng-ivs, the IVS player, and aws-sdk. I walked the head build's static import graph to see why a logged-out visitor on /about downloads a live-stream player. Most of it comes through one edge:

  • app.module.ts
  • header/header.component.ts
  • header-layout/header-layout.component.ts
  • welcome-video-modal/welcome-video-modal.component.ts
  • video-player-v2.component.ts → ng-ivs 551 KB, amazon-ivs-player 360 KB
  • comments.component.ts → upload.service.ts → aws-sdk 349 KB

HeaderLayoutComponent imports the modal's class for one @ContentChild(CustomWelcomeModalTemplate) query. Using the class as the token makes its whole compiled dependency tree part of the header. Remove that edge and 88 files, about 1,216,000 bytes, go lazy. That's a third of what this PR saved, from a one-file change. Query by a token instead:

// welcome-video-modal.token.ts
export const WELCOME_VIDEO_MODAL = new InjectionToken<WelcomeVideoModal>('WELCOME_VIDEO_MODAL');

// welcome-video-modal.component.ts
providers: [{ provide: WELCOME_VIDEO_MODAL, useExisting: CustomWelcomeModalTemplate }]

// header-layout.component.ts
@ContentChild(WELCOME_VIDEO_MODAL) welcomeVideoModal?: WelcomeVideoModal;

Pre-existing, but before this PR the header's imports didn't matter. Now they decide what a first visit downloads. I'd do this before the per-route SharedModule removals, since it helps every route at once.

suggest

video.js rides in on the minimized player

MinimizedPlayerComponent imports MediaService, which imports video.js at the top of the file. That edge holds 622 KB in the initial bundle. The minimized player plays audio; a dynamic import where MediaService creates a video player would leave the audio path alone.

note

Two preserved tokens are dead

PhoneMaskDirective and SsnMaskDirective are provided as services because DynamicFormBuilderModule did. Nothing injects either; four components reach the first through @ViewChild, which skips the injector. Cost is about 1 KB. Right call for a parity PR, drop them in the next cleanup.

note

The base branch was over budget

The PR says both sides build with no warnings. On a clean base worktree the QA build prints bundle initial exceeded maximum budget, 12.31 MB against 10 MB. Head is 8.92 MB and silent. The PR fixes a warning it doesn't claim; the author's build likely had different generated marketing data.

pre-existing

Lint

Two errors on head: [modal-extention] isn't camelCase, and UserTimeZone, a second pipe in timer-format.pipe.ts, still says standalone: false. Both predate the PR. The same files on base had 15 errors; the PR removed 13 of them.

note

Where my numbers differ

I get 10,481,271 and 7,099,070 initial bytes against the write-up's 10,579,176 and 7,196,975. The gap is the polyfills file, which isn't in main's import graph. Same −32%. I count 147 declarations, not 146, because CurrencyConversionModalComponent is listed twice. Harmless and not from this PR.

What I ran

Two detached worktrees at e6d7afd43c and 4ea9a1b5ee, shared node_modules, committed data files. ng build --configuration=qa --prerender=false --stats-json took 49 seconds each. Initial set is what main reaches by static import.

MeasureBaseHeadChange
Angular's initial total12.31 MB8.92 MB−3.39 MB
Estimated transfer1.98 MB1.45 MB−27%
Initial JS, static graph10,481,2717,099,070−32.3%
main raw bytes287,458287,572+114
src/ files in initial487258−229
SharedModule's chunk7.27 MB, initial3.30 MB, lazy
Budget warningyesno
Preloaded JS on /about, gzip1,865,9031,116,620−40%

Twelve packages left the initial bundle, the largest being ngx-emoji-mart at 437 KB. A full page still downloads about the same total (10.57 MB base, 10.50 MB head on /about) because the route chunk imports SharedModule a moment later. The write-up says this too.

I served both builds and fetched 19 URLs. After normalizing chunk hashes and component ids, every pair is byte-identical.

  • /, /home, /creators-home200same
  • /about, /pricing, /help-faq, /contact-us200same
  • /auth/login, /explore-creators, /topfan-vs-patreon200same
  • /dashboard/posts, /edit-preference, /wallet, /messages200logged-out shell, same
  • /triplespurband, /badbunny, /share/abc123200same
  • /not-found404same
  • /this-does-not-exist/x200app-sub-page-view, same as base

Headless Chrome, logged out, 1280 by 800: both builds render the same shell on /about and /badbunny, hydrated. FOLLOW on /badbunny opened the "Account Required" dialog on both, which goes through BsModalService and the new root providers. No Angular errors.

Not run: anything logged in, the music player maximize, the dev build, white-label builds, prerender.

QA after merge

The write-up's checklist covers it. If there's time for three items, these touch paths neither of us reached logged out:

  1. Log in as a creator with a delegate, switch to the delegate, and complete the OTP modal.
  2. On a fan account that doesn't qualify, open "Become a creator" from the user menu and the sidebar.
  3. Play a track, minimize, maximize. Then tap maximize twice quickly on a throttled connection and confirm one modal opens.