@livestore/react 0.0.0-snapshot-8d3edf87cb1e88c7b67c5f3ea9d0b307253c33df

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 (87) hide show
  1. package/README.md +1 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/LiveStoreContext.d.ts +7 -0
  4. package/dist/LiveStoreContext.d.ts.map +1 -0
  5. package/dist/LiveStoreContext.js +13 -0
  6. package/dist/LiveStoreContext.js.map +1 -0
  7. package/dist/LiveStoreProvider.d.ts +49 -0
  8. package/dist/LiveStoreProvider.d.ts.map +1 -0
  9. package/dist/LiveStoreProvider.js +168 -0
  10. package/dist/LiveStoreProvider.js.map +1 -0
  11. package/dist/LiveStoreProvider.test.d.ts +2 -0
  12. package/dist/LiveStoreProvider.test.d.ts.map +1 -0
  13. package/dist/LiveStoreProvider.test.js +62 -0
  14. package/dist/LiveStoreProvider.test.js.map +1 -0
  15. package/dist/__tests__/fixture.d.ts +567 -0
  16. package/dist/__tests__/fixture.d.ts.map +1 -0
  17. package/dist/__tests__/fixture.js +61 -0
  18. package/dist/__tests__/fixture.js.map +1 -0
  19. package/dist/experimental/components/LiveList.d.ts +21 -0
  20. package/dist/experimental/components/LiveList.d.ts.map +1 -0
  21. package/dist/experimental/components/LiveList.js +31 -0
  22. package/dist/experimental/components/LiveList.js.map +1 -0
  23. package/dist/experimental/mod.d.ts +2 -0
  24. package/dist/experimental/mod.d.ts.map +1 -0
  25. package/dist/experimental/mod.js +2 -0
  26. package/dist/experimental/mod.js.map +1 -0
  27. package/dist/mod.d.ts +8 -0
  28. package/dist/mod.d.ts.map +1 -0
  29. package/dist/mod.js +8 -0
  30. package/dist/mod.js.map +1 -0
  31. package/dist/useAtom.d.ts +10 -0
  32. package/dist/useAtom.d.ts.map +1 -0
  33. package/dist/useAtom.js +37 -0
  34. package/dist/useAtom.js.map +1 -0
  35. package/dist/useQuery.d.ts +9 -0
  36. package/dist/useQuery.d.ts.map +1 -0
  37. package/dist/useQuery.js +88 -0
  38. package/dist/useQuery.js.map +1 -0
  39. package/dist/useQuery.test.d.ts +2 -0
  40. package/dist/useQuery.test.d.ts.map +1 -0
  41. package/dist/useQuery.test.js +51 -0
  42. package/dist/useQuery.test.js.map +1 -0
  43. package/dist/useRow.d.ts +46 -0
  44. package/dist/useRow.d.ts.map +1 -0
  45. package/dist/useRow.js +96 -0
  46. package/dist/useRow.js.map +1 -0
  47. package/dist/useRow.test.d.ts +2 -0
  48. package/dist/useRow.test.d.ts.map +1 -0
  49. package/dist/useRow.test.js +212 -0
  50. package/dist/useRow.test.js.map +1 -0
  51. package/dist/useTemporaryQuery.d.ts +22 -0
  52. package/dist/useTemporaryQuery.d.ts.map +1 -0
  53. package/dist/useTemporaryQuery.js +75 -0
  54. package/dist/useTemporaryQuery.js.map +1 -0
  55. package/dist/useTemporaryQuery.test.d.ts +2 -0
  56. package/dist/useTemporaryQuery.test.d.ts.map +1 -0
  57. package/dist/useTemporaryQuery.test.js +59 -0
  58. package/dist/useTemporaryQuery.test.js.map +1 -0
  59. package/dist/utils/stack-info.d.ts +4 -0
  60. package/dist/utils/stack-info.d.ts.map +1 -0
  61. package/dist/utils/stack-info.js +11 -0
  62. package/dist/utils/stack-info.js.map +1 -0
  63. package/dist/utils/useStateRefWithReactiveInput.d.ts +13 -0
  64. package/dist/utils/useStateRefWithReactiveInput.d.ts.map +1 -0
  65. package/dist/utils/useStateRefWithReactiveInput.js +38 -0
  66. package/dist/utils/useStateRefWithReactiveInput.js.map +1 -0
  67. package/package.json +54 -0
  68. package/src/LiveStoreContext.ts +19 -0
  69. package/src/LiveStoreProvider.test.tsx +109 -0
  70. package/src/LiveStoreProvider.tsx +295 -0
  71. package/src/__snapshots__/useRow.test.tsx.snap +359 -0
  72. package/src/__tests__/fixture.tsx +119 -0
  73. package/src/ambient.d.ts +2 -0
  74. package/src/experimental/components/LiveList.tsx +84 -0
  75. package/src/experimental/mod.ts +1 -0
  76. package/src/mod.ts +13 -0
  77. package/src/useAtom.ts +55 -0
  78. package/src/useQuery.test.tsx +82 -0
  79. package/src/useQuery.ts +122 -0
  80. package/src/useRow.test.tsx +346 -0
  81. package/src/useRow.ts +182 -0
  82. package/src/useTemporaryQuery.test.tsx +98 -0
  83. package/src/useTemporaryQuery.ts +131 -0
  84. package/src/utils/stack-info.ts +13 -0
  85. package/src/utils/useStateRefWithReactiveInput.ts +51 -0
  86. package/tsconfig.json +20 -0
  87. package/vitest.config.js +17 -0
@@ -0,0 +1,109 @@
1
+ import type { BootDb } from '@livestore/common'
2
+ import { sql } from '@livestore/common'
3
+ import { querySQL } from '@livestore/livestore'
4
+ import { Schema } from '@livestore/utils/effect'
5
+ import { makeInMemoryAdapter } from '@livestore/web'
6
+ import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
7
+ import React from 'react'
8
+ import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { schema, tables } from './__tests__/fixture.js'
12
+ import { LiveStoreProvider } from './LiveStoreProvider.js'
13
+ import * as LiveStoreReact from './mod.js'
14
+
15
+ describe('LiveStoreProvider', () => {
16
+ it('simple', async () => {
17
+ let appRenderCount = 0
18
+
19
+ const allTodos$ = querySQL(`select * from todos`, { schema: Schema.Array(tables.todos.schema) })
20
+
21
+ const App = () => {
22
+ appRenderCount++
23
+
24
+ const todos = LiveStoreReact.useQuery(allTodos$)
25
+
26
+ return <div>{JSON.stringify(todos)}</div>
27
+ }
28
+
29
+ const abortController = new AbortController()
30
+
31
+ const Root = ({ forceUpdate }: { forceUpdate: number }) => {
32
+ const bootCb = React.useCallback(
33
+ (db: BootDb) =>
34
+ db.execute(sql`INSERT OR IGNORE INTO todos (id, text, completed) VALUES ('t1', 'buy milk', 0);`),
35
+ [],
36
+ )
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ const adapterMemo = React.useMemo(() => makeInMemoryAdapter(), [forceUpdate])
39
+ return (
40
+ <LiveStoreProvider
41
+ schema={schema}
42
+ renderLoading={(status) => <div>Loading LiveStore: {status.stage}</div>}
43
+ adapter={adapterMemo}
44
+ boot={bootCb}
45
+ signal={abortController.signal}
46
+ batchUpdates={batchUpdates}
47
+ >
48
+ <App />
49
+ </LiveStoreProvider>
50
+ )
51
+ }
52
+
53
+ const { rerender } = render(<Root forceUpdate={1} />)
54
+
55
+ expect(appRenderCount).toBe(0)
56
+
57
+ await waitForElementToBeRemoved(() => screen.getByText((_) => _.startsWith('Loading LiveStore')))
58
+
59
+ expect(appRenderCount).toBe(1)
60
+
61
+ rerender(<Root forceUpdate={2} />)
62
+
63
+ await waitFor(() => screen.getByText('Loading LiveStore: loading'))
64
+ await waitFor(() => screen.getByText((_) => _.includes('buy milk')))
65
+
66
+ expect(appRenderCount).toBe(2)
67
+
68
+ abortController.abort()
69
+
70
+ await waitFor(() => screen.getByText('LiveStore Shutdown due to abort signal'))
71
+ })
72
+
73
+ it('error during boot', async () => {
74
+ let appRenderCount = 0
75
+
76
+ const App = () => {
77
+ appRenderCount++
78
+
79
+ return <div>hello world</div>
80
+ }
81
+
82
+ const Root = ({ forceUpdate }: { forceUpdate: number }) => {
83
+ const bootCb = React.useCallback(
84
+ (db: BootDb) =>
85
+ db.execute(sql`INSERT INTO todos_mising_table (id, text, completed) VALUES ('t1', 'buy milk', 0);`),
86
+ [],
87
+ )
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ const adapterMemo = React.useMemo(() => makeInMemoryAdapter(), [forceUpdate])
90
+ return (
91
+ <LiveStoreProvider
92
+ schema={schema}
93
+ renderLoading={(status) => <div>Loading LiveStore: {status.stage}</div>}
94
+ adapter={adapterMemo}
95
+ boot={bootCb}
96
+ batchUpdates={batchUpdates}
97
+ >
98
+ <App />
99
+ </LiveStoreProvider>
100
+ )
101
+ }
102
+
103
+ render(<Root forceUpdate={1} />)
104
+
105
+ expect(appRenderCount).toBe(0)
106
+
107
+ await waitFor(() => screen.getByText((_) => _.startsWith('LiveStore.UnexpectedError')))
108
+ })
109
+ })
@@ -0,0 +1,295 @@
1
+ import type { Adapter, BootDb, BootStatus, IntentionalShutdownCause } from '@livestore/common'
2
+ import { UnexpectedError } from '@livestore/common'
3
+ import type { LiveStoreSchema } from '@livestore/common/schema'
4
+ import type {
5
+ BaseGraphQLContext,
6
+ CreateStoreOptions,
7
+ GraphQLOptions,
8
+ LiveStoreContext as StoreContext_,
9
+ OtelOptions,
10
+ } from '@livestore/livestore'
11
+ import { createStore, StoreAbort, StoreInterrupted } from '@livestore/livestore'
12
+ import { errorToString } from '@livestore/utils'
13
+ import { Effect, FiberSet, Logger, LogLevel, Schema } from '@livestore/utils/effect'
14
+ import type * as otel from '@opentelemetry/api'
15
+ import type { ReactElement, ReactNode } from 'react'
16
+ import React from 'react'
17
+
18
+ import { LiveStoreContext } from './LiveStoreContext.js'
19
+
20
+ interface LiveStoreProviderProps<GraphQLContext> {
21
+ schema: LiveStoreSchema
22
+ /**
23
+ * The `storeId` can be used to isolate multiple stores from each other.
24
+ * So it can be useful for multi-tenancy scenarios.
25
+ *
26
+ * The `storeId` is also used for persistence.
27
+ *
28
+ * Make sure to also provide `storeId` to `mountDevtools` in `_devtools.html`.
29
+ *
30
+ * @default 'default'
31
+ */
32
+ storeId?: string
33
+ boot?: (db: BootDb, parentSpan: otel.Span) => void | Promise<void> | Effect.Effect<void, unknown, otel.Tracer>
34
+ graphQLOptions?: GraphQLOptions<GraphQLContext>
35
+ otelOptions?: OtelOptions
36
+ renderLoading: (status: BootStatus) => ReactElement
37
+ renderError?: (error: UnexpectedError | unknown) => ReactElement
38
+ renderShutdown?: (cause: IntentionalShutdownCause | StoreAbort) => ReactElement
39
+ adapter: Adapter
40
+ /**
41
+ * In order for LiveStore to apply multiple mutations in a single render,
42
+ * you need to pass the `batchUpdates` function from either `react-dom` or `react-native`.
43
+ *
44
+ * ```ts
45
+ * // With React DOM
46
+ * import { unstable_batchedUpdates as batchUpdates } from 'react-dom'
47
+ *
48
+ * // With React Native
49
+ * import { unstable_batchedUpdates as batchUpdates } from 'react-native'
50
+ * ```
51
+ */
52
+ batchUpdates: (run: () => void) => void
53
+ disableDevtools?: boolean
54
+ signal?: AbortSignal
55
+ }
56
+
57
+ const defaultRenderError = (error: UnexpectedError | unknown) => (
58
+ <>{Schema.is(UnexpectedError)(error) ? error.toString() : errorToString(error)}</>
59
+ )
60
+ const defaultRenderShutdown = (cause: IntentionalShutdownCause | StoreAbort) => {
61
+ const reason =
62
+ cause._tag === 'LiveStore.StoreAbort'
63
+ ? 'abort signal'
64
+ : cause.reason === 'devtools-import'
65
+ ? 'devtools import'
66
+ : cause.reason === 'devtools-reset'
67
+ ? 'devtools reset'
68
+ : 'unknown reason'
69
+
70
+ return <>LiveStore Shutdown due to {reason}</>
71
+ }
72
+
73
+ export const LiveStoreProvider = <GraphQLContext extends BaseGraphQLContext>({
74
+ renderLoading,
75
+ renderError = defaultRenderError,
76
+ renderShutdown = defaultRenderShutdown,
77
+ graphQLOptions,
78
+ otelOptions,
79
+ children,
80
+ schema,
81
+ storeId = 'default',
82
+ boot,
83
+ adapter,
84
+ batchUpdates,
85
+ disableDevtools,
86
+ signal,
87
+ }: LiveStoreProviderProps<GraphQLContext> & { children?: ReactNode }): JSX.Element => {
88
+ const storeCtx = useCreateStore({
89
+ storeId,
90
+ schema,
91
+ graphQLOptions,
92
+ otelOptions,
93
+ boot,
94
+ adapter,
95
+ batchUpdates,
96
+ disableDevtools,
97
+ signal,
98
+ })
99
+
100
+ if (storeCtx.stage === 'error') {
101
+ return renderError(storeCtx.error)
102
+ }
103
+
104
+ if (storeCtx.stage === 'shutdown') {
105
+ return renderShutdown(storeCtx.cause)
106
+ }
107
+
108
+ if (storeCtx.stage !== 'running') {
109
+ return renderLoading(storeCtx)
110
+ }
111
+
112
+ globalThis.__debugLiveStore ??= {}
113
+ globalThis.__debugLiveStore[storeId] = storeCtx.store
114
+
115
+ return <LiveStoreContext.Provider value={storeCtx}>{children}</LiveStoreContext.Provider>
116
+ }
117
+
118
+ type SchemaKey = string
119
+ const semaphoreMap = new Map<SchemaKey, Effect.Semaphore>()
120
+
121
+ const withSemaphore = (storeId: SchemaKey) => {
122
+ let semaphore = semaphoreMap.get(storeId)
123
+ if (!semaphore) {
124
+ semaphore = Effect.makeSemaphore(1).pipe(Effect.runSync)
125
+ semaphoreMap.set(storeId, semaphore)
126
+ }
127
+ return semaphore.withPermits(1)
128
+ }
129
+
130
+ const useCreateStore = <GraphQLContext extends BaseGraphQLContext>({
131
+ schema,
132
+ storeId,
133
+ graphQLOptions,
134
+ otelOptions,
135
+ boot,
136
+ adapter,
137
+ batchUpdates,
138
+ disableDevtools,
139
+ reactivityGraph,
140
+ signal,
141
+ }: CreateStoreOptions<GraphQLContext, LiveStoreSchema> & { signal?: AbortSignal }) => {
142
+ const [_, rerender] = React.useState(0)
143
+ const ctxValueRef = React.useRef<{
144
+ value: StoreContext_ | BootStatus
145
+ fiberSet: FiberSet.FiberSet | undefined
146
+ counter: number
147
+ }>({
148
+ value: { stage: 'loading' },
149
+ fiberSet: undefined,
150
+ counter: 0,
151
+ })
152
+
153
+ // console.debug(`useCreateStore (${ctxValueRef.current.counter})`, ctxValueRef.current.value.stage)
154
+
155
+ const inputPropsCacheRef = React.useRef({
156
+ schema,
157
+ graphQLOptions,
158
+ otelOptions,
159
+ boot,
160
+ adapter,
161
+ batchUpdates,
162
+ disableDevtools,
163
+ reactivityGraph,
164
+ signal,
165
+ })
166
+
167
+ const interrupt = (fiberSet: FiberSet.FiberSet, error: StoreAbort | StoreInterrupted) =>
168
+ Effect.gen(function* () {
169
+ yield* FiberSet.clear(fiberSet)
170
+ yield* FiberSet.run(fiberSet, Effect.fail(error))
171
+ }).pipe(
172
+ Effect.tapErrorCause((cause) => Effect.logDebug(`[@livestore/livestore/react] interupting`, cause)),
173
+ Effect.runFork,
174
+ )
175
+
176
+ if (
177
+ inputPropsCacheRef.current.schema !== schema ||
178
+ inputPropsCacheRef.current.graphQLOptions !== graphQLOptions ||
179
+ inputPropsCacheRef.current.otelOptions !== otelOptions ||
180
+ inputPropsCacheRef.current.boot !== boot ||
181
+ inputPropsCacheRef.current.adapter !== adapter ||
182
+ inputPropsCacheRef.current.batchUpdates !== batchUpdates ||
183
+ inputPropsCacheRef.current.disableDevtools !== disableDevtools ||
184
+ inputPropsCacheRef.current.reactivityGraph !== reactivityGraph ||
185
+ inputPropsCacheRef.current.signal !== signal
186
+ ) {
187
+ inputPropsCacheRef.current = {
188
+ schema,
189
+ graphQLOptions,
190
+ otelOptions,
191
+ boot,
192
+ adapter,
193
+ batchUpdates,
194
+ disableDevtools,
195
+ reactivityGraph,
196
+ signal,
197
+ }
198
+ if (ctxValueRef.current.fiberSet !== undefined) {
199
+ interrupt(ctxValueRef.current.fiberSet, new StoreInterrupted())
200
+ ctxValueRef.current.fiberSet = undefined
201
+ }
202
+ ctxValueRef.current = { value: { stage: 'loading' }, fiberSet: undefined, counter: ctxValueRef.current.counter + 1 }
203
+ }
204
+
205
+ React.useEffect(() => {
206
+ const counter = ctxValueRef.current.counter
207
+
208
+ const setContextValue = (value: StoreContext_ | BootStatus) => {
209
+ if (ctxValueRef.current.counter !== counter) return
210
+ ctxValueRef.current.value = value
211
+ rerender((c) => c + 1)
212
+ }
213
+
214
+ signal?.addEventListener('abort', () => {
215
+ if (ctxValueRef.current.fiberSet !== undefined && ctxValueRef.current.counter === counter) {
216
+ interrupt(ctxValueRef.current.fiberSet, new StoreAbort())
217
+ ctxValueRef.current.fiberSet = undefined
218
+ }
219
+ })
220
+
221
+ Effect.gen(function* () {
222
+ const fiberSet = yield* FiberSet.make<
223
+ unknown,
224
+ UnexpectedError | IntentionalShutdownCause | StoreAbort | StoreInterrupted
225
+ >()
226
+
227
+ ctxValueRef.current.fiberSet = fiberSet
228
+
229
+ yield* Effect.gen(function* () {
230
+ const store = yield* createStore({
231
+ fiberSet,
232
+ schema,
233
+ storeId,
234
+ graphQLOptions,
235
+ otelOptions,
236
+ boot,
237
+ adapter,
238
+ reactivityGraph,
239
+ batchUpdates,
240
+ disableDevtools,
241
+ onBootStatus: (status) => {
242
+ if (ctxValueRef.current.value.stage === 'running' || ctxValueRef.current.value.stage === 'error') return
243
+ setContextValue(status)
244
+ },
245
+ })
246
+
247
+ setContextValue({ stage: 'running', store })
248
+
249
+ yield* Effect.never
250
+ }).pipe(Effect.scoped, FiberSet.run(fiberSet))
251
+
252
+ const shutdownContext = (cause: IntentionalShutdownCause | StoreAbort) =>
253
+ Effect.sync(() => setContextValue({ stage: 'shutdown', cause }))
254
+
255
+ yield* FiberSet.join(fiberSet).pipe(
256
+ Effect.catchTag('LiveStore.IntentionalShutdownCause', (cause) => shutdownContext(cause)),
257
+ Effect.catchTag('LiveStore.StoreAbort', (cause) => shutdownContext(cause)),
258
+ Effect.tapError((error) => Effect.sync(() => setContextValue({ stage: 'error', error }))),
259
+ Effect.tapDefect((defect) => Effect.sync(() => setContextValue({ stage: 'error', error: defect }))),
260
+ Effect.exit,
261
+ )
262
+ }).pipe(
263
+ Effect.scoped,
264
+ // NOTE we're running the code above in a semaphore to make sure a previous store is always fully
265
+ // shutdown before a new one is created - especially when shutdown logic is async. You can't trust `React.useEffect`.
266
+ // Thank you to Mattia Manzati for this idea.
267
+ withSemaphore(storeId),
268
+ Effect.tapCauseLogPretty,
269
+ Effect.annotateLogs({ thread: 'window' }),
270
+ Effect.provide(Logger.pretty),
271
+ Logger.withMinimumLogLevel(LogLevel.Debug),
272
+ Effect.runFork,
273
+ )
274
+
275
+ return () => {
276
+ if (ctxValueRef.current.fiberSet !== undefined) {
277
+ interrupt(ctxValueRef.current.fiberSet, new StoreInterrupted())
278
+ ctxValueRef.current.fiberSet = undefined
279
+ }
280
+ }
281
+ }, [
282
+ schema,
283
+ graphQLOptions,
284
+ otelOptions,
285
+ boot,
286
+ adapter,
287
+ batchUpdates,
288
+ disableDevtools,
289
+ signal,
290
+ reactivityGraph,
291
+ storeId,
292
+ ])
293
+
294
+ return ctxValueRef.current.value
295
+ }