spike: Data Connect subscription hook (review only, not for merge) - #817
tyler-reitz wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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 resulton a loop, no update ever comes, andonErrnever fires. The^15.22.3range already allows the newer release. 15.23.0, 15.24.0, 15.26.0, 15.28.1 and 15.32.1 all pushed. firebase12.12.0 or newer at the root. The root lockfile resolvesfirebase11.10.0, which brings@firebase/data-connect0.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.clientCacheinconnector.yamlfor a single-row lookup by key to get pushes. Without it that query got its first result and nothing after.@refreshon list queries. A list query got pushes only with@refresh(onMutationExecuted: ...), and with it did not needclientCache.- A
dataconnectblock infirebase.jsonplus 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/react2.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: truedoes not contain it either (generated with 15.32.1). - The closest thing is the hand-written "Web (React)"
useEffectexample 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'withunauthorized: 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
keyhad 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 insideonErrdid nothing. The new subscription was removed in the same pass. - Calling it synchronously inside
onCompletelooped, until my test's own limit cut it off at 13 calls. - Deferring it (a
setTimeoutof 0 afteronComplete) 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
keychanges.
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 withUnable 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/catcharoundsubscribe()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'stsconfig.jsonsetsnoUnusedLocals. - 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
liveref 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 totrue. -
initialDataonly fills React state. The hook still callssubscribe(queryRef, ...), so from reading the SDK its cache starts empty and the first subscribe goes to the server anyway.subscribe()accepts theSerializedRefitself as its first argument. -
hydrateQueryRefistoQueryRef, which callsgetDataConnect(connectorConfig)with no app and so binds to the default app. ReactFire takes its app fromFirebaseAppProvider, 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:
einonErr, andTandDinprobe-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.
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
subscribethere 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 exerciseit, 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 searchon that,which is keyword matching rather than enumeration, so my "nobody does" is really
"none found".
Why the hook exists
Walking
@firebase/data-connect0.7.4, the SDK already supplies what a wrapperclassically adds:
fetchPolicy, a TTL cache (CacheProvider,maxAgeSeconds),provenance (
OpResult.source,fetchTime) and atoJSON()/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/react2.1.1 ships two one-shot hooks, andsubscribeappears 0 times in that package against an
executeQuerycontrol of 4 in thesame module.
It ships nothing
Root
tsconfig.jsonincludes onlysrcandtypes;package.jsonpublishesonly
distandsrc;lintandformatare scoped tosrc test vite.config.ts; the probe has no test files so vitest does not collect it; andthe nested
package.jsonis not a workspace, so rootnpm ciignores it.Running it
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 cleanupstill compiles, exit 0". That is wrong. Deleting the call alone gives exit 2,
TS6133, becausetsconfig.jsonsetsnoUnusedLocals. Exit 0 needs theconst unsubscribe =binding deleted too. All three states re-run at5df2d1b: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 thelifecycle is the whole point of the hook. It is a weaker claim than the original,
because
noUnusedLocalsdoes 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 cannotbe fixed without a force-push to this branch.