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
161 changes: 161 additions & 0 deletions .changeset/solid-v2-wholesale-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
---
'@tanstack/solid-db': major
---

# Solid v2 RC migration + wholesale observer refactor

Migrates `@tanstack/solid-db` from Solid v1 to **Solid v2 RC** (`solid-js@2.0.0-rc.0`) and reworks the adapter to use the shared `LiveQueryObserver` in wholesale mode. This is a **breaking** release — the peer dependency is now `solid-js: >=2.0.0-rc.0` and `@solidjs/web: >=2.0.0-rc.0`.

## Breaking changes

### Solid v2 RC migration

Peer dependencies require Solid v2 RC. Code consuming `@tanstack/solid-db` must be migrated to Solid v2:

- `Suspense` → `Loading` (from `@solidjs/web`)
- `ErrorBoundary` → `Errored` (from `@solidjs/web`)
- `createResource` → async `createMemo` (internal; `useLiveQuery` now throws `NotReadyError` for `<Loading>` and the captured error for `<Errored>`)
- `createEffect` → split `createRenderEffect` (internal)
- `batch()` removed — v2 auto-batches
- `createStore`/`reconcile` imported from `solid-js` root (not `solid-js/store`)
- `reconcile(value, { key, merge })` → `reconcile(value, key | null)`
- Store setter uses draft callback form
- `ownedWrite: true` on status signal (written from observer callbacks)

### Removed status flags and data property from accessor

The accessor no longer exposes `data`, `status`, `isLoading`, `isReady`,
`isIdle`, `isError`, or `isCleanedUp`. Loading and error states are handled
exclusively through `<Loading>` and `<Errored>` boundaries, with `isPending`
and `latest` helpers for finer control. The accessor surface is now just
`query()` (data), `query.state` (ReactiveMap), and `query.collection`.

```diff
- query.data // removed — use query()
- query.status // removed — use <Loading>/<Errored> boundaries
- query.isLoading // removed — use isPending(query)
- query.isReady // removed — wrap reads in <Loading>
- query.isError // removed — wrap reads in <Errored>
+ query() // data access (throws NotReadyError when loading)
+ query.state // ReactiveMap<TKey, TResult>
+ query.collection // underlying Collection
```

### Data accessor throws during loading

Reading the accessor result (`query()`) while the collection is not yet ready
now throws `NotReadyError` (caught by `<Loading>`). Previously, data reads
during loading or revalidation returned stale or empty arrays synchronously.
Consumers must wrap data reads in a `<Loading>` boundary or check
`query.isReady` / `query.status` before reading.

### Wholesale observer mode

`useLiveQuery` now subscribes to the `LiveQueryObserver` in **wholesale** mode instead of granular. The observer delivers wake-up notifies; Solid's keyed `reconcile(rows, '$key')` handles the per-field diff that preserves fine-grained row reactivity.

On-demand collections that relied on the granular adapter's `includeInitialState: true` behavior must ensure initial data is loaded explicitly — matching the React adapter's wholesale policy.

The manual delta-patching layer (~160 lines: `rowIndex`, `syncRows`, `patchArrayChanges`, `patchSingleResultChanges`, `patchStoreRow`, `syncDataFromCollection`) has been removed. `useLiveQuery` adapter source went from 698 to 537 lines.

## New features

### `isPending` and `latest` helpers

The v2 migration unlocks Solid's built-in async helpers on the accessor result:

- `isPending(query)` — returns `true` while an unrevealed value change is in flight (e.g. during revalidation when a new collection is loading).
- `latest(query)` — returns the last resolved value, skipping the `<Loading>` boundary during revalidation (useful for stale-while-revalidate UIs).

```tsx
import { isPending, latest } from 'solid-js'
import { useLiveQuery } from '@tanstack/solid-db'

const query = useLiveQuery((q) => q.from({ todos: todosCollection }))

// Show a spinner refetching indicator during revalidation:
<Show when={isPending(query)}>
<Spinner />
</Show>

// Render stale data immediately during revalidation (no Loading flash):
<For each={latest(query)}>{(todo) => <li>{todo.text}</li>}</For>
```

These work because `useLiveQuery` now uses async `createMemo` whose previous
value is held in place until the new value resolves — the v2 reactive graph
contract `isPending` and `latest` read from.

### External-source bridge (opt-in)

New `enableSolidDBExternalSource()` and `trackSnapshot(observer)` exports. Uses Solid v2's `enableExternalSource` API to bridge `LiveQueryObserver` snapshots into Solid's tracking graph:

```tsx
import { enableSolidDBExternalSource, trackSnapshot } from '@tanstack/solid-db'

// Call once at app startup:
enableSolidDBExternalSource()

// trackSnapshot() inside any Solid compute auto-subscribes:
const snapshot = createMemo(() => trackSnapshot(observer))
```

Without the bridge, `useLiveQuery` handles subscription internally as before.

## Performance

Benchmarks comparing the previous Solid v1 adapter (main branch, commit
`2c35b588`) against the new Solid v2 wholesale adapter. JSDOM, median of
5 iterations each. The v1 adapter is the pre-renderer-rework version that
was running in production before this MR.

### Initial All-Row Mount

| Rows | v1 (main) | v2 wholesale | Result |
| ----- | --------: | -----------: | ------------ |
| 10 | 2.35ms | 1.54ms | 1.53× faster |
| 1,000 | 18.75ms | 11.53ms | 1.63× faster |
| 10,000| 129.74ms | 73.35ms | 1.77× faster |

### Single-Row Update in All-Row Query

| Rows | v1 (main) | v2 wholesale | Result |
| ----- | --------: | -----------: | ------------ |
| 10 | 0.06ms | 0.03ms | 2.00× faster |
| 1,000 | 0.08ms | 0.02ms | 4.00× faster |
| 10,000| 0.08ms | 0.02ms | 4.00× faster |

### 10% Row Batch Update

| Rows | v1 (main) | v2 wholesale | Result |
| ----- | --------: | -----------: | ------------ |
| 10 | 0.07ms | 0.04ms | 1.75× faster |
| 1,000 | 9.59ms | 1.68ms | 5.71× faster |
| 10,000| 97.40ms | 24.00ms | 4.06× faster |

### Repeated Single-Row Updates (1000 rows × 200 commits)

| v1 (main) | v2 wholesale | Result |
| --------: | -----------: | ------------ |
| 2.52ms | 2.60ms | 0.97× (par) |

### findOne Update (1000 rows)

| v1 (main) | v2 wholesale | Result |
| --------: | -----------: | ------------ |
| 0.01ms | 0.03ms | 0.33× slower |

### Remount After Update (1000 rows)

| v1 (main) | v2 wholesale | Result |
| --------: | -----------: | ------------ |
| 3.13ms | 3.77ms | 0.83× slower |

**Summary**: The v2 wholesale adapter is **1.5–5.7× faster** than the v1
adapter for mount, single-row updates, and batch updates — the scenarios
that dominate real-world usage. findOne and remount are marginally slower
(sub-millisecond absolute difference). Repeated rapid-fire single-row
updates are on par.

The gains come from eliminating the v1 adapter's full-store-reset on every
change (replaced by Solid v2's keyed `reconcile`) and from the wholesale
observer's efficient snapshot caching.
128 changes: 83 additions & 45 deletions docs/framework/solid/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ id: adapter
npm install @tanstack/solid-db
```

Requires `solid-js@>=2.0.0-rc.0` and `@solidjs/web@>=2.0.0-rc.0` as peer dependencies.

## Solid Primitives

See the [Solid Functions Reference](./reference/index.md) to see the full list of primitives available in the Solid Adapter.
Expand All @@ -19,12 +21,13 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio

### useLiveQuery

The `useLiveQuery` primitive creates a live query that automatically updates your component when data changes. It returns an object where `data` is a plain array and status fields (e.g. `isLoading()`, `status()`) are accessors:
The `useLiveQuery` primitive creates a live query that automatically updates your component when data changes. It returns an accessor — call it as a function (`query()`) to read data. Status fields (`isLoading`, `isReady`, `isError`, `status`) are plain properties:

```tsx
import { useLiveQuery } from '@tanstack/solid-db'
import { eq } from '@tanstack/db'
import { Show, For } from 'solid-js'
import { For } from 'solid-js'
import { Loading } from '@solidjs/web'

function TodoList() {
const query = useLiveQuery((q) =>
Expand All @@ -34,18 +37,66 @@ function TodoList() {
)

return (
<Show when={!query.isLoading()} fallback={<div>Loading...</div>}>
<Loading fallback={<div>Loading...</div>}>
<ul>
<For each={query.data}>
<For each={query()}>
{(todo) => <li>{todo.text}</li>}
</For>
</ul>
</Show>
</Loading>
)
}
```

**Note:** Call `query()` to read data. Use `<Loading>` and `<Errored>` boundaries to handle loading and error states. The accessor also exposes `query.state` (a `ReactiveMap`) and `query.collection` (the underlying `Collection`).

### Loading and Error Boundaries

In Solid v2, reading the accessor while the collection is loading throws `NotReadyError` (caught by `<Loading>`), and reading an errored query throws the error (caught by `<Errored>`):

```tsx
import { Loading, Errored } from '@solidjs/web'

function TodoList() {
const query = useLiveQuery((q) => q.from({ todos: todosCollection }))

return (
<Errored catch={(err) => <div>Error: {err.message}</div>}>
<Loading fallback={<div>Loading...</div>}>
<For each={query()}>
{(todo) => <li>{todo.text}</li>}
</For>
</Loading>
</Errored>
)
}
```

**Note:** `query.data` returns an array directly (not a function), but status fields like `isLoading()`, `status()`, etc. are accessor functions.
You can also check status without boundaries:

```tsx
<Show when={query.isError}>
<div>Error: {query.status}</div>
</Show>
```

### isPending and latest Helpers

Solid v2's async `createMemo` enables `isPending` and `latest` on the accessor result:

```tsx
import { isPending, latest } from 'solid-js'

// isPending: true during revalidation while new collection loads
<Show when={isPending(query)}>
<Spinner />
</Show>

// latest: returns stale value during revalidation, skipping <Loading>
<For each={latest(query)}>
{(todo) => <li>{todo.text}</li>}
</For>
```

### Reactive Queries with Signals

Expand All @@ -62,7 +113,7 @@ function FilteredTodos(props: { minPriority: number }) {
.where(({ todos }) => gt(todos.priority, props.minPriority))
)

return <div>{query.data.length} high-priority todos</div>
return <div>{query().length} high-priority todos</div>
}
```

Expand Down Expand Up @@ -98,7 +149,7 @@ function TodoList() {
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<div>{query.data.length} todos</div>
<div>{query().length} todos</div>
</div>
)
}
Expand All @@ -118,52 +169,25 @@ import { gt } from '@tanstack/db'
function TodoList() {
const [minPriority, setMinPriority] = createSignal(5)

// Good - signal accessed inside query function
const query = useLiveQuery((q) =>
q.from({ todos: todosCollection })
.where(({ todos }) => gt(todos.priority, minPriority()))
)

// Solid automatically tracks minPriority() and recomputes when it changes
return <div>{query.data.length} todos</div>
return <div>{query().length} todos</div>
}
```

**Don't read signals outside the query function:**

```tsx
import { createSignal } from 'solid-js'
import { useLiveQuery } from '@tanstack/solid-db'
import { gt } from '@tanstack/db'

function TodoList() {
const [minPriority, setMinPriority] = createSignal(5)

// Bad - reading signal outside query function
const currentPriority = minPriority()
const query = useLiveQuery((q) =>
q.from({ todos: todosCollection })
.where(({ todos }) => gt(todos.priority, currentPriority))
)
// Won't update when minPriority changes!

return <div>{query.data.length} todos</div>
}
```

**Static queries need no special handling:**

```tsx
import { useLiveQuery } from '@tanstack/solid-db'

function AllTodos() {
// No signals accessed - query never changes
const query = useLiveQuery((q) =>
q.from({ todos: todosCollection })
)

return <div>{query.data.length} todos</div>
}
// Bad - reading signal outside query function
const currentPriority = minPriority()
const query = useLiveQuery((q) =>
q.from({ todos: todosCollection })
.where(({ todos }) => gt(todos.priority, currentPriority))
)
// Won't update when minPriority changes!
```

### Using Pre-created Collections
Expand All @@ -181,9 +205,23 @@ const todosQuery = createLiveQueryCollection((q) =>
)

function TodoList() {
// Pass existing collection
// Pass existing collection via accessor
const query = useLiveQuery(() => todosQuery)

return <div>{query.data.length} todos</div>
return <div>{query().length} todos</div>
}
```

### External-Source Bridge (Opt-in)

For advanced use cases where you want observer snapshots to auto-track in any Solid compute without `useLiveQuery`, install the external-source bridge once at app startup:

```tsx
import { enableSolidDBExternalSource, trackSnapshot } from '@tanstack/solid-db'
import { createMemo } from 'solid-js'

enableSolidDBExternalSource()

// Now trackSnapshot() auto-subscribes inside any Solid compute:
const snapshot = createMemo(() => trackSnapshot(observer))
```
Loading