From e7bce39db19a630ad51e16237d3fefacd5f0d2ad Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:32:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20deepCompare=20=E2=86=92=20shall?= =?UTF-8?q?ow=20=EB=B9=84=EA=B5=90=EB=A1=9C=20=EC=A0=84=ED=99=98=20(zustan?= =?UTF-8?q?d=20=EB=B0=A9=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking change: React 어댑터의 selector 비교 방식을 deep compare에서 shallow compare로 변경. ## 변경 사항 - src/core/shared/shallow.ts: shallow 함수 구현 (zustand 참고) - Object.is → 1단계 비교 - Map: entries() 비교 - Set: iterator 비교 - 배열: iterator 비교 - 일반 객체: Object.entries() 비교 - Date: 프로토타입 비교 (같으면 같음 — selector에서 getTime() 권장) - 순환 참조 안전 (1단계만 비교) - src/core/React/createUseState.ts: - deepCompare 제거, shallow를 useStoreState에 내장 (항상 shallow 적용) - useSyncExternalStore에 직접 selector 전달 방식으로 단순화 - 메모이제이션 로직을 shallow 기반으로 재작성 - src/core/shared/deepCompare.ts: 삭제 - test/shallow.test.ts: 21개 단위 테스트 - 원시값, 객체, 배열, Map, Set, Date, 순환 참조 안전성 - README.md / README.ko.md: 마이그레이션 가이드 추가 - deep → shallow 변경 이유와 영향 - 깊은 비교가 필요한 경우 useMemo 권장 ## 성능 개선 - 매 렌더마다 전체 state를 재귀 순회 → 1단계 비교만 - Map/Set/Date 올바른 비교 (기존 deepCompare 버그 수정) - 순환 참조 스택 오버플로우 해결 ## 기존 테스트 회귀 115개 기존 테스트 전부 통과 (변경 없음) + 21개 shallow 테스트 = 136개 전부 통과 Closes #3 --- README.ko.md | 38 ++++++++++ README.md | 38 ++++++++++ src/core/React/createUseState.ts | 50 ++++++------- src/core/shared/deepCompare.ts | 46 ------------ src/core/shared/shallow.ts | 79 ++++++++++++++++++++ test/shallow.test.ts | 119 +++++++++++++++++++++++++++++++ 6 files changed, 296 insertions(+), 74 deletions(-) delete mode 100644 src/core/shared/deepCompare.ts create mode 100644 src/core/shared/shallow.ts create mode 100644 test/shallow.test.ts diff --git a/README.ko.md b/README.ko.md index 0f9a094..0f15ad0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -355,6 +355,44 @@ counterStore.getState().count; - `@ilokesto/state/middleware` → 미들웨어 헬퍼 - `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, pipe 타입 +## 마이그레이션: deep compare → shallow (v1.1.0) + +### 변경 사항 + +React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 selector 결과가 리렌더를 트리거해야 하는지 판단했습니다. v1.1.0부터는 **shallow 비교**를 사용합니다 — zustand가 사용하는 패턴과 동일합니다. + +### 이유 + +- **성능**: 깊은 비교는 매 렌더마다 실행되어 전체 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` | 잘못된 비교 | 같은 프로토타입 = 같음 (시간 비교는 selector에서 `getTime()` 사용) | + +### 깊은 비교가 필요한 경우 + +`useMemo`로 selector 결과를 메모이제이션하세요: + +```ts +const value = useMemo(() => { + return computeDerivedState(store.getState()); +}, [dependency]); +``` + +또는 selector에서 원시값을 반환하여 `Object.is`로 충분하게 만드세요: + +```ts +const time = useStore(s => s.date.getTime()); +``` + ## 개발 ```bash diff --git a/README.md b/README.md index b1131d1..03b02aa 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,44 @@ 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 (v1.1.0) + +### What changed + +The React adapter previously used **deep comparison** (`deepCompare`) to determine whether a selector result should trigger a re-render. Starting from v1.1.0, it uses **shallow comparison** instead — matching the pattern used by zustand. + +### 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 | Same prototype = equal (use `getTime()` in selector for time-based comparison) | + +### 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()); +``` + ## Development ```bash diff --git a/src/core/React/createUseState.ts b/src/core/React/createUseState.ts index a82f98f..c5d37a2 100644 --- a/src/core/React/createUseState.ts +++ b/src/core/React/createUseState.ts @@ -3,7 +3,7 @@ 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; @@ -17,37 +17,31 @@ 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 shallowSelector = useMemo(() => { + let prev: S | undefined; + let hasPrev = false; - const nSelection = selector(nStore); + return (state: T): S => { + const next = selector(state); - mStore = nStore; - mSelection = nSelection; - return nSelection; - }; + if (hasPrev && shallow(prev as S, next)) { + return prev as S; + } - return { - getSnapshot: () => mSelector(store.getState()), - getServerSnapshot: () => mSelector(store.getInitialState()), + hasPrev = true; + prev = next; + return next; }; - }, [store, selector]); + }, [selector]); + + const getSnapshot = useMemo( + () => () => shallowSelector(store.getState()), + [store, shallowSelector], + ); + const getServerSnapshot = useMemo( + () => () => shallowSelector(store.getInitialState()), + [store, shallowSelector], + ); 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..7bdbdff --- /dev/null +++ b/src/core/shared/shallow.ts @@ -0,0 +1,79 @@ +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 (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) }, + ); +} \ No newline at end of file diff --git a/test/shallow.test.ts b/test/shallow.test.ts new file mode 100644 index 0000000..9c53577 --- /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 true for Date with same prototype (shallow limitation — use getTime() in selector)", () => { + const dateA = new Date(2024, 0, 1); + const dateB = new Date(2024, 0, 2); + expect(shallow(dateA, dateB)).toBe(true); + }); + + 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 From 71d3c130d6db83f249d70c670e0590349337f693 Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:43:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=E2=80=94=20snapshot=20=ED=81=B4=EB=A1=9C?= =?UTF-8?q?=EC=A0=80=20=EB=B6=84=EB=A6=AC,=20Date=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=EA=B0=9C=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High: shallowSelector 클로저가 store 변경에 안전하지 않은 문제 수정 - getSnapshot과 getServerSnapshot이 별도의 createShallowSelector 인스턴스를 생성하도록 분리 - store 의존성이 두 useMemo에 모두 포함되어 store 변경 시 클로저가 재생성됨 - 서버/클라이언트 hydration 불일치 및 stale 참조 문제 해결 Medium: Date 비교를 getTime() 기반으로 개선 - shallow 함수에 Date instanceof 체크 추가 - 다른 시각의 Date가 같다고 판단되는 문제 해결 - 테스트 및 README 마이그레이션 가이드 업데이트 Low: shallow.ts 끝 개행 추가 --- README.ko.md | 2 +- README.md | 2 +- src/core/React/createUseState.ts | 53 +++++++++++++++++--------------- src/core/shared/shallow.ts | 6 +++- test/shallow.test.ts | 4 +-- 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/README.ko.md b/README.ko.md index 0f15ad0..e577fd9 100644 --- a/README.ko.md +++ b/README.ko.md @@ -375,7 +375,7 @@ React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 s | `useStore(s => ({ a: s.a, b: s.b }))` | 깊은 비교 (값이 같으면 항상 같음) | shallow 비교 (1단계 값이 같으면 같음) | | selector 결과의 중첩 객체 | 깊은 비교 | 참조 비교 (`Object.is`) | | state의 `Map` / `Set` | 잘못된 비교 | 올바른 shallow 비교 | -| state의 `Date` | 잘못된 비교 | 같은 프로토타입 = 같음 (시간 비교는 selector에서 `getTime()` 사용) | +| state의 `Date` | 잘못된 비교 | `getTime()` 기반 올바른 shallow 비교 | ### 깊은 비교가 필요한 경우 diff --git a/README.md b/README.md index 03b02aa..1394951 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ The React adapter previously used **deep comparison** (`deepCompare`) to determi | `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 | Same prototype = equal (use `getTime()` in selector for time-based comparison) | +| `Date` in state | Incorrect comparison | Correct shallow comparison via `getTime()` | ### If you need deep comparison diff --git a/src/core/React/createUseState.ts b/src/core/React/createUseState.ts index c5d37a2..f043e43 100644 --- a/src/core/React/createUseState.ts +++ b/src/core/React/createUseState.ts @@ -10,6 +10,25 @@ 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,31 +36,15 @@ export function useStoreState( ) { const subscribe = useMemo(() => store.subscribe.bind(store), [store]); - const shallowSelector = useMemo(() => { - 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; - }; - }, [selector]); - - const getSnapshot = useMemo( - () => () => shallowSelector(store.getState()), - [store, shallowSelector], - ); - const getServerSnapshot = useMemo( - () => () => shallowSelector(store.getInitialState()), - [store, shallowSelector], - ); + const getSnapshot = useMemo(() => { + const shallowSelector = createShallowSelector(selector); + return () => shallowSelector(store.getState()); + }, [store, selector]); + + 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/shallow.ts b/src/core/shared/shallow.ts index 7bdbdff..6485f0c 100644 --- a/src/core/shared/shallow.ts +++ b/src/core/shared/shallow.ts @@ -65,6 +65,10 @@ export function shallow(valueA: T, valueB: T): boolean { 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); @@ -76,4 +80,4 @@ export function shallow(valueA: T, valueB: T): boolean { { entries: () => Object.entries(valueA) }, { entries: () => Object.entries(valueB) }, ); -} \ No newline at end of file +} diff --git a/test/shallow.test.ts b/test/shallow.test.ts index 9c53577..0564570 100644 --- a/test/shallow.test.ts +++ b/test/shallow.test.ts @@ -91,10 +91,10 @@ describe("shallow", () => { expect(shallow(setA, setB)).toBe(false); }); - it("returns true for Date with same prototype (shallow limitation — use getTime() in selector)", () => { + 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(true); + expect(shallow(dateA, dateB)).toBe(false); }); it("returns true for Date with same time", () => { From 4e996281fe79b8bf8998953d4a564710b9f41af2 Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:42:06 +0900 Subject: [PATCH 3/3] docs: add shallow breaking-change changeset and selector stability guide - .changeset/shallow-selector-breaking.md: major changeset documenting the deepCompare -> shallow breaking change with migration notes - README.md / README.ko.md: bump migration heading to v2.0.0 (was v1.1.0), mark as breaking change, add 'Keep selector identity stable' section explaining how inline selectors reset createShallowSelector's cache and recommending module-scope selectors or useCallback --- .changeset/shallow-selector-breaking.md | 24 ++++++++++++++++++++++ README.ko.md | 27 +++++++++++++++++++++++-- README.md | 27 +++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 .changeset/shallow-selector-breaking.md 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 e577fd9..c1575f0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -355,11 +355,11 @@ counterStore.getState().count; - `@ilokesto/state/middleware` → 미들웨어 헬퍼 - `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, pipe 타입 -## 마이그레이션: deep compare → shallow (v1.1.0) +## 마이그레이션: deep compare → shallow (v2.0.0) ### 변경 사항 -React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 selector 결과가 리렌더를 트리거해야 하는지 판단했습니다. v1.1.0부터는 **shallow 비교**를 사용합니다 — zustand가 사용하는 패턴과 동일합니다. +React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 selector 결과가 리렌더를 트리거해야 하는지 판단했습니다. v2.0.0부터는 **shallow 비교**를 사용합니다 — zustand가 사용하는 패턴과 동일합니다. 이는 breaking change입니다. ### 이유 @@ -393,6 +393,29 @@ const value = useMemo(() => { 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 1394951..5033467 100644 --- a/README.md +++ b/README.md @@ -357,11 +357,11 @@ 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 (v1.1.0) +## 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 v1.1.0, it uses **shallow comparison** instead — matching the pattern used by zustand. +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 @@ -395,6 +395,29 @@ Or return a primitive from your selector so `Object.is` is sufficient: 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