fix(sdk): guard useStream hydrate reconnect against a cleared thread id (#2613)
## Summary
`useStream`'s mount-time **hydrate → reconnect** path can call
`client.threads.stream(null, …)` when the thread id is cleared while
hydration is in flight, throwing an uncaught `TypeError: Cannot read
properties of null (reading 'fetch')`. This adds a post-`await` guard in
the controller (root cause) and makes `ThreadsClient.stream` null-safe
at the boundary (defense-in-depth), plus a regression test.
## Root cause
`StreamController.hydrate()` null-guards `#currentThreadId` **before**
the `await`, then re-reads it **after** to open the reconnect, with no
re-check in between:
```ts
// controller.ts
if (this.#currentThreadId == null) { …; return; } // guard BEFORE await (L538)
…
const state = await this.#fetchHydrationState(); // getState() — async gap (L564)
…
const thread = this.#ensureThread(this.#currentThreadId, !threadActive); // L731, no re-check
```
`#ensureThread` forwards the id to `client.threads.stream(threadId, {
fetch, … })`. If `#currentThreadId` becomes `null` **during the await**
(the host clears the id, `hydrate(null)` runs, or the component unmounts
during navigation), `hydrate` resumes and calls `threads.stream(null,
{…})`. In `ThreadsClient.stream`, a non-string first arg is treated as
the **options** object, so `null` becomes `options` and `options.fetch`
throws:
```ts
// client/threads/index.ts
const { threadId, options } = typeof threadIdOrOptions === "string"
? { threadId: threadIdOrOptions, options: maybeOptions }
: { threadId: uuidv7(), options: threadIdOrOptions }; // null ⇒ options = null
const userFetch = options.fetch; // null.fetch → throw
```
## Why it's intermittent
The null has to land inside the narrow `await #fetchHydrationState()`
window, and only for an **active** thread (idle/finished threads take
the deferred path and skip `#ensureThread`). It needs a concurrent
thread-id clear (navigation/unmount) racing an in-flight hydrate — so
it's flaky, not deterministic.
## Changes
- **`stream/controller.ts` (root fix):** re-check `this.#disposed` /
`this.#currentThreadId != null` after the `await`, before
`#ensureThread(...)`. Hydration is already settled by then, so an early
return is safe. Mirrors the existing post-await guard idiom (e.g.
`controller.ts` discovery-seed path).
- **`client/threads/index.ts` (defense-in-depth):** treat a nullish
first arg as the generate-thread-id form (use the second arg as options)
instead of binding `null` to `options`.
- **`stream/controller.test.ts`:** regression test — `getState()`
resolves *after* the thread id is cleared mid-hydrate; asserts the
reconnect is never opened with a null id.
## Risk
Low. The controller guard only early-returns when there's no thread to
reconnect. The `stream` overload change preserves behavior for the
string- and object-first calls; it only fixes the nullish-first-arg
case.
## How it was found
Surfaced as a flaky teardown `pageerror` in a downstream app's
Playwright E2E (agent editor: create → chat → delete). Confirmed against
`@langchain/langgraph-sdk@1.9.27` and reproduced on `main` (this
branch).
## Notes for reviewers
- Opened as **draft**: the regression test was authored to match
existing `controller.test.ts` conventions but **not run** in my
environment (SDK deps weren't installed) — please verify it in CI /
locally.
- Either change fixes the reported crash on its own; the
`threads.stream` hardening is optional but cheap. S
Sreeram Sama committed
82a320faf6876238f8bac8aa6a7c593bef2be061
Parent: ceb4fa7
Committed by GitHub <noreply@github.com>
on 7/21/2026, 10:02:41 PM