Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/in-memory-fallback-for-ssr.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 0 additions & 16 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
65 changes: 53 additions & 12 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {
InvalidStorageDataFormatError,
InvalidStorageObjectFormatError,
NoStorageAvailableError,
NoStorageEventApiError,
SerializationError,
StorageKeyRequiredError,
} from "./errors"
Expand Down Expand Up @@ -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<string, string>()

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`
Expand Down Expand Up @@ -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<string | number, StoredItem<any>>()
Expand Down
44 changes: 23 additions & 21 deletions packages/db/tests/local-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading