Shared by @futuretrees
A practical field guide based on auditing the ditto-app codebase. Solid-query and SolidJS have a few sharp edges that don't exist in React; most bugs we found fall into one of six categories below. Each section gives the failure mode, a grep pattern to surface candidates, and the fix.
query.data without an isLoading guard inside a reactive scopeWhy it's a bug. Solid-query suspends automatically when .data is read while the query is pending. Inside a createMemo, createEffect, or a JSX expression, that throw is caught by the nearest <Suspense> boundary — which in most SolidStart apps is the root. The entire subtree blocks until the query resolves. Optional chaining (query.data?.foo) does NOT save you — the bug is the .data access itself, not the dereference.
// ❌ Suspends the whole tree
const paidTiers = createMemo(() =>
(tiersQuery.data?.tiers ?? []).filter(monthlyPriceForTier)
)
// ✅ Early return on isLoading
const paidTiers = createMemo(() => {
if (tiersQuery.isLoading) return []
return (tiersQuery.data?.tiers ?? []).filter(monthlyPriceForTier)
})
How to find these: Grep for \.data\? and \.data\. inside src/, then for each hit check whether the enclosing createMemo/createEffect has an if (X.isLoading) return ... (or ternary X.isLoading ? ... : ...) earlier in the body. Both forms are equivalent — early-return is just easier to scan.
grep -rn "\.data" src/ --include="*.tsx" --include="*.ts" | grep -v test
Then for each suspect file, look at the createMemo body. False positive rate is high (~70% in our audit had guards already), but the bugs that slip through block entire surfaces.
createResource(...) without initialValueWhy it's a bug. createResource suspends by default. If you wrap a component in <Suspense> and the resource hasn't loaded, the whole subtree blocks even when the component has its own loading UI ready to render.
// ❌ Will trigger Suspense on first load
const [prompts] = createResource(source, fetcher)
// ✅ Returns initialValue while loading; component's own .loading flag handles UI
const [prompts] = createResource(source, fetcher, { initialValue: null })
How to find these: grep -rn "createResource" src/ and check every call's third argument. If the JSX downstream already handles .loading, the fix is one line.
Why it's a bug. Solid-query results are proxies. Destructuring takes a one-time snapshot and breaks reactivity — subsequent updates to .data won't re-render the component.
// ❌ Breaks reactivity
const { data, isLoading } = useQuery(...)
// ✅ Access on the object
const query = useQuery(...)
query.data
query.isLoading
How to find these: grep -rn "const { .* } = use\(Query\|DittoQuery\)" src/. Cheap to enforce — there is no legitimate reason to destructure.
Date methods on proxy-wrapped query dataWhy it's a bug. SolidJS stores wrap objects in proxies. Date instances coming out of query results or stores are proxy-wrapped. Date.prototype.getTime, toISOString, toLocaleDateString, etc. require real Date internal slots — proxy-wrapped dates throw e.getTime is not a function.
// ❌ Throws on proxy-wrapped Dates from query data
const ts = bookmark.bookmarked_at.getTime()
// ✅ Wrap in new Date() first — restores the internal slot
const ts = new Date(bookmark.bookmarked_at).getTime()
How to find these: Grep for \.getTime\(\), \.toISOString\(\), \.toLocaleDateString\(\) and check whether the receiver came from a query result or store. Locally-constructed Dates (new Date(), dayjs(...).toDate()) are safe.
useQueryWhy it's a bug. useQuery is for reads. It refetches, caches, and re-runs on focus — none of which you want for a mutation. Mutations belong in createMutation.
How to find these: grep -rn "useQuery" src/ -A 10 | grep -E "method:\s*\"(POST|PUT|DELETE|PATCH)\"". Rare but catastrophic when it happens.
useQuery instead of a project wrapperWhy it's a bug (in projects that have one). Project-specific wrappers (in ditto-app it's useDittoQuery) typically add: auth-gating, userId in cache key, automatic Zod validation, and a consistent fetcher. Bypassing the wrapper means you re-implement these by hand and usually get something wrong (e.g. missing the userId in the cache key, so logout/login shows the previous user's data).
How to find these: grep -rn "from \"@tanstack/solid-query\"" src/ to find every direct useQuery import, then check if the call hits a project API endpoint. Direct useQuery is fine for browser APIs, third-party endpoints, or polling jobs that don't need auth headers — only flag the ones hitting your own backend.
Why it's a bug. When two boolean states have different sources or lifecycles, merging them into one signal hides important distinctions. Example from ditto-app's SendMessage.tsx:
// ❌ One flag conflates "this device is streaming" and "any device is streaming"
const [isWaitingForResponse, setIsWaitingForResponse] = createSignal(false)
// ✅ Split — local SSE vs polled remote detection
const [isLocalStreamActive, setIsLocalStreamActive] = createSignal(false)
const [isWaitingForResponse, setIsWaitingForResponse] = createSignal(false)
// Now polling can stay enabled when !isLocalStreamActive() even while UI shows loading
Hard to grep for. Look for components where a single isLoading (or similar) is set from multiple unrelated code paths — that's the smell.
When auditing a Solid codebase for these bugs:
Explore agents work well. Split into 2–3 independent searches (suspense bugs, anti-patterns, perf hotspots) and run them in parallel — they don't overlap.PersonalitySidebarContent.tsx did sync-status memos correctly), use it as the template for the broken sibling (PersonalityAssessmentOverlay.tsx).useDittoQuery (in src/lib/useDittoQuery.ts) is the preferred wrapper. List endpoints should pass fallback: [] so .data is typed T[] instead of T[] | undefined..claude/skills/js-perf-debugging/blocking-patterns.md skill has copy-paste fixes for createResource, placeholderData, and Suspense-boundary scoping.