diff --git a/.changeset/fix-svelte-store-proxy-equality.md b/.changeset/fix-svelte-store-proxy-equality.md new file mode 100644 index 00000000..358eeaf4 --- /dev/null +++ b/.changeset/fix-svelte-store-proxy-equality.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-store': patch +--- + +Fix `state_proxy_equality_mismatch` warning in `useSelector` by using `$state.raw()` instead of `$state()` for the slice variable. `$state()` wrapped object values in a Svelte Proxy, causing `===` comparison with the raw selector output to always fail, which triggered unnecessary re-renders and Svelte runtime warnings on every store update. diff --git a/packages/svelte-store/src/useSelector.svelte.ts b/packages/svelte-store/src/useSelector.svelte.ts index 82a72fb1..789c6722 100644 --- a/packages/svelte-store/src/useSelector.svelte.ts +++ b/packages/svelte-store/src/useSelector.svelte.ts @@ -35,7 +35,9 @@ export function useSelector>( options: UseSelectorOptions = {}, ): { readonly current: TSelected } { const compare = options.compare ?? defaultCompare - let slice = $state(selector(source.get())) + // `$state.raw` keeps the slice unproxied; a proxied value would never be `===` + // to the plain object the selector returns, defeating the equality check below. + let slice = $state.raw(selector(source.get())) $effect(() => { const unsub = source.subscribe((s) => { diff --git a/packages/svelte-store/tests/ProxyEquality.test.svelte b/packages/svelte-store/tests/ProxyEquality.test.svelte new file mode 100644 index 00000000..df577d85 --- /dev/null +++ b/packages/svelte-store/tests/ProxyEquality.test.svelte @@ -0,0 +1,35 @@ + + +
+

Number rendered: {renderCount}

+

Value: {selected.current.value}

+ +
diff --git a/packages/svelte-store/tests/SelectorMemoization.test.svelte b/packages/svelte-store/tests/SelectorMemoization.test.svelte new file mode 100644 index 00000000..042c0c74 --- /dev/null +++ b/packages/svelte-store/tests/SelectorMemoization.test.svelte @@ -0,0 +1,82 @@ + + +
+

Default renders: {defaultRenders}

+

Default identities: {defaultIdentities}

+

Same object: {sameObject}

+

Keyed renders: {keyedRenders}

+

Primitive renders: {primitiveRenders}

+ +
diff --git a/packages/svelte-store/tests/index.test.ts b/packages/svelte-store/tests/index.test.ts index dc304f01..6c5e4f25 100644 --- a/packages/svelte-store/tests/index.test.ts +++ b/packages/svelte-store/tests/index.test.ts @@ -1,13 +1,24 @@ -import { describe, expect, it, test } from 'vitest' +import { describe, expect, it, test, vi } from 'vitest' import { render, waitFor } from '@testing-library/svelte' import { userEvent } from '@testing-library/user-event' import { shallow } from '../src/index.svelte.js' import TestBaseStore from './BaseStore.test.svelte' import TestRerender from './Render.test.svelte' import TestValue from './Value.test.svelte' +import TestProxyEquality from './ProxyEquality.test.svelte' +import TestSelectorMemoization from './SelectorMemoization.test.svelte' const user = userEvent.setup() +/** Three updates that leave the selected slice untouched, each clicked + * separately: Svelte coalesces a synchronous burst into a single effect run, + * which would hide a cost paid once per notification. */ +async function threeUnrelatedUpdates(button: HTMLElement) { + for (let i = 0; i < 3; i++) { + await user.click(button) + } +} + describe('useSelector', () => { it('allows us to select state using a selector', () => { const { getByText } = render(TestBaseStore) @@ -28,6 +39,64 @@ describe('useSelector', () => { expect(getByText('Number rendered: 2')).toBeInTheDocument() }) + it('does not trigger re-render when selector returns same object reference', async () => { + const { getByText } = render(TestProxyEquality) + expect(getByText('Number rendered: 1')).toBeInTheDocument() + + await user.click(getByText('Update ignored')) + expect(getByText('Number rendered: 1')).toBeInTheDocument() + }) + + it('memoizes an unchanged object slice on every notification', async () => { + const { getByText } = render(TestSelectorMemoization) + expect(getByText('Default renders: 1')).toBeInTheDocument() + + await threeUnrelatedUpdates(getByText('Update ignored')) + + // The slice never changed, so a memoizing selector still reads 1 / 1 here. + // A proxied slice compares unequal to the raw object it wraps, re-sets the + // slice, and hands out a brand-new Proxy: 4 renders over 4 identities. + expect(getByText('Default renders: 1')).toBeInTheDocument() + expect(getByText('Default identities: 1')).toBeInTheDocument() + // `.current` is the very object the store holds, not a wrapper over it. + expect(getByText('Same object: true')).toBeInTheDocument() + }) + + it('memoizes the same slice under both controls', async () => { + const { getByText } = render(TestSelectorMemoization) + + await threeUnrelatedUpdates(getByText('Update ignored')) + + // A compare no proxy can fool, and a primitive slice that is never proxied. + // Neither arm regresses when the object arm does, which is what isolates + // the cause to the proxying rather than to the store or the subscription. + expect(getByText('Keyed renders: 1')).toBeInTheDocument() + expect(getByText('Primitive renders: 1')).toBeInTheDocument() + }) + + it('compares the slice without tripping a proxy-equality warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const { getByText } = render(TestSelectorMemoization) + + await threeUnrelatedUpdates(getByText('Update ignored')) + + // Svelte warns here only when the operands are one underlying object yet + // `===` disagrees, so this asserts the compare sees through no wrapper. + const warned = warn.mock.calls + .flat() + .some( + (arg) => + typeof arg === 'string' && + arg.includes('state_proxy_equality_mismatch'), + ) + expect(warned).toBe(false) + } finally { + warn.mockRestore() + } + }) + it('useSelector reads writable and readonly store state', async () => { const { getByText } = render(TestValue) expect(getByText('Value: 1')).toBeInTheDocument()