FirebaseExtended / FirebaseExtended/reactfire

The observable cache leaks subscriptions and is shared across SSR requests

未关闭
#790 2 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看
v5
主要语言
TypeScript
星标
3.6k
派生
403
平均合并
14 小时 53 分钟
30 天内合并 PR
5

描述

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/`).

**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 `firstEmission` only 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`) returning `this._hasValue || !!this._error` is 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".

贡献指南

打开贡献指南

调研方向

Start with SuspenseSubject.ts, especially the constructor, _subscribe, _reset, and hasValue, then trace the module-level cache binding in useObservable.ts and the three preload exports. Review the SSR behavior with renderToPipeableStream. Done means concurrent requests do not share entries, server rendering leaves no open subscriptions, and cache entries are evicted.

由索引模型根据 Issue 内容生成。

评估

技术栈
react, typescript
领域
backend, testing
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
活跃
描述清晰度
基本清楚
新手友好度
35/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。