Skip to content

fix(electric): release the shape stream's visibility listener on cleanup - #2257

Open
Nabil372 wants to merge 3 commits into
TanStack:mainfrom
Nabil372:fix/electric-cleanup-releases-stream-listeners
Open

Nabil372 wants to merge 3 commits into
TanStack:mainfrom
Nabil372:fix/electric-cleanup-releases-stream-listeners

Conversation

@Nabil372

@Nabil372 Nabil372 commented Oct 25, 2026 •

Copy link
Copy Markdown

Changes

Sync cleanup now calls stream.unsubscribeAll(), so a cleaned-up Electric collection no longer leaves its ShapeStream's visibility listener registered on document or on React Native's AppState.

We run TanStack DB with Electric in production, in web apps and in Expo apps on iOS and Android, and this line has been a local patch on our side since July. I'm sending it upstream so we can drop the patch.

The Electric client registers a visibilitychange listener in the ShapeStream constructor, so the stream can pause while the page is hidden. From @electric-sql/client 1.5.23, on React Native it registers an AppState subscription through its runtime visibility adapter instead. The client removes either one only in unsubscribeAll(). Aborting the stream's signal runs the client's teardown, which removes the wake-detection timer but not the visibility listener.

The adapter's cleanup unsubscribed its own callback and aborted the stream, so the listener stayed registered, and its closure kept the whole dead stream reachable: fetch wrappers, parser, and sync state. Every cleanup followed by a restart left one more. So did every managed cache rotation on an on-demand collection with SQLite persistence, because the retired provider session goes through the same cleanup. We found it in one of our Expo apps, where a screen rebuilt its collection every minute and kept one more dead stream each time.

The stream is private to the sync session, so unsubscribeAll() affects no other consumer. After unsubscribeStream() it only removes the visibility and wake listeners. It does not abort or pause anything, and the abort that follows still owns in-flight requests and pending awaitTxId and awaitMatch waits.

The root cause is arguably in the client: its documented way to stop a stream, aborting the signal, leaves the listener behind. That is a separate change for @electric-sql/client. This one holds either way, because cleanup should release what the session created, and it works across the whole @electric-sql/client range this package accepts.

Oracle Coverage

Coverage-map owner: Electric and TrailBase, installed-SDK delivery (electric-sdk-delivery-oracle.property.test.ts). The receiving boundary is the installed Electric client, which is where the listener lives, so the law goes in the lane that drives the real ShapeStream over controlled HTTP rather than in the controlled-stream oracles.

  • Law: cleanup ends a sync run and releases its adapter-owned resources (CleanupFn in packages/db/src/types.ts; "Sync Implementation" in the collection options creator guide). After cleanup, document holds no visibilitychange listener from a stream that the run created, whatever state the source reached. While a run is active it holds at most its one stream's listener.

  • Model: the number of active sync runs, 0 or 1. It reads no adapter or client state, and it does not predict when an active stream drops its listener, so a client that starts releasing it on abort still passes.

  • Histories: new property electric.sdk-source-release. Eager, on-demand, or progressive mode; one to three sync runs, the later ones restarted through preload() after cleanup. In each run the provider answers up to three requests with a row and an up-to-date, or with a 503 that onError retries. The run then stays open, gives up on a 503 whose onError returns no retry, or aborts the external signal, and is cleaned up. A run that starts after an abort starts with the signal already aborted. The grammar never answers a request after a give-up or an abort, because the client sends none.

  • Production path and observation: a real Collection, the Electric adapter, the installed ShapeStream, and controlled HTTP. A recorder wraps document.addEventListener and removeEventListener and counts live visibilitychange listeners. The count is compared with the model after each provider answer, give-up, abort, and cleanup. A calibration check at the end of each history shows that the recorder saw one listener per run.

  • Reach (40 histories per campaign; the property fails if any state is missed):

    State Fixed seed 20261024 Random run
    cleanup-while-loading 12 7
    cleanup-after-ready 5 10
    cleanup-after-give-up 16 23
    cleanup-after-abort 19 16
    cleanup-of-restarted-run 27 20
  • RED on main: both campaigns fail at the first cleanup. The shrunk history is one eager run with no answers: one listener is still live after cleanup.

  • GREEN: both campaigns pass.

  • Mutants (all assertion failures at a cleanup checkpoint):

    Design Result
    main (no unsubscribeAll()) fails: one eager run, no answers
    Release only while the stream is not aborted fails: progressive run, one retry, then an abort
    Release only when the initial sync did not finally fail fails: progressive run, one retry, then an abort
  • Rotation witness: the existing case "discards an old SDK snapshot after managed cache rotation" now also checks that only the replacement stream's listener is live after the rotation, and none after cleanup. On main it fails with 2 live listeners after the rotation.

  • Limits: the lane observes the browser listener in jsdom. It does not install a runtime visibility adapter, so React Native's AppState path is not exercised here; the fix does not depend on which one the client uses. A live Electric service is not exercised.

Replay: TANSTACK_DB_ORACLE_SEED=<seed> TANSTACK_DB_ORACLE_PATH=<path> TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-source-release pnpm --dir packages/electric-db-collection exec vitest run tests/electric-sdk-delivery-oracle.property.test.ts -t 'replay'

The controlled ShapeStream mocks in eight test files gained unsubscribeAll: vi.fn(). The new property is registered in oracle-config.ts and the replay manifest, and the coverage map's Electric row describes it.

Shipped size: pnpm size:compare reports +9 B gzip (+15 B brotli) for @tanstack/electric-db-collection and no change elsewhere. unsubscribeAll() also clears our subscription, so unsubscribeStream() in cleanup is now redundant. I kept it to keep this change small: removing it fails 13 existing tests that assert the per-subscription unsubscribe on a controlled stream, and those would need to move to unsubscribeAll(). Happy to make that change here if you prefer.

Checklist

  • I have tested this code locally with pnpm test.

@tanstack/electric-db-collection: 1,126 tests pass, 1 skipped, no type errors. pnpm lint reports no errors in the package (existing warnings only), pnpm lint:changed is clean, and the replay manifest test passes.

Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Sync cleanup now releases visibility listeners when a stream is stopped or replaced. This prevents listeners from lingering after an aborted sync and ensures only active streams retain listeners.
  • Tests
    • Added coverage for listener cleanup across sync runs, stream replacement, retries, and aborts in eager, on-demand, and progressive modes.

The Electric client registers a visibilitychange listener (AppState on React Native) when a ShapeStream is constructed and removes it only in unsubscribeAll(). Sync cleanup unsubscribed its own callback and aborted the stream, so the listener stayed registered and kept the dead stream reachable. Each cleanup and restart, and each managed cache rotation, retained one more stream.

Cleanup now calls stream.unsubscribeAll(). The stream is private to the sync session, so this affects no other consumer.

A generated lane in the installed-SDK delivery oracle checks that cleanup leaves no listener from a run's streams across eager, on-demand and progressive runs that stay open, give up, abort or restart. The managed cache rotation case checks the retired session.
@changeset-bot

changeset-bot Bot commented Oct 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3075725

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tanstack/electric-db-collection Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3efec429-cf3a-49d8-9b1c-c5c17955aa57
Commits

Reviewing files that changed from the base of the PR and between d2935d0 and 3075725.

Files selected for processing: 14
  • .changeset/electric-cleanup-stream-listeners.md
  • docs/contributing/oracle-coverage.md
  • packages/db/tests/oracle-config.ts
  • packages/db/tests/oracle-replay-manifest.ts
  • packages/electric-db-collection/src/electric.ts
  • packages/electric-db-collection/tests/async-hold-outcomes.test.ts
  • packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts
  • packages/electric-db-collection/tests/electric-live-query.test.ts
  • packages/electric-db-collection/tests/electric-oracle.property.test.ts
  • packages/electric-db-collection/tests/electric-recovery-oracle.test.ts
  • packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts
  • packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts
  • packages/electric-db-collection/tests/electric.test.ts
  • packages/electric-db-collection/tests/tags.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Walkthrough

Session cleanup now releases all listeners on the Electric shape stream before aborting its controller. Oracle tests cover listener release during cache rotation and across sync-run histories, and the new property is registered in the oracle configuration and replay manifest.

Changes: Electric listener cleanup

Layer / File(s) Summary
Release stream listeners during cleanup
.changeset/electric-cleanup-stream-listeners.md, packages/electric-db-collection/src/electric.ts, packages/electric-db-collection/tests/*
Session cleanup calls stream.unsubscribeAll() after removing the collection listener and before aborting the stream controller. Stream test doubles now provide unsubscribeAll.
Verify and register listener release
packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts, packages/db/tests/oracle-config.ts, packages/db/tests/oracle-replay-manifest.ts, docs/contributing/oracle-coverage.md
The delivery oracle checks listener counts during cache rotation and across sync modes and run histories. The source-release property is registered in the static oracle configuration and replay manifest, and added to the ownership documentation.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Possibly related issues

Possibly related PRs

  • TanStack/db#2237: Both PRs change replay ownership for electric-sdk-delivery-oracle.property.test.ts; this PR adds the source-release property to that manifest.
  • TanStack/db#2236: Both PRs affect campaign selection and replay registration for the Electric delivery oracle; this PR adds listener-cleanup coverage.
  • TanStack/db#2233: This PR imports createOracleReach from the helper introduced by that PR for the new cleanup property.

Suggested reviewers: kyleamathews

Merge Risk: Minimal · up to 30757

Sync cleanup now releases the Electric stream's visibility listener, so retired streams are no longer retained after cleanup or restart. No concrete merge-blocking risk was found. The new test checks the browser listener in jsdom and does not cover React Native's AppState path.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 12 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title names the fix and its scope: the Electric shape stream's visibility listener is released during cleanup. The changeset adds stream.unsubscribeAll() to sync cleanup, which matches this. The t…
Description check Passed The description covers the required template sections. Changes explains the cleanup call and its motivation. Oracle Coverage names the owner, states the law, histories, production path, observation po…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names the fix and its scope: the Electric shape stream's visibility listener is released during cleanup. The changeset adds stream.unsubscribeAll() to sync cleanup, which matches this. The title is short and specific.

Full details: Description check

Explanation

The description covers the required template sections. Changes explains the cleanup call and its motivation. Oracle Coverage names the owner, states the law, histories, production path, observation points, RED/GREEN results, mutants, rotation witness, and limits. The checklist marks local testing, and Release Impact marks a changeset. The template's Oracle Coverage section asks for bug-fix evidence, and the author supplied it in detail.

Full details: Docstring Coverage

Explanation

Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 12 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant