What changed
Thirteen classes stopped being SharedModule declarations. Each one is standalone and lists its own imports.
| Class | Kind | Rendered by |
|---|---|---|
HeaderComponent | component | AppComponent |
SidebarComponent | component | AppComponent |
HeaderUserMenuComponent | component | the header |
UserImageComponent, UserProfileComponent | component | the user menu, the sidebar, and many feature pages |
TickIconComponent | component | the user profile card |
CreatorRequirementsModalComponent | modal | the header, the user menu, and the sidebar |
VerifyOTPModalComponent | modal | the user menu, for the delegate switch |
ModalExtention | directive | the user menu |
HasPermissionPipe, ShowTabByApprovalPipe, MyProfile, TimerFormat | pipe | the 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, andMAT_BOTTOM_SHEET_DATA. PhoneMaskDirectiveandSsnMaskDirective, 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 loads | 4ea9a1b5ee | With the guard |
|---|---|---|
| Tap maximize twice | 2 modals | 1 modal |
| Close the player | 1 modal | none |
| Change track | 1 modal | none |
| Destroy the component | 1 modal | none |
| Chunk fails to load | none, audio keeps playing | none, audio keeps playing, guard released |
| Tap once | 1 modal | 1 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 build | Base | This branch |
|---|---|---|
| Initial JavaScript files | 16 | 16 |
| Initial JavaScript bytes | 10,579,176 | 7,196,975, −32% |
| Initial JavaScript, gzip | 2.33 MB | 1.61 MB, −31% |
main bytes | 287,458 | 287,572 |
modules/shared source in the initial load | 1,599 KB | 309 KB |
| SharedModule's chunk | inside a 6.92 MB initial chunk | 3.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 throughBsModalService, so it's a live test of the root injector. - The new build logged no
NG0errors, noNullInjectorError, 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:
| Route | Base | This branch |
|---|---|---|
/ | 2,502 KB | 2,490 KB |
/about | 2,347 KB | 2,334 KB |
/auth/login | 2,391 KB | 2,379 KB |
/music-artists | 2,492 KB | 2,479 KB |
/not-found | 2,370 KB | 2,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.
| Route | Measure | Base | This branch |
|---|---|---|---|
/about | First paint | 984 ms | 1,084 ms |
/about | Main thread goes quiet | 18.6 s | 18.7 s |
/about | Total blocking time | 1,161 ms | 999 ms |
/ | First paint | 2,384 ms | 2,484 ms |
/ | Main thread goes quiet | 32.5 s | 31.1 s |
/ | Total blocking time | 1,425 ms | 1,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.
| Area | Risk | Why |
|---|---|---|
| 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.
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.