From 8581bddcaff5d8f60b869a2f6c700c3b4d6c2dda Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:19:34 +0200 Subject: [PATCH] perf(web): keep the NgRx store devtools out of production bundles (#1810) * perf(web): keep the NgRx store devtools out of production bundles app.config.ts imported @ngrx/store-devtools statically and gated it on AppConfig.production at runtime, so the optimizer kept the module in main.js for the production, PWA and performance builds. The providers now come from environments/store-devtools.providers.ts, an empty list in every build; only the development and electron-e2e configurations swap in the devtools through fileReplacements. A build-config test keeps it that way. renderer.initialBytes: 1,608,610 -> 1,596,045 bytes (-12,565) on a local production build; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 * perf(web): cite #1810 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance-devtools-out-of-production.md | 7 +++ .../performance-build-config.spec.ts | 53 +++++++++++++++++++ apps/web/project.json | 20 ++++++- apps/web/src/app/app.config.ts | 5 +- .../store-devtools.providers.dev.ts | 8 +++ .../environments/store-devtools.providers.ts | 7 +++ docs/architecture/performance-journeys.md | 11 ++++ tools/performance/journey-baselines.json | 6 +-- 8 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 .changes/performance-devtools-out-of-production.md create mode 100644 apps/web/src/environments/store-devtools.providers.dev.ts create mode 100644 apps/web/src/environments/store-devtools.providers.ts diff --git a/.changes/performance-devtools-out-of-production.md b/.changes/performance-devtools-out-of-production.md new file mode 100644 index 000000000..a97b69599 --- /dev/null +++ b/.changes/performance-devtools-out-of-production.md @@ -0,0 +1,7 @@ +--- +type: perf +area: performance +--- + +The app loads slightly less code at startup: a developer-only state +inspection tool is no longer shipped in the desktop and web builds. diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 91a8701f4..1bf9822be 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -177,6 +177,59 @@ test('only the web performance build installs the tick counter the journeys read } }); +// @ngrx/store-devtools must not reach the production, PWA or performance +// bundles (renderer.initialBytes): the default providers file is empty and +// only the development configurations swap in the devtools. +test('only development web builds provide the NgRx store devtools', () => { + const environments = join(workspaceRoot, 'apps/web/src/environments'); + const devtoolsReplacement = { + replace: 'apps/web/src/environments/store-devtools.providers.ts', + with: 'apps/web/src/environments/store-devtools.providers.dev.ts', + }; + // electron-e2e-zoneless is electron-e2e plus the zoneless swap. + const developmentConfigurations = new Set([ + 'development', + 'electron-e2e', + 'electron-e2e-zoneless', + ]); + + assert.match( + readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'), + /export const storeDevtoolsProviders: EnvironmentProviders\[\] = \[\];/ + ); + // The replacement must still provide them, or development builds would + // lose the devtools while this test passed. + assert.match( + readFileSync( + join(environments, 'store-devtools.providers.dev.ts'), + 'utf8' + ), + /storeDevtoolsProviders: EnvironmentProviders\[\] = \[\s*provideStoreDevtools\(/ + ); + assert.doesNotMatch( + readFileSync( + join(workspaceRoot, 'apps/web/src/app/app.config.ts'), + 'utf8' + ), + /@ngrx\/store-devtools/ + ); + for (const [name, configuration] of Object.entries( + webProject.targets['build'].configurations ?? {} + )) { + const replacements = (configuration['fileReplacements'] ?? + []) as unknown[]; + if (developmentConfigurations.has(name)) { + assert.deepEqual(replacements[0], devtoolsReplacement, name); + } else { + assert.doesNotMatch( + JSON.stringify(replacements), + /store-devtools/, + name + ); + } + } +}); + // Plan item C6 measures zoneless change detection behind a build-time flag: // each *-zoneless configuration is its base configuration plus one swap of // the change-detection providers, and nothing else selects that swap. diff --git a/apps/web/project.json b/apps/web/project.json index 74c415311..6de9984ff 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -120,7 +120,13 @@ "serviceWorker": false, "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/store-devtools.providers.ts", + "with": "apps/web/src/environments/store-devtools.providers.dev.ts" + } + ] }, "electron-performance": { "baseHref": "./", @@ -173,7 +179,13 @@ "serviceWorker": false, "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/store-devtools.providers.ts", + "with": "apps/web/src/environments/store-devtools.providers.dev.ts" + } + ] }, "electron-e2e-zoneless": { "baseHref": "./", @@ -182,6 +194,10 @@ "extractLicenses": false, "sourceMap": true, "fileReplacements": [ + { + "replace": "apps/web/src/environments/store-devtools.providers.ts", + "with": "apps/web/src/environments/store-devtools.providers.dev.ts" + }, { "replace": "apps/web/src/environments/change-detection.providers.ts", "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index edd657996..6a5b072bb 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -16,7 +16,6 @@ import { provideServiceWorker } from '@angular/service-worker'; import { provideEffects } from '@ngrx/effects'; import { provideRouterStore, routerReducer } from '@ngrx/router-store'; import { provideStore } from '@ngrx/store'; -import { provideStoreDevtools } from '@ngrx/store-devtools'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { PlaylistEffects, playlistReducer } from '@iptvnator/m3u-state'; @@ -33,8 +32,8 @@ import { DataService, } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; -import { AppConfig } from '../environments/environment'; import { changeDetectionProviders } from '../environments/change-detection.providers'; +import { storeDevtoolsProviders } from '../environments/store-devtools.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; @@ -122,7 +121,7 @@ export const appConfig: ApplicationConfig = { }), provideEffects([PlaylistEffects]), provideRouterStore(), - ...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]), + ...storeDevtoolsProviders, provideServiceWorker('ngsw-worker.js', { enabled: shouldEnableServiceWorker(), registrationStrategy: 'registerWhenStable:30000', diff --git a/apps/web/src/environments/store-devtools.providers.dev.ts b/apps/web/src/environments/store-devtools.providers.dev.ts new file mode 100644 index 000000000..4776bc9f5 --- /dev/null +++ b/apps/web/src/environments/store-devtools.providers.dev.ts @@ -0,0 +1,8 @@ +import { EnvironmentProviders } from '@angular/core'; +import { provideStoreDevtools } from '@ngrx/store-devtools'; + +// Swapped in for store-devtools.providers.ts by the development, +// electron-e2e and electron-e2e-zoneless build configurations only. +export const storeDevtoolsProviders: EnvironmentProviders[] = [ + provideStoreDevtools({ maxAge: 25 }), +]; diff --git a/apps/web/src/environments/store-devtools.providers.ts b/apps/web/src/environments/store-devtools.providers.ts new file mode 100644 index 000000000..3925820db --- /dev/null +++ b/apps/web/src/environments/store-devtools.providers.ts @@ -0,0 +1,7 @@ +import { EnvironmentProviders } from '@angular/core'; + +// Every build ships without the NgRx store devtools. The development and +// electron-e2e configurations replace this file with +// store-devtools.providers.dev.ts, so @ngrx/store-devtools never enters the +// production, PWA or performance bundles. +export const storeDevtoolsProviders: EnvironmentProviders[] = []; diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 3071a139f..4312a6aae 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1066,6 +1066,17 @@ pnpm --silent run perf:initial-bytes -- --json # machine-readable; --silent ke node tools/performance/measure-initial-bytes.mjs --summary dist/performance/journey-summary.json ``` +Development-only code must stay out of the counter by construction, not by a +runtime flag: `if (!AppConfig.production)` keeps the imported module in +`main.js` because the optimizer does not fold the property read. The NgRx +store devtools therefore come from +`apps/web/src/environments/store-devtools.providers.ts`, an empty list in +every build, which only the `development`, `electron-e2e` and +`electron-e2e-zoneless` configurations replace with +`store-devtools.providers.dev.ts`; a build-config test in +`performance-build-config.spec.ts` keeps it that way. Removing the static +import lowered the counter by 12,565 bytes. + `--summary` writes the journey summary shape (`journeys..counters`) that the ratchet checker consumes. `--dist ` points the script at another build output, for example the `electron-performance` configuration. diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 6b5cd01ee..9728f8097 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1605046, + "value": 1595777, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-01", - "evidencePr": 1775, + "updatedAt": "2026-10-07", + "evidencePr": 1810, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": {