@livestore/livestore 0.4.0-dev.21 → 0.4.0-dev.22

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 (75) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/effect/LiveStore.d.ts +123 -2
  3. package/dist/effect/LiveStore.d.ts.map +1 -1
  4. package/dist/effect/LiveStore.js +195 -1
  5. package/dist/effect/LiveStore.js.map +1 -1
  6. package/dist/effect/mod.d.ts +1 -1
  7. package/dist/effect/mod.d.ts.map +1 -1
  8. package/dist/effect/mod.js +3 -1
  9. package/dist/effect/mod.js.map +1 -1
  10. package/dist/mod.d.ts +1 -0
  11. package/dist/mod.d.ts.map +1 -1
  12. package/dist/mod.js +1 -0
  13. package/dist/mod.js.map +1 -1
  14. package/dist/store/StoreRegistry.d.ts +190 -0
  15. package/dist/store/StoreRegistry.d.ts.map +1 -0
  16. package/dist/store/StoreRegistry.js +244 -0
  17. package/dist/store/StoreRegistry.js.map +1 -0
  18. package/dist/store/StoreRegistry.test.d.ts +2 -0
  19. package/dist/store/StoreRegistry.test.d.ts.map +1 -0
  20. package/dist/store/StoreRegistry.test.js +380 -0
  21. package/dist/store/StoreRegistry.test.js.map +1 -0
  22. package/dist/store/create-store.d.ts +50 -4
  23. package/dist/store/create-store.d.ts.map +1 -1
  24. package/dist/store/create-store.js +19 -0
  25. package/dist/store/create-store.js.map +1 -1
  26. package/dist/store/devtools.d.ts.map +1 -1
  27. package/dist/store/devtools.js +13 -0
  28. package/dist/store/devtools.js.map +1 -1
  29. package/dist/store/store-types.d.ts +10 -25
  30. package/dist/store/store-types.d.ts.map +1 -1
  31. package/dist/store/store-types.js.map +1 -1
  32. package/dist/store/store.d.ts +23 -6
  33. package/dist/store/store.d.ts.map +1 -1
  34. package/dist/store/store.js +20 -2
  35. package/dist/store/store.js.map +1 -1
  36. package/docs/building-with-livestore/complex-ui-state/index.md +0 -2
  37. package/docs/building-with-livestore/crud/index.md +0 -2
  38. package/docs/building-with-livestore/data-modeling/index.md +29 -0
  39. package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -6
  40. package/docs/building-with-livestore/opentelemetry/index.md +25 -6
  41. package/docs/building-with-livestore/rules-for-ai-agents/index.md +2 -2
  42. package/docs/building-with-livestore/state/sql-queries/index.md +22 -0
  43. package/docs/building-with-livestore/state/sqlite-schema/index.md +2 -2
  44. package/docs/building-with-livestore/store/index.md +344 -0
  45. package/docs/framework-integrations/react-integration/index.md +380 -361
  46. package/docs/framework-integrations/vue-integration/index.md +2 -2
  47. package/docs/getting-started/expo/index.md +189 -43
  48. package/docs/getting-started/react-web/index.md +77 -24
  49. package/docs/getting-started/vue/index.md +3 -3
  50. package/docs/index.md +1 -2
  51. package/docs/llms.txt +0 -1
  52. package/docs/misc/troubleshooting/index.md +3 -3
  53. package/docs/overview/how-livestore-works/index.md +1 -1
  54. package/docs/overview/introduction/index.md +409 -1
  55. package/docs/overview/why-livestore/index.md +108 -2
  56. package/docs/patterns/auth/index.md +185 -34
  57. package/docs/patterns/effect/index.md +11 -1
  58. package/docs/patterns/storybook/index.md +43 -26
  59. package/docs/platform-adapters/expo-adapter/index.md +36 -19
  60. package/docs/platform-adapters/web-adapter/index.md +71 -2
  61. package/docs/tutorial/1-setup-starter-project/index.md +5 -5
  62. package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +54 -35
  63. package/docs/tutorial/5-expand-business-logic/index.md +1 -1
  64. package/docs/tutorial/6-persist-ui-state/index.md +12 -12
  65. package/package.json +6 -6
  66. package/src/effect/LiveStore.ts +385 -3
  67. package/src/effect/mod.ts +13 -1
  68. package/src/mod.ts +1 -0
  69. package/src/store/StoreRegistry.test.ts +516 -0
  70. package/src/store/StoreRegistry.ts +393 -0
  71. package/src/store/create-store.ts +50 -4
  72. package/src/store/devtools.ts +15 -0
  73. package/src/store/store-types.ts +17 -5
  74. package/src/store/store.ts +25 -5
  75. package/docs/building-with-livestore/examples/index.md +0 -30
@@ -10,31 +10,43 @@ Use the `syncPayload` store option to send a custom payload to your sync backend
10
10
 
11
11
  The following example sends the authenticated user's JWT to the server.
12
12
 
13
- ## `patterns/auth/live-store-provider.tsx`
13
+ ## `patterns/auth/store-with-auth.tsx`
14
14
 
15
- ```tsx filename="patterns/auth/live-store-provider.tsx"
15
+ ```tsx filename="patterns/auth/store-with-auth.tsx"
16
16
 
17
- const schema = {} as Parameters<typeof LiveStoreProvider>[0]['schema']
17
+ const schema = {} as LiveStoreSchema
18
18
  const storeId = 'demo-store'
19
19
  const user = { jwt: 'user-token' }
20
- const children: ReactNode = null
21
20
  const adapter = makeInMemoryAdapter()
22
21
 
23
22
  // ---cut---
24
- export const AuthenticatedProvider = () => (
25
- <LiveStoreProvider
26
- schema={schema}
27
- storeId={storeId}
28
- adapter={adapter}
29
- batchUpdates={batchUpdates}
30
- syncPayload={{
23
+ const useAppStore = () =>
24
+ useStore({
25
+ storeId,
26
+ schema,
27
+ adapter,
28
+ batchUpdates,
29
+ syncPayload: {
31
30
  authToken: user.jwt, // Using a JWT
32
- }}
33
- >
34
- {/* ... */}
35
- {children}
36
- </LiveStoreProvider>
37
- )
31
+ },
32
+ })
33
+
34
+ export const App = () => {
35
+ const [storeRegistry] = useState(() => new StoreRegistry())
36
+ return (
37
+ <Suspense fallback={<div>Loading...</div>}>
38
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
39
+ <AppContent />
40
+ </StoreRegistryProvider>
41
+ </Suspense>
42
+ )
43
+ }
44
+
45
+ const AppContent = () => {
46
+ const _store = useAppStore()
47
+ // Use the store in your components
48
+ return <div>{/* Your app content */}</div>
49
+ }
38
50
  ```
39
51
 
40
52
  On the sync server, validate the token and allow or reject the sync based on the result. See the following example:
@@ -184,6 +196,133 @@ export const verifyJwt = (token: string): Claims => {
184
196
 
185
197
  You can extend `ensureAuthorized` to project additional claims, memoise verification per `authToken`, or enforce application-specific policies without changing LiveStore internals.
186
198
 
199
+ ## Cookie-based authentication
200
+
201
+ If you prefer cookie-based authentication (e.g., with [better-auth](https://www.better-auth.com/)), you can forward HTTP headers to your `onPush` and `onPull` callbacks using the `forwardHeaders` option.
202
+
203
+ ### Why forward headers?
204
+
205
+ Passing tokens in URL parameters (`syncPayload`) exposes them in browser history, server logs, and referrer headers. Cookie-based auth avoids these issues since cookies are sent automatically with each request and aren't logged in URLs.
206
+
207
+ ### Example
208
+
209
+ The following example forwards `Cookie` and `Authorization` headers to the Durable Object callbacks:
210
+
211
+ ## `patterns/auth/cookie-auth.ts`
212
+
213
+ ```ts filename="patterns/auth/cookie-auth.ts"
214
+
215
+ export class SyncBackendDO extends makeDurableObject({
216
+ // Forward Cookie and Authorization headers to onPush/onPull callbacks
217
+ forwardHeaders: ['Cookie', 'Authorization'],
218
+
219
+ onPush: async (message, context) => {
220
+ const { storeId, headers } = context
221
+
222
+ // Access forwarded headers in callbacks
223
+ const cookie = headers?.get('cookie')
224
+ const _authorization = headers?.get('authorization')
225
+
226
+ if (cookie) {
227
+ // Parse session from cookie (example with better-auth)
228
+ const sessionToken = parseCookie(cookie, 'session_token')
229
+ const session = await getSessionFromToken(sessionToken)
230
+
231
+ if (!session) {
232
+ throw new Error('Invalid session')
233
+ }
234
+
235
+ console.log('Push from user:', session.userId, 'store:', storeId)
236
+ }
237
+
238
+ console.log('onPush', message.batch)
239
+ },
240
+
241
+ onPull: async (message, context) => {
242
+ const { storeId, headers } = context
243
+
244
+ // Same header access in onPull
245
+ const cookie = headers?.get('cookie')
246
+
247
+ if (cookie) {
248
+ const sessionToken = parseCookie(cookie, 'session_token')
249
+ const session = await getSessionFromToken(sessionToken)
250
+
251
+ if (!session) {
252
+ throw new Error('Invalid session')
253
+ }
254
+
255
+ console.log('Pull from user:', session.userId, 'store:', storeId)
256
+ }
257
+
258
+ console.log('onPull', message)
259
+ },
260
+ }) {}
261
+
262
+ export default makeWorker({
263
+ syncBackendBinding: 'SYNC_BACKEND_DO',
264
+ // Optional: validate at worker level using headers
265
+ validatePayload: async (_payload, context) => {
266
+ const { headers } = context
267
+ const cookie = headers.get('cookie')
268
+
269
+ if (cookie) {
270
+ const sessionToken = parseCookie(cookie, 'session_token')
271
+ const session = await getSessionFromToken(sessionToken)
272
+
273
+ if (!session) {
274
+ throw new Error('Unauthorized: Invalid session')
275
+ }
276
+ }
277
+ },
278
+ enableCORS: true,
279
+ })
280
+
281
+ // --- Helper functions (implement based on your auth library) ---
282
+
283
+ function parseCookie(cookieHeader: string, name: string): string | undefined {
284
+ const cookies = cookieHeader.split(';').map((c) => c.trim())
285
+ for (const cookie of cookies) {
286
+ const [key, value] = cookie.split('=')
287
+ if (key === name) return value
288
+ }
289
+ return undefined
290
+ }
291
+
292
+ interface Session {
293
+ userId: string
294
+ email: string
295
+ }
296
+
297
+ async function getSessionFromToken(_token: string | undefined): Promise<Session | null> {
298
+ // Implement session lookup using your auth library
299
+ // Example with better-auth:
300
+ // return await auth.api.getSession({ headers: { cookie: `session_token=${token}` } })
301
+ return { userId: 'user-123', email: 'user@example.com' }
302
+ }
303
+ ```
304
+
305
+ ### How it works
306
+
307
+ 1. **Configure `forwardHeaders`** in `makeDurableObject()` to specify which headers to forward.
308
+ 2. **Headers are stored** in the WebSocket attachment during connection upgrade, surviving hibernation.
309
+ 3. **Access headers** via `context.headers` in `onPush` and `onPull` callbacks.
310
+ 4. **Worker-level validation** can also access headers via `context.headers` in `validatePayload`.
311
+
312
+ ### Custom header extraction
313
+
314
+ For more control, pass a function to `forwardHeaders`:
315
+
316
+ ```typescript
317
+ export class SyncBackendDO extends makeDurableObject({
318
+ forwardHeaders: (request) => ({
319
+ 'x-user-id': request.headers.get('x-user-id') ?? '',
320
+ 'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
321
+ }),
322
+ // ...
323
+ }) {}
324
+ ```
325
+
187
326
  ## Client identity vs user identity
188
327
 
189
328
  LiveStore's `clientId` identifies a client instance, while user identity is an application-level concern that must be modeled through your application's events and logic.
@@ -196,31 +335,43 @@ LiveStore's `clientId` identifies a client instance, while user identity is an a
196
335
 
197
336
  The `syncPayload` is primarily intended for authentication purposes:
198
337
 
199
- ## `patterns/auth/live-store-provider.tsx`
338
+ ## `patterns/auth/store-with-auth.tsx`
200
339
 
201
- ```tsx filename="patterns/auth/live-store-provider.tsx"
340
+ ```tsx filename="patterns/auth/store-with-auth.tsx"
202
341
 
203
- const schema = {} as Parameters<typeof LiveStoreProvider>[0]['schema']
342
+ const schema = {} as LiveStoreSchema
204
343
  const storeId = 'demo-store'
205
344
  const user = { jwt: 'user-token' }
206
- const children: ReactNode = null
207
345
  const adapter = makeInMemoryAdapter()
208
346
 
209
347
  // ---cut---
210
- export const AuthenticatedProvider = () => (
211
- <LiveStoreProvider
212
- schema={schema}
213
- storeId={storeId}
214
- adapter={adapter}
215
- batchUpdates={batchUpdates}
216
- syncPayload={{
348
+ const useAppStore = () =>
349
+ useStore({
350
+ storeId,
351
+ schema,
352
+ adapter,
353
+ batchUpdates,
354
+ syncPayload: {
217
355
  authToken: user.jwt, // Using a JWT
218
- }}
219
- >
220
- {/* ... */}
221
- {children}
222
- </LiveStoreProvider>
223
- )
356
+ },
357
+ })
358
+
359
+ export const App = () => {
360
+ const [storeRegistry] = useState(() => new StoreRegistry())
361
+ return (
362
+ <Suspense fallback={<div>Loading...</div>}>
363
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
364
+ <AppContent />
365
+ </StoreRegistryProvider>
366
+ </Suspense>
367
+ )
368
+ }
369
+
370
+ const AppContent = () => {
371
+ const _store = useAppStore()
372
+ // Use the store in your components
373
+ return <div>{/* Your app content */}</div>
374
+ }
224
375
  ```
225
376
 
226
377
  User identification and semantic data (like user IDs) should typically be handled through your event payloads and application state rather than relying solely on the sync payload.
@@ -2,6 +2,16 @@
2
2
 
3
3
  LiveStore itself is built on top of [Effect](https://effect.website) which is a powerful library to write production-grade TypeScript code. It's also possible (and recommended) to use Effect directly in your application code.
4
4
 
5
+ ## Store context for Effect
6
+
7
+ For applications built with Effect, LiveStore provides `makeStoreContext()` - a factory that creates typed store contexts for the Effect layer system. This preserves your schema types through Effect's dependency injection and supports multiple stores.
8
+
9
+ See the [Store Effect integration](/building-with-livestore/store#effect-integration) section for details on:
10
+ - Creating typed store contexts
11
+ - Using stores in Effect services
12
+ - Layer composition patterns
13
+ - Multiple stores in the same app
14
+
5
15
  ## Schema
6
16
 
7
17
  LiveStore uses the [Effect Schema](https://effect.website/docs/schema/introduction/) library to define schemas for the following:
@@ -430,7 +440,7 @@ export const pendingUsersAtom = Atom.make<PendingUser[]>([])
430
440
 
431
441
  ### Using queries in React components
432
442
 
433
- Access query results in React components with the `useAtomValue` hook. When using `StoreTag.makeQuery` (non-unsafe API), the result is wrapped in a Result type for proper loading and error handling:
443
+ Access query results in React components with the `useAtomValue()` hook. When using `StoreTag.makeQuery` (non-unsafe API), the result is wrapped in a Result type for proper loading and error handling:
434
444
 
435
445
  ## `patterns/effect/store-setup/user-list.tsx`
436
446
 
@@ -20,7 +20,7 @@ export const WithInitialText: Story = {
20
20
  ])
21
21
  ],
22
22
  }`,
23
-
23
+
24
24
  storybookPreview: `import React from 'react'
25
25
 
26
26
  // Default decorator with no seed data
@@ -28,40 +28,57 @@ const LiveStoreDecorator = createLiveStoreDecorator()
28
28
 
29
29
  export const decorators = [LiveStoreDecorator]`,
30
30
 
31
- decorator: `import React from 'react'
31
+ decorator: `import React, { Suspense, useState } from 'react'
32
+
33
+ const adapter = makeInMemoryAdapter()
32
34
 
33
35
  // Create LiveStore decorator with optional seeding
34
36
  export const createLiveStoreDecorator = (seedEvents = []) => (Story) => {
35
- const onBoot = (store) => {
36
- // Seed data through events during boot
37
- if (seedEvents.length > 0) {
38
- store.commit(...seedEvents)
39
- }
40
- }
37
+ const [storeRegistry] = useState(() => new StoreRegistry())
41
38
 
42
39
  return (
43
- <LiveStoreProvider
44
- schema={schema}
45
- adapter={makeInMemoryAdapter()}
46
- batchUpdates={batchUpdates}
47
- boot={onBoot}
48
- renderLoading={(status) => <div>Loading LiveStore ({status.stage})...</div>}
49
- >
50
- <Story />
51
- </LiveStoreProvider>
40
+ <Suspense fallback={<div>Loading LiveStore...</div>}>
41
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
42
+ <StoryWithStore Story={Story} seedEvents={seedEvents} />
43
+ </StoreRegistryProvider>
44
+ </Suspense>
52
45
  )
46
+ }
47
+
48
+ const StoryWithStore = ({ Story, seedEvents }) => {
49
+ const store = useStore({
50
+ storeId: 'storybook',
51
+ schema,
52
+ adapter,
53
+ batchUpdates,
54
+ boot: (store) => {
55
+ if (seedEvents.length > 0) {
56
+ store.commit(...seedEvents)
57
+ }
58
+ },
59
+ })
60
+ return <Story />
53
61
  }`,
54
62
 
55
- todoInput: `import React from 'react'
63
+ todoInput: `import { useStore } from '@livestore/react'
64
+
65
+ const adapter = makeInMemoryAdapter()
56
66
 
57
67
  // Define queries (like in TodoMVC)
58
68
  const uiState$ = queryDb(tables.uiState.get(), { label: 'uiState' })
59
69
 
70
+ const useAppStore = () => useStore({
71
+ storeId: 'todo-app',
72
+ schema,
73
+ adapter,
74
+ batchUpdates,
75
+ })
76
+
60
77
  export const TodoInput = () => {
61
- const { store } = useStore()
78
+ const store = useAppStore()
62
79
  const { newTodoText } = store.useQuery(uiState$)
63
80
 
64
- const updateNewTodoText = (text: string) =>
81
+ const updateNewTodoText = (text: string) =>
65
82
  store.commit(events.uiStateSet({ newTodoText: text }))
66
83
 
67
84
  const createTodo = () => {
@@ -110,13 +127,13 @@ export const tables = {
110
127
  // Client document for UI state
111
128
  uiState: State.SQLite.clientDocument({
112
129
  name: 'uiState',
113
- schema: Schema.Struct({
114
- newTodoText: Schema.String,
115
- filter: Schema.Literal('all', 'active', 'completed')
130
+ schema: Schema.Struct({
131
+ newTodoText: Schema.String,
132
+ filter: Schema.Literal('all', 'active', 'completed')
116
133
  }),
117
- default: {
118
- id: SessionIdSymbol,
119
- value: { newTodoText: '', filter: 'all' }
134
+ default: {
135
+ id: SessionIdSymbol,
136
+ value: { newTodoText: '', filter: 'all' }
120
137
  },
121
138
  }),
122
139
  }
@@ -31,7 +31,7 @@ For a complete setup including sync and devtools, see the [Expo getting started
31
31
 
32
32
  ## Basic Usage
33
33
 
34
- Create an adapter and wrap your app with the `LiveStoreProvider`:
34
+ Create an adapter and a custom `useAppStore()` hook, then set up a `StoreRegistry` with `<StoreRegistryProvider>`:
35
35
 
36
36
  ## `reference/platform-adapters/expo-adapter/usage.tsx`
37
37
 
@@ -39,27 +39,39 @@ Create an adapter and wrap your app with the `LiveStoreProvider`:
39
39
 
40
40
  const adapter = makePersistedAdapter()
41
41
 
42
- export const App = () => (
43
- <SafeAreaView style={{ flex: 1 }}>
44
- <LiveStoreProvider
45
- schema={schema}
46
- adapter={adapter}
47
- storeId="my-app"
48
- batchUpdates={batchUpdates}
49
- renderLoading={(status) => <Text>Loading ({status.stage})...</Text>}
50
- renderError={(error) => <Text>Error: {String(error)}</Text>}
51
- >
52
- {/* Your app content */}
53
- </LiveStoreProvider>
54
- </SafeAreaView>
55
- )
42
+ const useAppStore = () =>
43
+ useStore({
44
+ storeId: 'my-app',
45
+ schema,
46
+ adapter,
47
+ batchUpdates,
48
+ })
49
+
50
+ export const App = () => {
51
+ const [storeRegistry] = useState(() => new StoreRegistry())
52
+ return (
53
+ <SafeAreaView style={{ flex: 1 }}>
54
+ <Suspense fallback={<Text>Loading...</Text>}>
55
+ <StoreRegistryProvider storeRegistry={storeRegistry}>
56
+ <TodoList />
57
+ </StoreRegistryProvider>
58
+ </Suspense>
59
+ </SafeAreaView>
60
+ )
61
+ }
62
+
63
+ const TodoList = () => {
64
+ const store = useAppStore()
65
+ const todos = store.useQuery(queryDb(tables.todos.select()))
66
+ return <Text>{todos.length} todos</Text>
67
+ }
56
68
  ```
57
69
 
58
70
  ### `reference/platform-adapters/expo-adapter/schema.ts`
59
71
 
60
72
  ```ts filename="reference/platform-adapters/expo-adapter/schema.ts"
61
73
 
62
- const tables = {
74
+ export const tables = {
63
75
  todos: State.SQLite.table({
64
76
  name: 'todos',
65
77
  columns: {
@@ -88,7 +100,7 @@ const state = State.SQLite.makeState({ tables, materializers })
88
100
  export const schema = makeSchema({ events, state })
89
101
  ```
90
102
 
91
- The adapter requires minimal configuration. For more details on the provider props, see the [React integration guide](/framework-integrations/react-integration).
103
+ For more details on the registry, and hooks, see the [React integration guide](/framework-integrations/react-integration).
92
104
 
93
105
  ## Configuration Options
94
106
 
@@ -99,7 +111,11 @@ The adapter requires minimal configuration. For more details on the provider pro
99
111
  // ---cut---
100
112
 
101
113
  const adapter = makePersistedAdapter({
102
- storage: { subDirectory: 'my-app' },
114
+ storage: {
115
+ // Optional: custom base directory (defaults to expo-sqlite's default)
116
+ // directory: '/custom/path/to/databases',
117
+ subDirectory: 'my-app',
118
+ },
103
119
  })
104
120
  ```
105
121
 
@@ -107,7 +123,8 @@ const adapter = makePersistedAdapter({
107
123
 
108
124
  | Option | Type | Description |
109
125
  |--------|------|-------------|
110
- | `storage.subDirectory` | `string` | Subdirectory within the default SQLite location for organizing databases |
126
+ | `storage.directory` | `string` | Base directory for database files (defaults to `expo-sqlite`'s default directory) |
127
+ | `storage.subDirectory` | `string` | Subdirectory relative to `directory` for organizing databases |
111
128
  | `sync` | `SyncOptions` | Sync backend configuration (see [Syncing](/building-with-livestore/syncing)) |
112
129
  | `clientId` | `string` | Custom client identifier (defaults to device ID) |
113
130
  | `sessionId` | `string` | Session identifier (defaults to `'static'`) |
@@ -149,6 +149,43 @@ During development (`NODE_ENV !== 'production'`), LiveStore automatically copies
149
149
 
150
150
  LiveStore also uses `window.sessionStorage` to retain the identity of a client session (e.g. tab/window) across reloads.
151
151
 
152
+ ### Private browsing mode
153
+
154
+ In Safari and Firefox private browsing mode, OPFS is not available due to browser restrictions. When this happens, LiveStore automatically falls back to in-memory storage. This means:
155
+
156
+ - The app will continue to work normally during the session
157
+ - Data will not persist across page reloads or tab closures
158
+ - Sync functionality (if configured) will still work
159
+
160
+ You can detect when the store is running in-memory mode using `store.storageMode`:
161
+
162
+ ```tsx
163
+ if (store.storageMode === 'in-memory') {
164
+ // Show a warning to the user
165
+ showToast('Data will not be saved in private browsing mode')
166
+ }
167
+ ```
168
+
169
+ The `storageMode` property returns:
170
+ - `'persisted'`: Data is being persisted to disk (e.g., via OPFS)
171
+ - `'in-memory'`: Data is only stored in memory and will be lost on page refresh
172
+
173
+ You can also listen for boot status events including warnings using the `onBootStatus` callback in your store options:
174
+
175
+ ```ts
176
+ const useAppStore = () => useStore({
177
+ storeId: 'app',
178
+ schema,
179
+ adapter,
180
+ batchUpdates: ReactDOM.unstable_batchedUpdates,
181
+ onBootStatus: (status) => {
182
+ if (status.stage === 'warning') {
183
+ console.warn(`Storage warning (${status.reason}): ${status.message}`)
184
+ }
185
+ },
186
+ })
187
+ ```
188
+
152
189
  ### Resetting local persistence
153
190
 
154
191
  Resetting local persistence only clears data stored in the browser and does not affect any connected sync backend.
@@ -201,8 +238,40 @@ Assuming the web adapter in a multi-client, multi-tab browser application, a dia
201
238
 
202
239
  ## Browser support
203
240
 
204
- - Notable required browser APIs: OPFS, SharedWorker, `navigator.locks`, WASM
205
- - The web adapter of LiveStore currently doesn't work on Android browsers as they don't support the `SharedWorker` API (see [Chromium bug](https://issues.chromium.org/issues/40290702)).
241
+ - Notable required browser APIs: OPFS, `navigator.locks`, WASM
242
+ - SharedWorker is used for multi-tab synchronization but is not strictly required
243
+
244
+ ### Android Chrome (Single-tab mode)
245
+
246
+ Android Chrome does not support the `SharedWorker` API ([Chromium bug #40290702](https://issues.chromium.org/issues/40290702)). When running on Android Chrome, LiveStore automatically falls back to **single-tab mode**:
247
+
248
+ - Each browser tab runs independently with its own leader worker
249
+ - Data is still persisted to OPFS (same as full mode)
250
+ - Multi-tab synchronization is not available
251
+ - Devtools are not supported in single-tab mode
252
+ - A warning is logged to the console when this fallback occurs
253
+
254
+ You can also explicitly use single-tab mode if you don't need multi-tab support:
255
+
256
+ ## `reference/platform-adapters/web-adapter/single-tab.ts`
257
+
258
+ ```ts filename="reference/platform-adapters/web-adapter/single-tab.ts"
259
+ /** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
260
+ // ---cut---
261
+
262
+ // Use this only if you specifically need single-tab mode.
263
+ // Prefer makePersistedAdapter which auto-detects SharedWorker support.
264
+ const adapter = makeSingleTabAdapter({
265
+ worker: LiveStoreWorker,
266
+ storage: { type: 'opfs' },
267
+ })
268
+ ```
269
+
270
+ :::note
271
+ The single-tab adapter is intended as a fallback for browsers without SharedWorker support.
272
+ We plan to remove it once SharedWorker is supported in Android Chrome.
273
+ Track progress: [LiveStore #321](https://github.com/livestorejs/livestore/issues/321)
274
+ :::
206
275
 
207
276
  ## Best practices
208
277
 
@@ -25,7 +25,7 @@ Once you've downloaded the project, you can navigate to the project directory an
25
25
  The project currently is set up as follows:
26
26
  - Minimal project created via [`vite create`](https://vite.dev/guide/#scaffolding-your-first-vite-project) using React and TypeScript.
27
27
  - Using [Tailwind CSS](https://tailwindcss.com/) for styling.
28
- - Has basic functionality for adding and deleting todos via local [`React.useState`](https://react.dev/learn/state-a-components-memory).
28
+ - Has basic functionality for adding and deleting todos via local [`React.useState()`](https://react.dev/learn/state-a-components-memory).
29
29
 
30
30
  ## Understand the current project state
31
31
 
@@ -80,8 +80,8 @@ function App() {
80
80
 
81
81
  return (
82
82
  // Render input text field and todo list ...
83
- // ... and invoke `addTodo` and `deleteTodo`
84
- // ... when the buttons are clicked.
83
+ // ... and invoke `addTodo` and `deleteTodo`
84
+ // ... when the buttons are clicked.
85
85
  )
86
86
  }
87
87
  ```
@@ -98,8 +98,8 @@ The "problem" with this code is that the todo items are not _persisted_, meaning
98
98
  - the page is refreshed in the browser.
99
99
  - the development server is restarted.
100
100
 
101
- In the next chapters, you'll learn how to persist the todos in the list, so that they'll "survive" both actions.
101
+ In the next chapters, you'll learn how to persist the todos in the list, so that they'll "survive" both actions.
102
102
 
103
- Even more: They will not only persist, they will automatically sync across multiple browsers tabs/windows, and even across devices—without you needing to think about the syncing logic and managing remote state.
103
+ Even more: They will not only persist, they will automatically sync across multiple browsers tabs/windows, and even across devices—without you needing to think about the syncing logic and managing remote state.
104
104
 
105
105
  That's the power of LiveStore!