diff --git a/package-lock.json b/package-lock.json index dd42676..3263c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -951,27 +951,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -3759,10 +3775,11 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -5225,9 +5242,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/src/core/__tests__/createShallowStore.test.ts b/src/core/__tests__/createShallowStore.test.ts index fb8df9e..10f39b4 100644 --- a/src/core/__tests__/createShallowStore.test.ts +++ b/src/core/__tests__/createShallowStore.test.ts @@ -159,20 +159,4 @@ describe("createShallowStore", () => { expect(shallowRenders).toBe(1); expect(plainRenders).toBe(2); }); - - it("should preserve shallow selection reference across parent rerenders", () => { - const { useStore } = createShallowStore((set) => ({ - count: 0, - name: "test", - increment: () => set((state) => ({ count: state.count + 1 })), - setName: (name: string) => set({ name }), - })); - - const { result, rerender } = renderHook(() => useStore((state) => ({ count: state.count }))); - const initialSelection = result.current; - - rerender(); - - expect(result.current).toBe(initialSelection); - }); }); diff --git a/src/core/__tests__/createStoreToolkit.test.tsx b/src/core/__tests__/createStoreToolkit.test.tsx index 1666e4a..ed092f3 100644 --- a/src/core/__tests__/createStoreToolkit.test.tsx +++ b/src/core/__tests__/createStoreToolkit.test.tsx @@ -206,21 +206,6 @@ describe("createStoreToolkit", () => { expect(typeof plainResult.current.increment).toBe("function"); }); - it("should preserve resolved shallow selection reference across parent rerenders", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - const { result, rerender } = renderHook(() => - toolkit.useResolvedValue((state) => ({ count: state.count })) - ); - const initialSelection = result.current; - - rerender(); - - expect(result.current).toBe(initialSelection); - }); - it("should reset resolved selection reference when the resolved store changes", () => { interface CollectionStore { items: Array; diff --git a/src/core/createShallowStore.ts b/src/core/createShallowStore.ts index 154147f..ca5f4f3 100644 --- a/src/core/createShallowStore.ts +++ b/src/core/createShallowStore.ts @@ -1,7 +1,5 @@ -import { createStore, useStore } from "zustand"; -import { shallow } from "zustand/shallow"; -import { useSelectorWithEquality } from "../hooks"; -import { identitySelector } from "../utils"; +import { createStore } from "zustand"; +import { useStoreSelection, useStoreSelectionPlain } from "../hooks"; import type { MutatorsStateCreator, ShallowStoreBindings, @@ -155,20 +153,13 @@ export function createShallowStore T, equalityFn?: (a: T | TState, b: T | TState) => boolean ): T | TState { - const actualSelector = useSelectorWithEquality({ - cacheKey: storeApi, - equalityFn: equalityFn ?? shallow, - selector: (state: TState): T | TState => (selector ? selector(state) : state), - }); - - return useStore(storeApi, actualSelector); + return useStoreSelection(storeApi, selector, equalityFn); } function useStorePlain(): TState; function useStorePlain(selector: (state: TState) => T): T; function useStorePlain(selector?: (state: TState) => T): T | TState { - const actualSelector = selector ?? identitySelector; - return useStore(storeApi, actualSelector); + return useStoreSelectionPlain(storeApi, selector); } return { diff --git a/src/hooks/createResolvedStoreHooks.ts b/src/hooks/createResolvedStoreHooks.ts index dd0ae63..950e7d8 100644 --- a/src/hooks/createResolvedStoreHooks.ts +++ b/src/hooks/createResolvedStoreHooks.ts @@ -1,7 +1,4 @@ -import { useStore } from "zustand"; -import { shallow } from "zustand/shallow"; -import { identitySelector } from "../utils"; -import { useSelectorWithEquality } from "./useSelectorWithEquality"; +import { useStoreSelection, useStoreSelectionPlain } from "./storeSelection"; import type { StoreApiWithMutators, StoreMutatorTuple } from "../types"; /** @@ -50,21 +47,14 @@ export function createResolvedStoreHooks boolean ): T | TState { const store = useResolvedStoreApi(); - const actualSelector = useSelectorWithEquality({ - cacheKey: store, - equalityFn: equalityFn ?? shallow, - selector: (state: TState): T | TState => (selector ? selector(state) : state), - }); - - return useStore(store, actualSelector); + return useStoreSelection(store, selector, equalityFn); } function useResolvedStorePlain(): TState; function useResolvedStorePlain(selector: (state: TState) => T): T; function useResolvedStorePlain(selector?: (state: TState) => T): T | TState { const store = useResolvedStoreApi(); - const actualSelector = selector ?? identitySelector; - return useStore(store, actualSelector); + return useStoreSelectionPlain(store, selector); } return { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index adcd412..3746885 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,3 +6,4 @@ export type { UseSelectorWithEqualityReturn, UseSelectorWithEqualitySelector, } from "./useSelectorWithEquality"; +export { useStoreSelection, useStoreSelectionPlain } from "./storeSelection"; diff --git a/src/hooks/storeSelection/__tests__/useStoreSelection.test.ts b/src/hooks/storeSelection/__tests__/useStoreSelection.test.ts new file mode 100644 index 0000000..84c5e6c --- /dev/null +++ b/src/hooks/storeSelection/__tests__/useStoreSelection.test.ts @@ -0,0 +1,126 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { createStore } from "zustand"; +import { useStoreSelection, useStoreSelectionPlain } from "../useStoreSelection"; + +function createTestStore() { + return createStore(() => ({ count: 1, label: "initial", items: [1, 2] })); +} + +describe("store selection", () => { + it("should retain shallow references across rerenders and unrelated updates", () => { + const store = createTestStore(); + const { result, rerender } = renderHook(() => + useStoreSelection(store, (state) => ({ count: state.count })) + ); + const initialSelection = result.current; + + rerender(); + expect(result.current).toBe(initialSelection); + + act(() => store.setState({ label: "updated" })); + expect(result.current).toBe(initialSelection); + + act(() => store.setState({ count: 2 })); + expect(result.current).toEqual({ count: 2 }); + expect(result.current).not.toBe(initialSelection); + }); + + it("should use the latest selector and equality after rerender", () => { + const store = createTestStore(); + const { result, rerender } = renderHook( + ({ multiplier, equalityFn }) => + useStoreSelection(store, (state) => state.count * multiplier, equalityFn), + { initialProps: { multiplier: 1, equalityFn: Object.is } } + ); + expect(result.current).toBe(1); + + rerender({ multiplier: 2, equalityFn: Object.is }); + expect(result.current).toBe(2); + + rerender({ multiplier: 2, equalityFn: () => true }); + act(() => store.setState({ count: 3 })); + expect(result.current).toBe(2); + + rerender({ multiplier: 2, equalityFn: Object.is }); + expect(result.current).toBe(6); + }); + + it("should reset retained values and subscriptions when the store changes", () => { + const first = createTestStore(); + const second = createTestStore(); + const { result, rerender } = renderHook( + ({ store }) => + useStoreSelection( + store, + (state) => state.items, + () => true + ), + { initialProps: { store: first } } + ); + expect(result.current).toBe(first.getState().items); + + rerender({ store: second }); + expect(result.current).toBe(second.getState().items); + + act(() => first.setState({ items: [9] })); + expect(result.current).toBe(second.getState().items); + }); + + it("should subscribe to the replacement store in both selection modes", () => { + const first = createTestStore(); + const second = createTestStore(); + const { result, rerender, unmount } = renderHook( + ({ store }) => ({ + shallow: useStoreSelection(store, (state) => state.count), + plain: useStoreSelectionPlain(store, (state) => state.count), + }), + { initialProps: { store: first } } + ); + rerender({ store: second }); + act(() => second.setState({ count: 4 })); + expect(result.current).toEqual({ shallow: 4, plain: 4 }); + + act(() => first.setState({ count: 9 })); + expect(result.current).toEqual({ shallow: 4, plain: 4 }); + unmount(); + }); + + it("should return full state when the selector is omitted", () => { + const store = createTestStore(); + const { result } = renderHook(() => ({ + shallow: useStoreSelection(store), + plain: useStoreSelectionPlain(store), + })); + expect(result.current.shallow).toBe(store.getState()); + expect(result.current.plain).toBe(store.getState()); + + act(() => store.setState({ count: 2 })); + expect(result.current.shallow).toBe(store.getState()); + expect(result.current.plain).toBe(store.getState()); + }); + + it("should retain shallow-equal values while plain selection follows reference changes", () => { + const store = createTestStore(); + const { result } = renderHook(() => ({ + shallow: useStoreSelection(store, (state) => state.items), + plain: useStoreSelectionPlain(store, (state) => state.items), + })); + const initialItems = store.getState().items; + const replacement = [1, 2]; + + act(() => store.setState({ items: replacement })); + expect(result.current.shallow).toBe(initialItems); + expect(result.current.plain).toBe(replacement); + }); + + it("should retain separate selections for each consumer", () => { + const store = createTestStore(); + const { result } = renderHook(() => ({ + count: useStoreSelection(store, (state) => state.count), + label: useStoreSelection(store, (state) => state.label), + })); + act(() => store.setState({ count: 2 })); + expect(result.current).toEqual({ count: 2, label: "initial" }); + }); +}); diff --git a/src/hooks/storeSelection/index.ts b/src/hooks/storeSelection/index.ts new file mode 100644 index 0000000..54ffb10 --- /dev/null +++ b/src/hooks/storeSelection/index.ts @@ -0,0 +1 @@ +export { useStoreSelection, useStoreSelectionPlain } from "./useStoreSelection"; diff --git a/src/hooks/storeSelection/storeSelection.types.ts b/src/hooks/storeSelection/storeSelection.types.ts new file mode 100644 index 0000000..2fcc2af --- /dev/null +++ b/src/hooks/storeSelection/storeSelection.types.ts @@ -0,0 +1,6 @@ +import type { StoreApi } from "zustand"; + +export type SelectionStore = Pick< + StoreApi, + "getState" | "getInitialState" | "subscribe" +>; diff --git a/src/hooks/storeSelection/useStoreSelection.ts b/src/hooks/storeSelection/useStoreSelection.ts new file mode 100644 index 0000000..7c3361d --- /dev/null +++ b/src/hooks/storeSelection/useStoreSelection.ts @@ -0,0 +1,30 @@ +import { useStore } from "zustand"; +import { shallow } from "zustand/shallow"; +import { identitySelector } from "../../utils"; +import { useSelectorWithEquality } from "../useSelectorWithEquality"; +import type { SelectionStore } from "./storeSelection.types"; + +/** Selects from a store with store-scoped reference retention. */ +export function useStoreSelection( + store: SelectionStore, + selector?: (state: TState) => TSelected, + equalityFn?: (left: TSelected | TState, right: TSelected | TState) => boolean +): TSelected | TState { + const actualSelector = useSelectorWithEquality({ + cacheKey: store, + equalityFn: equalityFn ?? shallow, + selector: (state: TState): TSelected | TState => (selector ? selector(state) : state), + }); + + return useStore(store, actualSelector); +} + +/** Keeps plain selection independent of equality caching. */ +export function useStoreSelectionPlain( + store: SelectionStore, + selector?: (state: TState) => TSelected +): TSelected | TState { + const actualSelector = selector ?? identitySelector; + + return useStore(store, actualSelector); +} diff --git a/src/hooks/useSelectorWithEquality/__tests__/useSelectorWithEquality.test.ts b/src/hooks/useSelectorWithEquality/__tests__/useSelectorWithEquality.test.ts deleted file mode 100644 index f724eb2..0000000 --- a/src/hooks/useSelectorWithEquality/__tests__/useSelectorWithEquality.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { shallow } from "zustand/shallow"; -import { useSelectorWithEquality } from ".."; - -interface TestState { - count: number; - label: string; -} - -describe("useSelectorWithEquality", () => { - it("should preserve the selected reference when equality passes", () => { - const state: TestState = { - count: 1, - label: "initial", - }; - const { result, rerender } = renderHook(() => - useSelectorWithEquality({ - cacheKey: "store", - equalityFn: shallow, - selector: (currentState: TestState) => ({ count: currentState.count }), - }) - ); - const initialSelection = result.current(state); - - rerender(); - - expect(result.current(state)).toBe(initialSelection); - }); - - it("should reset the selected reference when the cache key changes", () => { - const state: TestState = { - count: 1, - label: "initial", - }; - const { result, rerender } = renderHook( - ({ cacheKey }: { cacheKey: string }) => - useSelectorWithEquality({ - cacheKey, - equalityFn: shallow, - selector: (currentState: TestState) => ({ count: currentState.count }), - }), - { - initialProps: { - cacheKey: "global", - }, - } - ); - const globalSelection = result.current(state); - - rerender({ - cacheKey: "context", - }); - - expect(result.current(state)).not.toBe(globalSelection); - }); -}); diff --git a/src/providers/__tests__/createStoreProvider.test.tsx b/src/providers/__tests__/createStoreProvider.test.tsx index 0888399..4b954f5 100644 --- a/src/providers/__tests__/createStoreProvider.test.tsx +++ b/src/providers/__tests__/createStoreProvider.test.tsx @@ -331,21 +331,4 @@ describe("createStoreProvider", () => { expect(result.current.label).toBe("ready"); expect(typeof result.current.increment).toBe("function"); }); - - it("should preserve context shallow selection reference across parent rerenders", () => { - const { Provider, useContextStore } = createStoreProvider((set) => ({ - value: 0, - increment: () => set((state) => ({ value: state.value + 1 })), - })); - const wrapper = ({ children }: PropsWithChildren) => {children}; - const { result, rerender } = renderHook( - () => useContextStore((state) => ({ value: state.value })), - { wrapper } - ); - const initialSelection = result.current; - - rerender(); - - expect(result.current).toBe(initialSelection); - }); }); diff --git a/src/providers/createStoreProvider.tsx b/src/providers/createStoreProvider.tsx index a576ce8..2a26496 100644 --- a/src/providers/createStoreProvider.tsx +++ b/src/providers/createStoreProvider.tsx @@ -1,8 +1,6 @@ import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react"; -import { createStore, useStore } from "zustand"; -import { shallow } from "zustand/shallow"; -import { useSelectorWithEquality } from "../hooks"; -import { identitySelector } from "../utils"; +import { createStore } from "zustand"; +import { useStoreSelection, useStoreSelectionPlain } from "../hooks"; import type { MutatorsStateCreator, StoreApiWithMutators, @@ -221,21 +219,16 @@ export function createStoreProvider boolean ): T | TState { const store = useStoreContext(); - const actualSelector = useSelectorWithEquality({ - cacheKey: store, - equalityFn: equalityFn ?? shallow, - selector: (state: TState): T | TState => (selector ? selector(state) : state), - }); - return useStore(store, actualSelector); + return useStoreSelection(store, selector, equalityFn); } function useContextStorePlain(): TState; function useContextStorePlain(selector: (state: TState) => T): T; function useContextStorePlain(selector?: (state: TState) => T): T | TState { const store = useStoreContext(); - const actualSelector = selector ?? identitySelector; - return useStore(store, actualSelector); + + return useStoreSelectionPlain(store, selector); } return {