Skip to content

fix: revert ObservableStatus to a strict (non-union) type#750

Merged
tyler-reitz merged 4 commits into
FirebaseExtended:mainfrom
tyler-reitz:fix/observablestatus-strict
Jul 22, 2026
Merged

fix: revert ObservableStatus to a strict (non-union) type#750
tyler-reitz merged 4 commits into
FirebaseExtended:mainfrom
tyler-reitz:fix/observablestatus-strict

Conversation

@tyler-reitz

@tyler-reitz tyler-reitz commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

4.2.4 shipped ObservableStatus<T> as a discriminated union (#583), so data became T | undefined unless narrowed on status. That broke the documented pattern of reading .data directly (e.g. const { data } = useFirestoreCollectionData(q); data.map(...)) across every data hook, including the suspense pattern, and it went out as a patch.

This reverts ObservableStatus to its previous strict shape (data: T) so the fix ships as a patch, and adds a type-check over the test suite in CI so a regression like this fails at compile time going forward.

Commits

  1. test: type-check the test suite in CI adds tsconfig.test.json (type-checks src + test) and runs it in the type-check workflow. With the union still in place this turns the check red with 28 TS18048 "possibly undefined" errors on the existing test assertions. Also fixes pre-existing test-only type issues the check surfaces (unused imports, node globals, a children prop annotation).
  2. fix: revert ObservableStatus to a strict (non-union) type reverts the type to data: T. The check goes green (0 errors).
  3. docs: regenerate reference docs for the ObservableStatus type change (drops the per-status sub-interface pages, retargets links from type-aliases/ to interfaces/).
  4. test: strengthen the type guard with a typed useDatabaseObjectData read (which fails to compile if ObservableStatus regresses to a union) and a concretely-typed useFirestoreDocData read, per review.

The existing tests already read .data off hook results the way consumers do, so once type-checked they catch this class of break with no separate type-regression file.

What stays correctly typed per hook

The genuinely-optional cases keep their undefined / null at the hook level, which is where it belongs:

Reverting the union cleared all 28 possibly-undefined errors, confirming no other hook needed per-hook undefined.

Removed types (for release notes)

Reverting removes the three interfaces 4.2.4 introduced: ObservableStatusSuccess, ObservableStatusError, ObservableStatusLoading. Anyone who adopted them in 4.2.4 should read data off ObservableStatus<T> directly again. Worth a changelog line on the 4.2.5 release.

Verification

  • Red then green: 28 TS18048 at commit 1, 0 at commit 2.
  • tsc --noEmit green on both src and src + test.
  • Non-emulator tests (useObservable, firebaseApp) pass (21/21); emulator suites green in CI.
  • Consumer check: packed this branch and type-checked the documented useFirestoreCollectionData(...).data.map(...) pattern under strict, green here, red on 4.2.4.

Intended to ship as a patch (4.2.5). Tracks #749.

Adds tsconfig.test.json (type-checks src + test) and runs it in the
type-check workflow. The test suite already exercises hooks the way
consumers do (reading .data off results), so a breaking change to the
public types now fails CI at compile time.

Also fixes pre-existing test-only type issues surfaced by turning the
check on: unused imports, node globals (require/global/Buffer) via the
node types, and a FunctionComponent children prop annotation.
4.2.4 shipped ObservableStatus<T> as a discriminated union (data is
T | undefined unless narrowed on status), which broke the documented
pattern of reading .data directly across every data hook. Revert the
type to the previous strict shape (data: T) so this ships as a patch.

The genuinely-optional cases stay correctly typed at the hook level
(useFirestoreDocData/DocDataOnce return T | undefined, useUser returns
User | null, useDatabaseListData returns T[] | null), which is where
the undefined belongs rather than on ObservableStatus itself.

The internal SuspenseSubject status builders keep an as-cast since the
in-flight loading value is internally T | undefined.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

ObservableStatus is now an interface again rather than a discriminated
union type-alias, so the generated reference docs drop the per-status
sub-interface pages and retarget links from type-aliases/ to interfaces/.
@tyler-reitz
tyler-reitz marked this pull request as ready for review July 22, 2026 19:37
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@armando-navarro armando-navarro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice fix, along with the CI type-check. I verified the whole thing on current main: the revert is faithful to the pre-4.2.4 shape, the red-then-green holds, and types, tests, and docs are all green.

What I verified

  • The revert restores ObservableStatus<T> to the flat data: T shape (identical to 4.2.3), and the genuinely-optional cases stay where they belong: useFirestoreDocData/DocDataOnceT | undefined, useUserUser | null, useDatabaseListDataT[] | null.
  • Red then green: tsc -p tsconfig.test.json reports exactly 28 TS18048 at the guard commit (union still in place) and 0 after the revert. The 28 are real .data reads, and the test fixes added no as any / @ts-ignore / ! suppressions, so the guard has real teeth.
  • tsc is clean on src and src + test; the non-emulator suites are 21/21; the firestore/database/auth emulator suites are green; and the regenerated reference docs match a clean npm run docs, so the docs check will pass.

The one decision that isn't mine: semver

Reverting removes three types that 4.2.4 published (ObservableStatusSuccess, ObservableStatusError, ObservableStatusLoading) and re-narrows data. It restores the 4.2.3 contract, so shipping it as a patch to undo a bad patch reads as reasonable to me, but removing exported types is technically breaking, so I would rather Jeff make that call explicitly.

Either way, a changelog line noting those three types are gone would help anyone who adopted them in 4.2.4.

Smaller notes

  • The guard is genuine but not exhaustive: the useFirestoreDocData tests use <any>, so .data is untyped there, and useDatabaseObjectData/useDatabaseListData are not exercised at all, so a future union reintroduction on those would not be caught. One concretely-typed .data read for doc data plus an object/list test would close the gaps. Non-blocking.
  • The description says two commits, but there are three (the docs regen is the third, and worth keeping).

Everything code-level checks out, so approving on that, with the semver call left to Jeff.

Comment thread src/useObservable.ts

@jhuleatt jhuleatt 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.

Thanks!

…eads

Per review: the guard did not exercise useDatabaseObjectData, and the
existing useFirestoreDocData tests use <any> so their .data reads are
untyped. Add a useDatabaseObjectData test with a typed .data read (data
is T, so it fails to compile if ObservableStatus regresses to a union)
and a concretely-typed useFirestoreDocData read.
@tyler-reitz

Copy link
Copy Markdown
Contributor Author

Thanks Armando! Got both in a new commit: added a useDatabaseObjectData test with a typed .data read (fails to compile if ObservableStatus goes back to a union, confirmed) plus a concretely-typed docData read instead of the ones, no suppressions. Also fixed the commit count and added a "Removed types" note for ObservableStatusSuccess/Error/Loading. Landing as a patch for 4.2.5.

@tyler-reitz

Copy link
Copy Markdown
Contributor Author

Final due-diligence: published .d.ts surface diff

Packed this branch and diffed the emitted type declarations against both the broken release (4.2.4) and the last-good one (4.2.3), the same release-time check tracked in #749.

vs 4.2.4 — surgical. The only .d.ts that changes is useObservable.d.ts (the ObservableStatus revert + removal of the three sub-interfaces). Every other module, firestore/database/auth/storage/etc., is byte-identical to 4.2.4. No collateral changes.

vs 4.2.3 — contract restored. The ObservableStatus interface is identical to 4.2.3. The only difference in the whole file is one additive line:

- preloadObservable<T>(source, id): SuspenseSubject<T>
+ preloadObservable<T>(source, id, suspenseEnabled?): SuspenseSubject<T>

That optional suspenseEnabled? comes from #583's useSyncExternalStore runtime (kept intentionally, we reverted only the type). Optional param = additive/non-breaking.

Net: 4.2.3's ObservableStatus contract is restored, #733's per-hook undefined is preserved, and the only delta from 4.2.3 is a benign additive param.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants