fix(electric): bound refresh wait for on-demand subsets#1575
Conversation
📝 WalkthroughWalkthroughBounds Electric stream refresh waits to 250ms before on-demand subset loading requests a snapshot. Timers are cleared after refresh completion, timeout, or rejection, with tests covering each path. ChangesBounded Electric Stream Refresh
Estimated code review effort: 2 (Simple) | ~10 minutes Sequence Diagram(s)sequenceDiagram
participant LoadSubset
participant ElectricStream
participant SnapshotRequest
LoadSubset->>ElectricStream: forceDisconnectAndRefresh()
alt resolves within 250ms
ElectricStream-->>LoadSubset: refresh completes
else timeout
LoadSubset->>SnapshotRequest: requestSnapshot()
end
LoadSubset->>LoadSubset: clear timeout
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
More templates
@tanstack/angular-db
@tanstack/browser-db-sqlite-persistence
@tanstack/capacitor-db-sqlite-persistence
@tanstack/cloudflare-durable-objects-db-sqlite-persistence
@tanstack/db
@tanstack/db-ivm
@tanstack/db-sqlite-persistence-core
@tanstack/electric-db-collection
@tanstack/electron-db-sqlite-persistence
@tanstack/expo-db-sqlite-persistence
@tanstack/node-db-sqlite-persistence
@tanstack/offline-transactions
@tanstack/powersync-db-collection
@tanstack/query-db-collection
@tanstack/react-db
@tanstack/react-native-db-sqlite-persistence
@tanstack/rxdb-db-collection
@tanstack/solid-db
@tanstack/svelte-db
@tanstack/tauri-db-sqlite-persistence
@tanstack/trailbase-db-collection
@tanstack/vue-db
commit: |
|
Size Change: 0 B Total Size: 125 kB ℹ️ View Unchanged
|
|
Size Change: 0 B Total Size: 4.22 kB ℹ️ View Unchanged
|
Code reviewFound 1 issue:
db/packages/electric-db-collection/src/electric.ts Lines 488 to 507 in 56f2bf3 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/electric-db-collection/tests/electric.test.ts`:
- Around line 2619-2650: Update the test case around loadSubset so rejectRefresh
is invoked before advancing the fake timer, ensuring the refresh rejection wins
the race and exercises the refresh-error catch path. Then await the refresh
rejection and loadSubset, and retain assertions that exactly one snapshot
request occurs and no timers remain.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 357c4487-85be-40aa-9f43-e3ba95034a67
📒 Files selected for processing (1)
packages/electric-db-collection/tests/electric.test.ts
| it(`should handle late refresh rejection after requesting the snapshot`, async () => { | ||
| vi.useFakeTimers() | ||
| try { | ||
| let rejectRefresh: (error: Error) => void = () => {} | ||
| const refresh = new Promise<void>((_resolve, reject) => { | ||
| rejectRefresh = reject | ||
| }) | ||
| mockStream.isUpToDate = true | ||
| mockForceDisconnectAndRefresh.mockReturnValueOnce(refresh) | ||
|
|
||
| const testCollection = createCollection( | ||
| electricCollectionOptions({ | ||
| id: `on-demand-refresh-timeout-rejection-test`, | ||
| shapeOptions: { | ||
| url: `http://test-url`, | ||
| params: { table: `test_table` }, | ||
| }, | ||
| syncMode: `on-demand`, | ||
| getKey: (item: Row) => item.id as number, | ||
| startSync: true, | ||
| }), | ||
| ) | ||
|
|
||
| const load = testCollection._sync.loadSubset({ limit: 10 }) | ||
| await vi.advanceTimersByTimeAsync(250) | ||
| await load | ||
|
|
||
| rejectRefresh(new Error(`late refresh failure`)) | ||
| await expect(refresh).rejects.toThrow(`late refresh failure`) | ||
| await Promise.resolve() | ||
| expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) | ||
| expect(vi.getTimerCount()).toBe(0) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Cover rejection before the timeout wins.
This rejects only after the 250 ms timeout settled Promise.race, so it does not exercise loadSubset’s refresh-error catch path. Make the refresh reject before advancing time, then assert loadSubset() still requests one snapshot and clears the pending timeout.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/electric-db-collection/tests/electric.test.ts` around lines 2619 -
2650, Update the test case around loadSubset so rejectRefresh is invoked before
advancing the fake timer, ensuring the refresh rejection wins the race and
exercises the refresh-error catch path. Then await the refresh rejection and
loadSubset, and retain assertions that exactly one snapshot request occurs and
no timers remain.
Source: Coding guidelines
Summary
On-demand Electric live queries no longer wait indefinitely for
forceDisconnectAndRefresh()before requesting subset snapshots. This prevents React Native/Expo-style native fetch implementations that don't promptly abort long polls from keeping live queries inloadinguntil the Electric live poll naturally times out.Root Cause
For on-demand sync,
@tanstack/electric-db-collectionasks Electric for a subset snapshot when a live query needs data. If the underlyingShapeStreamis already up-to-date, the collection first calls:That is intended to abort an in-flight live long-poll and force a fresh stream round-trip before
requestSnapshot(). In browsers this normally returns quickly becausefetchaborts the long-poll promptly.Some native fetch implementations, especially React Native/Expo, may not reject/abort long-poll requests promptly. In that case,
forceDisconnectAndRefresh()can remain pending until the long-poll times out. Because the live query tracks the subset load promise, the query can keep reportingloadingeven after rows are present locally.Approach
Bound the refresh wait with a small timeout:
If the refresh finishes quickly, behavior is unchanged. If it does not finish within
250ms, the collection proceeds torequestSnapshot()instead of waiting for the long-poll to finish naturally.Key Invariants
requestSnapshot()still runs after the bounded refresh attempt.handleSnapshotError()path.Non-goals
fetchClient.Trade-offs
A fully awaited refresh is slightly stricter, but it can turn native fetch abort quirks into user-visible multi-second or multi-minute loading states. A bounded wait preserves the intended fast path while making the on-demand path resilient when abort is slow or ignored.
The timeout is intentionally short (
250ms) because the refresh is only a pre-snapshot optimization. If it cannot complete quickly, making progress withrequestSnapshot()is better than holding the live query in loading until the live poll naturally returns.Verification
pnpm --filter @tanstack/electric-db-collection build pnpm --filter @tanstack/electric-db-collection testBoth passed.
Note: in a fresh worktree, running the filtered test before building produced type-resolution errors for e2e suite imports of
@tanstack/electric-db-collection. Building the package first fixed that, and the package tests passed.Files changed
packages/electric-db-collection/src/electric.tsFORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS.stream.forceDisconnectAndRefresh()in a boundedPromise.race()before on-demand subset snapshot requests..changeset/tame-rn-live-poll-refresh.md@tanstack/electric-db-collection.Summary by CodeRabbit