Skip to content
Open
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
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
289 changes: 235 additions & 54 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { ReactiveMap } from '@solid-primitives/map'
import {
BaseQueryBuilder,
createLiveQueryCollection,
createLiveQueryObserver,
isCollection,
} from '@tanstack/db'
import {
batch,
createEffect,
Expand All @@ -6,14 +13,6 @@ import {
createSignal,
onCleanup,
} from 'solid-js'
import { ReactiveMap } from '@solid-primitives/map'
import {
BaseQueryBuilder,
createLiveQueryCollection,
createLiveQueryObserver,
isCollection,
isSingleResultCollection,
} from '@tanstack/db'
import { createStore, reconcile } from 'solid-js/store'
import type { Accessor } from 'solid-js'
import type {
Expand All @@ -28,8 +27,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 @@ -334,6 +341,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 @@ -342,13 +387,143 @@ 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 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 (!needsResync) setData(0, reconcile(row, RECONCILE_DEEP))
}

if (needsResync) {
syncDataOnlyFromCollection(currentCollection)
}

return !needsResync
}

// Generation guard for the resource's async continuations: Solid discards a
Expand All @@ -358,12 +533,9 @@ export function useLiveQuery(
let resourceGeneration = 0

const [getDataResource] = createResource(
() => ({ currentCollection: collection() }),
async ({ currentCollection }) => {
collection,
async (currentCollection) => {
const generation = ++resourceGeneration
if (!currentCollection) {
return []
}
setStatus(currentCollection.status)
try {
await currentCollection.toArrayWhenReady()
Expand All @@ -374,15 +546,10 @@ export function useLiveQuery(
if (generation !== resourceGeneration) {
return data
}
// 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 @@ -396,36 +563,42 @@ 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
}

// The shared observer owns subscription, the ready-race, and status; Solid
// materializes into its keyed ReactiveMap (granular) + reconciled store.
const singleResult = isSingleResult(currentCollection)
const canPatchUpdates = currentCollection.config.compare === undefined
// The shared observer owns the subscription, the ready-race, and status;
// Solid materializes the delivered deltas into the keyed store, patching
// rows granularly when membership and order cannot change.
const observer = createLiveQueryObserver(currentCollection)
// Clear any keys carried over from a previous collection before the new
// observer re-seeds via `includeInitialState` (which only inserts current
// rows, never deletes stale ones). Without this, switching collections
// leaves the dropped keys in `state` until the async resource reconciles.
state.clear()
const unsubscribe = observer.subscribe(
(changes: Array<ChangeMessage<any>> | undefined) => {
batch(() => {
if (changes) {
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 observer replays the initial state on attach, which can win
// the race against the resource. Do one authoritative sync instead
// of patching stale row indices from the previous collection.
syncDataFromCollection(currentCollection)
} else if (changes !== undefined && canPatchUpdates) {
if (singleResult) {
patchSingleResultChanges(currentCollection, changes)
} else {
patchArrayChanges(currentCollection, changes)
}
} else {
// Synthetic status notifies carry no change set, and sorted
// queries can reorder rows on any delta; both need a full resync.
syncDataFromCollection(currentCollection)
}
syncDataFromCollection(currentCollection)

// Update status ref on every change
setStatus(observer.getSnapshot().status)
})
},
Expand All @@ -440,12 +613,10 @@ export function useLiveQuery(
// We have to remove getters from the resource function so we wrap it
function getData() {
const currentCollection = collection()
if (currentCollection) {
if (isSingleResultCollection(currentCollection)) {
// 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 @@ -468,6 +639,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