Skip to content

Commit 6738247

Browse files
authored
Feat: Add support for custom parsers/serializers in LocalStorage collections (#730)
* feat(local-storage): add support for custom parsers/serializers * added changeset * feat(local-storage): using parser instead of JSON in loadFromStorage * feat(local-storage): changed argument order in loadFromStorage to respect previous one * feat(local-storage): exporting parser type
1 parent 2d4d5e1 commit 6738247

File tree

5 files changed

+113
-18
lines changed

5 files changed

+113
-18
lines changed

.changeset/cruel-signs-work.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Added support for custom parsers/serializers like superjson in LocalStorage collections

packages/db/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"devDependencies": {
1010
"@vitest/coverage-istanbul": "^3.2.4",
1111
"arktype": "^2.1.23",
12+
"superjson": "^2.2.5",
1213
"temporal-polyfill": "^0.3.0"
1314
},
1415
"exports": {

packages/db/src/local-storage.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ interface StoredItem<T> {
4444
data: T
4545
}
4646

47+
export interface Parser {
48+
parse: (data: string) => unknown
49+
stringify: (data: unknown) => string
50+
}
51+
4752
/**
4853
* Configuration interface for localStorage collection options
4954
* @template T - The type of items in the collection
@@ -71,6 +76,12 @@ export interface LocalStorageCollectionConfig<
7176
* Can be any object that implements addEventListener/removeEventListener for storage events
7277
*/
7378
storageEventApi?: StorageEventApi
79+
80+
/**
81+
* Parser to use for serializing and deserializing data to and from storage
82+
* Defaults to JSON
83+
*/
84+
parser?: Parser
7485
}
7586

7687
/**
@@ -113,13 +124,18 @@ export interface LocalStorageCollectionUtils extends UtilsRecord {
113124

114125
/**
115126
* Validates that a value can be JSON serialized
127+
* @param parser - The parser to use for serialization
116128
* @param value - The value to validate for JSON serialization
117129
* @param operation - The operation type being performed (for error messages)
118130
* @throws Error if the value cannot be JSON serialized
119131
*/
120-
function validateJsonSerializable(value: any, operation: string): void {
132+
function validateJsonSerializable(
133+
parser: Parser,
134+
value: any,
135+
operation: string
136+
): void {
121137
try {
122-
JSON.stringify(value)
138+
parser.stringify(value)
123139
} catch (error) {
124140
throw new SerializationError(
125141
operation,
@@ -314,6 +330,9 @@ export function localStorageCollectionOptions(
314330
(typeof window !== `undefined` ? window : null) ||
315331
createNoOpStorageEventApi()
316332

333+
// Default to JSON parser if no parser is provided
334+
const parser = config.parser || JSON
335+
317336
// Track the last known state to detect changes
318337
const lastKnownData = new Map<string | number, StoredItem<any>>()
319338

@@ -322,6 +341,7 @@ export function localStorageCollectionOptions(
322341
config.storageKey,
323342
storage,
324343
storageEventApi,
344+
parser,
325345
config.getKey,
326346
lastKnownData
327347
)
@@ -349,7 +369,7 @@ export function localStorageCollectionOptions(
349369
dataMap.forEach((storedItem, key) => {
350370
objectData[String(key)] = storedItem
351371
})
352-
const serialized = JSON.stringify(objectData)
372+
const serialized = parser.stringify(objectData)
353373
storage.setItem(config.storageKey, serialized)
354374
} catch (error) {
355375
console.error(
@@ -383,7 +403,7 @@ export function localStorageCollectionOptions(
383403
const wrappedOnInsert = async (params: InsertMutationFnParams<any>) => {
384404
// Validate that all values in the transaction can be JSON serialized
385405
params.transaction.mutations.forEach((mutation) => {
386-
validateJsonSerializable(mutation.modified, `insert`)
406+
validateJsonSerializable(parser, mutation.modified, `insert`)
387407
})
388408

389409
// Call the user handler BEFORE persisting changes (if provided)
@@ -394,7 +414,7 @@ export function localStorageCollectionOptions(
394414

395415
// Always persist to storage
396416
// Load current data from storage
397-
const currentData = loadFromStorage<any>(config.storageKey, storage)
417+
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
398418

399419
// Add new items with version keys
400420
params.transaction.mutations.forEach((mutation) => {
@@ -418,7 +438,7 @@ export function localStorageCollectionOptions(
418438
const wrappedOnUpdate = async (params: UpdateMutationFnParams<any>) => {
419439
// Validate that all values in the transaction can be JSON serialized
420440
params.transaction.mutations.forEach((mutation) => {
421-
validateJsonSerializable(mutation.modified, `update`)
441+
validateJsonSerializable(parser, mutation.modified, `update`)
422442
})
423443

424444
// Call the user handler BEFORE persisting changes (if provided)
@@ -429,7 +449,7 @@ export function localStorageCollectionOptions(
429449

430450
// Always persist to storage
431451
// Load current data from storage
432-
const currentData = loadFromStorage<any>(config.storageKey, storage)
452+
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
433453

434454
// Update items with new version keys
435455
params.transaction.mutations.forEach((mutation) => {
@@ -459,7 +479,7 @@ export function localStorageCollectionOptions(
459479

460480
// Always persist to storage
461481
// Load current data from storage
462-
const currentData = loadFromStorage<any>(config.storageKey, storage)
482+
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
463483

464484
// Remove items
465485
params.transaction.mutations.forEach((mutation) => {
@@ -518,18 +538,19 @@ export function localStorageCollectionOptions(
518538
switch (mutation.type) {
519539
case `insert`:
520540
case `update`:
521-
validateJsonSerializable(mutation.modified, mutation.type)
541+
validateJsonSerializable(parser, mutation.modified, mutation.type)
522542
break
523543
case `delete`:
524-
validateJsonSerializable(mutation.original, mutation.type)
544+
validateJsonSerializable(parser, mutation.original, mutation.type)
525545
break
526546
}
527547
}
528548

529549
// Load current data from storage
530550
const currentData = loadFromStorage<Record<string, unknown>>(
531551
config.storageKey,
532-
storage
552+
storage,
553+
parser
533554
)
534555

535556
// Apply each mutation
@@ -579,21 +600,23 @@ export function localStorageCollectionOptions(
579600

580601
/**
581602
* Load data from storage and return as a Map
603+
* @param parser - The parser to use for deserializing the data
582604
* @param storageKey - The key used to store data in the storage API
583605
* @param storage - The storage API to load from (localStorage, sessionStorage, etc.)
584606
* @returns Map of stored items with version tracking, or empty Map if loading fails
585607
*/
586608
function loadFromStorage<T extends object>(
587609
storageKey: string,
588-
storage: StorageApi
610+
storage: StorageApi,
611+
parser: Parser
589612
): Map<string | number, StoredItem<T>> {
590613
try {
591614
const rawData = storage.getItem(storageKey)
592615
if (!rawData) {
593616
return new Map()
594617
}
595618

596-
const parsed = JSON.parse(rawData)
619+
const parsed = parser.parse(rawData)
597620
const dataMap = new Map<string | number, StoredItem<T>>()
598621

599622
// Handle object format where keys map to StoredItem values
@@ -644,6 +667,7 @@ function createLocalStorageSync<T extends object>(
644667
storageKey: string,
645668
storage: StorageApi,
646669
storageEventApi: StorageEventApi,
670+
parser: Parser,
647671
_getKey: (item: T) => string | number,
648672
lastKnownData: Map<string | number, StoredItem<T>>
649673
): SyncConfig<T> & {
@@ -704,7 +728,7 @@ function createLocalStorageSync<T extends object>(
704728
const { begin, write, commit } = syncParams
705729

706730
// Load the new data
707-
const newData = loadFromStorage<T>(storageKey, storage)
731+
const newData = loadFromStorage<T>(storageKey, storage, parser)
708732

709733
// Find the specific changes
710734
const changes = findChanges(lastKnownData, newData)
@@ -713,7 +737,7 @@ function createLocalStorageSync<T extends object>(
713737
begin()
714738
changes.forEach(({ type, value }) => {
715739
if (value) {
716-
validateJsonSerializable(value, type)
740+
validateJsonSerializable(parser, value, type)
717741
write({ type, value })
718742
}
719743
})
@@ -739,11 +763,11 @@ function createLocalStorageSync<T extends object>(
739763
collection = params.collection
740764

741765
// Initial load
742-
const initialData = loadFromStorage<T>(storageKey, storage)
766+
const initialData = loadFromStorage<T>(storageKey, storage, parser)
743767
if (initialData.size > 0) {
744768
begin()
745769
initialData.forEach((storedItem) => {
746-
validateJsonSerializable(storedItem.data, `load`)
770+
validateJsonSerializable(parser, storedItem.data, `load`)
747771
write({ type: `insert`, value: storedItem.data })
748772
})
749773
commit()

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
import superjson from "superjson"
23
import { createCollection } from "../src/index"
34
import { localStorageCollectionOptions } from "../src/local-storage"
45
import { createTransaction } from "../src/transactions"
@@ -175,6 +176,40 @@ describe(`localStorage collection`, () => {
175176
// Restore window
176177
globalThis.window = originalWindow
177178
})
179+
180+
it(`should support custom parsers like superjson`, async () => {
181+
const collection = createCollection(
182+
localStorageCollectionOptions<Todo>({
183+
storageKey: `todos`,
184+
storage: mockStorage,
185+
storageEventApi: mockStorageEventApi,
186+
getKey: (item) => item.id,
187+
parser: superjson,
188+
})
189+
)
190+
191+
const todo: Todo = {
192+
id: `1`,
193+
title: `superjson`,
194+
completed: false,
195+
createdAt: new Date(),
196+
}
197+
198+
const insertTx = collection.insert(todo)
199+
200+
await insertTx.isPersisted.promise
201+
202+
const storedData = mockStorage.getItem(`todos`)
203+
expect(storedData).toBeDefined()
204+
205+
const parsed = superjson.parse<Record<string, { data: Todo }>>(
206+
storedData!
207+
)
208+
209+
expect(parsed[`1`]?.data.title).toBe(`superjson`)
210+
expect(parsed[`1`]?.data.completed).toBe(false)
211+
expect(parsed[`1`]?.data.createdAt).toBeInstanceOf(Date)
212+
})
178213
})
179214

180215
describe(`data persistence`, () => {

pnpm-lock.yaml

Lines changed: 31 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)