Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/small-doors-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-db': minor
---

Rework to the solid js renderer that increases performance up to 4x
283 changes: 233 additions & 50 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { ReactiveMap } from '@solid-primitives/map'
import {
BaseQueryBuilder,
CollectionImpl,
createLiveQueryCollection,
} from '@tanstack/db'
import {
batch,
createEffect,
Expand All @@ -6,18 +12,11 @@ import {
createSignal,
onCleanup,
} from 'solid-js'
import { ReactiveMap } from '@solid-primitives/map'
import {
BaseQueryBuilder,
CollectionImpl,
createLiveQueryCollection,
} from '@tanstack/db'
import { createStore, reconcile } from 'solid-js/store'
import type { Accessor } from 'solid-js'
import type {
ChangeMessage,
Collection,
CollectionConfigSingleRowOption,
CollectionStatus,
Context,
GetResult,
Expand All @@ -27,8 +26,16 @@ import type {
NonSingleResult,
QueryBuilder,
SingleResult,
WithVirtualProps,
} from '@tanstack/db'

const RECONCILE_KEY = { key: `$key` } as const

const RECONCILE_DEEP = { merge: true } as const

type AnyCollection = Collection<any, any, any>
type AnyChange = ChangeMessage<any, string | number>

/**
* Create a live query using a query function
* @param queryFn - Query function that defines what data to fetch
Expand Down Expand Up @@ -333,6 +340,44 @@ export function useLiveQuery(
name: `TanstackDBData`,
})

const rowIndex = new Map<string | number, number>()
const syncRows: Array<any> = []

// The collection currently reflected by `data`.
let syncedCollection: AnyCollection | null = null

// `.state` is maintained lazily and can lag behind `data` until accessed.
let stateSyncedCollection: AnyCollection | null = null
let stateAccessed = false

// The row currently exposed by findOne-style queries.
let singleRowKey: string | number | undefined

// Read the collection config once at call sites that need single-row behavior.
const isSingleResult = (currentCollection: AnyCollection) => {
const config = currentCollection.config
return 'singleResult' in config && config.singleResult === true
}

// Patch an existing store row instead of replacing the array. This keeps
// Solid's per-field subscriptions alive for rows that did not change.
const patchStoreRow = (index: number, row: WithVirtualProps<any>) => {
if (index >= data.length) return false

setData(index, reconcile(row, RECONCILE_DEEP))
return true
}

// Public `.state` is lazy. Most consumers only use the accessor result, so we
// avoid maintaining a second reactive map until `.state` is actually read.
const syncStateFromCollection = (currentCollection: AnyCollection) => {
state.clear()
for (const value of currentCollection.values()) {
state.set(value.$key, value)
}
stateSyncedCollection = currentCollection
}

// Track collection status reactively
const [status, setStatus] = createSignal(
collection() ? collection()!.status : (`disabled` as const),
Expand All @@ -341,37 +386,161 @@ export function useLiveQuery(
},
)

// Helper to sync data array from collection in correct order
// Sync the ordered result array from the collection, reusing scratch storage.
const syncDataFromCollection = (
currentCollection: Collection<any, any, any>,
currentCollection: AnyCollection,
stateTarget = stateAccessed ? state : undefined,
) => {
setData((prev) =>
reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean),
)
syncedCollection = currentCollection

// Unsorted result collections keep stable positions by key; sorted queries
// may move rows, so they always resync instead of using rowIndex patches.
const shouldTrackIndex = currentCollection.config.compare === undefined
if (shouldTrackIndex) rowIndex.clear()

stateTarget?.clear()

if (isSingleResult(currentCollection)) {
const value = currentCollection.values().next().value
if (!value) {
singleRowKey = undefined
syncRows.length = 0
if (stateTarget) stateSyncedCollection = currentCollection
setData(reconcile(syncRows, RECONCILE_KEY))
return
}

const row = value
singleRowKey = row.$key
if (stateTarget) {
stateTarget.set(row.$key, row)
stateSyncedCollection = currentCollection
}
syncRows[0] = row
syncRows.length = 1
setData(reconcile(syncRows, RECONCILE_KEY))
return
}

syncRows.length = 0

let index = 0
for (const value of currentCollection.values()) {
const row = value
syncRows[index] = row
if (shouldTrackIndex) rowIndex.set(row.$key, index)
if (stateTarget) stateTarget.set(row.$key, row)
index++
}
syncRows.length = index
if (stateTarget) stateSyncedCollection = currentCollection

setData(reconcile(syncRows, RECONCILE_KEY))
}

const [getDataResource] = createResource(
() => ({ currentCollection: collection() }),
async ({ currentCollection }) => {
if (!currentCollection) {
return []
const syncDataOnlyFromCollection = (currentCollection: AnyCollection) => {
// Used after `.state` has already been incrementally updated while `data`
// still needs an authoritative rebuild for ordering/membership.
syncDataFromCollection(currentCollection, undefined)
}

// Fast path for update-only batches. Inserts/deletes or sorted queries can
// change membership/order, so those fall back to a collection resync.
const patchArrayChanges = (
currentCollection: AnyCollection,
changes: Array<AnyChange>,
) => {
let needsResync = false

for (const change of changes) {
if (change.type !== `update`) {
// Inserts/deletes can change membership; update `.state` while we are
// here, then rebuild `data` once after the loop.
needsResync = true
if (stateAccessed) {
if (change.type === `delete`) {
state.delete(change.key)
} else {
state.set(change.key, change.value)
}
}
continue
}

const row = change.value

if (stateAccessed) state.set(change.key, row)

// Once a batch needs a resync, avoid doing wasted row-level patches for
// later updates in the same batch.
if (needsResync) continue

const index = rowIndex.get(change.key)
if (index === undefined || !patchStoreRow(index, row)) {
needsResync = true
}
}

if (needsResync) {
syncDataOnlyFromCollection(currentCollection)
}

return !needsResync
}

const patchSingleResultChanges = (
currentCollection: AnyCollection,
changes: Array<AnyChange>,
) => {
let needsResync = false

for (const change of changes) {
if (change.type !== `update`) {
// Non-update changes can replace/remove the single result; update the
// lazy state map now and rebuild `data` after this pass.
needsResync = true
if (stateAccessed) {
if (change.type === `delete`) {
state.delete(change.key)
} else {
state.set(change.key, change.value)
}
}
continue
}

// Updates for non-matching rows do not affect the exposed single result.
if (change.key !== singleRowKey) continue

const row = change.value
if (stateAccessed) state.set(change.key, row)

// If the batch already needs a resync, defer the visible update to the
// final rebuild so ordering/membership stays authoritative.
if (!needsResync) setData(0, reconcile(row))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

if (needsResync) {
syncDataOnlyFromCollection(currentCollection)
}

return !needsResync
}

const [getDataResource] = createResource(
collection,
async (currentCollection) => {
setStatus(currentCollection.status)
try {
await currentCollection.toArrayWhenReady()
} catch (error) {
setStatus(`error`)
throw error
}
// Initialize state with current collection data
batch(() => {
state.clear()
for (const [key, value] of currentCollection.entries()) {
state.set(key, value)
}
if (syncedCollection !== currentCollection) {
syncDataFromCollection(currentCollection)
setStatus(currentCollection.status)
})
}
setStatus(currentCollection.status)
return data
},
{
Expand All @@ -385,35 +554,43 @@ export function useLiveQuery(
const currentCollection = collection()
if (!currentCollection) {
setStatus(`disabled` as const)
state.clear()
setData([])
syncedCollection = null
stateSyncedCollection = null
singleRowKey = undefined
rowIndex.clear()
if (stateAccessed) state.clear()
syncRows.length = 0
setData(reconcile(syncRows, RECONCILE_KEY))
return
}
const singleResult = isSingleResult(currentCollection)
const canPatchUpdates = currentCollection.config.compare === undefined
const subscription = currentCollection.subscribeChanges(
(changes: Array<ChangeMessage<any>>) => {
// Apply each change individually to the reactive state
(changes) => {
batch(() => {
for (const change of changes) {
switch (change.type) {
case `insert`:
case `update`:
state.set(change.key, change.value)
break
case `delete`:
state.delete(change.key)
break
if (syncedCollection !== currentCollection) {
// The resource usually hydrates first, but a live change can win
// that race. Do one authoritative sync instead of patching stale
// row indices from the previous collection.
syncDataFromCollection(currentCollection)
} else if (canPatchUpdates) {
if (singleResult) {
patchSingleResultChanges(currentCollection, changes)
} else {
patchArrayChanges(currentCollection, changes)
}
} else {
syncDataFromCollection(currentCollection)
}

syncDataFromCollection(currentCollection)

// Update status ref on every change
setStatus(currentCollection.status)
})
},
{
// Include initial state to ensure immediate population for pre-created collections
includeInitialState: true,
// The resource hydrates the initial snapshot. Replaying it here causes
// stale keyed rows to survive deletes in some joined/optimistic cases.
includeInitialState: false,
},
)

Expand All @@ -425,14 +602,10 @@ export function useLiveQuery(
// We have to remove getters from the resource function so we wrap it
function getData() {
const currentCollection = collection()
if (currentCollection) {
const config: CollectionConfigSingleRowOption<any, any, any> =
currentCollection.config
if (config.singleResult) {
// Force resource tracking so Suspense works
getDataResource()
return data[0]
}
if (currentCollection && isSingleResult(currentCollection)) {
// Force resource tracking so Suspense works before the collection is ready.
if (status() !== `ready`) getDataResource()
return data[0]
}
return getDataResource()
}
Expand All @@ -455,6 +628,16 @@ export function useLiveQuery(
},
state: {
get() {
stateAccessed = true
const currentCollection = collection()
if (!currentCollection) {
if (stateSyncedCollection !== null) {
state.clear()
stateSyncedCollection = null
}
} else if (stateSyncedCollection !== currentCollection) {
syncStateFromCollection(currentCollection)
}
return state
},
},
Expand Down
Loading