Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/polite-eels-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/electric-db-collection": patch
"@tanstack/db": patch
---

Fix repeated renders when markReady called when the collection was already ready. This would occur after each long poll on an Electric collection.
28 changes: 20 additions & 8 deletions packages/db/src/collection/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CollectionInErrorStateError,
CollectionStateError,
InvalidCollectionStatusTransitionError,
} from "../errors"
import {
Expand Down Expand Up @@ -90,7 +91,18 @@ export class CollectionLifecycleManager<
* Safely update the collection status with validation
* @private
*/
public setStatus(newStatus: CollectionStatus): void {
public setStatus(
newStatus: CollectionStatus,
allowReady: boolean = false
): void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prevent same status calls: if (newStatus === this.status) return

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to address this in a follow-up pr, when this is done it triggers at least one test failure.

if (newStatus === `ready` && !allowReady) {
// setStatus('ready') is an internal method that should not be called directly
// Instead, use markReady to transition to ready triggering the necessary events
// and side effects.
throw new CollectionStateError(
`You can't directly call "setStatus('ready'). You must use markReady instead.`
)
}
this.validateStatusTransition(this.status, newStatus)
const previousStatus = this.status
this.status = newStatus
Expand Down Expand Up @@ -129,9 +141,10 @@ export class CollectionLifecycleManager<
* @private - Should only be called by sync implementations
*/
public markReady(): void {
this.validateStatusTransition(this.status, `ready`)
// Can transition to ready from loading or initialCommit states
if (this.status === `loading` || this.status === `initialCommit`) {
this.setStatus(`ready`)
this.setStatus(`ready`, true)

// Call any registered first ready callbacks (only on first time becoming ready)
if (!this.hasBeenReady) {
Expand All @@ -146,12 +159,11 @@ export class CollectionLifecycleManager<
this.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())
}
}

// Always notify dependents when markReady is called, after status is set
// This ensures live queries get notified when their dependencies become ready
if (this.changes.changeSubscriptions.size > 0) {
this.changes.emitEmptyReadyEvent()
// Notify dependents when markReady is called, after status is set
// This ensures live queries get notified when their dependencies become ready
if (this.changes.changeSubscriptions.size > 0) {
this.changes.emitEmptyReadyEvent()
}
Comment on lines +162 to +166
Copy link
Collaborator Author

@samwillis samwillis Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this inside the if above ensures we only send this empty change event when we first transition to ready. This is the fix to prevent additional renders.

}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ export class CollectionStateManager<

// Ensure listeners are active before emitting this critical batch
if (this.lifecycle.status !== `ready`) {
this.lifecycle.setStatus(`ready`)
this.lifecycle.markReady()
Copy link
Collaborator Author

@samwillis samwillis Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change fixed what the real cause of the stuck loading status bug from #532. We should always use markReady when transitioning to ready.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we just remove setStatus? It's nice to have proper functions where we can put in more logic when needed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed for this PR of course but a thought

}
}

Expand Down
99 changes: 99 additions & 0 deletions packages/electric-db-collection/tests/electric-live-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ describe.each([
subscriber(messages)
}

function simulateUpToDateOnly() {
// Send only an up-to-date message with no data changes
subscriber([{ headers: { control: `up-to-date` } }])
}

beforeEach(() => {
electricCollection = createElectricUsersCollection()
})
Expand Down Expand Up @@ -338,4 +343,98 @@ describe.each([
expect(testElectricCollection.status).toBe(`ready`)
expect(liveQuery.status).toBe(`ready`)
})

it(`should not emit changes on up-to-date messages with no data changes`, async () => {
// Test to verify that up-to-date messages without actual data changes
// don't trigger unnecessary renders in live query collections

// Create a live query collection
const liveQuery = createLiveQueryCollection({
startSync: true,
query: (q) =>
q
.from({ user: electricCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
active: user.active,
})),
})

// Track changes emitted by the live query
const changeNotifications: Array<any> = []
const subscription = liveQuery.subscribeChanges((changes) => {
changeNotifications.push(changes)
})

// Initial sync with data
simulateInitialSync()
expect(liveQuery.status).toBe(`ready`)
expect(liveQuery.size).toBe(3) // Only active users

// Clear any initial change notifications
changeNotifications.length = 0

// Send an up-to-date message with no data changes
// This simulates the scenario where Electric sends up-to-date
// but there are no actual changes to the data
simulateUpToDateOnly()

// Wait a tick to ensure any async operations complete
await new Promise((resolve) => setTimeout(resolve, 0))

// The live query should not have emitted any changes
// because there were no actual data changes
expect(changeNotifications).toHaveLength(0)

// Verify the collection is still in ready state
expect(liveQuery.status).toBe(`ready`)
expect(liveQuery.size).toBe(3)

// Clean up
subscription.unsubscribe()
})

it(`should not emit changes on multiple consecutive up-to-date messages with no data changes`, async () => {
// Test to verify that multiple consecutive up-to-date messages
// without data changes don't accumulate unnecessary renders

const liveQuery = createLiveQueryCollection({
startSync: true,
query: (q) => q.from({ user: electricCollection }),
})

// Track changes emitted by the live query
const changeNotifications: Array<any> = []
const subscription = liveQuery.subscribeChanges((changes) => {
changeNotifications.push(changes)
})

// Initial sync
simulateInitialSync()
expect(liveQuery.status).toBe(`ready`)
expect(liveQuery.size).toBe(4)

// Clear initial change notifications
changeNotifications.length = 0

// Send multiple up-to-date messages with no data changes
simulateUpToDateOnly()
simulateUpToDateOnly()
simulateUpToDateOnly()

// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 0))

// Should not have emitted any changes despite multiple up-to-date messages
expect(changeNotifications).toHaveLength(0)

// Verify collection state is still correct
expect(liveQuery.status).toBe(`ready`)
expect(liveQuery.size).toBe(4)

// Clean up
subscription.unsubscribe()
})
})
Loading