Repository navigation
feat(app): add initializeBrowserOrServerApp for browser & server apps - #3844
Conversation
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
tyler-reitz
left a comment
There was a problem hiding this comment.
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:
- The "default release is not affected" claim doesn't hold, and the timed-mode caveat is narrower than the real behavior. Inline at
:151and:183. The same sentences are in #3835'sdocs/auth.md:246, so it'd be good to fix both together. - 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
provideFirebaseAppin a lazy route's providers never releases, since route injectors aren't destroyed at app teardown. - The resolve hook reads only the
defaultexport condition, so a futurenodecondition would be hidden from the Node specs. - Only the
authIdTokenhalf of the mismatch check at:173is 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. |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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
renderHolderinto 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.
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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
|
All five inline items are in the new commit, plus two of the follow-ups:
On the other two:
|
tyler-reitz
left a comment
There was a problem hiding this comment.
Re-checked on ae887a0, all good. I mutated the built library for each fix (the old closure holder, a fresh object, a module-level array, a double-release timer, SyntaxError-only, no App Check comparison) and each one fails its new spec, and test:node is 440/0.
Two small questions, neither blocking: was the "Verified outside CI" run repeated after the switch to ɵRequestLifetime? And I think the default mode now also releases a provideFirebaseApp placed in a lazy route's providers, since the lifetime is the root service. If that's right, it might be worth a line in #3835.
…iders Tyler asked on angular#3844 whether the default release, now tied to a root service, releases a server app built in a lazy route's providers. It does: the request's root injector is destroyed, the route's child injector is not, so only a releaseAfterRender release registered there is lost. Checked with a child environment injector that is never destroyed, in both modes. Refs angular#3844
|
On the two questions:
|
The bullet said a second registration makes a signed-in render fail on a freshly started server. With more than one registration, FirebaseApp resolves to the default app. Run on fresh servers, a second plain initializeApp(config) registration creates that app on the first request, so signed-in pages render signed out with no error. Only when no registration creates the default app, as with two initializeBrowserOrServerApp registrations, does the render fail, and only until a signed-out request creates it. Found in the angular#3835 review. Refs angular#3844
* docs: say which wrapped calls do not hold the app unstable docs/zones.md says callback APIs hold the app unstable until an initial value, so a server render waits for data. #3827 makes a set of calls exceptions because their first callback may never come. Waiting on a callback that never comes would keep the app from ever becoming stable. List those calls in docs/zones.md, and point at onValue, object, list, listVal and fromRef with the value event as the calls that still make a server render wait. In docs/database.md, say the same under stateChanges, auditTrail and fromRef. Refs #3826 * docs: describe firebase 13 support in the v22 upgrade guide #3831 lets AngularFire 22 accept firebase 12 or 13, and the ng update migration now writes the range of the firebase major already installed. The upgrade guide still said the migration writes ^12.18.0 and quoted peer warnings that name only that range. State the supported range and what the migration writes, update the Yarn 1 and pnpm 9/10 warnings to their current wording, and add a section on moving to firebase 13: VertexAIBackend is removed and its packages declare Node 24.12 or later. Refs #3830 * docs: describe microtask delivery in the v22 upgrade guide #3833 moves every Observable read from a setInterval timer to the next microtask checkpoint. As a breaking change it needs a section in the upgrade guide: which tests now see a value they used to wait for, the setTimeout ordering flip, and the zoneless server render case for hand-written TransferState entries. Refs #3832 * docs: describe the callback pending-task change in the v22 upgrade guide #3837 removes a callback read's pending task right after the callback instead of one setTimeout(fn, 0) delay later. As a breaking change it needs a section in the upgrade guide: what moves sooner, and which tests notice. Refs #3836 * docs: say a handler passed as an observer object is wrapped too #3829 wraps a handler passed as an observer object, such as { next, error }, the way it wraps a function, so the injection context reaches both forms. Refs #3828 * docs(compat): add a setup section in NgModule and standalone form The compat API's setup steps left docs/install-and-setup.md when #3236 rewrote it for the modular API, and nothing replaced them. docs/compat.md had no setup at all, the Realtime Database pages pointed at an "Installation guide" that no longer covered compat, and no page showed the standalone form. A standalone app with only the modular providers then fails its first compat injection with NG0201 for angularfire2.app.options (#3683). The new section on docs/compat.md gives both forms, says when the modular providers may stay, states the error the missing module produces, and lists the compat module of each product. The twelve compat pages that had an NgModule-only snippet or no setup at all now point at it. Fixes #3683 * docs: say which package managers refuse an incompatible firebase The v22 upgrade guide said an incompatible firebase is refused at install time. Only npm refuses it, and only when package.json requests the incompatible version. Yarn and pnpm install it with a warning. * docs: say which Firestore functions are not zone wrapped #3842 re-exports Firestore's query and reference builders, field values, comparators and setLogLevel without the zone wrapper. In docs/zones.md, add a paragraph listing them, change the switchMap example to a read, since building the query no longer warns, and say the Verbose log level covers only wrapped calls. Refs #3842 * docs(auth): build the server app with initializeBrowserOrServerApp The server-rendering guide's step 4 becomes the one call, the five details become three, the App Check token gets a relay section, the response gets a Cache-Control header, and the manual callback stays in a "Building the app yourself" section that step 4 points at, for apps that prefer it or whose AngularFire version has no such function. The release modes say what a throwing ngOnDestroy does to a timed release. * docs(auth): describe the sample as an example of the one-call server app The sample app now has one provideFirebaseApp with initializeBrowserOrServerApp in its shared app.config.ts (#3845), so the sentence about its split client and server files no longer holds. Refs #3843 * docs: say how to move from 22.0.0-rc.0 in the v22 upgrade guide The guide covered upgrades from 21, a 21 release candidate and a 22 canary build, but not from the first 22 release candidate. ng add saves a prerelease as an exact version, so an app on 22.0.0-rc.0 stays there until ng update @angular/fire@next moves it. That command also runs migration-v22 again, which reports "No changes made" for a firebase entry already inside the supported range. Refs #3834 * docs(auth): say which hooks stop a releaseAfterRender release 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 #3844 * docs(auth): say the default release still happens from a route's providers Tyler asked on #3844 whether the default release, now tied to a root service, releases a server app built in a lazy route's providers. It does: the request's root injector is destroyed, the route's child injector is not, so only a releaseAfterRender release registered there is lost. Checked with a child environment injector that is never destroyed, in both modes. Refs #3844 * docs: add onConfigUpdate to the calls that do not hold the app unstable #3829 set onConfigUpdate to blockUntilFirst: false in tools/build.ts, because its observer runs only when a fetched template differs from the active one. The list in docs/zones.md left it out. Found in the #3835 review. Refs #3829 * docs(auth): say a second provideFirebaseApp can render signed out The bullet said a second registration makes a signed-in render fail on a freshly started server. With more than one registration, FirebaseApp resolves to the default app. Run on fresh servers, a second plain initializeApp(config) registration creates that app on the first request, so signed-in pages render signed out with no error. Only when no registration creates the default app, as with two initializeBrowserOrServerApp registrations, does the render fail, and only until a signed-out request creates it. Found in the #3835 review. Refs #3844
Fixes #3843
docs/auth.md,docs/app-check.mdanddocs/version-22-upgrade.mdchanges go on the rc.1 docs pull request, docs: document the 22.0.0-rc.1 changes #3835, which merges just before the tag.src/app/browser-or-server-app.jasmine.ts.src/app/app.spec.tsand two insrc/app-check/app-check.spec.ts.yarn install,yarn testrun successfully? yes, with npm:npm ci,npm run build,npm run lint,npm run test:nodeon Node 22, 24 and 26, andnpm run test:chrome-headless.Description
This adds
initializeBrowserOrServerAppto@angular/fire/app, one call insideprovideFirebaseAppthat returns the ordinary app in the browser and aFirebaseServerAppfor 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 readsauthIdTokenandappCheckTokenfromREQUEST_CONTEXT, or fromsettingswhen passed there, and builds a server app when either is present.releaseOnDerefworks, so a repeat request from the same visitor reuses it.settings.releaseAfterRenderopts into a release at render end (0) or that many milliseconds after it.FinalizationRegistrythe release is at render end.releaseOnDerefis typednever, and stripped at runtime, so one server app is never released twice.SyntaxErrororTypeError) returns the ordinary app after one fixed warning per process, then one line per hundred. EveryFirebaseErrorstill propagates.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 showsdeleteAppon the injected app.tools/angular-fire-resolve-hook.mjsandtools/jasmine-register-angular-fire.mjs, preloaded bytest:nodeandtest:node-esm: a resolve hook that maps@angular/fireimports to the built library, so Node specs can import it. No Node spec could before.Behavior to know
provideFirebaseAppand passinject(FirebaseApp)to every product provider, as the guide says.deleteAppon the injected app when using the function. It releases the server app itself.releaseAfterRender: 0every repeat request pays that cost again, which the docs state.releaseAfterRenderset, the release is aDestroyRef.onDestroycallback. AnngOnDestroymethod or anApplicationRef.onDestroylistener 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:
releaseAfterRender: 0, after about a second with1000). Also underng serveand in an NgModule-bootstrapped app.app.config.client.tsandapp.config.server.tslayout renders signed out and signed in. The production browser bundle is the same size with and without the function.firebase/firestoreread 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'sonDestroy.