livestorejs/livestore

feat(solid): implement suspend-at-read pattern for idiomatic Solid.js Suspense

Closed

#987 opened on Jan 23, 2026

 (2 comments) (0 reactions) (0 assignees)TypeScript (128 forks)github user discovery
enhancementhelp wantedintegration:solid

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

  1. Idiomatic to Solid.js - Suspense should trigger when signals are read, not declared
  2. Aligned with Solid 2.0 - Ryan Carniato confirmed lazy memos are coming natively
  3. Used by popular libraries - @tanstack/solid-query uses this pattern
  4. 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:

  1. Alice joins a chat room
  2. Bob joins the same room (different browser context)
  3. Alice should see Bob appear in the user sidebar via sync
  4. Bug: Bob never appeared - the users query 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:

  1. The store's internal state updates
  2. The query subscription detects the change
  3. But the memoized accessor returned by bypassSuspense doesn't propagate the update
  4. 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

  1. 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?
  2. Alternative approaches to explore:

    • Could we use Solid.untrack() strategically?
    • Could we use Solid.createDeferred() or Solid.startTransition()?
    • Should we wait for Solid 2.0's native lazy memos?
    • Could we patch the Resource's .loading state differently?
  3. 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 - withSolidApi implementation
  • packages/@livestore/solid/src/utils.ts - May need new utilities
  • packages/@livestore/solid/src/useStore.client.test.tsx - Test assertions

Related

Contributor guide