Skip to content

Commit 8b29841

Browse files
KyleAMathewsclaude
andauthored
feat: Add in-memory fallback for localStorage collections in SSR environments (TanStack#696)
* feat: Add in-memory fallback for localStorage collections in SSR environments Prevents LocalStorageCollectionError when localStorage collections are imported on the server by automatically falling back to an in-memory store. This addresses issue TanStack#691 where importing client code with localStorage collections in server environments would throw errors during module initialization. Changes: - Add createInMemoryStorage() to provide in-memory StorageApi fallback - Add createNoOpStorageEventApi() for server environments without window - Update localStorageCollectionOptions to use fallbacks instead of throwing errors - Remove unused NoStorageAvailableError and NoStorageEventApiError imports - Update tests to verify fallback behavior instead of expecting errors - Update JSDoc to document fallback behavior This allows isomorphic JavaScript applications to safely import localStorage collection modules without errors, though data will not persist across reloads or sync across tabs when using the in-memory fallback. Fixes TanStack#691 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: Remove unused NoStorageAvailableError and NoStorageEventApiError classes These error classes are no longer used after implementing the in-memory fallback for localStorage collections in SSR environments. Removes dead code to keep the codebase clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: Add changeset for in-memory fallback feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Update changeset to patch version (pre-1.0 project) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: Update changeset to not reference deleted error class 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63a7958 commit 8b29841

File tree

4 files changed

+87
-49
lines changed

4 files changed

+87
-49
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Add in-memory fallback for localStorage collections in SSR environments
6+
7+
Prevents errors when localStorage collections are imported on the server by automatically falling back to an in-memory store. This allows isomorphic JavaScript applications to safely import localStorage collection modules without errors during module initialization.
8+
9+
When localStorage is not available (e.g., in server-side rendering environments), the collection automatically uses an in-memory storage implementation. Data will not persist across page reloads or be shared across tabs when using the in-memory fallback, but the collection will function normally otherwise.
10+
11+
Fixes #691

packages/db/src/errors.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -521,22 +521,6 @@ export class StorageKeyRequiredError extends LocalStorageCollectionError {
521521
}
522522
}
523523

524-
export class NoStorageAvailableError extends LocalStorageCollectionError {
525-
constructor() {
526-
super(
527-
`[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.`
528-
)
529-
}
530-
}
531-
532-
export class NoStorageEventApiError extends LocalStorageCollectionError {
533-
constructor() {
534-
super(
535-
`[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.`
536-
)
537-
}
538-
}
539-
540524
export class InvalidStorageDataFormatError extends LocalStorageCollectionError {
541525
constructor(storageKey: string, key: string) {
542526
super(

packages/db/src/local-storage.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import {
22
InvalidStorageDataFormatError,
33
InvalidStorageObjectFormatError,
4-
NoStorageAvailableError,
5-
NoStorageEventApiError,
64
SerializationError,
75
StorageKeyRequiredError,
86
} from "./errors"
@@ -138,12 +136,58 @@ function generateUuid(): string {
138136
return crypto.randomUUID()
139137
}
140138

139+
/**
140+
* Creates an in-memory storage implementation that mimics the StorageApi interface
141+
* Used as a fallback when localStorage is not available (e.g., server-side rendering)
142+
* @returns An object implementing the StorageApi interface using an in-memory Map
143+
*/
144+
function createInMemoryStorage(): StorageApi {
145+
const storage = new Map<string, string>()
146+
147+
return {
148+
getItem(key: string): string | null {
149+
return storage.get(key) ?? null
150+
},
151+
setItem(key: string, value: string): void {
152+
storage.set(key, value)
153+
},
154+
removeItem(key: string): void {
155+
storage.delete(key)
156+
},
157+
}
158+
}
159+
160+
/**
161+
* Creates a no-op storage event API for environments without window (e.g., server-side)
162+
* This provides the required interface but doesn't actually listen to any events
163+
* since cross-tab synchronization is not possible in server environments
164+
* @returns An object implementing the StorageEventApi interface with no-op methods
165+
*/
166+
function createNoOpStorageEventApi(): StorageEventApi {
167+
return {
168+
addEventListener: () => {
169+
// No-op: cannot listen to storage events without window
170+
},
171+
removeEventListener: () => {
172+
// No-op: cannot remove listeners without window
173+
},
174+
}
175+
}
176+
141177
/**
142178
* Creates localStorage collection options for use with a standard Collection
143179
*
144180
* This function creates a collection that persists data to localStorage/sessionStorage
145181
* and synchronizes changes across browser tabs using storage events.
146182
*
183+
* **Fallback Behavior:**
184+
*
185+
* When localStorage is not available (e.g., in server-side rendering environments),
186+
* this function automatically falls back to an in-memory storage implementation.
187+
* This prevents errors during module initialization and allows the collection to
188+
* work in any environment, though data will not persist across page reloads or
189+
* be shared across tabs when using the in-memory fallback.
190+
*
147191
* **Using with Manual Transactions:**
148192
*
149193
* For manual transactions, you must call `utils.acceptMutations()` in your transaction's `mutationFn`
@@ -257,21 +301,18 @@ export function localStorageCollectionOptions(
257301
}
258302

259303
// Default to window.localStorage if no storage is provided
304+
// Fall back to in-memory storage if localStorage is not available (e.g., server-side rendering)
260305
const storage =
261306
config.storage ||
262-
(typeof window !== `undefined` ? window.localStorage : null)
263-
264-
if (!storage) {
265-
throw new NoStorageAvailableError()
266-
}
307+
(typeof window !== `undefined` ? window.localStorage : null) ||
308+
createInMemoryStorage()
267309

268310
// Default to window for storage events if not provided
311+
// Fall back to no-op storage event API if window is not available (e.g., server-side rendering)
269312
const storageEventApi =
270-
config.storageEventApi || (typeof window !== `undefined` ? window : null)
271-
272-
if (!storageEventApi) {
273-
throw new NoStorageEventApiError()
274-
}
313+
config.storageEventApi ||
314+
(typeof window !== `undefined` ? window : null) ||
315+
createNoOpStorageEventApi()
275316

276317
// Track the last known state to detect changes
277318
const lastKnownData = new Map<string | number, StoredItem<any>>()

packages/db/tests/local-storage.test.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
22
import { createCollection } from "../src/index"
33
import { localStorageCollectionOptions } from "../src/local-storage"
44
import { createTransaction } from "../src/transactions"
5-
import {
6-
NoStorageAvailableError,
7-
NoStorageEventApiError,
8-
StorageKeyRequiredError,
9-
} from "../src/errors"
5+
import { StorageKeyRequiredError } from "../src/errors"
106
import type { StorageEventApi } from "../src/local-storage"
117

128
// Mock storage implementation for testing that properly implements Storage interface
@@ -138,37 +134,43 @@ describe(`localStorage collection`, () => {
138134
).toThrow(StorageKeyRequiredError)
139135
})
140136

141-
it(`should throw error when no storage is available`, () => {
137+
it(`should fall back to in-memory storage when no storage is available`, () => {
142138
// Mock window to be undefined globally
143139
const originalWindow = globalThis.window
144140
// @ts-ignore - Temporarily delete window to test error condition
145141
delete globalThis.window
146142

147-
expect(() =>
148-
localStorageCollectionOptions({
149-
storageKey: `test`,
150-
storageEventApi: mockStorageEventApi,
151-
getKey: (item: any) => item.id,
152-
})
153-
).toThrow(NoStorageAvailableError)
143+
// Should not throw - instead falls back to in-memory storage
144+
const collectionOptions = localStorageCollectionOptions({
145+
storageKey: `test`,
146+
storageEventApi: mockStorageEventApi,
147+
getKey: (item: any) => item.id,
148+
})
149+
150+
// Verify collection was created successfully
151+
expect(collectionOptions).toBeDefined()
152+
expect(collectionOptions.id).toBe(`local-collection:test`)
154153

155154
// Restore window
156155
globalThis.window = originalWindow
157156
})
158157

159-
it(`should throw error when no storage event API is available`, () => {
158+
it(`should fall back to no-op event API when no storage event API is available`, () => {
160159
// Mock window to be undefined globally
161160
const originalWindow = globalThis.window
162161
// @ts-ignore - Temporarily delete window to test error condition
163162
delete globalThis.window
164163

165-
expect(() =>
166-
localStorageCollectionOptions({
167-
storageKey: `test`,
168-
storage: mockStorage,
169-
getKey: (item: any) => item.id,
170-
})
171-
).toThrow(NoStorageEventApiError)
164+
// Should not throw - instead falls back to no-op storage event API
165+
const collectionOptions = localStorageCollectionOptions({
166+
storageKey: `test`,
167+
storage: mockStorage,
168+
getKey: (item: any) => item.id,
169+
})
170+
171+
// Verify collection was created successfully
172+
expect(collectionOptions).toBeDefined()
173+
expect(collectionOptions.id).toBe(`local-collection:test`)
172174

173175
// Restore window
174176
globalThis.window = originalWindow

0 commit comments

Comments
 (0)