feat(solid): implement suspend-at-read pattern for idiomatic Solid.js Suspense
#987 opened on Jan 23, 2026
Repository metrics
- Stars
- (3,599 stars)
- PR merge metrics
- (PR metrics pending)
Description
Summary
Implement the "suspend-at-read" pattern for the Solid adapter, where Suspense triggers at the read site (when the accessor is called in JSX) rather than at declaration time (when store.useQuery() or store.useClientDocument() is called).
This pattern is idiomatic to Solid.js and aligns with Solid 2.0's direction for lazy memos.
Background
Current Behavior
Currently, store.useQuery() and store.useClientDocument() eagerly evaluate the store Resource inside a createMemo, which triggers Suspense at the component level where they're declared:
// Current: "root" fallback shows (counter-intuitive)
const Child = () => {
const store = useStore()
const todos = store.useQuery(allTodos$) // Suspense triggers HERE
return (
<Suspense fallback="child">
{todos()} // User expects Suspense to trigger here
</Suspense>
)
}
const App = () => <Suspense fallback="root"><Child /></Suspense>
Desired Behavior (Suspend-at-Read)
With suspend-at-read, Suspense should trigger only when the accessor is read:
// Desired: "child" fallback shows (intuitive)
const Child = () => {
const store = useStore()
const todos = store.useQuery(allTodos$) // NO Suspense here
return (
<Suspense fallback="child">
{todos()} // Suspense triggers HERE
</Suspense>
)
}
const App = () => <Suspense fallback="root"><Child /></Suspense>
This gives developers precise control over which Suspense boundary catches the loading state.
Research Summary
Why This Matters
- Idiomatic to Solid.js - Suspense should trigger when signals are read, not declared
- Aligned with Solid 2.0 - Ryan Carniato confirmed lazy memos are coming natively
- Used by popular libraries - @tanstack/solid-query uses this pattern
- Developer expectations - Users expect the closest Suspense boundary to catch
Prior Art
- @tanstack/solid-query - Uses a similar pattern where queries set up subscriptions eagerly but defer Suspense
- Solid 2.0 lazy memos - Will provide native support for this pattern
Attempted Implementation
An implementation was attempted using a bypassSuspense utility that wrapped Resource reads in an internal Suspense boundary via Solid.children():
function bypassSuspense<T>(accessor: T | Accessor<T | undefined>): Accessor<T | undefined> {
return Solid.children(() => (
<Solid.Suspense>{accessor as unknown as Solid.JSXElement}</Solid.Suspense>
)) as unknown as Accessor<T | undefined>
}
The implementation for useQuery was:
useQuery(queryDef) {
const latestStore = bypassSuspense(store)
const queryMemo = Solid.createMemo(
when(
latestStore,
(store) => useQuery(queryDef, { store }),
(previous) => previous, // Preserve during transitions
),
)
// Suspense triggers here when store is read
return when(store, () => resolve(queryMemo()))
}
Challenge: Reactivity Breaking
The bypassSuspense approach broke reactive updates in multi-client sync scenarios.
Reproduction
The cf-chat-solid Playwright test "user sidebar shows current user and others correctly" consistently failed:
- Alice joins a chat room
- Bob joins the same room (different browser context)
- Alice should see Bob appear in the user sidebar via sync
- Bug: Bob never appeared - the
usersquery wasn't updating reactively
Root Cause Analysis
The Solid.children() + internal <Suspense> wrapper appears to break Solid's reactive tracking chain. When data syncs from another client:
- The store's internal state updates
- The query subscription detects the change
- But the memoized accessor returned by
bypassSuspensedoesn't propagate the update - The UI never re-renders
Evidence
- Test passed on commit before suspend-at-read implementation
- Test failed consistently (not flaky) with suspend-at-read
- Even with 10-second timeouts, the data never appeared
- Reverting to original implementation fixed the test
Open Questions
-
Why does
Solid.children()break reactivity?- Is it creating a new reactive scope that doesn't propagate?
- Is there a different way to "trap" Suspense without breaking tracking?
-
Alternative approaches to explore:
- Could we use
Solid.untrack()strategically? - Could we use
Solid.createDeferred()orSolid.startTransition()? - Should we wait for Solid 2.0's native lazy memos?
- Could we patch the Resource's
.loadingstate differently?
- Could we use
-
Is there a way to test reactivity in isolation?
- Create a minimal reproduction case
- Test with just signals (no store/sync complexity)
Acceptance Criteria
-
store.useQuery()does NOT trigger Suspense when called -
store.useClientDocument()state accessor does NOT trigger Suspense when called - Suspense triggers when the returned accessor is read inside a Suspense boundary
- Reactive updates work correctly (data syncing between clients)
- All existing unit tests pass (with updated assertions)
- All Playwright integration tests pass (
cf-chat-solid,web-todomvc-solid) - SSR behavior is preserved
Files to Modify
packages/@livestore/solid/src/useStore.ts-withSolidApiimplementationpackages/@livestore/solid/src/utils.ts- May need new utilitiespackages/@livestore/solid/src/useStore.client.test.tsx- Test assertions
Related
- PR #958 - Solid bindings implementation
- Solid 2.0 lazy memos discussion: https://github.com/solidjs/solid/discussions/1811
- @tanstack/solid-query implementation for reference