@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/StoreRegistryContext.d.ts +56 -0
- package/dist/StoreRegistryContext.d.ts.map +1 -0
- package/dist/StoreRegistryContext.jsx +60 -0
- package/dist/StoreRegistryContext.jsx.map +1 -0
- package/dist/__tests__/fixture.d.ts +14 -0
- package/dist/__tests__/fixture.d.ts.map +1 -0
- package/dist/__tests__/fixture.jsx +13 -0
- package/dist/__tests__/fixture.jsx.map +1 -0
- package/dist/experimental/components/LiveList.d.ts +24 -0
- package/dist/experimental/components/LiveList.d.ts.map +1 -0
- package/dist/experimental/components/LiveList.jsx +24 -0
- package/dist/experimental/components/LiveList.jsx.map +1 -0
- package/dist/experimental/mod.d.ts +2 -0
- package/dist/experimental/mod.d.ts.map +1 -0
- package/dist/experimental/mod.js +2 -0
- package/dist/experimental/mod.js.map +1 -0
- package/dist/mod.d.ts +6 -2
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +4 -2
- package/dist/mod.js.map +1 -1
- package/dist/useClientDocument.client.test.d.ts +2 -0
- package/dist/useClientDocument.client.test.d.ts.map +1 -0
- package/dist/useClientDocument.client.test.jsx +177 -0
- package/dist/useClientDocument.client.test.jsx.map +1 -0
- package/dist/useClientDocument.d.ts +71 -0
- package/dist/useClientDocument.d.ts.map +1 -0
- package/dist/useClientDocument.js +74 -0
- package/dist/useClientDocument.js.map +1 -0
- package/dist/useClientDocument.server.test.d.ts +6 -0
- package/dist/useClientDocument.server.test.d.ts.map +1 -0
- package/dist/useClientDocument.server.test.jsx +76 -0
- package/dist/useClientDocument.server.test.jsx.map +1 -0
- package/dist/useQuery.client.test.d.ts +2 -0
- package/dist/useQuery.client.test.d.ts.map +1 -0
- package/dist/useQuery.client.test.jsx +165 -0
- package/dist/useQuery.client.test.jsx.map +1 -0
- package/dist/useQuery.d.ts +32 -0
- package/dist/useQuery.d.ts.map +1 -0
- package/dist/useQuery.js +64 -0
- package/dist/useQuery.js.map +1 -0
- package/dist/useQuery.server.test.d.ts +6 -0
- package/dist/useQuery.server.test.d.ts.map +1 -0
- package/dist/useQuery.server.test.jsx +88 -0
- package/dist/useQuery.server.test.jsx.map +1 -0
- package/dist/useStore.client.test.d.ts +2 -0
- package/dist/useStore.client.test.d.ts.map +1 -0
- package/dist/useStore.client.test.jsx +438 -0
- package/dist/useStore.client.test.jsx.map +1 -0
- package/dist/useStore.d.ts +91 -0
- package/dist/useStore.d.ts.map +1 -0
- package/dist/useStore.js +94 -0
- package/dist/useStore.js.map +1 -0
- package/dist/useStore.server.test.d.ts +6 -0
- package/dist/useStore.server.test.d.ts.map +1 -0
- package/dist/useStore.server.test.jsx +56 -0
- package/dist/useStore.server.test.jsx.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +7 -0
- package/dist/utils.js.map +1 -0
- package/dist/whenever.d.ts +32 -0
- package/dist/whenever.d.ts.map +1 -0
- package/dist/whenever.js +51 -0
- package/dist/whenever.js.map +1 -0
- package/package.json +64 -16
- package/src/StoreRegistryContext.tsx +70 -0
- package/src/__snapshots__/useClientDocument.client.test.tsx.snap +570 -0
- package/src/__snapshots__/useQuery.client.test.tsx.snap +1550 -0
- package/src/__tests__/fixture.tsx +42 -0
- package/src/experimental/components/LiveList.tsx +54 -0
- package/src/experimental/mod.ts +1 -0
- package/src/mod.ts +6 -2
- package/src/useClientDocument.client.test.tsx +299 -0
- package/src/useClientDocument.server.test.tsx +107 -0
- package/src/useClientDocument.ts +146 -0
- package/src/useQuery.client.test.tsx +293 -0
- package/src/useQuery.server.test.tsx +128 -0
- package/src/useQuery.ts +115 -0
- package/src/useStore.client.test.tsx +632 -0
- package/src/useStore.server.test.tsx +70 -0
- package/src/useStore.ts +179 -0
- package/src/utils.ts +10 -0
- package/src/whenever.ts +80 -0
- package/dist/query.d.ts +0 -4
- package/dist/query.d.ts.map +0 -1
- package/dist/query.js +0 -15
- package/dist/query.js.map +0 -1
- package/dist/store.d.ts +0 -6
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -99
- package/dist/store.js.map +0 -1
- package/src/query.ts +0 -22
- 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
|
+
})
|