SharedModule leaves the initial bundle

AppModule imported SharedModule, so every page loaded SharedModule's code before the router picked a route. The header, the sidebar, and the pieces they need are standalone now, and AppModule imports them directly. On the QA build the initial JavaScript drops from 10.09 MB to 6.86 MB, 31% less.

Pages aren't faster yet. 41 lazy routes still import SharedModule, so most pages fetch it anyway, just a little later. This branch moves SharedModule off the critical path. The per-page savings come when those routes stop importing it.

branch t3code/shared-module-app-shell base e6d7afd43c, the PR 10831 merge commits 4ea9a1b5ee, e774e58c88 17 files, +242 −64 PR 10832

What changed

Thirteen classes stopped being SharedModule declarations. Each one is standalone and lists its own imports.

ClassKindRendered by
HeaderComponentcomponentAppComponent
SidebarComponentcomponentAppComponent
HeaderUserMenuComponentcomponentthe header
UserImageComponent, UserProfileComponentcomponentthe user menu, the sidebar, and many feature pages
TickIconComponentcomponentthe user profile card
CreatorRequirementsModalComponentmodalthe header, the user menu, and the sidebar
VerifyOTPModalComponentmodalthe user menu, for the delegate switch
ModalExtentiondirectivethe user menu
HasPermissionPipe, ShowTabByApprovalPipe, MyProfile, TimerFormatpipethe header, the sidebar, and the OTP modal

SharedModule imports all 13 and exports the same list it did before. The feature modules that import SharedModule compile without changes. SharedModule now has 133 declarations, down from 146.

AppModule imports the five things its own template uses. Those are HeaderComponent, SidebarComponent, FooterComponent, LanguageSwitcherComponent, and SpaLinkDirective. I also removed exports: [SharedModule]. Nothing imports AppModule, so that export did nothing.

The root providers stay at the root

Importing SharedModule into AppModule had a side effect. Every provider SharedModule listed, plus the providers of the modules it imports, ended up in the root injector. ngx-bootstrap's BsModalService builds modal content with the root injector, not the injector of the page that opened the modal. So a modal opened from a lazy page could inject CurrencyToLocal or the ng-select config only because AppModule imported SharedModule. Dropping the import without a replacement would break those modals at runtime, and the build wouldn't catch it.

The new file shared-root.providers.ts keeps them in place. AppModule spreads provideSharedRootProviders() into its providers, which returns:

  • The 11 providers SharedModule listed itself, such as DatePipe, MyProfile, and MAT_BOTTOM_SHEET_DATA.
  • PhoneMaskDirective and SsnMaskDirective, which come from DynamicFormBuilderModule.
  • The root providers of eight library modules, through importProvidersFrom. Those are ng-select, the multiselect dropdown, owl carousel, the OTP input, the color picker, circle progress, and the two share-button modules.
  • An environment initializer that registers the X and SMS Font Awesome icons. SharedModule's constructor did this before, and it still does when a lazy page loads SharedModule.

SharedModule reads its provider list, icon list, and share-button options from the same file, so the two lists can't drift apart.

To check that nothing fell off, I loaded each build's server bundle in Node, built the root injector from AppModule, and listed every token it can resolve. On base, 25 tokens reached the root only through SharedModule. The new build resolves all 25. Among them are CircleProgressOptions, ColorPickerService, shareButtonsConfig, WindowRef, OwlLogger, and ResizeService.

The music player import

After the first pass, SharedModule was still in the initial bundle. AppModule renders MinimizedPlayerComponent, and that file imported MusicPlayerModalComponent at the top. The modal's file imports SharedModule, so esbuild kept SharedModule in main's static import graph.

maximizeModal() now loads the modal with a dynamic import when the user taps maximize. dashboard-module.service.ts already opens modals this way. The first maximize waits for one chunk. After that, the browser has it cached.

The first version of this had a race, which the astra review caught. The player stays clickable while the chunk loads. Two taps opened two modals, and closing the player during the wait still opened the modal a moment later. Commit e774e58c88 adds a guard:

async maximizeModal() {
  if (!this.currentMusic || !this.initialState || this.pendingMaximize) return;

  const request = {};
  const trackKey = this.trackKey;
  this.pendingMaximize = request;
  try {
    const { MusicPlayerModalComponent } = await import(
      '@components/player-modal/music-player-modal/music-player-modal.component'
    );
    if (
      this.pendingMaximize !== request ||
      !this.isMinimized ||
      this.trackKey !== trackKey
    ) {
      return;
    }
    this.openMaximizedPlayer(MusicPlayerModalComponent);
  } finally {
    if (this.pendingMaximize === request) this.pendingMaximize = null;
  }
}

Taps during the load do nothing. Closing the player clears the token, and so does ngOnDestroy. A track change leaves the token alone but fails the track check. If the chunk fails to load, the audio was never paused, so the track keeps playing, and the finally lets the next tap try again. The modal type comes in through import type, which the compiler erases, so it adds no static import.

I bundled the real component with esbuild, once from 4ea9a1b5ee and once with the guard. The service is the real MinimizedPlayerService. The modal import points at a stub module that waits on a promise the test releases.

While the chunk loads4ea9a1b5eeWith the guard
Tap maximize twice2 modals1 modal
Close the player1 modalnone
Change track1 modalnone
Destroy the component1 modalnone
Chunk fails to loadnone, audio keeps playingnone, audio keeps playing, guard released
Tap once1 modal1 modal

The repo has no spec files, because commit 4ffa00b728 deleted them all, so this check lives outside the repo. It exercises the component's code, not playback in a browser.

How it was tested

Builds

I built base from e6d7afd43c in a separate worktree and this branch in the main checkout. The development and QA builds pass on both sides. The development build has no warnings. The QA build has 42 on this branch, and all of them are on base too. 41 say a dependency such as lodash or video.js isn't ESM, and one says two CSS rules were skipped. Base has one more, because its initial bundle misses the 10 MB budget at 12.37 MB with CSS. This branch meets the budget.

After the maximize fix, ngc -p tsconfig.app.json --noEmit passes, and a fresh QA build gives the same 6.86 MB initial load. main grew by 418 bytes.

Bundle size

I rebuilt both sides with --stats-json and walked the static imports from main to find the initial files.

QA buildBaseThis branch
Initial JavaScript files1616
Initial JavaScript bytes10,579,1767,196,975, −32%
Initial JavaScript, gzip2.33 MB1.61 MB, −31%
main bytes287,458287,572
modules/shared source in the initial load1,599 KB309 KB
SharedModule's chunkinside a 6.92 MB initial chunk3.14 MB, lazy

No static import path from main reaches shared.module.ts now. What's left in the initial load is mostly libraries. The biggest are moment-timezone at 715 KB, google-libphonenumber at 567 KB, video.js at 546 KB, ng-ivs at 539 KB, and the IVS player at 351 KB. The generated marketing and comparison data files add 285 KB and 252 KB.

Server-rendered HTML

I ran the SSR server from each build and requested 32 URLs on both. First I diffed base against itself, to be sure the comparison had no noise. Then I replaced chunk hashes and Angular's style IDs with placeholders and diffed base against the branch. All 32 match.

The URLs were /, /home, /creators-home, /athletes, /music-artists, /topfan-vs-patreon, /apps/music-band-fan-club, /about, /pricing, /privacy-policy, /terms-of-service, /contact-us, /request-demo, /help-faq, /explore-creators, /resources, /forum, three /auth pages, /dashboard/posts, /edit-preference, /wallet, /monetize, /messages, /insights, /order-status, /content, /not-found, an unknown URL that returns 404, /creators/login, and /share/abc123.

The server renders the header and the footer. The sidebar only renders in the browser. Both match base. The creator profile /triplespurband differs by 60 raw bytes, and all 60 are three component IDs. Angular hashes the standalone flag into those IDs, so they change when a component goes standalone. The server and the browser run the same build, so hydration still lines up.

In the browser

I drove both builds logged out with Playwright and Chrome, at 1280 by 800 and at 390 by 844.

  • The header, footer, and sidebar looked the same on every page I opened, and the Features dropdown opened.
  • On /badbunny, the sidebar showed the guest sign-in cards on both builds.
  • On /badbunny, clicking FOLLOW and then CHAT ROOM opened the "Account Required" login dialog on both builds. That dialog goes through BsModalService, so it's a live test of the root injector.
  • The new build logged no NG0 errors, no NullInjectorError, and no uncaught page errors.

Profile pages on the QA backend sometimes returned 429 or 504. That happened on both builds, and I retried until they loaded.

Load timing

Most pages still import SharedModule, so I checked whether moving it out of the initial load made anything slower. First, the compressed JavaScript each page downloads, logged out, on a local server:

RouteBaseThis branch
/2,502 KB2,490 KB
/about2,347 KB2,334 KB
/auth/login2,391 KB2,379 KB
/music-artists2,492 KB2,479 KB
/not-found2,370 KB2,358 KB

Every page saves about 12 KB, which is noise. What changed is when the SharedModule chunk starts. On base it's in the preload list and downloads alongside main. On this branch the browser asks for it once the router resolves the route. On /about that was 1.3 seconds later on my machine.

So I reran with Lighthouse's mobile throttling, which is 150 ms of latency, 1.6 Mbps down, and a CPU slowed down 4 times. Each number is the median of three loads.

RouteMeasureBaseThis branch
/aboutFirst paint984 ms1,084 ms
/aboutMain thread goes quiet18.6 s18.7 s
/aboutTotal blocking time1,161 ms999 ms
/First paint2,384 ms2,484 ms
/Main thread goes quiet32.5 s31.1 s
/Total blocking time1,425 ms1,174 ms

I'd call it a wash. Blocking time is 160 to 250 ms lower, most likely because the browser parses less code before the first route. The later chunk request gives some of that back. First paint was 100 ms later on this branch under throttling, but the same or earlier in the unthrottled runs, and the server sends the same HTML on both. Neither result is a regression, and neither is a speedup worth reporting.

Affected areas

I tested logged out. Most of what changed renders for logged-in users, so the risk tags lean on what I couldn't click.

AreaRiskWhy
Header user menu Medium Hand-written import list of 13 entries. The delegate switch and its OTP modal only appear when logged in, and I never opened them.
Modals opened from lazy pages Medium Their dependencies come from the root providers, which moved to a new file. The token diff is clean, but I only opened the login dialog.
Sidebar when logged in Medium Permission and approval pipes decide which nav items show. I only saw the guest view.
Minimized music player Low Maximize waits for a chunk download the first time. The guard against double taps and closing mid-load is checked outside the browser only.
Public pages and SSR Low 32 server-rendered URLs match base.

Verification checklist

Use a QA creator account that has a delegate. Keep the console open the whole time. Checks are saved in this browser.

0 of 0 checked

Medium Header and user menu

Medium Sidebar

Medium Modals and root providers on lazy pages

Low Music player

Every page

Not tested

  • Anything behind login, including the user menu, the delegate switch, and the logged-in sidebar.
  • The music player maximize, which needs a track playing.
  • Modals other than the login dialog.
  • The production build configuration, a white-label build, and a prerender run.

What's next

SharedModule still loads on almost every page. 41 lazy entry points import it, including /about, /pricing, /privacy-policy, the 404 page, and the marketing and comparison pages. Most of these are one-component pages that use a handful of SharedModule's 133 declarations. Swapping each one's SharedModule import for the few pieces it uses is the change that shrinks downloads, and each page can go in its own small PR.

The five large modules come after that, as the plan already says. They're edit-preference, profile, monetize, dashboard, and wallet.