Skip to content

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

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

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

Conversation

@armando-navarro

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, a service whose ngOnDestroy throws stops Angular's teardown before the release runs, and that render's server app stays behind. The default release 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 holder object kept alive only by 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.

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".


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.

});

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.

}
});

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?

});
});

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.

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