@livestore/solid 0.4.0-dev.22 → 0.4.0-dev.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/StoreRegistryContext.d.ts +56 -0
  3. package/dist/StoreRegistryContext.d.ts.map +1 -0
  4. package/dist/StoreRegistryContext.jsx +60 -0
  5. package/dist/StoreRegistryContext.jsx.map +1 -0
  6. package/dist/__tests__/fixture.d.ts +14 -0
  7. package/dist/__tests__/fixture.d.ts.map +1 -0
  8. package/dist/__tests__/fixture.jsx +13 -0
  9. package/dist/__tests__/fixture.jsx.map +1 -0
  10. package/dist/experimental/components/LiveList.d.ts +24 -0
  11. package/dist/experimental/components/LiveList.d.ts.map +1 -0
  12. package/dist/experimental/components/LiveList.jsx +24 -0
  13. package/dist/experimental/components/LiveList.jsx.map +1 -0
  14. package/dist/experimental/mod.d.ts +2 -0
  15. package/dist/experimental/mod.d.ts.map +1 -0
  16. package/dist/experimental/mod.js +2 -0
  17. package/dist/experimental/mod.js.map +1 -0
  18. package/dist/mod.d.ts +6 -2
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js +4 -2
  21. package/dist/mod.js.map +1 -1
  22. package/dist/useClientDocument.client.test.d.ts +2 -0
  23. package/dist/useClientDocument.client.test.d.ts.map +1 -0
  24. package/dist/useClientDocument.client.test.jsx +177 -0
  25. package/dist/useClientDocument.client.test.jsx.map +1 -0
  26. package/dist/useClientDocument.d.ts +71 -0
  27. package/dist/useClientDocument.d.ts.map +1 -0
  28. package/dist/useClientDocument.js +74 -0
  29. package/dist/useClientDocument.js.map +1 -0
  30. package/dist/useClientDocument.server.test.d.ts +6 -0
  31. package/dist/useClientDocument.server.test.d.ts.map +1 -0
  32. package/dist/useClientDocument.server.test.jsx +76 -0
  33. package/dist/useClientDocument.server.test.jsx.map +1 -0
  34. package/dist/useQuery.client.test.d.ts +2 -0
  35. package/dist/useQuery.client.test.d.ts.map +1 -0
  36. package/dist/useQuery.client.test.jsx +165 -0
  37. package/dist/useQuery.client.test.jsx.map +1 -0
  38. package/dist/useQuery.d.ts +32 -0
  39. package/dist/useQuery.d.ts.map +1 -0
  40. package/dist/useQuery.js +64 -0
  41. package/dist/useQuery.js.map +1 -0
  42. package/dist/useQuery.server.test.d.ts +6 -0
  43. package/dist/useQuery.server.test.d.ts.map +1 -0
  44. package/dist/useQuery.server.test.jsx +88 -0
  45. package/dist/useQuery.server.test.jsx.map +1 -0
  46. package/dist/useStore.client.test.d.ts +2 -0
  47. package/dist/useStore.client.test.d.ts.map +1 -0
  48. package/dist/useStore.client.test.jsx +438 -0
  49. package/dist/useStore.client.test.jsx.map +1 -0
  50. package/dist/useStore.d.ts +91 -0
  51. package/dist/useStore.d.ts.map +1 -0
  52. package/dist/useStore.js +94 -0
  53. package/dist/useStore.js.map +1 -0
  54. package/dist/useStore.server.test.d.ts +6 -0
  55. package/dist/useStore.server.test.d.ts.map +1 -0
  56. package/dist/useStore.server.test.jsx +56 -0
  57. package/dist/useStore.server.test.jsx.map +1 -0
  58. package/dist/utils.d.ts +4 -0
  59. package/dist/utils.d.ts.map +1 -0
  60. package/dist/utils.js +7 -0
  61. package/dist/utils.js.map +1 -0
  62. package/dist/whenever.d.ts +32 -0
  63. package/dist/whenever.d.ts.map +1 -0
  64. package/dist/whenever.js +51 -0
  65. package/dist/whenever.js.map +1 -0
  66. package/package.json +64 -16
  67. package/src/StoreRegistryContext.tsx +70 -0
  68. package/src/__snapshots__/useClientDocument.client.test.tsx.snap +570 -0
  69. package/src/__snapshots__/useQuery.client.test.tsx.snap +1550 -0
  70. package/src/__tests__/fixture.tsx +42 -0
  71. package/src/experimental/components/LiveList.tsx +54 -0
  72. package/src/experimental/mod.ts +1 -0
  73. package/src/mod.ts +6 -2
  74. package/src/useClientDocument.client.test.tsx +299 -0
  75. package/src/useClientDocument.server.test.tsx +107 -0
  76. package/src/useClientDocument.ts +146 -0
  77. package/src/useQuery.client.test.tsx +293 -0
  78. package/src/useQuery.server.test.tsx +128 -0
  79. package/src/useQuery.ts +115 -0
  80. package/src/useStore.client.test.tsx +632 -0
  81. package/src/useStore.server.test.tsx +70 -0
  82. package/src/useStore.ts +179 -0
  83. package/src/utils.ts +10 -0
  84. package/src/whenever.ts +80 -0
  85. package/dist/query.d.ts +0 -4
  86. package/dist/query.d.ts.map +0 -1
  87. package/dist/query.js +0 -15
  88. package/dist/query.js.map +0 -1
  89. package/dist/store.d.ts +0 -6
  90. package/dist/store.d.ts.map +0 -1
  91. package/dist/store.js +0 -99
  92. package/dist/store.js.map +0 -1
  93. package/src/query.ts +0 -22
  94. package/src/store.ts +0 -196
@@ -0,0 +1,632 @@
1
+ import { makeInMemoryAdapter } from '@livestore/adapter-web'
2
+ import {
3
+ queryDb,
4
+ type RegistryStoreOptions,
5
+ type Store,
6
+ StoreInternalsSymbol,
7
+ StoreRegistry,
8
+ storeOptions,
9
+ } from '@livestore/livestore'
10
+ import { Schema } from '@livestore/utils/effect'
11
+ import * as SolidTesting from '@solidjs/testing-library'
12
+ import * as Solid from 'solid-js'
13
+ import { describe, expect, it } from 'vitest'
14
+
15
+ import { events, schema, tables } from './__tests__/fixture.tsx'
16
+ import { StoreRegistryProvider } from './StoreRegistryContext.tsx'
17
+ import { useStore } from './useStore.ts'
18
+
19
+ const suspenseCountById = new Map<string, number>()
20
+
21
+ const SuspenseFallback = (props: { id: string }) => {
22
+ Solid.onMount(() => {
23
+ suspenseCountById.set(props.id, (suspenseCountById.get(props.id) ?? 0) + 1)
24
+ })
25
+
26
+ return <div data-testid={props.id} data-suspense-id={props.id} />
27
+ }
28
+
29
+ const makeSuspenseFallback = (id: string) => {
30
+ return <SuspenseFallback id={id} />
31
+ }
32
+
33
+ const createSuspenseCount = (id: string) => {
34
+ suspenseCountById.set(id, 0)
35
+ const Comp = (props: Solid.ParentProps) => {
36
+ const fallback = makeSuspenseFallback(id)
37
+
38
+ return <Solid.Suspense fallback={fallback}>{props.children}</Solid.Suspense>
39
+ }
40
+ return Object.assign(Comp, { count: () => suspenseCountById.get(id) ?? 0, id })
41
+ }
42
+
43
+ describe('useStore', () => {
44
+ it('should return the same promise instance for concurrent getOrLoadStore calls', async () => {
45
+ const storeRegistry = new StoreRegistry()
46
+ const options = testStoreOptions()
47
+
48
+ const firstStore = storeRegistry.getOrLoadPromise(options)
49
+ const secondStore = storeRegistry.getOrLoadPromise(options)
50
+
51
+ expect(firstStore).toBeInstanceOf(Promise)
52
+ expect(secondStore).toBeInstanceOf(Promise)
53
+
54
+ expect(firstStore).toBe(secondStore)
55
+ })
56
+
57
+ it('triggers Suspense when store() is read', async () => {
58
+ const storeRegistry = new StoreRegistry()
59
+ const options = testStoreOptions()
60
+
61
+ const RootSuspense = createSuspenseCount('root')
62
+ const ChildSuspense = createSuspenseCount('child')
63
+
64
+ const ChildComponent = () => {
65
+ const store = useStore(() => options)
66
+ return (
67
+ <ChildSuspense>
68
+ <div data-testid="ready">Store loaded: {store()?.storeId}</div>
69
+ </ChildSuspense>
70
+ )
71
+ }
72
+
73
+ const { findByTestId, queryByTestId } = SolidTesting.render(() => (
74
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
75
+ <RootSuspense>
76
+ <ChildComponent />
77
+ </RootSuspense>
78
+ </StoreRegistryProvider>
79
+ ))
80
+
81
+ await findByTestId(ChildSuspense.id)
82
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
83
+
84
+ await findByTestId('ready')
85
+ expect(queryByTestId(ChildSuspense.id)).toBeNull()
86
+
87
+ await cleanupAfterUnmount(() => {})
88
+ })
89
+
90
+ it('does not re-suspend on subsequent renders when store is already loaded', async () => {
91
+ const storeRegistry = new StoreRegistry()
92
+ const options = testStoreOptions()
93
+
94
+ const [currentOptions, setCurrentOptions] = Solid.createSignal(options)
95
+
96
+ const RootSuspense = createSuspenseCount('root')
97
+ const ChildSuspense = createSuspenseCount('child')
98
+
99
+ const StoreConsumer = (props: { options: () => RegistryStoreOptions<typeof schema> }) => {
100
+ const store = useStore(props.options)
101
+ return (
102
+ <ChildSuspense>
103
+ <div data-testid="ready">Store: {store()?.storeId}</div>
104
+ </ChildSuspense>
105
+ )
106
+ }
107
+
108
+ const { findByTestId, queryByTestId } = SolidTesting.render(() => (
109
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
110
+ <RootSuspense>
111
+ <StoreConsumer options={currentOptions} />
112
+ </RootSuspense>
113
+ </StoreRegistryProvider>
114
+ ))
115
+
116
+ await findByTestId(ChildSuspense.id)
117
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
118
+
119
+ // Wait for initial load
120
+ await findByTestId('ready')
121
+ expect(queryByTestId(ChildSuspense.id)).toBeNull()
122
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
123
+
124
+ // Update with new options object (but same storeId) - this triggers reactivity
125
+ setCurrentOptions({ ...options })
126
+
127
+ // Should not show fallback - store is already cached and returns synchronously
128
+ expect(queryByTestId(ChildSuspense.id)).toBeNull()
129
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
130
+ expect(queryByTestId('ready')).not.toBeNull()
131
+
132
+ await cleanupAfterUnmount(() => {})
133
+ })
134
+
135
+ it('throws when store loading fails', async () => {
136
+ const storeRegistry = new StoreRegistry()
137
+ const badOptions = testStoreOptions({
138
+ // @ts-expect-error - intentionally passing invalid adapter to trigger error
139
+ adapter: null,
140
+ })
141
+
142
+ // Pre-load the store to cache the error (error happens synchronously)
143
+ expect(() => storeRegistry.getOrLoadPromise(badOptions)).toThrow()
144
+ })
145
+
146
+ it('basic useStore hook works', async () => {
147
+ const storeRegistry = new StoreRegistry()
148
+ const options = testStoreOptions()
149
+
150
+ const { result } = SolidTesting.renderHook(() => useStore(options), {
151
+ wrapper: makeProvider(storeRegistry),
152
+ })
153
+
154
+ // Wait for store to be ready
155
+ await waitForStoreReady(result)
156
+ expect(result()?.[StoreInternalsSymbol].clientSession).toBeDefined()
157
+
158
+ await cleanupAfterUnmount(() => {})
159
+ })
160
+
161
+ it('handles switching between different storeId values', async () => {
162
+ const storeRegistry = new StoreRegistry()
163
+
164
+ const optionsA = testStoreOptions({ storeId: 'store-a' })
165
+ const optionsB = testStoreOptions({ storeId: 'store-b' })
166
+
167
+ // Use a signal to trigger reactive updates (Solid's pattern instead of rerender)
168
+ const [currentOptions, setCurrentOptions] = Solid.createSignal<RegistryStoreOptions<typeof schema>>(optionsA)
169
+
170
+ const { result } = SolidTesting.renderHook(() => useStore(currentOptions), {
171
+ wrapper: makeProvider(storeRegistry),
172
+ })
173
+
174
+ // Wait for first store to load
175
+ await waitForStoreReady(result)
176
+ const storeA = result()
177
+ expect(storeA![StoreInternalsSymbol].clientSession).toBeDefined()
178
+
179
+ // Switch to different storeId - Solid's reactivity will automatically update
180
+ setCurrentOptions(optionsB)
181
+
182
+ // Wait for second store to load and verify it's different from the first
183
+ await SolidTesting.waitFor(() => {
184
+ const current = result()
185
+ expect(current).not.toBe(storeA)
186
+ expect(current?.[StoreInternalsSymbol].clientSession).toBeDefined()
187
+ })
188
+
189
+ const storeB = result()
190
+ expect(storeB![StoreInternalsSymbol].clientSession).toBeDefined()
191
+ expect(storeB).not.toBe(storeA)
192
+
193
+ await cleanupAfterUnmount(() => {})
194
+ })
195
+
196
+ // useStore doesn't handle unusedCacheTime=0 correctly because retain is called in createComputed (after resource fetch)
197
+ // See https://github.com/livestorejs/livestore/issues/916
198
+ it.skip('should load store with unusedCacheTime set to 0', async () => {
199
+ // Skipped: retain timing issue with unusedCacheTime=0
200
+ })
201
+ })
202
+
203
+ describe('useStore.useQuery', () => {
204
+ it('Triggers Suspense - returns undefined while store is loading', async () => {
205
+ const storeRegistry = new StoreRegistry()
206
+ const options = testStoreOptions()
207
+
208
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
209
+
210
+ const RootSuspense = createSuspenseCount('root')
211
+ const UseStoreSuspense = createSuspenseCount('useStore')
212
+ const UseQuerySuspense = createSuspenseCount('useQuery')
213
+
214
+ const UseQueryComponent = (props: { store: any }) => {
215
+ const todos = props.store.useQuery(allTodos$)
216
+ return (
217
+ <UseQuerySuspense>
218
+ <div data-testid="content">Todos: {todos()?.length ?? 'loading'}</div>
219
+ </UseQuerySuspense>
220
+ )
221
+ }
222
+
223
+ const UseStoreComponent = () => {
224
+ const store = useStore(() => options)
225
+ return (
226
+ <UseStoreSuspense>
227
+ <UseQueryComponent store={store} />
228
+ </UseStoreSuspense>
229
+ )
230
+ }
231
+
232
+ const { findByTestId, queryByTestId } = SolidTesting.render(() => (
233
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
234
+ <RootSuspense>
235
+ <UseStoreComponent />
236
+ </RootSuspense>
237
+ </StoreRegistryProvider>
238
+ ))
239
+
240
+ await findByTestId(UseStoreSuspense.id)
241
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
242
+ expect(queryByTestId(UseQuerySuspense.id)).toBeNull()
243
+
244
+ // Wait for store to fully load
245
+ await SolidTesting.waitFor(() => {
246
+ const content = queryByTestId('content')
247
+ expect(content?.textContent).toBe('Todos: 0')
248
+ })
249
+
250
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
251
+ expect(queryByTestId(UseStoreSuspense.id)).toBeNull()
252
+ expect(queryByTestId(UseQuerySuspense.id)).toBeNull()
253
+
254
+ await cleanupAfterUnmount(() => {})
255
+ })
256
+
257
+ it('returns undefined before store is loaded, then returns result', async () => {
258
+ const storeRegistry = new StoreRegistry()
259
+ const options = testStoreOptions()
260
+
261
+ const allTodos$ = queryDb({ query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) })
262
+
263
+ const { result } = SolidTesting.renderHook(
264
+ () => {
265
+ const store = useStore(() => options)
266
+ return store.useQuery(allTodos$)
267
+ },
268
+ { wrapper: makeProvider(storeRegistry) },
269
+ )
270
+
271
+ expect(result()).toBeUndefined()
272
+
273
+ // Wait for store to load and query to return results
274
+ await SolidTesting.waitFor(() => {
275
+ expect(result()).toBeDefined()
276
+ })
277
+
278
+ expect(result()).toEqual([])
279
+
280
+ await cleanupAfterUnmount(() => {})
281
+ })
282
+
283
+ it('updates when store changes', async () => {
284
+ const storeRegistry = new StoreRegistry()
285
+ const optionsA = testStoreOptions({ storeId: 'store-a' })
286
+ const optionsB = testStoreOptions({ storeId: 'store-b' })
287
+
288
+ const [currentOptions, setCurrentOptions] = Solid.createSignal(optionsA)
289
+
290
+ const allTodos$ = queryDb(
291
+ { query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) },
292
+ { label: 'allTodos' },
293
+ )
294
+
295
+ const { result } = SolidTesting.renderHook(
296
+ () => {
297
+ const store = useStore(currentOptions)
298
+ return { store, todos: store.useQuery(allTodos$) }
299
+ },
300
+ { wrapper: makeProvider(storeRegistry) },
301
+ )
302
+
303
+ // Wait for store A to load
304
+ await SolidTesting.waitFor(() => {
305
+ expect(result.store()).toBeDefined()
306
+ })
307
+
308
+ // Add todo to store A
309
+ result.store()!.commit(events.todoCreated({ id: 't1', text: 'store A todo', completed: false }))
310
+ expect(result.todos()?.length).toBe(1)
311
+ expect(result.todos()?.[0]?.text).toBe('store A todo')
312
+
313
+ // Switch to store B
314
+ setCurrentOptions(optionsB)
315
+
316
+ // Wait for store B to load
317
+ await SolidTesting.waitFor(() => {
318
+ expect(result.store()?.storeId).toBe('store-b')
319
+ })
320
+
321
+ // Store B should have no todos (it's a fresh store)
322
+ expect(result.todos()).toEqual([])
323
+
324
+ // Add todo to store B
325
+ result.store()!.commit(events.todoCreated({ id: 't2', text: 'store B todo', completed: false }))
326
+ expect(result.todos()?.length).toBe(1)
327
+ expect(result.todos()?.[0]?.text).toBe('store B todo')
328
+
329
+ await cleanupAfterUnmount(() => {})
330
+ })
331
+
332
+ it('updates reactively when data changes', async () => {
333
+ const storeRegistry = new StoreRegistry()
334
+ const options = testStoreOptions()
335
+
336
+ const allTodos$ = queryDb(
337
+ { query: `select * from todos`, schema: Schema.Array(tables.todos.rowSchema) },
338
+ { label: 'allTodos' },
339
+ )
340
+
341
+ const { result } = SolidTesting.renderHook(
342
+ () => {
343
+ const store = useStore(() => options)
344
+ return { store, todos: store.useQuery(allTodos$) }
345
+ },
346
+ { wrapper: makeProvider(storeRegistry) },
347
+ )
348
+
349
+ // Wait for store to load
350
+ await SolidTesting.waitFor(() => {
351
+ expect(result.store()).toBeDefined()
352
+ })
353
+
354
+ expect(result.todos()).toEqual([])
355
+
356
+ // Add a todo
357
+ result.store()!.commit(events.todoCreated({ id: 't1', text: 'buy milk', completed: false }))
358
+
359
+ expect(result.todos()?.length).toBe(1)
360
+ expect(result.todos()?.[0]?.text).toBe('buy milk')
361
+
362
+ await cleanupAfterUnmount(() => {})
363
+ })
364
+ })
365
+
366
+ describe('useStore.useClientDocument', () => {
367
+ it('can set state before store loads', async () => {
368
+ const storeRegistry = new StoreRegistry()
369
+ const options = testStoreOptions()
370
+
371
+ const RootSuspense = createSuspenseCount('root')
372
+ const ChildSuspense = createSuspenseCount('child')
373
+
374
+ const ChildComponent = () => {
375
+ const store = useStore(() => options)
376
+ const [state, setState] = store.useClientDocument(tables.userInfo, 'u1')
377
+
378
+ // Set state immediately - should work even before store loads
379
+ setState({ username: 'early-bird', text: 'set before load' })
380
+
381
+ return (
382
+ <ChildSuspense>
383
+ <div data-testid="content">Username: {state().username}</div>
384
+ </ChildSuspense>
385
+ )
386
+ }
387
+
388
+ const { findByTestId, queryByTestId } = SolidTesting.render(() => (
389
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
390
+ <RootSuspense>
391
+ <ChildComponent />
392
+ </RootSuspense>
393
+ </StoreRegistryProvider>
394
+ ))
395
+
396
+ await findByTestId(RootSuspense.id)
397
+ expect(queryByTestId(ChildSuspense.id)).toBeNull()
398
+
399
+ await findByTestId('content')
400
+
401
+ const content = queryByTestId('content')
402
+ expect(content?.textContent).toBe('Username: early-bird')
403
+
404
+ expect(queryByTestId(RootSuspense.id)).toBeNull()
405
+ expect(queryByTestId(ChildSuspense.id)).toBeNull()
406
+
407
+ await cleanupAfterUnmount(() => {})
408
+ })
409
+
410
+ it('returns state accessor and setter', async () => {
411
+ const storeRegistry = new StoreRegistry()
412
+ const options = testStoreOptions()
413
+
414
+ const { result } = SolidTesting.renderHook(
415
+ () => {
416
+ const store = useStore(() => options)
417
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
418
+ return { store, state, setState, id }
419
+ },
420
+ { wrapper: makeProvider(storeRegistry) },
421
+ )
422
+
423
+ // Wait for store to load
424
+ await SolidTesting.waitFor(() => {
425
+ expect(result.store()).toBeDefined()
426
+ })
427
+
428
+ expect(result.id()).toBe('u1')
429
+ expect(result.state()?.username).toBe('')
430
+
431
+ // Update via setState
432
+ result.setState({ username: 'test-user' })
433
+
434
+ expect(result.state()?.username).toBe('test-user')
435
+
436
+ await cleanupAfterUnmount(() => {})
437
+ })
438
+
439
+ it('setter works with multiple updates', async () => {
440
+ const storeRegistry = new StoreRegistry()
441
+ const options = testStoreOptions()
442
+
443
+ const { result } = SolidTesting.renderHook(
444
+ () => {
445
+ const store = useStore(() => options)
446
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
447
+ return { store, state, setState, id }
448
+ },
449
+ { wrapper: makeProvider(storeRegistry) },
450
+ )
451
+
452
+ // Wait for store to load first
453
+ await SolidTesting.waitFor(() => {
454
+ expect(result.store()).toBeDefined()
455
+ })
456
+
457
+ // Multiple setState calls should work
458
+ result.setState({ username: 'first' })
459
+ expect(result.state()?.username).toBe('first')
460
+
461
+ result.setState({ username: 'second' })
462
+ expect(result.state()?.username).toBe('second')
463
+
464
+ result.setState({ username: 'third', text: 'hello' })
465
+ expect(result.state()?.username).toBe('third')
466
+ expect(result.state()?.text).toBe('hello')
467
+
468
+ await cleanupAfterUnmount(() => {})
469
+ })
470
+
471
+ it('buffers state when called before store loads', async () => {
472
+ const storeRegistry = new StoreRegistry()
473
+ const options = testStoreOptions()
474
+
475
+ const { result } = SolidTesting.renderHook(
476
+ () => {
477
+ const store = useStore(() => options)
478
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
479
+ return { store, state, setState, id }
480
+ },
481
+ { wrapper: makeProvider(storeRegistry) },
482
+ )
483
+
484
+ // Call setState BEFORE store is loaded - should buffer
485
+ result.setState({ username: 'buffered', text: 'test' })
486
+
487
+ // The buffered state should be synced
488
+ expect(result.state().username).toBe('buffered')
489
+
490
+ // Wait for store to load
491
+ await SolidTesting.waitFor(() => {
492
+ expect(result.store()).toBeDefined()
493
+ })
494
+
495
+ // The buffered state should be synced
496
+ expect(result.state().username).toBe('buffered')
497
+
498
+ // Now update again - this should overwrite
499
+ result.setState({ username: 'updated', text: 'test2' })
500
+ expect(result.state()?.username).toBe('updated')
501
+
502
+ await cleanupAfterUnmount(() => {})
503
+ })
504
+
505
+ it('updates reactively via raw store commit', async () => {
506
+ const storeRegistry = new StoreRegistry()
507
+ const options = testStoreOptions()
508
+
509
+ const { result } = SolidTesting.renderHook(
510
+ () => {
511
+ const store = useStore(() => options)
512
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
513
+ return { store, state, setState, id }
514
+ },
515
+ { wrapper: makeProvider(storeRegistry) },
516
+ )
517
+
518
+ // Wait for store to load
519
+ await SolidTesting.waitFor(() => {
520
+ expect(result.store()).toBeDefined()
521
+ })
522
+
523
+ expect(result.state()?.username).toBe('')
524
+
525
+ // Update via raw store commit
526
+ result.store()!.commit(events.UserInfoSet({ username: 'commit-user', text: 'hello' }, 'u1'))
527
+
528
+ expect(result.state()?.username).toBe('commit-user')
529
+
530
+ await cleanupAfterUnmount(() => {})
531
+ })
532
+
533
+ it('updates when store changes', async () => {
534
+ const storeRegistry = new StoreRegistry()
535
+ const optionsA = testStoreOptions({ storeId: 'store-a' })
536
+ const optionsB = testStoreOptions({ storeId: 'store-b' })
537
+
538
+ const [currentOptions, setCurrentOptions] = Solid.createSignal(optionsA)
539
+
540
+ const { result } = SolidTesting.renderHook(
541
+ () => {
542
+ const store = useStore(currentOptions)
543
+ const [state, setState, id] = store.useClientDocument(tables.userInfo, 'u1')
544
+ return { store, state, setState, id }
545
+ },
546
+ { wrapper: makeProvider(storeRegistry) },
547
+ )
548
+
549
+ // Wait for store A to load
550
+ await SolidTesting.waitFor(() => {
551
+ expect(result.store()).toBeDefined()
552
+ })
553
+
554
+ // Set username in store A
555
+ result.setState({ username: 'store-a-user', text: 'hello from A' })
556
+ expect(result.state()?.username).toBe('store-a-user')
557
+
558
+ // Switch to store B
559
+ setCurrentOptions(optionsB)
560
+
561
+ // Wait for store B to load
562
+ await SolidTesting.waitFor(() => {
563
+ expect(result.store()?.storeId).toBe('store-b')
564
+ })
565
+
566
+ // Store B should have default/empty state (fresh store)
567
+ expect(result.state()?.username).toBe('')
568
+
569
+ // Set username in store B
570
+ result.setState({ username: 'store-b-user', text: 'hello from B' })
571
+ expect(result.state()?.username).toBe('store-b-user')
572
+
573
+ // Switch back to store A
574
+ setCurrentOptions(optionsA)
575
+
576
+ // Wait for store A to load and useClientDocument state to propagate
577
+ await SolidTesting.waitFor(() => {
578
+ expect(result.store()?.storeId).toBe('store-a')
579
+ expect(result.state()).toBeDefined()
580
+ })
581
+
582
+ // Store A is re-created fresh after retain/release cycle (RcMap disposes
583
+ // entries eagerly on release in Effect 3.19.19+), so state resets to defaults.
584
+ // Verify the hook correctly reflects the new store's default state.
585
+ expect(result.state()?.username).toBe('')
586
+
587
+ // Verify we can write to the re-created store A
588
+ result.setState({ username: 'store-a-new', text: 'fresh data' })
589
+ expect(result.state()?.username).toBe('store-a-new')
590
+
591
+ await cleanupAfterUnmount(() => {})
592
+ })
593
+ })
594
+
595
+ const makeProvider = (storeRegistry: StoreRegistry) => (props: { children: Solid.JSX.Element }) => {
596
+ return <StoreRegistryProvider storeRegistry={storeRegistry}>{props.children}</StoreRegistryProvider>
597
+ }
598
+
599
+ let testStoreCounter = 0
600
+
601
+ const testStoreOptions = (overrides: Partial<RegistryStoreOptions<typeof schema>> = {}) =>
602
+ storeOptions({
603
+ storeId: overrides.storeId ?? `test-store-${testStoreCounter++}`,
604
+ schema,
605
+ adapter: makeInMemoryAdapter(),
606
+ ...overrides,
607
+ })
608
+
609
+ /**
610
+ * Cleans up after component unmount and waits for pending operations to settle.
611
+ *
612
+ * When components using stores unmount, the StoreRegistry schedules garbage collection
613
+ * timers for inactive stores. This helper waits for those timers to complete naturally.
614
+ */
615
+ const cleanupAfterUnmount = async (cleanup: () => void): Promise<void> => {
616
+ cleanup()
617
+ // Allow any pending microtasks/timers to settle
618
+ await new Promise((resolve) => setTimeout(resolve, 100))
619
+ }
620
+
621
+ /**
622
+ * Waits for a store resource to be fully loaded and ready to use.
623
+ * The store is considered ready when it has a defined clientSession.
624
+ */
625
+ const waitForStoreReady = async (result: () => Store<any> | undefined): Promise<void> => {
626
+ await SolidTesting.waitFor(() => {
627
+ const store = result()
628
+ expect(store).not.toBeNull()
629
+ expect(store).not.toBeUndefined()
630
+ expect(store![StoreInternalsSymbol].clientSession).toBeDefined()
631
+ })
632
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SSR tests for useStore
3
+ * These tests run in node environment with SSR JSX transform using renderToString.
4
+ */
5
+
6
+ import { isServer, renderToString } from 'solid-js/web'
7
+ import { describe, expect, it } from 'vitest'
8
+
9
+ import { makeInMemoryAdapter } from '@livestore/adapter-web'
10
+ import { provideOtel } from '@livestore/common'
11
+ import { createStore } from '@livestore/livestore'
12
+ import { Effect } from '@livestore/utils/effect'
13
+
14
+ import { schema, tables } from './__tests__/fixture.tsx'
15
+ import { withSolidApi } from './useStore.ts'
16
+
17
+ describe('environment', () => {
18
+ it('runs on server', () => {
19
+ // Use 'window' in globalThis to avoid TypeScript error without DOM lib
20
+ expect('window' in globalThis).toBe(false)
21
+ expect(isServer).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe('useStore SSR', () => {
26
+ it('renders component with pre-created store to string', async () => {
27
+ await Effect.gen(function* () {
28
+ const store = yield* createStore({
29
+ schema,
30
+ storeId: 'ssr-store-test',
31
+ adapter: makeInMemoryAdapter(),
32
+ debug: { instanceId: 'ssr-store-test' },
33
+ })
34
+
35
+ const storeWithSolidApi = withSolidApi(store)
36
+
37
+ const StoreStatus = () => {
38
+ return <div>Store ID: {storeWithSolidApi.storeId}</div>
39
+ }
40
+
41
+ const html = renderToString(() => <StoreStatus />)
42
+
43
+ expect(html).toContain('Store ID:')
44
+ expect(html).toContain('ssr-store-test')
45
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
46
+ })
47
+
48
+ it('renders component using store queries to string', async () => {
49
+ await Effect.gen(function* () {
50
+ const store = yield* createStore({
51
+ schema,
52
+ storeId: 'ssr-store-query-test',
53
+ adapter: makeInMemoryAdapter(),
54
+ debug: { instanceId: 'ssr-store-query-test' },
55
+ })
56
+
57
+ const storeWithSolidApi = withSolidApi(store)
58
+
59
+ const [state] = storeWithSolidApi.useClientDocument(tables.userInfo, 'u1')
60
+
61
+ const UserInfo = () => {
62
+ return <div>User: {state().username || 'anonymous'}</div>
63
+ }
64
+
65
+ const html = renderToString(() => <UserInfo />)
66
+
67
+ expect(html).toContain('User:')
68
+ }).pipe(provideOtel({}), Effect.scoped, Effect.runPromise)
69
+ })
70
+ })