PR 10823 and 10824: hydration and provideRouter review

Two pull requests, opened thirteen seconds apart from the same base commit, each enable Angular 21 client hydration with event replay and replace RouterModule.forRoot with provideRouter while converting eleven class guards to functions. They touch the same 23 files and are alternatives, not a stack. This review compares them line by line and checks the parts of the migration that the diff alone does not show.

Verdict

Merge PR 10824 after two small fixes: replace the deprecated ENVIRONMENT_INITIALIZER token with provideEnvironmentInitializer, and decide explicitly about the HTTP transfer cache that provideClientHydration() switches on. Close PR 10823 as superseded. Both compile cleanly and preserve guard behaviour; 10824 makes better structural choices and its defects are one-line changes, while 10823 introduces a layering inversion that would need a real rework.

reviewed 2026-09-22 base 0accd16451 10823 head 8d4761ac72 (+614 / -615) 10824 head 7c66c9e741 (+531 / -626) Angular 21.2.23

What both PRs change

Each PR has two commits. The first adds provideClientHydration(withEventReplay()) to AppModule providers, which the server module inherits because AppServerModule imports AppModule. The second removes RouterModule.forRoot(routes, {...}) and rebuilds the same configuration with provideRouter plus withComponentInputBinding, withPreloading(NoPreloading), withInMemoryScrolling, and withRouterConfig. The initialNavigation: 'enabledBlocking' option is dropped. The eleven guards in src/app/core and src/app/services become exported functions that call inject(), and every canActivate and canDeactivate array in the routing modules is updated to reference them. SocialAuthService stops injecting CreatorApprovalGuard and calls the extracted getBlockedTabRedirectPath helper instead.

Findings that apply to both PRs

fix

Hydration silently turns on Angular's HTTP transfer cache, on top of the app's own

provideClientHydration() includes withHttpTransferCache({}) unless withNoHttpTransferCache() or withHttpTransferCacheOptions() is passed (@angular/platform-browser, provideClientHydration body). Neither PR passes either. From now on every GET or HEAD request made during server rendering, without an Authorization header, is serialized into the ng-state script and replayed on the client's first matching request.

The codebase already does this by hand: src/app/modules/shared/services/cache.service.ts writes API responses into TransferState on the server and reads them back in the browser. With both layers active, public SSR pages such as the homepage, creator profiles, and post detail will carry every API response twice in the HTML payload, and two caches with different keys and lifetimes will answer the same requests.

  • Either add withNoHttpTransferCache() and keep CacheService, or keep the built-in cache and remove the custom TransferState writes in a follow-up. The PR should state which.
  • Check the size of <script id="ng-state"> on / and on a creator profile before and after.
note

withComponentInputBinding() is a behaviour change unrelated to hydration

It binds route params, query params, resolved data, and static data keys to any @Input() of the same name on the routed component. With paramsInheritanceStrategy: 'always' also set, parent params flow down too. A scan of the 181 routed components found five whose inputs collide with route param names: CustomerInformationComponent, MerchandiseDetailComponent, MerchandiseDetailsComponent, and OrderPaymentComponent all declare @Input() redirect and sit on routes with a :redirect segment; MerchandiseDetailsComponent also has @Input() type on a :type route. Each of them already does this.redirect = this.redirect || snapshot.params.redirect, so the bound value is the one they would have read anyway and no regression is expected.

LeaderboardComponent has @Input() userId and reads :id from the route; a stray ?userId= query string would now overwrite it. Low risk, but the feature is not needed for this migration and neither PR description explains why it was added. Keep it if the audit asked for it, and mention it in the QA plan.

correct

Dropping enabledBlocking is required, not optional

Angular 21 ships a dev-mode detector inside provideClientHydration that warns with NG05001 when hydration and enabledBlocking initial navigation are both present. Both PRs remove the option and rely on the default non-blocking navigation. Server rendering still waits for the router because navigation registers a pending task, so SSR output is unchanged.

correct

The 64px scroll offset survives the migration

withInMemoryScrolling accepts only anchorScrolling and scrollPositionRestoration; the old scrollOffset: [0, 64] has no provideRouter equivalent. Both PRs noticed and call ViewportScroller.setOffset([0, 64]) from an environment initializer, which is what RouterModule.forRoot did internally. They differ in which API they use for that initializer; see below.

assessed

Hydration mismatch risk is lower than the template count suggests

234 templates branch on isBrowser. That looks alarming for hydration, but almost all of them live behind authGuard, which returns false on the server, so those routes never render on the server and never hydrate. Of the modules that do SSR (promotional, profile, post-detail, public, global) fifteen templates branch on cs.isBrowser or platformService.isBrowser. Angular tolerates an *ngIf that is false on the server and true on the client: it creates the view fresh and removes unclaimed server views afterwards, without throwing. The failure mode that does throw is DOM mutated inside Angular-rendered elements before hydration finishes; the four occurrences in SSR-rendered code all append <script> tags to document.body, outside the root component. No template uses i18n, so withI18nSupport() is not needed. Neither PR adds ngSkipHydration, and on this evidence none is required. The QA list below still exercises the public routes.

note

No specs cover the migrated guards

None of the eleven guards has a test before or after. The conversions were verified by reading each branch side by side with the class version; every condition, redirect target, and early return is preserved in both PRs. Role-based routes still need a manual pass.

Where the two PRs differ

Only 14 files differ between the two heads. The routing module edits in auth, dashboard, portfolio, edit-preference, monetize, promotional, wallet, and messages are byte-identical, as is the SocialAuthService change.

Aspect PR 10823 PR 10824
Environment initializer for the scroll offset provideEnvironmentInitializer(() => ...), the current API. { provide: ENVIRONMENT_INITIALIZER, multi: true, useValue }. The token is marked @deprecated from v19.0.0 in @angular/core types.
Where the router is configured Inline in app.module.ts, which now imports seven router symbols and ViewportScroller. Routes and their options live in different files. appRouterProviders exported from app-routing.module.ts and spread into providers. Routing stays in one place.
Mutable guard state (deferredCheckActive) Two root-injected *GuardState classes. Per-injector, so per-request on the server, and resettable in tests. Verbose: the deferred check helper takes seven positional arguments. Module-level let deferredCheckActive in two files. Shared across every SSR request in the Node process and not resettable between tests. Not a live bug today because the flag is only written behind an isBrowser check, but it is hidden global state.
Permission guard data source Keeps a PermissionGuardState service that subscribes to the store forever and mirrors it into a BehaviorSubject, copied from the old class. Pipes store.select(getUserProfileItems) directly. NgRx selectors emit synchronously from a BehaviorSubject, so the guard still resolves on the current value. The lingering subscription and the duplicate state are gone.
Unsaved-changes confirmation text Added to MESSAGES in @modules/messages/constants. The global src/app/services/can-deactivate-guard.service.ts now imports a feature module constant, inverting the dependency direction. New CAN_DEACTIVATE_CONSTANTS (as const) in @shared/constants/confirmation.constant.ts, matching the repository's constants convention.
Guard typing Every guard is annotated CanActivateFn or CanDeactivateFn<ComponentCanDeactivate>, so a wrong signature fails to compile. Plain arrow functions with explicit parameter types. Inferred return types are compatible with the router, but the contract is not stated.
Guard helper structure Separate evaluateApproval, deferredApprovalCheck, and tabIsVisible functions. One shouldShowTabForProfile helper and the deferred check inlined. Shorter and reads top to bottom.
Net size -1 line -95 lines

Requested changes on PR 10824 before merge

  1. Use the current initializer API. In src/app/app-routing.module.ts, replace the deprecated token with the function Angular introduced in v19. Import provideEnvironmentInitializer from @angular/core and drop ENVIRONMENT_INITIALIZER.

    -  {
    -    provide: ENVIRONMENT_INITIALIZER,
    -    multi: true,
    -    useValue: () => inject(ViewportScroller).setOffset([0, 64])
    -  }
    +  provideEnvironmentInitializer(() => {
    +    inject(ViewportScroller).setOffset([0, 64]);
    +  })
  2. Decide about the HTTP transfer cache. The smallest safe change keeps the existing CacheService as the only server-to-client cache. In src/app/app.module.ts:

    -    provideClientHydration(withEventReplay()),
    +    provideClientHydration(withEventReplay(), withNoHttpTransferCache()),

    If the team would rather adopt Angular's cache and retire the custom one, say so in the PR and open a follow-up; do not ship both.

  3. Optional: annotate the guards. Adding : CanActivateFn to the eight canActivate guards and : CanDeactivateFn<ComponentCanDeactivate> to canDeactivateGuard costs nothing and catches signature drift. If the module-level deferredCheckActive flags bother anyone, the root-injected state class from 10823 is the alternative, but it is not required for correctness.

What was verified

  • Both heads were checked out into temporary worktrees and type-checked with tsc --noEmit -p tsconfig.app.json against the repository's node_modules. Both pass with no errors.
  • The eleven guard conversions were read side by side with the class versions in both PRs. Every branch, redirect, and platform check is preserved.
  • userProfileSubscription() was confirmed to be store.select(getUserProfileItems), so PR 10824's direct subscription keeps synchronous emission.
  • ENVIRONMENT_INITIALIZER deprecation, the NG05001 enabled-blocking detector, and the default withHttpTransferCache({}) were confirmed in the installed @angular/core 21.2.23 and @angular/platform-browser sources.
  • Routed components were scanned for @Input() names colliding with route params, query params, and data keys.
  • SSR-rendered modules were scanned for isBrowser template branches, direct DOM mutation, and i18n attributes.

Not run here: a full ng build with SSR and a browser session. A previous review of the same two heads reported both builds passing and no hydration errors on / and /privacy-policy; this review does not contradict that.

QA after merge

  1. Load /, a creator profile, and a post detail page with the network tab open. Confirm no NG0500 to NG0507 console errors and that the server HTML is not replaced wholesale.
  2. Inspect <script id="ng-state"> on the same pages and confirm each API response appears once.
  3. Signed out, open /wallet and /messages and confirm the redirect to login. Sign in and confirm the saved return URL is honoured.
  4. Walk the role matrix: fan, pending creator, approved creator, delegated user, white-label owner, white-label non-owner. Each blocked tab should redirect where it did before.
  5. On a white-label site, confirm marketing routes redirect home and /monetize, /insights, and /resources are owner-only.
  6. Type in Messages, navigate away, and confirm the native confirm dialog appears and both buttons behave.
  7. Open a merchandise detail and order payment page from a link that includes a redirect segment; confirm the back navigation target is unchanged.
  8. Click an anchor link on a marketing page and confirm the target sits 64px below the top, under the fixed header.
  9. Generate and prerender one white-label project (for example AJ White QA) and confirm the cover page and profile HTML are written.