Skip to content

spike: Data Connect subscription hook (review only, not for merge) - #817

Draft
tyler-reitz wants to merge 2 commits into
FirebaseExtended:mainfrom
tyler-reitz:spike/data-connect-subscription
Draft

tyler-reitz wants to merge 2 commits into
FirebaseExtended:mainfrom
tyler-reitz:spike/data-connect-subscription

Conversation

@tyler-reitz

@tyler-reitz tyler-reitz commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Not for merge, and I will close it once you have looked or decided not to. It
exists so the code can be read and run rather than described in a doc.

The question I actually want

Can subscribe() be exercised against the Data Connect emulator?

Everything here is compile-time. If the emulator does not support subscribe
there is no local development path and CI cannot cover this, which moves the
answer to "should ReactFire support Data Connect" much more than the line count
does. search() is the precedent: no emulator path meant CI could not exercise
it, so it got held to a later minor.

Smaller second question: do you know of any React binding that wires up Data
Connect subscriptions, or AI Logic live sessions?
I have npm search on that,
which is keyword matching rather than enumeration, so my "nobody does" is really
"none found".

Why the hook exists

Walking @firebase/data-connect 0.7.4, the SDK already supplies what a wrapper
classically adds: fetchPolicy, a TTL cache (CacheProvider, maxAgeSeconds),
provenance (OpResult.source, fetchTime) and a toJSON() / toQueryRef()
handoff. Firestore supplies all four too, onSnapshotResume() included.

So the gap is not data access, it is the subscription lifecycle, which no data
SDK can supply from outside React. subscribe() exists and nothing binds it:
@tanstack-query-firebase/react 2.1.1 ships two one-shot hooks, and subscribe
appears 0 times in that package against an executeQuery control of 4 in the
same module.

It ships nothing

Root tsconfig.json includes only src and types; package.json publishes
only dist and src; lint and format are scoped to src test vite.config.ts; the probe has no test files so vitest does not collect it; and
the nested package.json is not a workspace, so root npm ci ignores it.

Running it

cd spikes/data-connect-subscription
npm ci
npx tsc -p tsconfig.json     # exit 0

What the typecheck does not prove

Mutation controls are in the README. Corrected 2026-10-02, Armando's catch:
this section previously read "deleting the unsubscribe() call from the cleanup
still compiles, exit 0". That is wrong. Deleting the call alone gives exit 2,
TS6133
, because tsconfig.json sets noUnusedLocals. Exit 0 needs the const unsubscribe = binding deleted too. All three states re-run at 5df2d1b:
baseline exit 0, call-only exit 2, both-deleted exit 0.

The point holds in the second form: a teardown removed properly compiles clean,
so "compiles under strict" says nothing about lifecycle correctness, and the
lifecycle is the whole point of the hook. It is a weaker claim than the original,
because noUnusedLocals does catch the most likely accidental break. Teardown,
liveness guard and re-subscribe key are backed by review only, which is the other
reason this is a branch and not a number.

The same wrong sentence is still in the commit message for 5df2d1b, which cannot
be fixed without a force-push to this branch.

Not for merge. The branch exists so the code can be read and run.

Ships nothing: the root tsconfig includes only src and types, package.json
publishes only dist and src, lint and format are scoped to src/test/
vite.config.ts, and the probe carries no test files so vitest does not collect
it. The nested package.json is not a workspace, so root npm ci ignores it.

Walking @firebase/data-connect 0.7.4, the SDK already supplies what a wrapper
classically adds: fetchPolicy, a TTL cache, provenance, and a toJSON/toQueryRef
handoff. Firestore supplies all four too, onSnapshotResume included. So the gap
is the subscription lifecycle, which no data SDK can supply from outside React.
subscribe() exists and nothing binds it: @tanstack-query-firebase/react 2.1.1
ships two one-shot hooks and subscribe appears 0 times in the package, against
an executeQuery control of 4 in the same module.

Pinned and reproducible: npm ci && npx tsc, expect exit 0.

The README leads with the two questions worth a reviewer's time, the first being
whether subscribe() can be exercised against the emulator. It also records a
mutation control that did NOT fire: deleting unsubscribe() from the cleanup
compiles exit 0, so the typecheck says nothing about lifecycle correctness, and
the lifecycle is the whole point of the hook.

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

Short answer to the main question: yes, subscribe() can be exercised against the emulator, but not with the versions the repo has locked today. Running the hook there also showed three lifecycle problems, and I think those are the best argument that a ReactFire binding would earn its place.

Can subscribe() be exercised against the emulator?

Yes. With one client subscribed and a second, separate client writing, the subscriber received each write with no call of its own, and nothing after it unsubscribed. What it took:

  • firebase-tools 15.23.0 or newer (emulator build 3.4.15). The root lockfile has 15.22.3 (build 3.4.14). There the first result still arrives, then the SDK logs WebSocket message from emulator did not include result on a loop, no update ever comes, and onErr never fires. The ^15.22.3 range already allows the newer release. 15.23.0, 15.24.0, 15.26.0, 15.28.1 and 15.32.1 all pushed.
  • firebase 12.12.0 or newer at the root. The root lockfile resolves firebase 11.10.0, which brings @firebase/data-connect 0.3.10, and that build contains no streaming code at all. 12.12.0 is the minimum the realtime guide gives. I only ran 12.19.0.
  • clientCache in connector.yaml for a single-row lookup by key to get pushes. Without it that query got its first result and nothing after.
  • @refresh on list queries. A list query got pushes only with @refresh(onMutationExecuted: ...), and with it did not need clientCache.
  • A dataconnect block in firebase.json plus a small schema and connector folder. The repo has neither today.

It needed no Java. I ran all of this under a demo- project id and outside the repo's own suite, so wiring it into npm test under rxfire-525a3 is the part I have not tried.

That also answers two of the README's known limits. It pushes over a WebSocket and does not poll. After a dropped connection the SDK reconnects on its own, and a subscription that was already live picked up later writes with no new subscribe() call.

One naming note: the docs and the emulator's own log output now call the product SQL Connect, which seems worth knowing before a public hook gets a name.

Is there a React binding already?

I found none either:

  • @tanstack-query-firebase/react 2.1.1 is still the latest, and the word "subscribe" does not appear anywhere in the package.
  • The React SDK that firebase-tools generates with react: true does not contain it either (generated with 15.32.1).
  • The closest thing is the hand-written "Web (React)" useEffect example in the realtime guide. It has the same shape as this hook, so I would expect it to share the three problems below, though I did not run it.

On AI Logic live sessions I have nothing to add. One keyword search found nothing, which has the same limit you described.

Signing in or out ends the subscription for good

With the hook mounted and showing data, I called signInAnonymously:

  • The hook went to status: 'error' with unauthorized: Stream disconnected due to auth change.
  • The SDK removed the hook's callback. From reading the source, it does this to every subscription when the user changes.
  • The effect never ran again because key had not changed. Writes from another client stopped arriving, and it stayed that way through a sign-out and a second sign-in.

Any page with a live query where a user can sign in or out would hit this. The SDK does call onComplete when it lets a subscription go, but how the hook subscribes again matters:

  • Calling subscribe() again inside onErr did nothing. The new subscription was removed in the same pass.
  • Calling it synchronously inside onComplete looped, until my test's own limit cut it off at 13 calls.
  • Deferring it (a setTimeout of 0 after onComplete) worked, and pushes resumed.

After the variables change, the previous query's data is reported as success

I rendered the hook with one movie id and then switched to another:

  • The first render for the new id returned the old movie with status: 'success'.
  • It stayed that way until the new result arrived.
  • State is only written inside the callbacks, so nothing clears it when key changes.

subscribe() can throw, and that removes the whole tree

If the connection drops while a subscription is live:

  • About 1.4 seconds in, the SDK starts refusing new subscriptions, and it keeps refusing until it reconnects.
  • In that window subscribe() throws synchronously with Unable to connect streaming connection to server. Subscriptions are unavailable.
  • In the hook that throw comes out of useEffect.

What I saw in headless Chrome:

  • The mounted hook kept showing its data with status: 'success' during the outage.
  • Changing the variables made React remove the entire tree, the surrounding page content included.
  • With a try/catch around subscribe() that sets the error state, the tree survived.
  • Even then, the query whose subscribe() threw never received a push again after the backend came back, while a different query did. The SDK registers the callback before it throws and hands back no way to remove it.

That last part looks like something for firebase-js-sdk more than for the hook, unless I'm missing a public way to clear it.

The README's headline mutation row

"Remove unsubscribe() from the cleanup: exit 0" does not reproduce for me at 5df2d1b:

  • Deleting only the call gives exit 2, TS6133: 'unsubscribe' is declared but its value is never read, because the spike's tsconfig.json sets noUnusedLocals.
  • Deleting the call and the const unsubscribe = binding gives exit 0.
  • The sentence appears as written in the README, the PR body and the commit message.

Did you run a different variant? Your point still stands in the second form.

The good news is that the lifecycle does not have to stay "backed by review only". Four tests of the hook against the emulator caught it: with the teardown removed three of them failed, and with the dependency list emptied one failed.

Smaller things

  • The live ref check did not change any outcome I could produce. All four tests pass with it deleted. As a ref shared between effect runs it also cannot tell an old subscription from a new one, because the next run sets it back to true.

  • initialData only fills React state. The hook still calls subscribe(queryRef, ...), so from reading the SDK its cache starts empty and the first subscribe goes to the server anyway. subscribe() accepts the SerializedRef itself as its first argument.

  • hydrateQueryRef is toQueryRef, which calls getDataConnect(connectorConfig) with no app and so binds to the default app. ReactFire takes its app from FirebaseAppProvider, so a named app would get the wrong instance. This one is from reading the source.

  • Single-letter names make the code harder to read: e in onErr, and T and D in probe-types.ts.

If you take this further I'm happy to share the emulator project and the tests. And if any of this does not match what you see, tell me which part and I'll run it again.

The table claimed that deleting unsubscribe() from the cleanup compiles at
exit 0. Deleting the call alone gives exit 2, TS6133, because tsconfig.json sets
noUnusedLocals. Exit 0 needs the const unsubscribe = binding deleted too.

Armando caught it on the PR. All three states re-run at 5df2d1b: baseline exit 0,
call-only exit 2, both-deleted exit 0.

The conclusion holds in the second form, and the README now says it is weaker
than first stated: noUnusedLocals does catch the most likely accidental break, so
the compiler is not quite as blind to the lifecycle as the original row implied.

The same wrong sentence remains in the commit message for 5df2d1b, which cannot
be fixed without a force-push to this branch.
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.

2 participants