diff --git a/.changeset/in-memory-fallback-for-ssr.md b/.changeset/in-memory-fallback-for-ssr.md new file mode 100644 index 000000000..5afb61018 --- /dev/null +++ b/.changeset/in-memory-fallback-for-ssr.md @@ -0,0 +1,11 @@ +--- +"@tanstack/db": patch +--- + +Add in-memory fallback for localStorage collections in SSR environments + +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. + +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. + +Fixes #691 diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 3858df178..7ec6dd478 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -521,22 +521,6 @@ export class StorageKeyRequiredError extends LocalStorageCollectionError { } } -export class NoStorageAvailableError extends LocalStorageCollectionError { - constructor() { - super( - `[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.` - ) - } -} - -export class NoStorageEventApiError extends LocalStorageCollectionError { - constructor() { - super( - `[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.` - ) - } -} - export class InvalidStorageDataFormatError extends LocalStorageCollectionError { constructor(storageKey: string, key: string) { super( diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index cb9e3e2ce..df3b1b2e5 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -1,8 +1,6 @@ import { InvalidStorageDataFormatError, InvalidStorageObjectFormatError, - NoStorageAvailableError, - NoStorageEventApiError, SerializationError, StorageKeyRequiredError, } from "./errors" @@ -138,12 +136,58 @@ function generateUuid(): string { return crypto.randomUUID() } +/** + * Creates an in-memory storage implementation that mimics the StorageApi interface + * Used as a fallback when localStorage is not available (e.g., server-side rendering) + * @returns An object implementing the StorageApi interface using an in-memory Map + */ +function createInMemoryStorage(): StorageApi { + const storage = new Map() + + return { + getItem(key: string): string | null { + return storage.get(key) ?? null + }, + setItem(key: string, value: string): void { + storage.set(key, value) + }, + removeItem(key: string): void { + storage.delete(key) + }, + } +} + +/** + * Creates a no-op storage event API for environments without window (e.g., server-side) + * This provides the required interface but doesn't actually listen to any events + * since cross-tab synchronization is not possible in server environments + * @returns An object implementing the StorageEventApi interface with no-op methods + */ +function createNoOpStorageEventApi(): StorageEventApi { + return { + addEventListener: () => { + // No-op: cannot listen to storage events without window + }, + removeEventListener: () => { + // No-op: cannot remove listeners without window + }, + } +} + /** * Creates localStorage collection options for use with a standard Collection * * This function creates a collection that persists data to localStorage/sessionStorage * and synchronizes changes across browser tabs using storage events. * + * **Fallback Behavior:** + * + * When localStorage is not available (e.g., in server-side rendering environments), + * this function automatically falls back to an in-memory storage implementation. + * This prevents errors during module initialization and allows the collection to + * work in any environment, though data will not persist across page reloads or + * be shared across tabs when using the in-memory fallback. + * * **Using with Manual Transactions:** * * For manual transactions, you must call `utils.acceptMutations()` in your transaction's `mutationFn` @@ -257,21 +301,18 @@ export function localStorageCollectionOptions( } // Default to window.localStorage if no storage is provided + // Fall back to in-memory storage if localStorage is not available (e.g., server-side rendering) const storage = config.storage || - (typeof window !== `undefined` ? window.localStorage : null) - - if (!storage) { - throw new NoStorageAvailableError() - } + (typeof window !== `undefined` ? window.localStorage : null) || + createInMemoryStorage() // Default to window for storage events if not provided + // Fall back to no-op storage event API if window is not available (e.g., server-side rendering) const storageEventApi = - config.storageEventApi || (typeof window !== `undefined` ? window : null) - - if (!storageEventApi) { - throw new NoStorageEventApiError() - } + config.storageEventApi || + (typeof window !== `undefined` ? window : null) || + createNoOpStorageEventApi() // Track the last known state to detect changes const lastKnownData = new Map>() diff --git a/packages/db/tests/local-storage.test.ts b/packages/db/tests/local-storage.test.ts index f2a488d75..b991622dc 100644 --- a/packages/db/tests/local-storage.test.ts +++ b/packages/db/tests/local-storage.test.ts @@ -2,11 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { createCollection } from "../src/index" import { localStorageCollectionOptions } from "../src/local-storage" import { createTransaction } from "../src/transactions" -import { - NoStorageAvailableError, - NoStorageEventApiError, - StorageKeyRequiredError, -} from "../src/errors" +import { StorageKeyRequiredError } from "../src/errors" import type { StorageEventApi } from "../src/local-storage" // Mock storage implementation for testing that properly implements Storage interface @@ -138,37 +134,43 @@ describe(`localStorage collection`, () => { ).toThrow(StorageKeyRequiredError) }) - it(`should throw error when no storage is available`, () => { + it(`should fall back to in-memory storage when no storage is available`, () => { // Mock window to be undefined globally const originalWindow = globalThis.window // @ts-ignore - Temporarily delete window to test error condition delete globalThis.window - expect(() => - localStorageCollectionOptions({ - storageKey: `test`, - storageEventApi: mockStorageEventApi, - getKey: (item: any) => item.id, - }) - ).toThrow(NoStorageAvailableError) + // Should not throw - instead falls back to in-memory storage + const collectionOptions = localStorageCollectionOptions({ + storageKey: `test`, + storageEventApi: mockStorageEventApi, + getKey: (item: any) => item.id, + }) + + // Verify collection was created successfully + expect(collectionOptions).toBeDefined() + expect(collectionOptions.id).toBe(`local-collection:test`) // Restore window globalThis.window = originalWindow }) - it(`should throw error when no storage event API is available`, () => { + it(`should fall back to no-op event API when no storage event API is available`, () => { // Mock window to be undefined globally const originalWindow = globalThis.window // @ts-ignore - Temporarily delete window to test error condition delete globalThis.window - expect(() => - localStorageCollectionOptions({ - storageKey: `test`, - storage: mockStorage, - getKey: (item: any) => item.id, - }) - ).toThrow(NoStorageEventApiError) + // Should not throw - instead falls back to no-op storage event API + const collectionOptions = localStorageCollectionOptions({ + storageKey: `test`, + storage: mockStorage, + getKey: (item: any) => item.id, + }) + + // Verify collection was created successfully + expect(collectionOptions).toBeDefined() + expect(collectionOptions.id).toBe(`local-collection:test`) // Restore window globalThis.window = originalWindow