diff --git a/.changeset/shallow-selector-breaking.md b/.changeset/shallow-selector-breaking.md new file mode 100644 index 0000000..84c538a --- /dev/null +++ b/.changeset/shallow-selector-breaking.md @@ -0,0 +1,24 @@ +--- +"@ilokesto/state": major +--- + +### Breaking change: React adapter selector comparison switched from deep to shallow + +The React adapter's `useStoreState` now compares selector results with a 1-level shallow comparison (`shallow`, zustand-style) instead of recursive deep comparison (`deepCompare`). + +#### What changed +- Added `src/core/shared/shallow.ts`: zustand-style shallow comparison covering `Object.is`, `Map` (entries), `Set` (iterator), arrays, plain objects, and `Date` (`getTime()`). Circular-reference safe by design (1-level only). +- `src/core/React/createUseState.ts`: `deepCompare` removed; `shallow` is now baked into `useStoreState` and always applied. `getSnapshot` and `getServerSnapshot` use separate `createShallowSelector` instances so the cached `prev` snapshot is correctly invalidated when the store or selector identity changes (fixes stale-closure issues during SSR hydration). +- Removed `src/core/shared/deepCompare.ts`. + +#### Why +- Deep comparison ran on every render and recursively traversed the whole state; shallow checks one level only. +- The previous `deepCompare` mishandled `Map`, `Set`, and `Date`, and could stack-overflow on circular references. +- Aligns with zustand v5's standard shallow-compare pattern. + +#### Migration +- `useStore(s => s.count)` and `useStore(s => ({ a: s.a, b: s.b }))` keep working — the latter now compares first-level values instead of recursing. +- Selectors that return **nested objects** are now compared by reference (`Object.is`). If you need stable equality for a derived nested object, memoize the selector result with `useMemo`, or return a primitive (e.g. `useStore(s => s.date.getTime())`). +- Inline selectors re-create identity every render, which resets `createShallowSelector`'s cache and defeats the optimization. Define selectors at module scope or wrap them in `useCallback`. + +Test coverage: `test/shallow.test.ts` (21 cases) covers primitives, objects, arrays, `Map`, `Set`, `Date`, prototype guards, and circular-reference safety. \ No newline at end of file diff --git a/README.ko.md b/README.ko.md index 0f9a094..c1575f0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -355,6 +355,67 @@ counterStore.getState().count; - `@ilokesto/state/middleware` → 미들웨어 헬퍼 - `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, pipe 타입 +## 마이그레이션: deep compare → shallow (v2.0.0) + +### 변경 사항 + +React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 selector 결과가 리렌더를 트리거해야 하는지 판단했습니다. v2.0.0부터는 **shallow 비교**를 사용합니다 — zustand가 사용하는 패턴과 동일합니다. 이는 breaking change입니다. + +### 이유 + +- **성능**: 깊은 비교는 매 렌더마다 실행되어 전체 state를 재귀적으로 순회했습니다. shallow 비교는 1단계만 확인합니다. +- **정확성**: `deepCompare`는 `Map`, `Set`, `Date`를 올바르게 처리하지 못했고, 순환 참조 시 스택 오버플로우가 발생했습니다. shallow 비교는 `Map`, `Set`, 배열, 일반 객체를 올바르게 처리하며 순환 참조에 안전합니다. +- **생태계 정합**: zustand v5가 shallow 비교를 표준 패턴으로 사용합니다. + +### 사용자에게 미치는 영향 + +| 패턴 | 이전 (deep) | 이후 (shallow) | +|---|---|---| +| `useStore(s => s.count)` | 동작 | 동작 (동일) | +| `useStore(s => ({ a: s.a, b: s.b }))` | 깊은 비교 (값이 같으면 항상 같음) | shallow 비교 (1단계 값이 같으면 같음) | +| selector 결과의 중첩 객체 | 깊은 비교 | 참조 비교 (`Object.is`) | +| state의 `Map` / `Set` | 잘못된 비교 | 올바른 shallow 비교 | +| state의 `Date` | 잘못된 비교 | `getTime()` 기반 올바른 shallow 비교 | + +### 깊은 비교가 필요한 경우 + +`useMemo`로 selector 결과를 메모이제이션하세요: + +```ts +const value = useMemo(() => { + return computeDerivedState(store.getState()); +}, [dependency]); +``` + +또는 selector에서 원시값을 반환하여 `Object.is`로 충분하게 만드세요: + +```ts +const time = useStore(s => s.date.getTime()); +``` + +### selector 참조를 안정적으로 유지하기 + +shallow selector 캐시는 selector 함수의 참조 동일성을 기준으로 동작합니다. 인라인 selector +(`useStore(s => ({ a: s.a, b: s.b }))`)는 매 렌더마다 새로운 함수 참조를 만들어 캐시를 +초기화하고 shallow 최적화를 무의미하게 만듭니다. 다음 중 하나를 선호하세요: + +```ts +// 1. 모듈 스코프 selector (순수 파생은 권장) +const selectSlice = (s: State) => ({ a: s.a, b: s.b }); +const slice = useStore(selectSlice); + +// 2. selector가 props나 다른 반응성 입력에 의존할 때는 useCallback +const selectFiltered = useCallback( + (s: State) => s.items.filter(i => i.id === activeId), + [activeId], +); +const filtered = useStore(selectFiltered); +``` + +인라인 selector에서 새 객체/배열 리터럴을 반환하면 호출마다 새 참조가 생깁니다. shallow 비교로 +1단계 값이 같을 때 리렌더는 막을 수 있지만, selector 참조를 안정화하면 `useSyncExternalStore`가 +비교 자체를 건너뛸 수 있습니다. + ## 개발 ```bash diff --git a/README.md b/README.md index b1131d1..5033467 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,67 @@ This is a breaking change. Callable and variadic pipe syntax has been removed. R - `@ilokesto/state/middleware` → middleware helpers - `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, and pipe types +## Migration: deep compare → shallow (v2.0.0) + +### What changed + +The React adapter previously used **deep comparison** (`deepCompare`) to determine whether a selector result should trigger a re-render. Starting from v2.0.0, it uses **shallow comparison** instead — matching the pattern used by zustand. This is a breaking change. + +### Why + +- **Performance**: deep comparison ran on every render, recursively traversing the entire state. Shallow comparison checks one level only. +- **Correctness**: `deepCompare` could not handle `Map`, `Set`, or `Date` correctly, and would stack-overflow on circular references. Shallow comparison handles `Map`, `Set`, arrays, and plain objects correctly, and is inherently safe against circular references. +- **Ecosystem alignment**: zustand v5 uses shallow comparison as the standard pattern. + +### What this means for you + +| Pattern | Before (deep) | After (shallow) | +|---|---|---| +| `useStore(s => s.count)` | Works | Works (same) | +| `useStore(s => ({ a: s.a, b: s.b }))` | Deep-compared (always equal if values match) | Shallow-compared (equal if 1st-level values match) | +| Nested object in selector result | Deep-compared | Reference-compared (`Object.is`) | +| `Map` / `Set` in state | Incorrect comparison | Correct shallow comparison | +| `Date` in state | Incorrect comparison | Correct shallow comparison via `getTime()` | + +### If you need deep comparison + +Use `useMemo` to memoize your selector result: + +```ts +const value = useMemo(() => { + return computeDerivedState(store.getState()); +}, [dependency]); +``` + +Or return a primitive from your selector so `Object.is` is sufficient: + +```ts +const time = useStore(s => s.date.getTime()); +``` + +### Keep selector identity stable + +The shallow selector cache is keyed on the selector function identity. An inline selector +(e.g. `useStore(s => ({ a: s.a, b: s.b }))`) creates a new function reference on every render, +which resets the cache and defeats the shallow optimization. Prefer one of: + +```ts +// 1. Module-scope selector (preferred for pure derivations) +const selectSlice = (s: State) => ({ a: s.a, b: s.b }); +const slice = useStore(selectSlice); + +// 2. useCallback when the selector depends on props or other reactive inputs +const selectFiltered = useCallback( + (s: State) => s.items.filter(i => i.id === activeId), + [activeId], +); +const filtered = useStore(selectFiltered); +``` + +Returning a new object/array literal inside an inline selector also produces a new reference +each call; the shallow comparison still avoids a re-render when first-level values match, but +stabilizing the selector identity lets `useSyncExternalStore` skip the comparison entirely. + ## Development ```bash diff --git a/src/core/React/createUseState.ts b/src/core/React/createUseState.ts index a82f98f..f043e43 100644 --- a/src/core/React/createUseState.ts +++ b/src/core/React/createUseState.ts @@ -3,13 +3,32 @@ import { useMemo, useSyncExternalStore } from 'react'; import { dispatchStoreAction } from '../../lib/actionMetadata.js'; import type { ReducerAction } from '../../types/ReduceFn.js'; -import { deepCompare } from '../shared/deepCompare.js'; +import { shallow } from '../shared/shallow.js'; import type { UseReducer, UseState } from './types.js'; type Selector = (state: T) => S; const identity = (value: Value): Value => value; +function createShallowSelector( + selector: (state: T) => S, +): (state: T) => S { + let prev: S | undefined; + let hasPrev = false; + + return (state: T): S => { + const next = selector(state); + + if (hasPrev && shallow(prev as S, next)) { + return prev as S; + } + + hasPrev = true; + prev = next; + return next; + }; +} + export function useStoreState( store: Store, selector: (state: T) => S, @@ -17,36 +36,14 @@ export function useStoreState( ) { const subscribe = useMemo(() => store.subscribe.bind(store), [store]); - const { getSnapshot, getServerSnapshot } = useMemo(() => { - let hasMemo = false; - let mStore: T | undefined; - let mSelection: S | undefined; - - const mSelector = (nStore: T): S => { - if (!hasMemo) { - hasMemo = true; - mStore = nStore; - const nSelection = selector(nStore); - mSelection = nSelection; - return nSelection; - } - - const pStore = mStore as T; - const pSelection = mSelection as S; - - if (deepCompare(pStore, nStore)) return pSelection; - - const nSelection = selector(nStore); - - mStore = nStore; - mSelection = nSelection; - return nSelection; - }; + const getSnapshot = useMemo(() => { + const shallowSelector = createShallowSelector(selector); + return () => shallowSelector(store.getState()); + }, [store, selector]); - return { - getSnapshot: () => mSelector(store.getState()), - getServerSnapshot: () => mSelector(store.getInitialState()), - }; + const getServerSnapshot = useMemo(() => { + const shallowSelector = createShallowSelector(selector); + return () => shallowSelector(store.getInitialState()); }, [store, selector]); const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/src/core/shared/deepCompare.ts b/src/core/shared/deepCompare.ts deleted file mode 100644 index 4035aae..0000000 --- a/src/core/shared/deepCompare.ts +++ /dev/null @@ -1,46 +0,0 @@ -const isObject = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null; -}; - -export function deepCompare(left: unknown, right: unknown): boolean { - if (typeof left !== typeof right) { - return false; - } - - if (!isObject(left) || !isObject(right)) { - return left === right; - } - - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) { - return false; - } - - for (let index = 0; index < left.length; index += 1) { - if (!deepCompare(left[index], right[index])) { - return false; - } - } - - return true; - } - - if (Array.isArray(left) || Array.isArray(right)) { - return false; - } - - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - - if (leftKeys.length !== rightKeys.length) { - return false; - } - - for (const key of leftKeys) { - if (!rightKeys.includes(key) || !deepCompare(left[key], right[key])) { - return false; - } - } - - return true; -} diff --git a/src/core/shared/shallow.ts b/src/core/shared/shallow.ts new file mode 100644 index 0000000..6485f0c --- /dev/null +++ b/src/core/shared/shallow.ts @@ -0,0 +1,83 @@ +const isIterable = (obj: object): obj is Iterable => + Symbol.iterator in obj; + +const hasIterableEntries = ( + value: Iterable, +): value is Iterable & { + entries(): Iterable<[unknown, unknown]>; +} => 'entries' in value; + +const compareEntries = ( + valueA: { entries(): Iterable<[unknown, unknown]> }, + valueB: { entries(): Iterable<[unknown, unknown]> }, +): boolean => { + const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries() as Iterable<[unknown, unknown]>); + const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries() as Iterable<[unknown, unknown]>); + + if (mapA.size !== mapB.size) { + return false; + } + + for (const [key, value] of mapA) { + if (!mapB.has(key) || !Object.is(value, mapB.get(key))) { + return false; + } + } + + return true; +}; + +const compareIterables = ( + valueA: Iterable, + valueB: Iterable, +): boolean => { + const iteratorA = valueA[Symbol.iterator](); + const iteratorB = valueB[Symbol.iterator](); + let nextA = iteratorA.next(); + let nextB = iteratorB.next(); + + while (!nextA.done && !nextB.done) { + if (!Object.is(nextA.value, nextB.value)) { + return false; + } + nextA = iteratorA.next(); + nextB = iteratorB.next(); + } + + return !!nextA.done && !!nextB.done; +}; + +export function shallow(valueA: T, valueB: T): boolean { + if (Object.is(valueA, valueB)) { + return true; + } + + if ( + typeof valueA !== 'object' || + valueA === null || + typeof valueB !== 'object' || + valueB === null + ) { + return false; + } + + if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) { + return false; + } + + if (valueA instanceof Date && valueB instanceof Date) { + return valueA.getTime() === valueB.getTime(); + } + + if (isIterable(valueA) && isIterable(valueB)) { + if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) { + return compareEntries(valueA, valueB); + } + return compareIterables(valueA, valueB); + } + + return compareEntries( + { entries: () => Object.entries(valueA) }, + { entries: () => Object.entries(valueB) }, + ); +} diff --git a/test/shallow.test.ts b/test/shallow.test.ts new file mode 100644 index 0000000..0564570 --- /dev/null +++ b/test/shallow.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "bun:test"; +import { shallow } from "../src/core/shared/shallow.js"; + +describe("shallow", () => { + it("returns true for identical primitives", () => { + expect(shallow(1, 1)).toBe(true); + expect(shallow("a", "a")).toBe(true); + expect(shallow(true, true)).toBe(true); + expect(shallow(null, null)).toBe(true); + expect(shallow(undefined, undefined)).toBe(true); + }); + + it("returns false for different primitives", () => { + expect(shallow(1, 2)).toBe(false); + expect(shallow("a", "b")).toBe(false); + expect(shallow(true, false)).toBe(false); + }); + + it("returns true for same reference objects", () => { + const obj = { a: 1 }; + expect(shallow(obj, obj)).toBe(true); + }); + + it("returns true for shallow-equal objects", () => { + expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + }); + + it("returns false for shallow-unequal objects", () => { + expect(shallow({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false); + }); + + it("returns false for different keys", () => { + expect(shallow({ a: 1 }, { b: 1 })).toBe(false); + }); + + it("returns false for different key count", () => { + expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false); + }); + + it("does deep comparison of nested objects (uses Object.is for values)", () => { + const nested = { inner: 1 }; + expect(shallow({ a: nested }, { a: nested })).toBe(true); + expect(shallow({ a: { inner: 1 } }, { a: { inner: 1 } })).toBe(false); + }); + + it("returns true for shallow-equal arrays", () => { + expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it("returns false for arrays with different values", () => { + expect(shallow([1, 2, 3], [1, 2, 4])).toBe(false); + }); + + it("returns false for arrays with different length", () => { + expect(shallow([1, 2], [1, 2, 3])).toBe(false); + }); + + it("compares Map entries shallowly", () => { + const mapA = new Map([["a", 1], ["b", 2]]); + const mapB = new Map([["a", 1], ["b", 2]]); + expect(shallow(mapA, mapB)).toBe(true); + }); + + it("returns false for Map with different values", () => { + const mapA = new Map([["a", 1]]); + const mapB = new Map([["a", 2]]); + expect(shallow(mapA, mapB)).toBe(false); + }); + + it("returns false for Map with different size", () => { + const mapA = new Map([["a", 1]]); + const mapB = new Map([["a", 1], ["b", 2]]); + expect(shallow(mapA, mapB)).toBe(false); + }); + + it("compares Set values shallowly", () => { + const setA = new Set([1, 2, 3]); + const setB = new Set([1, 2, 3]); + expect(shallow(setA, setB)).toBe(true); + }); + + it("returns false for Set with different values", () => { + const setA = new Set([1, 2, 3]); + const setB = new Set([1, 2, 4]); + expect(shallow(setA, setB)).toBe(false); + }); + + it("returns false for Set with different size", () => { + const setA = new Set([1, 2]); + const setB = new Set([1, 2, 3]); + expect(shallow(setA, setB)).toBe(false); + }); + + it("returns false for Date with different time", () => { + const dateA = new Date(2024, 0, 1); + const dateB = new Date(2024, 0, 2); + expect(shallow(dateA, dateB)).toBe(false); + }); + + it("returns true for Date with same time", () => { + const dateA = new Date(2024, 0, 1); + const dateB = new Date(2024, 0, 1); + expect(shallow(dateA, dateB)).toBe(true); + }); + + it("returns false for different prototypes", () => { + expect(shallow({} as unknown, [] as unknown)).toBe(false); + expect(shallow(new Map() as unknown, new Set() as unknown)).toBe(false); + }); + + it("handles circular references safely (shallow only)", () => { + const a: Record = { x: 1 }; + a.self = a; + const b: Record = { x: 1 }; + b.self = b; + + expect(() => shallow(a, b)).not.toThrow(); + }); +}); \ No newline at end of file