get-convex / get-convex/convex-react-query
TypeError on "removed" event: subscriptions[queryHash] undefined when listener missed the "added" event (likely SSR/hydration)
- Dominant language
- TypeScript
- Stars
- 41
- Forks
- 7
- PR merge metrics
- No merged PRs in 30d
Description
### Version
`@convex-dev/react-query@0.1.0` (latest).
### Symptom
Production error reported by Sentry, hitting real users on a Next.js 16 (App Router) app:
```
TypeError: Cannot read properties of undefined (reading 'unsubscribe')
at node_modules/@convex-dev/react-query/dist/esm/index.js:146
this.subscriptions[event.query.queryHash].unsubscribe();
```
That line lives inside the `case "removed":` branch of the queryCache listener installed by `subscribeInner` (`src/index.ts:264` in v0.1.0).
Stack:
```
ConvexQueryClient.subscribeInner (case "removed")
← QueryCache.notify ({ type: "removed", query })
← Query.optionalRemove
← Removable (gcTime expiry)
```
3 distinct users, 10 events over ~17 hours, escalating. Mechanism: `auto.browser.browserapierrors.setTimeout` (caught from a setTimeout callback — TanStack's gc batch).
### Hypothesis
`subscribeInner` early-returns on the server (`if (isServer) return () => {}`), so the listener only runs on the client. In a Next.js setup that uses `preloadQuery` extensively, queries can land in the TanStack `QueryCache` via hydration **before** the convex listener attaches:
1. Server renders, `preloadQuery(api.foo, args)` populates result.
2. Client hydrates — query enters `QueryCache` from preloaded state.
3. Some time later (e.g. once `` mounts), `subscribeInner` attaches its listener.
4. No `"added"` event fires for the already-hydrated query → no entry in `this.subscriptions[queryHash]`.
5. `gcTime` expires → `"removed"` event fires → unsubscribe call crashes on `undefined`.
A second possible path is React 19 concurrent rendering delivering a duplicate `"removed"` event for the same query — first call deletes the entry, second crashes — but I don't have direct evidence of that.
### Suggested fix
The unsubscribe path can simply be defensive — both branches need the corresponding subscription to exist, and treating "no record" as "nothing to do" is consistent with the intent:
```ts
case "removed": {
this.subscriptions[event.query.queryHash]?.unsubscribe();
delete this.subscriptions[event.query.queryHash];
break;
}
```
The deeper question is whether the listener should also walk the existing `QueryCache` on attach to backfill subscriptions for queries that were hydrated before it mounted. Without that, those queries are tracked by TanStack but have no live Convex subscription — fine if `staleTime: Infinity` keeps them from refetching, but they won't update reactively from the Convex backend until they're re-mounted with a fresh useQuery.
### Environment
- Next.js 16.1.1 (Turbopack, Cache Components / PPR enabled)
- React `19.3.0-canary-f93b9fd4-20251217`
- `@tanstack/react-query@^5.99.0`
- `@convex-dev/react-query@0.1.0`
- `convex@^1.29.x`
- Hosted on Vercel; affected route is an admin page that uses both `preloadQuery` (server) and `useQuery(convexQuery(...))` (client) for the same data.
Happy to put a PR together if the optional-chain fix looks right; the backfill-on-attach version is a bigger conversation.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.