get-convex / get-convex/convex-react-query

TypeError on "removed" event: subscriptions[queryHash] undefined when listener missed the "added" event (likely SSR/hydration)

Open
#32 0 comments 1 reaction 0 assignees View on GitHub
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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.