diff --git a/packages/use-store/src/experimental/Store.ts b/packages/use-store/src/experimental/Store.ts index 39178e0..aa93309 100644 --- a/packages/use-store/src/experimental/Store.ts +++ b/packages/use-store/src/experimental/Store.ts @@ -10,10 +10,18 @@ function reactTransitionIsActive() { return !!sharedReactInternals.T; } -export class Store extends Emitter<[]> { - private source: ISource; - private state: S; - private committedState: S; +export interface ReactStore { + getState(): S; + getCommittedState(): S; + handleUpdate(action: A): void; + subscribe(listener: () => void): () => void; + commit(state: S): void; +} + +export class Store extends Emitter<[]> implements ReactStore { + source: ISource; + state: S; + committedState: S; constructor(source: ISource) { super(); this.source = source; diff --git a/packages/use-store/src/experimental/StoreManager.ts b/packages/use-store/src/experimental/StoreManager.ts index 3f526c1..d80a6f2 100644 --- a/packages/use-store/src/experimental/StoreManager.ts +++ b/packages/use-store/src/experimental/StoreManager.ts @@ -6,7 +6,10 @@ type RefCountedSubscription = { unsubscribe: () => void; }; -type StoresSnapshot = Map, unknown>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyStore = Store; + +type StoresSnapshot = Map; /** * StoreManager tracks all actively rendered stores in the tree and maintains a @@ -15,8 +18,7 @@ type StoresSnapshot = Map, unknown>; * state. */ export class StoreManager extends Emitter<[]> { - _storeRefCounts: Map, RefCountedSubscription> = - new Map(); + _storeRefCounts: Map = new Map(); getAllCommittedStates(): StoresSnapshot { return new Map( @@ -36,7 +38,7 @@ export class StoreManager extends Emitter<[]> { ); } - addStore(store: Store) { + addStore(store: AnyStore) { const prev = this._storeRefCounts.get(store); if (prev == null) { this._storeRefCounts.set(store, { @@ -57,11 +59,11 @@ export class StoreManager extends Emitter<[]> { this.sweep(); } - removeStore(store: Store) { + removeStore(store: AnyStore) { const prev = this._storeRefCounts.get(store); if (prev == null) { throw new Error( - "Imblance in concurrent-safe store reference counting. This is a bug in react-use-store, please report it.", + "Imbalance in concurrent-safe store reference counting. This is a bug in react-use-store, please report it.", ); } // We decrement the count here, but don't actually do the cleanup. This is diff --git a/packages/use-store/src/experimental/index.ts b/packages/use-store/src/experimental/index.ts index 2c8f9df..6625050 100644 --- a/packages/use-store/src/experimental/index.ts +++ b/packages/use-store/src/experimental/index.ts @@ -1,7 +1,13 @@ export { useStore, useStoreSelector, + useStoreSelectorWithEquality, createStore, createStoreFromSource, StoreProvider, } from "./useStore"; + +// Export types needed for public API +export type { ISource, Reducer } from "../types"; +export type { ReactStore } from "./Store"; +export { Store } from "./Store"; diff --git a/packages/use-store/src/experimental/useStore.spec.tsx b/packages/use-store/src/experimental/useStore.spec.tsx index b8462ec..9a274fb 100644 --- a/packages/use-store/src/experimental/useStore.spec.tsx +++ b/packages/use-store/src/experimental/useStore.spec.tsx @@ -1,18 +1,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { render, act } from "@testing-library/react"; -import { +import React, { useState, startTransition, useEffect, useLayoutEffect, Suspense, use, + Component, } from "react"; import { flushSync } from "react-dom"; import { experimental } from "../index"; import Logger from "../../test/TestLogger"; -const { createStore, StoreProvider, useStoreSelector } = experimental; +const { + createStore, + StoreProvider, + useStoreSelector, + useStoreSelectorWithEquality, +} = experimental; type State = number; @@ -1456,3 +1462,2158 @@ describe("Selectors can be dynamic", () => { expect(store._listeners.length).toBe(0); }); }); + +describe("prevResult selector behavior", () => { + // Helper for shallow equality comparison + function shallowEqual>( + objA: T, + objB: T, + ): boolean { + if (objA === objB) return true; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (objA[key] !== objB[key]) return false; + } + return true; + } + + // Object-based state for more complex tests + type ObjectState = { a: number; b: number; c: number }; + type ObjectAction = + | { type: "SET_A"; value: number } + | { type: "SET_B"; value: number } + | { type: "INCREMENT_ALL" }; + + function objectReducer( + state: ObjectState, + action: ObjectAction, + ): ObjectState { + switch (action.type) { + case "SET_A": + return { ...state, a: action.value }; + case "SET_B": + return { ...state, b: action.value }; + case "INCREMENT_ALL": + return { a: state.a + 1, b: state.b + 1, c: state.c + 1 }; + default: + return state; + } + } + + // Note: The selector is called multiple times during mount due to the concurrent-safe + // fixup logic in useLayoutEffect. This is expected behavior. Tests should account for this + // by either: + // 1. Only checking meaningful prevResult values (not exact call counts) + // 2. Using the prevResult equality pattern for object-returning selectors + + // Basic Functionality Tests + + it("prevResult is undefined on the very first selector call", async () => { + const store = createStore(reducer, 1); + + // Track only the first prevResult we receive + let firstPrevResult: number | undefined = "NOT_SET" as any; + let callCount = 0; + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + callCount++; + if (callCount === 1) { + firstPrevResult = prevResult; + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + // The very first call should have undefined prevResult + expect(firstPrevResult).toBe(undefined); + // Selector may be called multiple times due to fixup logic + expect(callCount).toBeGreaterThanOrEqual(1); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult receives previous value on store update", async () => { + const store = createStore(reducer, 1); + + // Track prevResult by state value, not call count + const prevResultByState = new Map(); + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + // Only record first time we see each state + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + // First state (1) should have undefined prevResult + expect(prevResultByState.get(1)).toBe(undefined); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 2 }]); + // When we first see state 2, prevResult should be 1 + expect(prevResultByState.get(2)).toBe(1); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult updates correctly on subsequent renders", async () => { + const store = createStore(reducer, 1); + + // Track prevResult by state value + const prevResultByState = new Map(); + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + + // Chain of updates + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + logger.assertLog([{ count: 2 }]); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + logger.assertLog([{ count: 3 }]); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + logger.assertLog([{ count: 4 }]); + + // Each state should have received the previous state as prevResult + expect(prevResultByState.get(1)).toBe(undefined); + expect(prevResultByState.get(2)).toBe(1); + expect(prevResultByState.get(3)).toBe(2); + expect(prevResultByState.get(4)).toBe(3); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Store Update Scenarios + + it("prevResult during synchronous store updates", async () => { + const store = createStore(reducer, 1); + + const prevResultByState = new Map(); + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + expect(prevResultByState.get(1)).toBe(undefined); + + // Multiple sync updates in single act + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + store.dispatch({ type: "INCREMENT" }); + }); + + // Due to auto-batching, we get only one render with final state + logger.assertLog([{ count: 3 }]); + // The selector may be called for intermediate states during fixup, + // so prevResult for state 3 could be 2 (if intermediate states were seen) + // or 1 (if only final state was seen). We just verify it's defined and correct. + const prevResultFor3 = prevResultByState.get(3); + expect(prevResultFor3).toBeDefined(); + expect([1, 2]).toContain(prevResultFor3); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult during batched updates", async () => { + const store = createStore(reducer, 1); + + const prevResultByState = new Map(); + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + + // Batched updates using flushSync + await act(async () => { + flushSync(() => { + store.dispatch({ type: "INCREMENT" }); + store.dispatch({ type: "INCREMENT" }); + }); + }); + + logger.assertLog([{ count: 3 }]); + // Initial state had undefined + expect(prevResultByState.get(1)).toBe(undefined); + // The selector may see intermediate states, so prevResult for state 3 + // could be 2 (if intermediate state was seen) or 1 (if not) + const prevResultFor3 = prevResultByState.get(3); + expect(prevResultFor3).toBeDefined(); + expect([1, 2]).toContain(prevResultFor3); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Selector Change Scenarios + + it("prevResult when selector changes", async () => { + const store = createStore(reducer, 10); + + let firstIdentityPrevResult: number | undefined = "NOT_SET" as any; + let firstDoublePrevResult: number | undefined = "NOT_SET" as any; + + let setSelector: React.Dispatch< + React.SetStateAction<(state: number, prev?: number) => number> + >; + + function Count() { + const [selector, _setSelector] = useState< + (state: number, prev?: number) => number + >(() => (state: number, prevResult?: number) => { + if (firstIdentityPrevResult === ("NOT_SET" as any)) { + firstIdentityPrevResult = prevResult; + } + return state; + }); + setSelector = _setSelector; + const count = useStoreSelector(store, selector); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 10 }]); + expect(firstIdentityPrevResult).toBe(undefined); + + // Change selector to double + await act(async () => { + setSelector(() => (state: number, prevResult?: number): number => { + if (firstDoublePrevResult === ("NOT_SET" as any)) { + firstDoublePrevResult = prevResult; + } + return state * 2; + }); + }); + + logger.assertLog([{ count: 20 }]); + // New selector should receive previous result from old selector (10) + expect(firstDoublePrevResult).toBe(10); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult when selector identity changes but logic is same", async () => { + const store = createStore(reducer, 5); + + let firstPrevResult: number | undefined = "NOT_SET" as any; + let secondPrevResult: number | undefined = "NOT_SET" as any; + let selectorCallCount = 0; + let setTrigger: (v: number) => void; + + function Count() { + const [trigger, _setTrigger] = useState(0); + setTrigger = _setTrigger; + // Create new selector function on each render when trigger changes + const selector = (state: number, prevResult?: number) => { + selectorCallCount++; + if (trigger === 0 && firstPrevResult === ("NOT_SET" as any)) { + firstPrevResult = prevResult; + } else if (trigger === 100 && secondPrevResult === ("NOT_SET" as any)) { + secondPrevResult = prevResult; + } + return state + trigger; + }; + const count = useStoreSelector(store, selector); + logger.log({ count, trigger }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 5, trigger: 0 }]); + expect(firstPrevResult).toBe(undefined); + + // Change trigger, causing selector identity to change + await act(async () => { + setTrigger(100); + }); + + logger.assertLog([{ count: 105, trigger: 100 }]); + // New selector should receive previous result (5) + expect(secondPrevResult).toBe(5); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Two-Layer Architecture (Custom Equality) + + it("selector can use prevResult for custom equality", async () => { + const store = createStore(objectReducer, { a: 1, b: 2, c: 3 }); + + let renderCount = 0; + function Count() { + const result = useStoreSelector( + store, + (state, prevResult?: { a: number; b: number }) => { + const newResult = { a: state.a, b: state.b }; + // Return prevResult if equal to prevent re-render + if ( + prevResult && + prevResult.a === newResult.a && + prevResult.b === newResult.b + ) { + return prevResult; + } + return newResult; + }, + ); + renderCount++; + logger.log({ a: result.a, b: result.b, renderCount }); + return ( +
+ {result.a}-{result.b} +
+ ); + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ a: 1, b: 2, renderCount: 1 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 1-2 +
+
+ `); + + // Update c only - selector should return prevResult, no re-render + const initialRenderCount = renderCount; + await act(async () => { + store.dispatch({ type: "SET_A", value: 1 }); // Same value + }); + + // The subscription fires but selector returns same reference + // React should bail out from re-render + logger.assertLog([]); + expect(renderCount).toBe(initialRenderCount); + + // Update a - should trigger re-render + await act(async () => { + store.dispatch({ type: "SET_A", value: 100 }); + }); + + logger.assertLog([{ a: 100, b: 2, renderCount: 2 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("selector with shallowEqual using prevResult", async () => { + const store = createStore(objectReducer, { a: 1, b: 2, c: 3 }); + + let renderCount = 0; + function Count() { + const result = useStoreSelector( + store, + (state, prevResult?: { a: number; b: number }) => { + const newResult = { a: state.a, b: state.b }; + if (prevResult && shallowEqual(newResult, prevResult)) { + return prevResult; // Prevent unnecessary re-render + } + return newResult; + }, + ); + renderCount++; + logger.log({ a: result.a, b: result.b, renderCount }); + return ( +
+ {result.a}-{result.b} +
+ ); + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ a: 1, b: 2, renderCount: 1 }]); + + // Update c only - shallowEqual should prevent re-render + const initialRenderCount = renderCount; + await act(async () => { + store.dispatch({ type: "SET_A", value: 1 }); // Same value for a + }); + + logger.assertLog([]); + expect(renderCount).toBe(initialRenderCount); + + // Update both a and b + await act(async () => { + store.dispatch({ type: "SET_A", value: 10 }); + }); + + logger.assertLog([{ a: 10, b: 2, renderCount: 2 }]); + + await act(async () => { + store.dispatch({ type: "SET_B", value: 20 }); + }); + + logger.assertLog([{ a: 10, b: 20, renderCount: 3 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Concurrent/Transition Scenarios + + it("prevResult during startTransition", async () => { + const store = createStore(reducer, 1); + + const prevResultByState = new Map(); + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + expect(prevResultByState.get(1)).toBe(undefined); + + let resolve: () => void; + + // Start a transition + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT" }); + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Transition not completed yet + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 1 +
+
+ `); + + // Complete the transition + await act(async () => { + resolve(); + }); + + logger.assertLog([{ count: 2 }]); + // prevResult should have been 1 during the transition render + expect(prevResultByState.get(2)).toBe(1); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult when store updates mid-transition", async () => { + const store = createStore(reducer, 1); + + let setShowOther: (v: boolean) => void; + + function Count({ testid }: { testid: string }) { + const count = useStoreSelector(store, identity); + logger.log({ testid, count }); + return
{count}
; + } + + function App() { + const [showOther, _setShowOther] = useState(false); + setShowOther = _setShowOther; + return ( + + + {showOther && } + + ); + } + + const { asFragment, unmount } = await act(async () => { + return render(); + }); + + logger.assertLog([{ testid: "count", count: 1 }]); + + let resolve: () => void; + + // Start a transition + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT" }); + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Mount new component mid-transition (sync) + await act(async () => { + setShowOther(true); + }); + + // The new component mounts with transition state (2) then fixup to sync state (1) + logger.assertLog([ + { testid: "count", count: 1 }, + { testid: "otherCount", count: 2 }, // First render with transition state + { testid: "otherCount", count: 1 }, // Fixup to sync state + ]); + + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 1 +
+
+ 1 +
+
+ `); + + // Complete transition + await act(async () => { + resolve(); + }); + + logger.assertLog([ + { testid: "count", count: 2 }, + { testid: "otherCount", count: 2 }, + ]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult with concurrent renders - each component tracks independently", async () => { + const store = createStore(reducer, 1); + + // Track prevResult by state for each component + const prevResultByStateA = new Map(); + const prevResultByStateB = new Map(); + + function CountA() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByStateA.has(state)) { + prevResultByStateA.set(state, prevResult); + } + return state; + }, + ); + logger.log({ testid: "A", count }); + return
A: {count}
; + } + + function CountB() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByStateB.has(state)) { + prevResultByStateB.set(state, prevResult); + } + return state; + }, + ); + logger.log({ testid: "B", count }); + return
B: {count}
; + } + + const { unmount } = await act(async () => { + return render( + + + + , + ); + }); + + logger.assertLog([ + { testid: "A", count: 1 }, + { testid: "B", count: 1 }, + ]); + + // Both start with undefined for state 1 + expect(prevResultByStateA.get(1)).toBe(undefined); + expect(prevResultByStateB.get(1)).toBe(undefined); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([ + { testid: "A", count: 2 }, + { testid: "B", count: 2 }, + ]); + + // Each component should have prevResult=1 when rendering state=2 + expect(prevResultByStateA.get(2)).toBe(1); + expect(prevResultByStateB.get(2)).toBe(1); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult consistency across interrupted renders", async () => { + const store = createStore(reducer, 1); + + // Use identity selector to avoid infinite loops from object creation + function Count() { + const count = useStoreSelector(store, identity); + logger.log({ count }); + return
{count}
; + } + + let setShowOther: (v: boolean) => void; + + function App() { + const [showOther, _setShowOther] = useState(false); + setShowOther = _setShowOther; + return ( + + + {showOther && } + + ); + } + + const { asFragment, unmount } = await act(async () => { + return render(); + }); + + logger.assertLog([{ count: 1 }]); + + let resolve: () => void; + + // Start a transition + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT" }); + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Interrupt with sync update that mounts new component + await act(async () => { + setShowOther(true); + }); + + logger.assertLog([ + { count: 1 }, + { count: 2 }, // New component renders with transition state + { count: 1 }, // Fixup to sync state + ]); + + // Complete the transition + await act(async () => { + resolve(); + }); + + logger.assertLog([{ count: 2 }, { count: 2 }]); + + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 2 +
+
+ 2 +
+
+ `); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Edge Cases + + it("prevResult with selector that throws", async () => { + const store = createStore(reducer, 1); + + let shouldThrow = false; + let prevResultWhenThrowing: number | undefined; + const prevResultByState = new Map(); + + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + if (shouldThrow) { + prevResultWhenThrowing = prevResult; + throw new Error("Selector error"); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + // Wrap in error boundary for the test + class ErrorBoundary extends Component< + { children: React.ReactNode }, + { hasError: boolean } + > { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + render() { + if (this.state.hasError) { + return
Error Boundary
; + } + return this.props.children; + } + } + + const { unmount } = await act(async () => { + return render( + + + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + expect(prevResultByState.get(1)).toBe(undefined); + + // Normal update + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 2 }]); + expect(prevResultByState.get(2)).toBe(1); + + // Suppress expected console.error from React error boundary + const originalError = console.error; + console.error = () => {}; + + // Now make selector throw + shouldThrow = true; + try { + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + } catch { + // Expected error + } finally { + console.error = originalError; + } + + // prevResult should have been 2 when the selector threw + expect(prevResultWhenThrowing).toBe(2); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult after component remounts", async () => { + const store = createStore(reducer, 10); + + // Track first prevResult per mount + let mountCount = 0; + const firstPrevResultPerMount: (number | undefined)[] = []; + + function Count() { + const count = useStoreSelector( + store, + (state, prevResult) => { + // Track first call of each mount + if (firstPrevResultPerMount.length === mountCount) { + firstPrevResultPerMount.push(prevResult); + } + return state; + }, + ); + useEffect(() => { + mountCount++; + }, []); + logger.log({ count }); + return
{count}
; + } + + let setShow: (v: boolean) => void; + + function App() { + const [show, _setShow] = useState(true); + setShow = _setShow; + return {show && }; + } + + const { unmount } = await act(async () => { + return render(); + }); + + logger.assertLog([{ count: 10 }]); + // First mount starts with undefined + expect(firstPrevResultPerMount[0]).toBe(undefined); + + // Unmount component + await act(async () => { + setShow(false); + }); + + logger.assertLog([]); + + // Remount component + await act(async () => { + setShow(true); + }); + + // Fresh mount gets undefined prevResult + logger.assertLog([{ count: 10 }]); + expect(firstPrevResultPerMount[1]).toBe(undefined); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult with derived/computed values using equality check", async () => { + const store = createStore(reducer, 5); + + type DerivedResult = { doubled: number; tripled: number }; + // Track prevResult by state value + const prevResultByState = new Map(); + + function Count() { + const result = useStoreSelector( + store, + (state, prevResult?: DerivedResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + const newResult = { + doubled: state * 2, + tripled: state * 3, + }; + // Use prevResult equality pattern to prevent infinite loops + if ( + prevResult && + prevResult.doubled === newResult.doubled && + prevResult.tripled === newResult.tripled + ) { + return prevResult; + } + return newResult; + }, + ); + logger.log({ doubled: result.doubled, tripled: result.tripled }); + return ( +
+ {result.doubled}-{result.tripled} +
+ ); + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ doubled: 10, tripled: 15 }]); + expect(prevResultByState.get(5)).toBe(undefined); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ doubled: 12, tripled: 18 }]); + // prevResult when rendering state 6 should be the result from state 5 + expect(prevResultByState.get(6)).toEqual({ doubled: 10, tripled: 15 }); + + await act(async () => { + store.dispatch({ type: "DOUBLE" }); + }); + + logger.assertLog([{ doubled: 24, tripled: 36 }]); + // prevResult when rendering state 12 should be the result from state 6 + expect(prevResultByState.get(12)).toEqual({ doubled: 12, tripled: 18 }); + + unmount(); + expect(store._listeners.length).toBe(0); + }); +}); + +describe("useStoreSelectorWithEquality", () => { + // Helper for shallow equality comparison + function shallowEqual>( + objA: T, + objB: T, + ): boolean { + if (objA === objB) return true; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (objA[key] !== objB[key]) return false; + } + return true; + } + + // Object-based state for more complex tests + type UserState = { + user: { name: string; age: number; version?: number }; + other?: string; + value?: number | null; + }; + type UserAction = + | { type: "SET_NAME"; payload: string } + | { type: "SET_AGE"; payload: number } + | { type: "SET_OTHER"; payload: string } + | { type: "SET_VALUE"; payload: number | null } + | { type: "INCREMENT_VERSION" }; + + function userReducer(state: UserState, action: UserAction): UserState { + switch (action.type) { + case "SET_NAME": + return { ...state, user: { ...state.user, name: action.payload } }; + case "SET_AGE": + return { ...state, user: { ...state.user, age: action.payload } }; + case "SET_OTHER": + return { ...state, other: action.payload }; + case "SET_VALUE": + return { ...state, value: action.payload }; + case "INCREMENT_VERSION": + return { + ...state, + user: { ...state.user, version: (state.user.version || 0) + 1 }, + }; + default: + return state; + } + } + + // Basic Functionality + + it("returns selected value from store", async () => { + const store = createStore(reducer, 5); + + function Test() { + const count = useStoreSelectorWithEquality(store, (s: State) => s); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 5 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("updates when store changes", async () => { + const store = createStore(reducer, 0); + + function Test() { + const count = useStoreSelectorWithEquality(store, (s: State) => s); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 0 }]); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 1 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("uses default Object.is equality when no equalityFn provided", async () => { + // Create store with NaN value to test Object.is behavior + // Object.is(NaN, NaN) === true, unlike === comparison + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + value: NaN, + }); + + function Test() { + const value = useStoreSelectorWithEquality( + store, + (s: UserState) => s.value, + ); + logger.log({ value: String(value) }); + return
{String(value)}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + // Clear initial render logs (may include fixup renders from concurrent-safe hook) + logger._logs = []; + + // Dispatch but value stays NaN + await act(async () => { + store.dispatch({ type: "SET_VALUE", payload: NaN }); + }); + + // Object.is(NaN, NaN) is true, so the selected value shouldn't change + // and no additional renders should occur from this dispatch + logger.assertLog([]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Equality Function Behavior + + it("skips re-render when equalityFn returns true", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + }); + + let renderCount = 0; + + function Test() { + renderCount++; + const user = useStoreSelectorWithEquality( + store, + (s: UserState) => ({ name: s.user.name }), + shallowEqual, + ); + logger.log({ name: user.name, renderCount }); + return
{user.name}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ name: "Alice", renderCount: 1 }]); + const initialRenderCount = renderCount; + + // Change age (not selected), name stays the same + await act(async () => { + store.dispatch({ type: "SET_AGE", payload: 31 }); + }); + + // Should not re-render because shallowEqual({ name: "Alice" }, { name: "Alice" }) is true + logger.assertLog([]); + expect(renderCount).toBe(initialRenderCount); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("preserves reference identity when equal", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + other: "x", + }); + + const results: object[] = []; + + function Test() { + const obj = useStoreSelectorWithEquality( + store, + (s: UserState) => ({ name: s.user.name }), + shallowEqual, + ); + results.push(obj); + logger.log({ name: obj.name }); + return null; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ name: "Alice" }]); + expect(results.length).toBe(1); + + // Change 'other' field, not the selected 'name' + await act(async () => { + store.dispatch({ type: "SET_OTHER", payload: "y" }); + }); + + // No re-render should occur, and if any fixup happened, same reference should be returned + logger.assertLog([]); + // If there was any additional render (due to fixup), verify same reference + if (results.length > 1) { + expect(results[0]).toBe(results[results.length - 1]); + } + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("works with shallowEqual", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + }); + + let renderCount = 0; + + function Test() { + renderCount++; + const user = useStoreSelectorWithEquality( + store, + (s: UserState) => ({ name: s.user.name, age: s.user.age }), + shallowEqual, + ); + logger.log({ name: user.name, age: user.age, renderCount }); + return ( +
+ {user.name}-{user.age} +
+ ); + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ name: "Alice", age: 30, renderCount: 1 }]); + + // Update name - should trigger re-render + await act(async () => { + store.dispatch({ type: "SET_NAME", payload: "Bob" }); + }); + + logger.assertLog([{ name: "Bob", age: 30, renderCount: 2 }]); + + // Update age - should trigger re-render + await act(async () => { + store.dispatch({ type: "SET_AGE", payload: 31 }); + }); + + logger.assertLog([{ name: "Bob", age: 31, renderCount: 3 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("re-renders when equalityFn returns false", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + }); + + let renderCount = 0; + + function Test() { + renderCount++; + const user = useStoreSelectorWithEquality( + store, + (s: UserState) => ({ name: s.user.name }), + shallowEqual, + ); + logger.log({ name: user.name, renderCount }); + return
{user.name}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ name: "Alice", renderCount: 1 }]); + + // Change name - should re-render + await act(async () => { + store.dispatch({ type: "SET_NAME", payload: "Bob" }); + }); + + logger.assertLog([{ name: "Bob", renderCount: 2 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Selector Changes + + it("handles selector function changes", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + }); + + let setField: (field: "name" | "age") => void; + + function Test() { + const [field, _setField] = useState<"name" | "age">("name"); + setField = _setField; + // Inline selector - new function each render when field changes + const value = useStoreSelectorWithEquality(store, (s: UserState) => + field === "name" ? s.user.name : s.user.age, + ); + logger.log({ field, value }); + return ( +
+ {field}: {value} +
+ ); + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ field: "name", value: "Alice" }]); + + // Change selector to read age + await act(async () => { + setField("age"); + }); + + logger.assertLog([{ field: "age", value: 30 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("handles equalityFn changes", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + }); + + let setUseShallowEqual: (use: boolean) => void; + + // Custom equality that always returns false (never equal) + const neverEqual = () => false; + + const selector = (s: UserState) => ({ name: s.user.name }); + + // Track the rendered values and equality function used + const renders: { name: string; useShallow: boolean }[] = []; + + function Test() { + const [useShallow, _setUseShallowEqual] = useState(true); + setUseShallowEqual = _setUseShallowEqual; + const user = useStoreSelectorWithEquality( + store, + selector, + useShallow ? shallowEqual : neverEqual, + ); + renders.push({ name: user.name, useShallow }); + logger.log({ name: user.name, useShallow }); + return
{user.name}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + // Clear logs and reset render tracking after mount + logger._logs = []; + const rendersAfterMount = renders.length; + + // With shallowEqual, changing age should not cause re-render + // because name stays the same and shallowEqual({ name: "Alice" }, { name: "Alice" }) is true + await act(async () => { + store.dispatch({ type: "SET_AGE", payload: 31 }); + }); + + // No new renders should have occurred + expect(renders.length).toBe(rendersAfterMount); + logger.assertLog([]); + + // Switch to neverEqual - this will cause at least one render + await act(async () => { + setUseShallowEqual(false); + }); + + // Clear logs after the equality function switch + logger._logs = []; + const rendersAfterSwitch = renders.length; + // Verify we've rendered with useShallow: false at least once + expect(renders.some((r) => r.useShallow === false)).toBe(true); + + // Now with neverEqual, any store update should cause re-render + // because neverEqual always returns false + await act(async () => { + store.dispatch({ type: "SET_AGE", payload: 32 }); + }); + + // With neverEqual, we should have at least one new render + expect(renders.length).toBeGreaterThan(rendersAfterSwitch); + // And the latest render should still show "Alice" with useShallow: false + expect(renders[renders.length - 1]).toEqual({ + name: "Alice", + useShallow: false, + }); + + logger.assertLog([ + { + name: "Alice", + useShallow: false, + }, + ]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Concurrent Behavior + + it("maintains consistency during concurrent updates", async () => { + const store = createStore(reducer, 1); + const values: number[] = []; + + const selector = (s: State) => s; + + function Display({ testid }: { testid: string }) { + const count = useStoreSelectorWithEquality(store, selector); + values.push(count); + logger.log({ testid, count }); + return
{count}
; + } + + let setShowSecond: (show: boolean) => void; + + function Trigger() { + const [showSecond, _setShowSecond] = useState(false); + setShowSecond = _setShowSecond; + return ( + <> + + {showSecond && } + + ); + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ testid: "first", count: 1 }]); + + let resolve: () => void; + + // Start transition and mount second component + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT" }); // count = 2 + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Mount second component mid-transition + await act(async () => { + setShowSecond(true); + }); + + // First component re-renders with old state + // Second component mounts with transition state, then fixes up to sync state + logger.assertLog([ + { testid: "first", count: 1 }, + { testid: "second", count: 2 }, // Initially renders with transition state + { testid: "second", count: 1 }, // Fixup to sync state + ]); + + // Both should show same value (no tearing) + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 1 +
+
+ 1 +
+
+ `); + + // Complete the transition + await act(async () => { + resolve(); + }); + + logger.assertLog([ + { testid: "first", count: 2 }, + { testid: "second", count: 2 }, + ]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("works correctly with startTransition", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30, version: 1 }, + }); + + let renderCount = 0; + + function Test() { + renderCount++; + const name = useStoreSelectorWithEquality( + store, + (s: UserState) => ({ name: s.user.name }), + shallowEqual, + ); + logger.log({ name: name.name, renderCount }); + return
{name.name}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ name: "Alice", renderCount: 1 }]); + const initialRenders = renderCount; + + let resolve: () => void; + + // Transition that only changes version, not name + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT_VERSION" }); + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Complete the transition + await act(async () => { + resolve(); + }); + + // Should not cause additional renders because name didn't change + // (shallowEqual returns true for { name: "Alice" } === { name: "Alice" }) + logger.assertLog([]); + expect(renderCount).toBe(initialRenders); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + // Edge Cases + + it("handles selector that throws", async () => { + const store = createStore(userReducer, { + user: { name: "Alice", age: 30 }, + value: null, + }); + + // Suppress expected console.error from React error boundary + const originalError = console.error; + console.error = () => {}; + + function Test() { + const value = useStoreSelectorWithEquality( + store, + (s: UserState) => (s.value as any).property, // Will throw when value is null + ); + return
{value}
; + } + + // Wrap in error boundary for the test + class ErrorBoundary extends Component< + { children: React.ReactNode }, + { hasError: boolean } + > { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + render() { + if (this.state.hasError) { + return
Error caught
; + } + return this.props.children; + } + } + + let renderResult: Awaited> | null = null; + + try { + renderResult = await act(async () => { + return render( + + + + + , + ); + }); + } catch { + // Error was thrown during render + } + + console.error = originalError; + + // Either error boundary caught it or render threw + if (renderResult) { + expect(renderResult.asFragment().textContent).toBe("Error caught"); + renderResult.unmount(); + } + }); +}); + +describe("inline selector (new reference every render)", () => { + // This tests the common React-Redux pattern: + // const todos = useSelector(state => state.todos) + // Where a new function reference is created every render + + it("handles inline selector without causing infinite loops", async () => { + const store = createStore(reducer, 5); + + let renderCount = 0; + function Component() { + renderCount++; + // Inline selector - new function reference every render! + const count = useStoreSelector(store, (state) => state); + logger.log({ count, renderCount }); + return
{count}
; + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 5, renderCount: 1 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 5 +
+
+ `); + + // Should not have infinite loops - renderCount should be reasonable + expect(renderCount).toBeLessThan(5); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("returns consistent values with inline selector", async () => { + const store = createStore(reducer, 10); + + const renderedValues: number[] = []; + function Component() { + // Inline selector - new function reference every render! + const count = useStoreSelector(store, (state) => state * 2); + renderedValues.push(count); + logger.log({ count }); + return
{count}
; + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 20 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 20 +
+
+ `); + + // Update the store + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 22 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 22 +
+
+ `); + + // All rendered values should be consistent (multiples of state * 2) + expect(renderedValues.every((v) => v % 2 === 0)).toBe(true); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("prevResult works correctly with changing selector identity", async () => { + const store = createStore(reducer, 1); + + // Track prevResult values when we see each state + const prevResultByState = new Map(); + + function Component() { + // Inline selector - new function reference every render! + const count = useStoreSelector( + store, + (state, prevResult) => { + if (!prevResultByState.has(state)) { + prevResultByState.set(state, prevResult); + } + return state; + }, + ); + logger.log({ count }); + return
{count}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + expect(prevResultByState.get(1)).toBe(undefined); + + // Dispatch updates + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 2 }]); + // Even though selector identity changed, prevResult should be 1 + expect(prevResultByState.get(2)).toBe(1); + + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 3 }]); + // prevResult should be 2 + expect(prevResultByState.get(3)).toBe(2); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("inline selector works during transitions", async () => { + const store = createStore(reducer, 1); + + function Component() { + // Inline selector - new function reference every render! + const count = useStoreSelector(store, (state) => state); + logger.log({ count }); + return
{count}
; + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 1 }]); + + let resolve: () => void; + + // Start a transition + await act(async () => { + startTransition(async () => { + store.dispatch({ type: "INCREMENT" }); + await new Promise((_resolve) => { + resolve = _resolve; + }); + }); + }); + + // Transition not completed yet + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 1 +
+
+ `); + + // Complete the transition + await act(async () => { + resolve(); + }); + + logger.assertLog([{ count: 2 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 2 +
+
+ `); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("inline selector with object result uses prevResult for stability", async () => { + // Object-based state + type ObjState = { a: number; b: number }; + type ObjAction = + | { type: "SET_A"; value: number } + | { type: "SET_B"; value: number }; + + function objReducer(state: ObjState, action: ObjAction): ObjState { + switch (action.type) { + case "SET_A": + return { ...state, a: action.value }; + case "SET_B": + return { ...state, b: action.value }; + default: + return state; + } + } + + const store = createStore(objReducer, { a: 1, b: 2 }); + + let renderCount = 0; + function Component() { + renderCount++; + // Inline selector with prevResult equality pattern + const result = useStoreSelector( + store, + (state, prevResult?: { a: number }) => { + const newResult = { a: state.a }; + if (prevResult && prevResult.a === newResult.a) { + return prevResult; // Return same reference if equal + } + return newResult; + }, + ); + logger.log({ a: result.a, renderCount }); + return
{result.a}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ a: 1, renderCount: 1 }]); + const initialRenderCount = renderCount; + + // Update b only - a stays the same + await act(async () => { + store.dispatch({ type: "SET_B", value: 100 }); + }); + + // Should not re-render because selector returns prevResult + logger.assertLog([]); + expect(renderCount).toBe(initialRenderCount); + + // Update a - should trigger re-render + await act(async () => { + store.dispatch({ type: "SET_A", value: 10 }); + }); + + logger.assertLog([{ a: 10, renderCount: 2 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("useStoreSelectorWithEquality handles inline selector", async () => { + // Object-based state + type ObjState = { a: number; b: number }; + type ObjAction = + | { type: "SET_A"; value: number } + | { type: "SET_B"; value: number }; + + function objReducer(state: ObjState, action: ObjAction): ObjState { + switch (action.type) { + case "SET_A": + return { ...state, a: action.value }; + case "SET_B": + return { ...state, b: action.value }; + default: + return state; + } + } + + function shallowEqual>( + objA: T, + objB: T, + ): boolean { + if (objA === objB) return true; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (objA[key] !== objB[key]) return false; + } + return true; + } + + const store = createStore(objReducer, { a: 1, b: 2 }); + + let renderCount = 0; + function Component() { + renderCount++; + // Inline selector - new function reference every render! + const result = useStoreSelectorWithEquality( + store, + (state) => ({ a: state.a }), + shallowEqual, + ); + logger.log({ a: result.a, renderCount }); + return
{result.a}
; + } + + const { unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ a: 1, renderCount: 1 }]); + const initialRenderCount = renderCount; + + // Update b only - a stays the same, should not re-render due to shallowEqual + await act(async () => { + store.dispatch({ type: "SET_B", value: 100 }); + }); + + // Should not re-render because shallowEqual({ a: 1 }, { a: 1 }) is true + logger.assertLog([]); + expect(renderCount).toBe(initialRenderCount); + + // Update a - should trigger re-render + await act(async () => { + store.dispatch({ type: "SET_A", value: 10 }); + }); + + logger.assertLog([{ a: 10, renderCount: 2 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("inline selector does not cause issues with multiple components", async () => { + const store = createStore(reducer, 1); + + const renderCounts = { A: 0, B: 0 }; + + function ComponentA() { + renderCounts.A++; + // Inline selector + const count = useStoreSelector(store, (state) => state); + logger.log({ component: "A", count }); + return
A: {count}
; + } + + function ComponentB() { + renderCounts.B++; + // Inline selector + const count = useStoreSelector(store, (state) => state * 2); + logger.log({ component: "B", count }); + return
B: {count}
; + } + + const { asFragment, unmount } = await act(async () => { + return render( + + + + , + ); + }); + + logger.assertLog([ + { component: "A", count: 1 }, + { component: "B", count: 2 }, + ]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ A: 1 +
+
+ B: 2 +
+
+ `); + + // Reasonable render counts + expect(renderCounts.A).toBeLessThan(5); + expect(renderCounts.B).toBeLessThan(5); + + // Update store + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([ + { component: "A", count: 2 }, + { component: "B", count: 4 }, + ]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); + + it("inline selector with closure captures current props", async () => { + const store = createStore(reducer, 10); + + function Component({ multiplier }: { multiplier: number }) { + // Inline selector that captures props - new function reference every render! + const count = useStoreSelector(store, (state) => state * multiplier); + logger.log({ count, multiplier }); + return
{count}
; + } + + const { rerender, asFragment, unmount } = await act(async () => { + return render( + + + , + ); + }); + + logger.assertLog([{ count: 20, multiplier: 2 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 20 +
+
+ `); + + // Change multiplier prop + await act(async () => { + rerender( + + + , + ); + }); + + logger.assertLog([{ count: 30, multiplier: 3 }]); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ 30 +
+
+ `); + + // Update store - should use current multiplier (3) + await act(async () => { + store.dispatch({ type: "INCREMENT" }); + }); + + logger.assertLog([{ count: 33, multiplier: 3 }]); + + unmount(); + expect(store._listeners.length).toBe(0); + }); +}); diff --git a/packages/use-store/src/experimental/useStore.tsx b/packages/use-store/src/experimental/useStore.tsx index d9ee45d..b9ea57f 100644 --- a/packages/use-store/src/experimental/useStore.tsx +++ b/packages/use-store/src/experimental/useStore.tsx @@ -6,8 +6,10 @@ import { useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, + useDebugValue, } from "react"; import { Store } from "./Store"; import { ISource, Reducer } from "../types"; @@ -103,7 +105,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { type HookState = { value: T; - selector: (state: S) => T; + selector: (state: S, prevResult?: T) => T; }; /** @@ -137,8 +139,8 @@ type HookState = { * scheduled to catch us up with the rest of the app. */ export function useStoreSelector( - store: Store, - selector: (state: S) => T, + store: Store, + selector: (state: S, prevResult?: T) => T, ): T { const storeManager = useContext(storeManagerContext); if (storeManager == null) { @@ -169,7 +171,7 @@ export function useStoreSelector( // We also track the selector used for each state so that we can determine if // the selector has changed since our last updated. const [hookState, setState] = useState>(() => ({ - value: selector(store.getState()), + value: selector(store.getState(), undefined), selector, })); @@ -177,24 +179,34 @@ export function useStoreSelector( // the mount was sync, we'll apply a fixup in useLayoutEffect, just like we do // on mount. const selectorChange = hookState.selector !== selector; - const state = selectorChange ? selector(store.getState()) : hookState.value; + const state = selectorChange + ? selector(store.getState(), hookState.value) + : hookState.value; useLayoutEffect(() => { // Ensure our store is managed by the tracker. storeManager.addStore(store); - const mountState = selector(store.getState()); - const mountCommittedState = selector(store.getCommittedState()); + const mountState = selector(store.getState(), hookState.value); + const mountCommittedState = selector( + store.getCommittedState(), + hookState.value, + ); + + function resolveHookState( + newValue: T, + prev: HookState, + ): HookState { + // If nothing has changed... + if (is(prev.value, newValue) && prev.selector === selector) { + // Preserve object identity. + return prev; + } + return { value: newValue, selector }; + } // Helper to ensure we preserve object identity if neither state nor selector has changed. function setHookState(value: T) { - setState((prev) => { - // If nothing has changed... - if (prev.value === value && prev.selector === selector) { - // Preserve object identity. - return prev; - } - return { value, selector }; - }); + setState((prev) => resolveHookState(value, prev)); } // If we are mounting as part of a sync update mid transition, our initial @@ -234,7 +246,12 @@ export function useStoreSelector( } const unsubscribe = store.subscribe(() => { - setHookState(selector(store.getState())); + // Capture store state eagerly when subscription fires, not when React processes the update + const currentStoreState = store.getState(); + setState((prev) => { + const newValue = selector(currentStoreState, prev.value); + return resolveHookState(newValue, prev); + }); }); return () => { unsubscribe(); @@ -252,6 +269,82 @@ function identity(x: T): T { return x; } -export function useStore(store: Store): S { +function is(x: unknown, y: unknown) { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y; + } else { + return x !== x && y !== y; + } +} + +export function useStore(store: Store): S { return useStoreSelector(store, identity); } + +export function useStoreSelectorWithEquality( + store: Store, + selector: (state: State) => Selection, + isEqual: (a: Selection, b: Selection) => boolean = Object.is, +): Selection { + const memoizedSelector = useMemo(() => { + // Track the memoized state using closure variables that are local to this + // memoized instance of a getSnapshot function. Intentionally not using a + // useRef hook, because that state would be shared across all concurrent + // copies of the hook/component. + let hasMemo = false; + let memoizedSnapshot: State; + let memoizedSelection: Selection; + + const memoizedSelector = (nextSnapshot: State, prevResult?: Selection) => { + if (!hasMemo) { + // The first time the hook is called, there is no memoized result. + hasMemo = true; + memoizedSnapshot = nextSnapshot; + const nextSelection = selector(nextSnapshot); + // Even if the selector has changed, the currently rendered selection + // may be equal to the new selection. We should attempt to reuse the + // current value if possible, to preserve downstream memoizations. + if (prevResult !== undefined && isEqual(prevResult, nextSelection)) { + memoizedSelection = prevResult; + return prevResult; + } + memoizedSelection = nextSelection; + return nextSelection; + } + + // We may be able to reuse the previous invocation's result. + const prevSnapshot = memoizedSnapshot; + const prevSelection = memoizedSelection; + + if (is(prevSnapshot, nextSnapshot)) { + // The snapshot is the same as last time. Reuse the previous selection. + return prevSelection; + } + + // The snapshot has changed, so we need to compute a new selection. + const nextSelection = selector(nextSnapshot); + + // If a custom isEqual function is provided, use that to check if the data + // has changed. If it hasn't, return the previous selection. That signals + // to React that the selections are conceptually equal, and we can bail + // out of rendering. + if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) { + // The snapshot still has changed, so make sure to update to not keep + // old references alive + memoizedSnapshot = nextSnapshot; + return prevSelection; + } + + memoizedSnapshot = nextSnapshot; + memoizedSelection = nextSelection; + return nextSelection; + }; + + return memoizedSelector; + }, [selector, isEqual]); + + const value = useStoreSelector(store, memoizedSelector); + + useDebugValue(value); + return value; +} diff --git a/packages/use-store/src/index.ts b/packages/use-store/src/index.ts index 0cbe357..a6d1dc1 100644 --- a/packages/use-store/src/index.ts +++ b/packages/use-store/src/index.ts @@ -1,4 +1,4 @@ -export type { ReactStore } from "./types"; +export type { ReactStore, ISource, Reducer } from "./types"; import * as Experimental from "./experimental"; export { createStore, useStore } from "./useStore";