The observable cache leaks subscriptions and is shared across SSR requests
維護者通常 1 天內回覆
還沒有人認領這個 Issue。
評估
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 新手友好度
- 35/100
- Issue 類型
- 缺陷
- 描述清晰度
- 基本清楚
- 活躍度
- 活躍
- 技術堆疊
- react, typescript
研究方向
從 SuspenseSubject.ts 開始,特別檢查 constructor、_subscribe、_reset 和 hasValue,接著追蹤 useObservable.ts 中的模組層級快取繫結以及三個 preload 匯出。使用 renderToPipeableStream 檢查 SSR 行為。完成的標準是並行請求不會共用項目、伺服器端渲染不會留下未關閉的訂閱,且快取項目會被逐出。
由索引模型根據 Issue 內容生成。
描述
Tracking issue for cluster 0b. #748 covers only the getServerSnapshot half of this (fixed by #779); the cache lifecycle itself has never had an issue.
Everything below was verified against upstream/v5 on 2026-08-11 unless labelled otherwise.
Two defects, one lifecycle
1. Nothing is ever evicted, and the reset timer is cancelled permanently by the first subscriber.
_timeoutHandler is assigned only in the SuspenseSubject constructor (SuspenseSubject.ts:61,63) and cleared only in _subscribe (:124). Nothing re-arms it. So the first subscriber cancels the reset for the life of the object, and _reset() can only ever fire on an entry that nothing subscribed to within the timeout window.
⚠️ The comment at SuspenseSubject.ts:58 says the timer reschedules "on unsubscribe". That code was never written.
Separately, nothing removes entries from the cache Map at all: a sweep for preloadedObservables.delete / .clear across src/ on v5 returns zero hits, and the same sweep for .set / .has / .get returns three, so the search can come back non-empty.
Consequence: any cache entry that is ever used leaks its warmup subscription for the life of the process. Not SSR-only, the browser leaks too, bounded only by session length, which is likely why it has never been reported. A server has no such bound, and keys are per-user (firestore:doc:app:users/<uid>).
2. The cache is a module-level global, so concurrent SSR requests share entries.
useObservable.ts:11 binds the Map at import time off globalThis. ⚠️ Swapping globalThis._reactFirePreloadedObservables does not redirect it: the module keeps writing to the original object, silently. Any test that isolates that way is testing nothing.
Why this is a prerequisite, not cleanup
Measured under renderToPipeableStream (what the App Router runs, and it surfaces what renderToString hides):
- In suspense mode reactfire suspends, waits for the emission, then renders the placeholder anyway, so the caller pays full latency for a loading state.
- A cold cache in suspense mode hangs the stream rather than erroring, because
firstEmissiononly resolves on a first emission.
So until the cache is request-scoped there is no way to server-render real data at all. Anything that seeds per-request data on a server depends on this landing first.
Plan: one decision, then three PRs
Decision first: suspense-mode eviction semantics. Error state persists indefinitely in suspense mode via a noop timeout, and resets after the window in non-suspense mode. Eviction has to pick a rule. Not a PR, and it overlaps #742, which asks for a retry path out of the same error state.
PR A. Browser lifecycle: refcount, re-arm, evict. Independent of SSR and a live bug today, so it is the half worth shipping even if the rest slips. Behavior change, no new API.
PR B. Per-request scoping via an explicit cache handle: createObservableCache(), a cache prop, an optional trailing argument on the three preload exports. Prototyped: scoping falls out, the preload API survives, no Node-only APIs, browser behaviour unchanged when omitted. New API surface.
PR C. Server disposal, riding on that handle. ⚠️ Scoping must never ship without it: alone, scoping converts one shared leak into one leak per request.
#779 lands ahead of all of it, since it is the getServerSnapshot half.
Acceptance
- a test proving two concurrent SSR requests do not share cache entries
- ⚠️ a test proving a server render leaves no open subscription and that entries are evicted
The second signal exists because the first one alone passes with every subscription still open and nothing evicted. That was caught on the prototype.
Notes
- Estimated 5-6 days. An estimate, not a measurement.
- ⚠️
hasValue(SuspenseSubject.ts:67-71) returningthis._hasValue || !!this._erroris load-bearing: it is what stops suspense re-throwing after the promise resolves. A naive redefinition produces an infinite suspend loop. It reads like a wart and a rewrite deletes it by accident. - Ruled out: React 19
cache()(RSC-only),AsyncLocalStorage(unnecessary once a handle threads through, kept in reserve), not caching on the server (does not survive the leak finding). - Zero confirmed users of the v4 preload API, though a code search cannot see private code, so "zero confirmed" rather than "zero".
- 主要語言
- TypeScript
- 星號
- 3.6k
- 分支
- 403
- 平均合併
- 1 天 21 小時
- 30 天內合併 PR
- 14
環境準備
- 沒有 Dockerfile 或 Docker Compose 檔案
- 有 Pull Request 範本
- 閱讀貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
FirebaseExtended/reactfire 的其他 Issue
-
難度 5/5 一週以上 新手友好度 38/100
FirebaseExtended/reactfire#801 ·
維護者通常 1 天內回覆
-
v5
難度 4/5 3-5 天 新手友好度 48/100
FirebaseExtended/reactfire#793 ·
維護者通常 1 天內回覆
-
v5
難度 4/5 3-5 天 新手友好度 55/100
FirebaseExtended/reactfire#789 ·
維護者通常 1 天內回覆
-
難度 5/5 一週以上 新手友好度 35/100
FirebaseExtended/reactfire#788 ·
維護者通常 1 天內回覆
-
難度 4/5 3-5 天 新手友好度 55/100
FirebaseExtended/reactfire#784 ·
維護者通常 1 天內回覆
查看 FirebaseExtended/reactfire 的全部 Issue
相似的 Issue
-
難度 2/5 1-3 小時 新手友好度 86/100
維護者通常 1 天內回覆
-
難度 1/5 1 小時以內 新手友好度 90/100
chrisparsons83/flexspotff#215 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 78/100
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 82/100
vitest-dev/vitest#11633 · 1 則留言 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 65/100
維護者通常 1 天內回覆