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/ready-bats-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/react-db": patch
"@tanstack/db": patch
---

Refactored live queries to execute eagerly during sync. Live queries now materialize their results immediately as data arrives from source collections, even while those collections are still in a "loading" state, rather than waiting for all sources to be "ready" before executing.
2 changes: 1 addition & 1 deletion packages/angular-db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ An object with Angular signals:
- state: Signal<Map<Key, T>> - Map of results by key, automatically updates
- collection: Signal<Collection> - The underlying collection instance
- status: Signal<CollectionStatus> - Current status ('idle' | 'loading' | 'ready' | 'error' | 'cleaned-up')
- isLoading: Signal<boolean> - true when status is 'loading' or 'initialCommit'
- isLoading: Signal<boolean> - true when status is 'loading'
- isReady: Signal<boolean> - true when status is 'ready'
- isIdle: Signal<boolean> - true when status is 'idle'
- isError: Signal<boolean> - true when status is 'error'
Expand Down
4 changes: 1 addition & 3 deletions packages/angular-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,7 @@ export function injectLiveQuery(opts: any) {
data,
collection,
status,
isLoading: computed(
() => status() === `loading` || status() === `initialCommit`
),
isLoading: computed(() => status() === `loading`),
isReady: computed(() => status() === `ready`),
isIdle: computed(() => status() === `idle`),
isError: computed(() => status() === `error`),
Expand Down
280 changes: 280 additions & 0 deletions packages/angular-db/tests/inject-live-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,4 +751,284 @@ describe(`injectLiveQuery`, () => {
expect(returnedCollection()).toBe(liveQueryCollection)
})
})

describe(`eager execution during sync`, () => {
it(`should show state while isLoading is true during sync`, async () => {
await TestBed.runInInjectionContext(async () => {
let syncBegin: (() => void) | undefined
let syncWrite: ((op: any) => void) | undefined
let syncCommit: (() => void) | undefined
let syncMarkReady: (() => void) | undefined

const collection = createCollection<Person>({
id: `eager-execution-test-angular`,
getKey: (person: Person) => person.id,
startSync: false,
sync: {
sync: ({ begin, write, commit, markReady }) => {
syncBegin = begin
syncWrite = write
syncCommit = commit
syncMarkReady = markReady
},
},
onInsert: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
})

const {
isLoading,
state,
data,
collection: liveQueryCollection,
} = injectLiveQuery({
query: (q) =>
q
.from({ persons: collection })
.where(({ persons }) => gt(persons.age, 30))
.select(({ persons }) => ({
id: persons.id,
name: persons.name,
})),
startSync: false,
})

// Start the live query sync manually
liveQueryCollection().preload()

await waitForAngularUpdate()

// Now isLoading should be true
expect(isLoading()).toBe(true)
expect(state().size).toBe(0)
expect(data()).toEqual([])

// Add first batch of data (but don't mark ready yet)
syncBegin!()
syncWrite!({
type: `insert`,
value: {
id: `1`,
name: `John Smith`,
age: 35,
email: `john.smith@example.com`,
isActive: true,
team: `team1`,
},
})
syncCommit!()

await waitForAngularUpdate()

// Data should be visible even though still loading
expect(state().size).toBe(1)
expect(isLoading()).toBe(true) // Still loading
expect(data()).toHaveLength(1)
expect(data()[0]).toMatchObject({
id: `1`,
name: `John Smith`,
})

// Add second batch of data
syncBegin!()
syncWrite!({
type: `insert`,
value: {
id: `2`,
name: `Jane Doe`,
age: 32,
email: `jane.doe@example.com`,
isActive: true,
team: `team2`,
},
})
syncCommit!()

await waitForAngularUpdate()

// More data should be visible
expect(state().size).toBe(2)
expect(isLoading()).toBe(true) // Still loading
expect(data()).toHaveLength(2)

// Now mark as ready
syncMarkReady!()

await waitForAngularUpdate()

// Should now be ready
expect(isLoading()).toBe(false)
expect(state().size).toBe(2)
expect(data()).toHaveLength(2)
})
})

it(`should show filtered results during sync with isLoading true`, async () => {
await TestBed.runInInjectionContext(async () => {
let syncBegin: (() => void) | undefined
let syncWrite: ((op: any) => void) | undefined
let syncCommit: (() => void) | undefined
let syncMarkReady: (() => void) | undefined

const collection = createCollection<Person>({
id: `eager-filter-test-angular`,
getKey: (person: Person) => person.id,
startSync: false,
sync: {
sync: ({ begin, write, commit, markReady }) => {
syncBegin = begin
syncWrite = write
syncCommit = commit
syncMarkReady = markReady
},
},
onInsert: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
})

const {
isLoading,
state,
data,
collection: liveQueryCollection,
} = injectLiveQuery({
query: (q) =>
q
.from({ persons: collection })
.where(({ persons }) => eq(persons.team, `team1`))
.select(({ persons }) => ({
id: persons.id,
name: persons.name,
team: persons.team,
})),
startSync: false,
})

// Start the live query sync manually
liveQueryCollection().preload()

await waitForAngularUpdate()

expect(isLoading()).toBe(true)

// Add items from different teams
syncBegin!()
syncWrite!({
type: `insert`,
value: {
id: `1`,
name: `Alice`,
age: 30,
email: `alice@example.com`,
isActive: true,
team: `team1`,
},
})
syncWrite!({
type: `insert`,
value: {
id: `2`,
name: `Bob`,
age: 25,
email: `bob@example.com`,
isActive: true,
team: `team2`,
},
})
syncWrite!({
type: `insert`,
value: {
id: `3`,
name: `Charlie`,
age: 35,
email: `charlie@example.com`,
isActive: true,
team: `team1`,
},
})
syncCommit!()

await waitForAngularUpdate()

// Should only show team1 members, even while loading
expect(state().size).toBe(2)
expect(isLoading()).toBe(true)
expect(data()).toHaveLength(2)
expect(data().every((p) => p.team === `team1`)).toBe(true)

// Mark ready
syncMarkReady!()

await waitForAngularUpdate()

expect(isLoading()).toBe(false)
expect(state().size).toBe(2)
})
})

it(`should update isReady when source collection is marked ready with no data`, async () => {
await TestBed.runInInjectionContext(async () => {
let syncMarkReady: (() => void) | undefined

const collection = createCollection<Person>({
id: `ready-no-data-test-angular`,
getKey: (person: Person) => person.id,
startSync: false,
sync: {
sync: ({ markReady }) => {
syncMarkReady = markReady
// Don't call begin/commit - just provide markReady
},
},
onInsert: () => Promise.resolve(),
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
})

const {
isLoading,
isReady,
state,
data,
status,
collection: liveQueryCollection,
} = injectLiveQuery({
query: (q) =>
q
.from({ persons: collection })
.where(({ persons }) => gt(persons.age, 30))
.select(({ persons }) => ({
id: persons.id,
name: persons.name,
})),
startSync: false,
})

// Start the live query sync manually
liveQueryCollection().preload()

await waitForAngularUpdate()

// Now isLoading should be true
expect(isLoading()).toBe(true)
expect(isReady()).toBe(false)
expect(state().size).toBe(0)
expect(data()).toEqual([])

// Mark ready without any data commits
syncMarkReady!()

await waitForAngularUpdate()

// Should now be ready, even with no data
expect(isReady()).toBe(true)
expect(isLoading()).toBe(false)
expect(state().size).toBe(0) // Still no data
expect(data()).toEqual([]) // Empty array
expect(status()).toBe(`ready`)
})
})
})
})
2 changes: 1 addition & 1 deletion packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export class CollectionImpl<
// Managers
private _events: CollectionEventsManager
private _changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
private _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
public _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
private _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
private _indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
private _mutations: CollectionMutationsManager<
Expand Down
7 changes: 3 additions & 4 deletions packages/db/src/collection/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ export class CollectionLifecycleManager<
Array<CollectionStatus>
> = {
idle: [`loading`, `error`, `cleaned-up`],
loading: [`initialCommit`, `ready`, `error`, `cleaned-up`],
initialCommit: [`ready`, `error`, `cleaned-up`],
loading: [`ready`, `error`, `cleaned-up`],
ready: [`cleaned-up`, `error`],
error: [`cleaned-up`, `idle`],
"cleaned-up": [`loading`, `error`],
Expand Down Expand Up @@ -145,8 +144,8 @@ export class CollectionLifecycleManager<
*/
public markReady(): void {
this.validateStatusTransition(this.status, `ready`)
// Can transition to ready from loading or initialCommit states
if (this.status === `loading` || this.status === `initialCommit`) {
// Can transition to ready from loading state
if (this.status === `loading`) {
this.setStatus(`ready`, true)

// Call any registered first ready callbacks (only on first time becoming ready)
Expand Down
5 changes: 1 addition & 4 deletions packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,12 +785,9 @@ export class CollectionStateManager<
this.recentlySyncedKeys.clear()
})

// Call any registered one-time commit listeners
// Mark that we've received the first commit (for tracking purposes)
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true
const callbacks = [...this.lifecycle.onFirstReadyCallbacks]
this.lifecycle.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())
}
}
}
Expand Down
6 changes: 0 additions & 6 deletions packages/db/src/collection/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,6 @@ export class CollectionSyncManager<

pendingTransaction.committed = true

// Update status to initialCommit when transitioning from loading
// This indicates we're in the process of committing the first transaction
if (this.lifecycle.status === `loading`) {
this.lifecycle.setStatus(`initialCommit`)
}

this.state.commitPendingTransactions()
},
markReady: () => {
Expand Down
8 changes: 0 additions & 8 deletions packages/db/src/indexes/auto-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@ function shouldAutoIndex(collection: CollectionImpl<any, any, any, any, any>) {
return false
}

// Don't auto-index during sync operations
if (
collection.status === `loading` ||
collection.status === `initialCommit`
) {
return false
}

return true
}

Expand Down
Loading
Loading