Skip to content

feat(app): add initializeBrowserOrServerApp for browser & server apps - #3844

Open
armando-navarro wants to merge 3 commits into
angular:mainfrom
armando-navarro:browser-or-server-app
Open

armando-navarro wants to merge 3 commits into
angular:mainfrom
armando-navarro:browser-or-server-app

Conversation

@armando-navarro

@armando-navarro armando-navarro commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3843

  • Docs included?: no. The docs/auth.md, docs/app-check.md and docs/version-22-upgrade.md changes go on the rc.1 docs pull request, docs: document the 22.0.0-rc.1 changes #3835, which merges just before the tag.
  • Test units included?: yes.
    • New Node specs in src/app/browser-or-server-app.jasmine.ts.
    • A browser spec in src/app/app.spec.ts and two in src/app-check/app-check.spec.ts.
  • Breaking change?: no. Nothing calls the new function until an app opts in. The App Check server warning's text changes.
  • In a clean directory, yarn install, yarn test run successfully? yes, with npm: npm ci, npm run build, npm run lint, npm run test:node on Node 22, 24 and 26, and npm run test:chrome-headless.

Description

This adds initializeBrowserOrServerApp to @angular/fire/app, one call inside provideFirebaseApp that returns the ordinary app in the browser and a FirebaseServerApp for the visitor's tokens on the server. It replaces the callback the Authentication guide asks server-rendered apps to write. The reasoning is in the linked issue.

Changes

  • src/app/browser-or-server-app.ts, exported from @angular/fire/app:
    • initializeBrowserOrServerApp(options, settings?). On the server it reads authIdToken and appCheckToken from REQUEST_CONTEXT, or from settings when passed there, and builds a server app when either is present.
    • By default the server app is released when the render is garbage collected, the way releaseOnDeref works, so a repeat request from the same visitor reuses it.
      • settings.releaseAfterRender opts into a release at render end (0) or that many milliseconds after it.
      • On a runtime without FinalizationRegistry the release is at render end.
    • releaseOnDeref is typed never, and stripped at runtime, so one server app is never released twice.
    • A token the SDK cannot parse (its own check throws a SyntaxError or TypeError) returns the ordinary app after one fixed warning per process, then one line per hundred. Every FirebaseError still propagates.
    • In dev mode, a request with no request context is noted once per process.
    • When the server app the SDK returns does not carry the tokens asked for, the ordinary app is returned instead.
  • src/app-check/app-check.module.ts: the dev-mode server warning now says that server renders without a relayed App Check token send none, and points at the new function.
  • src/app/app.ts: the comment's usage example no longer shows deleteApp on the injected app.
  • tools/angular-fire-resolve-hook.mjs and tools/jasmine-register-angular-fire.mjs, preloaded by test:node and test:node-esm: a resolve hook that maps @angular/fire imports to the built library, so Node specs can import it. No Node spec could before.

Behavior to know

  • The function always builds the default app. Keep exactly one provideFirebaseApp and pass inject(FirebaseApp) to every product provider, as the guide says.
  • Never call deleteApp on the injected app when using the function. It releases the server app itself.
  • A fresh server app costs about 300 to 400 ms (a Firestore connection and an account lookup).
    • With the default release a repeat request for the same visitor is as fast as a signed-out one.
    • With releaseAfterRender: 0 every repeat request pays that cost again, which the docs state.
  • With releaseAfterRender set, the release is a DestroyRef.onDestroy callback. An ngOnDestroy method or an ApplicationRef.onDestroy listener that throws keeps it from running, and that render's server app stays behind. The default release does not run during that destroy and is not affected.

Verified outside CI

On a fresh Angular 22 app wired by following the rewritten guide literally, against a real project:

  • 24 concurrent signed-in renders with distinct tokens, and 24 sharing one token, in each release mode: every page rendered signed in with its Firestore data, and the server-app count returned to zero (after a forced collection in the default mode, without one with releaseAfterRender: 0, after about a second with 1000). Also under ng serve and in an NgModule-bootstrapped app.
  • Unparseable, junk, expired and real stale cookies: HTTP 200 and a signed-out page every time, with the helper's warning only for the unparseable one.
  • With App Check enforcement on for Firestore: a render with a relayed token reads Firestore, the same render without one is rejected, and an expired App Check token fails the read in 340 ms rather than hanging.
  • The sample app's split app.config.client.ts and app.config.server.ts layout renders signed out and signed in. The production browser bundle is the same size with and without the function.
  • A raw firebase/firestore read still pending when the app is released at render end resolves normally, and a client abort mid-render lets the render finish and release its app.

Tyler, the part I'd most like a second look at is the release in buildServerApp: the default hands the SDK a root service (ɵRequestLifetime) that lives as long as the render's injector, and the timed mode releases from the injector's onDestroy.

One call inside provideFirebaseApp that returns the ordinary app in the
browser and, on the server, a FirebaseServerApp for the visitor's ID
token and App Check token, read from REQUEST_CONTEXT or passed in the
settings. With neither token it returns the ordinary app.

The server app is released when the render is garbage collected, as the
releaseOnDeref setup in the guide does. releaseAfterRender opts into a
release at render end or a fixed time after it.

A token Firebase cannot parse now renders signed out with one warning
instead of an HTTP 500. In dev mode a request with no request context is
noted once. The App Check server warning now says which renders send no
token. The Node specs can import the built library through a resolve
hook.

Fixes angular#3843
@armando-navarro armando-navarro added backport: 21.1.x Cherry-pick onto the 21.1.x branch for a 21.1 minor release bump: minor Size of the change: a new capability with no breaking change comp: app-check App Check (src/app-check). comp: auth Authentication (src/auth). comp: core FirebaseApp DI core (src/app). comp: ssr Server-side rendering, hydration, @angular/ssr interop. type: feature New capability or enhancement. labels Nov 10, 2026
@armando-navarro armando-navarro added this to the 22.0.0-rc.1 milestone Nov 10, 2026

@tyler-reitz tyler-reitz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function itself looks solid. I checked refcount balance on every path, cross-visitor isolation (the token check at :173 correctly catches an SDK name-hash collision, which I reproduced with two JWT-shaped tokens), and that no token text reaches a log line. The fallback catch is scoped to the SDK call only.

Two things before approving, both small:

  1. The "default release is not affected" claim doesn't hold, and the timed-mode caveat is narrower than the real behavior. Inline at :151 and :183. The same sentences are in #3835's docs/auth.md:246, so it'd be good to fix both together.
  2. Three spec gaps on the release path. Mutants that break it still pass the suite. Inline in the spec file.

Fine as follow-up issues after rc.1, not blocking:

  • Make the release survive a teardown throw (e.g. register it before anything else can throw, or catch around it).
  • A provideFirebaseApp in a lazy route's providers never releases, since route injectors aren't destroyed at app teardown.
  • The resolve hook reads only the default export condition, so a future node condition would be hidden from the Node specs.
  • Only the authIdToken half of the mismatch check at :173 is tested.

Comment thread src/app/browser-or-server-app.ts Outdated
const { releaseOnDeref: _releaseOnDeref, releaseAfterRender, automaticDataCollectionEnabled: _automaticDataCollectionEnabled, ...serverSettings } = settings;
const timedRelease = releaseAfterRender !== undefined || typeof FinalizationRegistry === 'undefined';

// Kept reachable only through the injector's destroy hooks, which Angular empties at destroy, so the SDK can collect it right after the render.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default mode can leak too. If a teardown hook throws (e.g. a service's ngOnDestroy), R3Injector.destroy exits before it clears _onDestroyHooks, so the injector keeps () => renderHolder. Then if the render also left a zone-wrapped listener on the server app (say a root service calling onAuthStateChanged(auth, ...) without unsubscribing), the server app keeps that injector reachable through the listener closure. The holder is never collected and the app never releases. Reproduced with --expose-gc: a throw on its own frees the app, and only a throw plus a listener leaks it.

So this comment and "The default release does not depend on teardown and is not affected" (#3835, docs/auth.md:246, and the PR body) need rewording, e.g. "is affected only when the render also leaves a listener on the server app".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed rather than reworded: the object handed to the SDK as releaseOnDeref is now a providedIn: 'root' service (ɵRequestLifetime), so it sits in the injector's records, which the finally of destroy clears whatever threw before it.

A spec reproduces your case (a root service whose ngOnDestroy throws, plus a retained reference to the injector) and the app is still released after a collection. The old closure-held object fails that spec.


if (timedRelease) {
// Inside an environment injector DestroyRef is the injector itself, so this runs when the render's injector is destroyed.
destroyRef.onDestroy(() => scheduleRelease(serverApp, releaseAfterRender ?? 0));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any teardown throw skips this, not just a service's ngOnDestroy. I ran it with an ApplicationRef.onDestroy listener that throws: the server app stays in _serverApps. From reading Angular 22's teardown (not run), a component ngOnDestroy throw escapes the same way, through ApplicationRef.ngOnDestroy. That's a common SSR bug, e.g. this.sub.unsubscribe() on a browser-only subscription. Worth saying "any ngOnDestroy or destroy hook" in the caveat.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caveat in the docs and the PR body now says the release is a DestroyRef.onDestroy callback and that an ngOnDestroy method or an ApplicationRef.onDestroy listener that throws keeps it from running.

});

describe('releasing the server app', () => {
it('by default hands the SDK its own object to release the app at garbage collection', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing here checks that the default-mode release actually happens after GC. Two mutants pass the full suite:

  • Delete the keep-alive at :154. The server app can then be deleted mid-render.
  • Push renderHolder into a module-level array. That leaks one app per visitor.

The callThrough spy also records the holder as a call argument, which keeps it reachable. A spec that drops the spy and forces a collection (--expose-gc) catches both. Have the spec assert the app is still alive while the render runs, then deleted once it's gone.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a spec that takes a WeakRef to the registered ɵRequestLifetime, clears the spy's call record, forces a collection while the render is alive (the app must survive), destroys the render, and forces collections until the object is gone and the app is deleted.

test:node and test:node-esm now pass --expose-gc.

Both of your mutants, redone against the service, fail it: handing the SDK a fresh object instead of the injected service deletes the app mid-render, and pushing the service into a module-level array never releases it.

}
});

it('shares one app between two renders with the same token and releases it after the last', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catches a double release at delay 0 but not with a positive releaseAfterRender: making the timer at :130 call release twice still passes. Same spec with e.g. releaseAfterRender: 100, asserting the app survives the first timer?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the same two-render spec with releaseAfterRender: 100: after the first render's timer fires the app must still be alive for the second render, then deleted after the second. A timer that releases twice fails it.

});
});

describe('tokens the SDK rejects', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeError half of isTokenParseError has no spec. If the check is narrowed to SyntaxError only, the suite stays green, and a token whose payload is null (h.${Buffer.from('null').toString('base64url')}.s) then fails the render with a 500.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a spec with a payload of null, which JSON.parse accepts and whose exp read throws a TypeError. It renders signed out with the warning, and narrowing the check to SyntaxError fails it.

The object handed to the SDK as releaseOnDeref was kept alive by a
DestroyRef.onDestroy closure. R3Injector.destroy runs every service's
ngOnDestroy before it empties those closures, so a throw there left the
object reachable from the injector, and any listener that kept the
injector alive kept the server app alive with it. The object is now a
providedIn: 'root' service, which the finally of destroy drops whatever
threw before it. The timed mode still releases from DestroyRef.onDestroy
and a throw before it still skips that release, as the docs say.

New specs on the release path: the default release observed through
forced collections, a shared app with a positive releaseAfterRender
released once per render, a null JSON payload, the App Check half of
the token comparison, and a throwing ngOnDestroy next to a retained
injector reference. The test:node scripts pass --expose-gc for the
forced collections.

The resolve hook picks the exports entry the way Node does, the first
key in the map's order that is an active condition or default.

Refs angular#3844
armando-navarro added a commit to armando-navarro/angularfire that referenced this pull request Nov 11, 2026
The caveat on releaseAfterRender said a throwing ngOnDestroy "stops that
teardown". It now says the release is a DestroyRef.onDestroy callback,
that Angular runs it after every service's and component's ngOnDestroy,
and that an ngOnDestroy method or an ApplicationRef.onDestroy listener
that throws keeps it from running. The default release, which does not
run during the injector's destroy, is unaffected either way.

A new paragraph says to call initializeBrowserOrServerApp in
app.config.ts rather than a route's providers: a route's child injector
is not destroyed with the request, so a release registered there never
runs.

Refs angular#3844
@armando-navarro

Copy link
Copy Markdown
Collaborator Author

All five inline items are in the new commit, plus two of the follow-ups:

  • The resolve hook now picks the exports entry the way Node does (the first key in the map's order that is an active condition or default, so a future node condition is not hidden from the specs)
  • The App Check half of the token comparison has its own spec.

On the other two:

  • The docs on rc1-docs now say to call the function in app.config.ts rather than a route's providers, since a route injector is not destroyed with the request.
  • Making the timed release survive a teardown throw is not something the function can do from inside the injector's own destroy loop, so that stays a documented limit, with the default release as the mode that does not depend on teardown.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport: 21.1.x Cherry-pick onto the 21.1.x branch for a 21.1 minor release bump: minor Size of the change: a new capability with no breaking change comp: app-check App Check (src/app-check). comp: auth Authentication (src/auth). comp: core FirebaseApp DI core (src/app). comp: ssr Server-side rendering, hydration, @angular/ssr interop. type: feature New capability or enhancement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One call to build the right Firebase app for server rendering

2 participants