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
5 changes: 5 additions & 0 deletions .changeset/spotty-impalas-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": patch
---

core improvements and tests
1 change: 1 addition & 0 deletions examples/angular/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore());

export const persistHandle = persist(counterStore, {
name: 'classy-angular-example',
storage: localStorage,
properties: ['count', 'step', 'label'],
});

Expand Down
27 changes: 27 additions & 0 deletions examples/nextjs/src/stores/_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* SSR-safe storage adapter. Uses real `localStorage` in the browser; falls
* back to a per-process in-memory map on the server so module-init
* `persist()` calls never throw.
*/
const memory = new Map<string, string>();

export const ssrSafeLocalStorage = {
getItem(key: string): string | null {
if (typeof localStorage !== 'undefined') return localStorage.getItem(key);
return memory.get(key) ?? null;
},
setItem(key: string, value: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
return;
}
memory.set(key, value);
},
removeItem(key: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
return;
}
memory.delete(key);
},
};
2 changes: 2 additions & 0 deletions examples/nextjs/src/stores/planner-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {createClassyStore} from '@codebelt/classy-store';
import {devtools, persist} from '@codebelt/classy-store/utils';
import {ssrSafeLocalStorage} from './_storage';

export type MealSlot = {
recipeId: string | null;
Expand Down Expand Up @@ -82,6 +83,7 @@ export const plannerStore = createClassyStore(new PlannerStore());

export const plannerPersist = persist(plannerStore, {
name: 'classy-meal-planner',
storage: ssrSafeLocalStorage,
properties: ['days'],
debounce: 500,
version: 2,
Expand Down
3 changes: 2 additions & 1 deletion examples/nextjs/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {createClassyStore} from '@codebelt/classy-store';
import {devtools, persist} from '@codebelt/classy-store/utils';
import {ssrSafeLocalStorage} from './_storage';

class SettingsStore {
theme = 'system' as 'light' | 'dark' | 'system';
Expand All @@ -14,8 +15,8 @@ export const settingsStore = createClassyStore(new SettingsStore());

export const settingsPersist = persist(settingsStore, {
name: 'classy-kitchen-settings',
storage: ssrSafeLocalStorage,
debounce: 300,
merge: 'replace',
});

devtools(settingsStore, {name: 'Settings Store'});
2 changes: 2 additions & 0 deletions examples/nextjs/src/stores/shopping-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {createClassyStore} from '@codebelt/classy-store';
import type {PropertyTransform} from '@codebelt/classy-store/utils';
import {devtools, persist, subscribeKey} from '@codebelt/classy-store/utils';
import {ssrSafeLocalStorage} from './_storage';

export type ShoppingItem = {
id: string;
Expand Down Expand Up @@ -84,6 +85,7 @@ const itemsTransform: PropertyTransform<ShoppingStore> = {

export const shoppingPersist = persist(shoppingStore, {
name: 'classy-shopping-list',
storage: ssrSafeLocalStorage,
properties: [itemsTransform, 'lastAction'],
merge: (persisted, current) => ({
...current,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const STORE_CODE = `class KitchenSinkStore {

persist(store, {
name: 'kitchen-sink',
storage: localStorage,
debounce: 300,
version: 1,
merge: 'shallow',
Expand Down
4 changes: 2 additions & 2 deletions examples/react/src/demos/persist/SimplePersistDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const STORE_CODE = `class PreferencesStore {
}

const store = createClassyStore(new PreferencesStore());
persist(store, { name: 'preferences' });`;
persist(store, { name: 'preferences', storage: localStorage });`;

function ThemeToggle() {
const theme = useClassyStore(preferencesStore, (state) => state.theme);
Expand Down Expand Up @@ -183,7 +183,7 @@ export function SimplePersistDemo() {
return (
<DemoContainer
title="Simple Persist"
description="persist(store, { name: 'preferences' }) — that's it. All properties are saved to localStorage automatically."
description="persist(store, { name: 'preferences', storage: localStorage }) — pass any storage adapter; properties are saved on every batched mutation."
codeTabs={[{label: 'Store', code: STORE_CODE, language: 'typescript'}]}
>
<div className="flex flex-col gap-4">
Expand Down
4 changes: 2 additions & 2 deletions examples/react/src/pages/PersistPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export function PersistPage() {
<SimplePersistDemo />
<KitchenSinkPersistDemo />
<TipBox>
The simplest setup is just{' '}
The simplest setup is{' '}
<code className="text-xs bg-zinc-800 px-1 rounded">
persist(store, {'{'} name: 'key' {'}'})
persist(store, {'{'} name: 'key', storage: localStorage {'}'})
</code>
. Add{' '}
<code className="text-xs bg-zinc-800 px-1 rounded">properties</code>{' '}
Expand Down
2 changes: 2 additions & 0 deletions examples/react/src/stores/persistStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const preferencesStore = createClassyStore(new PreferencesStore());

export const preferencesHandle = persist(preferencesStore, {
name: 'preferences',
storage: localStorage,
});

// ── Kitchen Sink Store ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -150,6 +151,7 @@ export const kitchenSinkStore = createClassyStore(new KitchenSinkStore());

export const kitchenSinkHandle = persist(kitchenSinkStore, {
name: 'kitchen-sink',
storage: localStorage,
debounce: 300,
version: 1,
merge: 'shallow',
Expand Down
1 change: 1 addition & 0 deletions examples/solid/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore());

export const persistHandle = persist(counterStore, {
name: 'classy-solid-example',
storage: localStorage,
properties: ['count', 'step', 'label'],
});

Expand Down
1 change: 1 addition & 0 deletions examples/svelte/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const counterStore = createClassyStore(new CounterStore());

export const persistHandle = persist(counterStore, {
name: 'classy-svelte-example',
storage: localStorage,
properties: ['count', 'step', 'label'],
});

Expand Down
1 change: 1 addition & 0 deletions examples/vue/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore());

export const persistHandle = persist(counterStore, {
name: 'classy-vue-example',
storage: localStorage,
properties: ['count', 'step', 'label'],
});

Expand Down
40 changes: 40 additions & 0 deletions packages/classy-store/src/collections/collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,46 @@ describe('reactiveMap() — edge cases', () => {
['y', 20],
]);
});

test('keys() / values() / entries() return snapshot iterators, not live iterators', () => {
const m = reactiveMap<string, number>([
['a', 1],
['b', 2],
['c', 3],
]);
const keysIter = m.keys();
const valuesIter = m.values();
const entriesIter = m.entries();

m.set('d', 4);
m.delete('a');

expect([...keysIter]).toEqual(['a', 'b', 'c']);
expect([...valuesIter]).toEqual([1, 2, 3]);
expect([...entriesIter]).toEqual([
['a', 1],
['b', 2],
['c', 3],
]);
});

test('large initial iterable with duplicates dedupes correctly (last value wins)', () => {
const initial: [number, string][] = [];
for (let i = 0; i < 1000; i++) {
initial.push([i, `v${i}`]);
}
// Add duplicates that overwrite the first half with new values.
for (let i = 0; i < 500; i++) {
initial.push([i, `dup${i}`]);
}

const m = reactiveMap(initial);
expect(m.size).toBe(1000);
expect(m.get(0)).toBe('dup0');
expect(m.get(499)).toBe('dup499');
expect(m.get(500)).toBe('v500');
expect(m.get(999)).toBe('v999');
});
});

// ── ReactiveSet — additional edge cases ────────────────────────────────────
Expand Down
118 changes: 102 additions & 16 deletions packages/classy-store/src/collections/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ export class ReactiveMap<K, V> {
/** Deduplicates initial entries by key (last value wins, matching native `Map`). */
constructor(initial?: Iterable<[K, V]>) {
if (initial) {
// Track key → index for O(1) dedupe instead of O(n) linear scan.
const indexByKey = new Map<K, number>();
for (const [k, v] of initial) {
const index = this._entries.findIndex(([ek]) => Object.is(ek, k));
if (index !== -1) {
this._entries[index] = [k, v];
const existing = indexByKey.get(k);
if (existing !== undefined) {
this._entries[existing] = [k, v];
} else {
indexByKey.set(k, this._entries.length);
this._entries.push([k, v]);
}
}
Expand Down Expand Up @@ -75,19 +78,64 @@ export class ReactiveMap<K, V> {
this._entries.splice(0, this._entries.length);
}

/** Returns an iterator over the keys. */
/**
* Returns an iterator over the keys.
* Snapshot semantics: the iterator reflects the entries at call time and
* is unaffected by subsequent mutations.
*/
keys(): IterableIterator<K> {
return this._entries.map(([k]) => k)[Symbol.iterator]();
const snap = this._entries.slice();
let i = 0;
return {
next: () =>
i < snap.length
? {value: snap[i++][0], done: false}
: {value: undefined as unknown as K, done: true},
[Symbol.iterator]() {
return this;
},
};
}

/** Returns an iterator over the values. */
/**
* Returns an iterator over the values.
* Snapshot semantics: the iterator reflects the entries at call time and
* is unaffected by subsequent mutations.
*/
values(): IterableIterator<V> {
return this._entries.map(([, v]) => v)[Symbol.iterator]();
const snap = this._entries.slice();
let i = 0;
return {
next: () =>
i < snap.length
? {value: snap[i++][1], done: false}
: {value: undefined as unknown as V, done: true},
[Symbol.iterator]() {
return this;
},
};
}

/** Returns an iterator over [key, value] pairs. */
/**
* Returns an iterator over [key, value] pairs.
* Snapshot semantics: the iterator reflects the entries at call time and
* is unaffected by subsequent mutations.
*/
entries(): IterableIterator<[K, V]> {
return this._entries.map(([k, v]) => [k, v] as [K, V])[Symbol.iterator]();
const snap = this._entries.slice();
let i = 0;
return {
next: () => {
if (i >= snap.length) {
return {value: undefined as unknown as [K, V], done: true};
}
const e = snap[i++];
return {value: [e[0], e[1]] as [K, V], done: false};
},
[Symbol.iterator]() {
return this;
},
};
}

/** Calls `callback` for each entry, matching the native `Map.forEach` signature. */
Expand Down Expand Up @@ -127,8 +175,11 @@ export class ReactiveSet<T> {
/** Deduplicates initial values using `Object.is` comparison. */
constructor(initial?: Iterable<T>) {
if (initial) {
// Use a Set for O(1) dedupe (Set uses SameValueZero — NaN-aware, like Object.is for our purposes).
const seen = new Set<T>();
for (const v of initial) {
if (!this._items.some((item) => Object.is(item, v))) {
if (!seen.has(v)) {
seen.add(v);
this._items.push(v);
}
}
Expand Down Expand Up @@ -166,19 +217,54 @@ export class ReactiveSet<T> {
this._items.splice(0, this._items.length);
}

/** Returns an iterator over the values (same as `values()`, matching Set API). */
/**
* Returns an iterator over the values (same as `values()`, matching Set API).
* Snapshot semantics: the iterator reflects the items at call time and
* is unaffected by subsequent mutations.
*/
keys(): IterableIterator<T> {
return this._items.map((v) => v)[Symbol.iterator]();
return this.values();
}

/** Returns an iterator over the values. */
/**
* Returns an iterator over the values.
* Snapshot semantics: the iterator reflects the items at call time and
* is unaffected by subsequent mutations.
*/
values(): IterableIterator<T> {
return this._items.map((v) => v)[Symbol.iterator]();
const snap = this._items.slice();
let i = 0;
return {
next: () =>
i < snap.length
? {value: snap[i++], done: false}
: {value: undefined as unknown as T, done: true},
[Symbol.iterator]() {
return this;
},
};
}

/** Returns an iterator over [value, value] pairs, matching the native Set API. */
/**
* Returns an iterator over [value, value] pairs, matching the native Set API.
* Snapshot semantics: the iterator reflects the items at call time and
* is unaffected by subsequent mutations.
*/
entries(): IterableIterator<[T, T]> {
return this._items.map((v) => [v, v] as [T, T])[Symbol.iterator]();
const snap = this._items.slice();
let i = 0;
return {
next: () => {
if (i >= snap.length) {
return {value: undefined as unknown as [T, T], done: true};
}
const v = snap[i++];
return {value: [v, v] as [T, T], done: false};
},
[Symbol.iterator]() {
return this;
},
};
}

/** Calls `callback` for each item, matching the native `Set.forEach` signature. */
Expand Down
Loading