From b82001712b279eee99871be64856a1374940c515 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 14 Sep 2024 03:16:22 -0700 Subject: [PATCH] feat: add shallowEqual export --- packages/vue-redux/src/index.ts | 2 + packages/vue-redux/src/utils/Subscription.ts | 14 ++- packages/vue-redux/src/utils/batch.ts | 4 - packages/vue-redux/src/utils/shallowEqual.ts | 36 ++++++++ packages/vue-redux/tests/Subscription.spec.ts | 60 +++++++++++++ packages/vue-redux/tests/shallowEqual.spec.ts | 84 +++++++++++++++++ .../vue-redux/tests/use-selector.spec.tsx | 89 ++++++++++++++++++- 7 files changed, 274 insertions(+), 15 deletions(-) delete mode 100644 packages/vue-redux/src/utils/batch.ts create mode 100644 packages/vue-redux/src/utils/shallowEqual.ts create mode 100644 packages/vue-redux/tests/Subscription.spec.ts create mode 100644 packages/vue-redux/tests/shallowEqual.spec.ts diff --git a/packages/vue-redux/src/index.ts b/packages/vue-redux/src/index.ts index bc89da2..741454f 100644 --- a/packages/vue-redux/src/index.ts +++ b/packages/vue-redux/src/index.ts @@ -5,3 +5,5 @@ export * from './compositions/use-dispatch' export * from './compositions/use-selector' export * from './compositions/use-redux-context' export * from './types' +export * from './utils/shallowEqual' +export type { Subscription } from './utils/Subscription' diff --git a/packages/vue-redux/src/utils/Subscription.ts b/packages/vue-redux/src/utils/Subscription.ts index c9f8412..e6ad9e3 100644 --- a/packages/vue-redux/src/utils/Subscription.ts +++ b/packages/vue-redux/src/utils/Subscription.ts @@ -1,5 +1,3 @@ -import { defaultNoopBatch as batch } from './batch' - // encapsulates the subscription logic for connecting a component to the redux store, as // well as nesting subscriptions of descendant components, so that we can ensure the // ancestor components re-render before descendants @@ -23,13 +21,11 @@ function createListenerCollection() { }, notify() { - batch(() => { - let listener = first - while (listener) { - listener.callback() - listener = listener.next - } - }) + let listener = first + while (listener) { + listener.callback() + listener = listener.next + } }, get() { diff --git a/packages/vue-redux/src/utils/batch.ts b/packages/vue-redux/src/utils/batch.ts deleted file mode 100644 index b3bcb58..0000000 --- a/packages/vue-redux/src/utils/batch.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Default to a dummy "batch" implementation that just runs the callback -export function defaultNoopBatch(callback: () => void) { - callback() -} diff --git a/packages/vue-redux/src/utils/shallowEqual.ts b/packages/vue-redux/src/utils/shallowEqual.ts new file mode 100644 index 0000000..d416d9a --- /dev/null +++ b/packages/vue-redux/src/utils/shallowEqual.ts @@ -0,0 +1,36 @@ +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 shallowEqual(objA: any, objB: any) { + if (is(objA, objB)) return true + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false + } + + const keysA = Object.keys(objA) + const keysB = Object.keys(objB) + + if (keysA.length !== keysB.length) return false + + for (let i = 0; i < keysA.length; i++) { + if ( + !(Object.prototype.hasOwnProperty as Function).call(objB, keysA[i]) || + !is(objA[keysA[i]!], objB[keysA[i]!]) + ) { + return false + } + } + + return true +} diff --git a/packages/vue-redux/tests/Subscription.spec.ts b/packages/vue-redux/tests/Subscription.spec.ts new file mode 100644 index 0000000..4d093f3 --- /dev/null +++ b/packages/vue-redux/tests/Subscription.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSubscription } from '../src/utils/Subscription' +import type { Subscription } from '../src/utils/Subscription' +import type { Store } from 'redux' + +describe('Subscription', () => { + let notifications: string[] + let store: Store + let parent: Subscription + + beforeEach(() => { + notifications = [] + store = { subscribe: () => vi.fn() } as unknown as Store + + parent = createSubscription(store) + parent.onStateChange = () => {} + parent.trySubscribe() + }) + + function subscribeChild(name: string) { + const child = createSubscription(store, parent) + child.onStateChange = () => notifications.push(name) + child.trySubscribe() + return child + } + + it('listeners are notified in order', () => { + subscribeChild('child1') + subscribeChild('child2') + subscribeChild('child3') + subscribeChild('child4') + + parent.notifyNestedSubs() + + expect(notifications).toEqual(['child1', 'child2', 'child3', 'child4']) + }) + + it('listeners can be unsubscribed', () => { + const child1 = subscribeChild('child1') + const child2 = subscribeChild('child2') + const child3 = subscribeChild('child3') + + child2.tryUnsubscribe() + parent.notifyNestedSubs() + + expect(notifications).toEqual(['child1', 'child3']) + notifications.length = 0 + + child1.tryUnsubscribe() + parent.notifyNestedSubs() + + expect(notifications).toEqual(['child3']) + notifications.length = 0 + + child3.tryUnsubscribe() + parent.notifyNestedSubs() + + expect(notifications).toEqual([]) + }) +}) diff --git a/packages/vue-redux/tests/shallowEqual.spec.ts b/packages/vue-redux/tests/shallowEqual.spec.ts new file mode 100644 index 0000000..010ecbe --- /dev/null +++ b/packages/vue-redux/tests/shallowEqual.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { shallowEqual } from '../src' + +describe('Utils', () => { + describe('shallowEqual', () => { + it('should return true if arguments fields are equal', () => { + expect( + shallowEqual( + { a: 1, b: 2, c: undefined }, + { a: 1, b: 2, c: undefined }, + ), + ).toBe(true) + + expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toBe( + true, + ) + + const o = {} + expect(shallowEqual({ a: 1, b: 2, c: o }, { a: 1, b: 2, c: o })).toBe( + true, + ) + + const d = function () { + return 1 + } + expect( + shallowEqual({ a: 1, b: 2, c: o, d }, { a: 1, b: 2, c: o, d }), + ).toBe(true) + }) + + it('should return false if arguments fields are different function identities', () => { + expect( + shallowEqual( + { + a: 1, + b: 2, + d: function () { + return 1 + }, + }, + { + a: 1, + b: 2, + d: function () { + return 1 + }, + }, + ), + ).toBe(false) + }) + + it('should return false if first argument has too many keys', () => { + expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false) + }) + + it('should return false if second argument has too many keys', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false) + }) + + it('should return false if arguments have different keys', () => { + expect( + shallowEqual( + { a: 1, b: 2, c: undefined }, + { a: 1, bb: 2, c: undefined }, + ), + ).toBe(false) + }) + + it('should compare two NaN values', () => { + expect(shallowEqual(NaN, NaN)).toBe(true) + }) + + it('should compare empty objects, with false', () => { + expect(shallowEqual({}, false)).toBe(false) + expect(shallowEqual(false, {})).toBe(false) + expect(shallowEqual([], false)).toBe(false) + expect(shallowEqual(false, [])).toBe(false) + }) + + it('should compare two zero values', () => { + expect(shallowEqual(0, 0)).toBe(true) + }) + }) +}) diff --git a/packages/vue-redux/tests/use-selector.spec.tsx b/packages/vue-redux/tests/use-selector.spec.tsx index a7fd93c..0c13039 100644 --- a/packages/vue-redux/tests/use-selector.spec.tsx +++ b/packages/vue-redux/tests/use-selector.spec.tsx @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { defineComponent, h, inject, watchSyncEffect } from 'vue' +import { Fragment, defineComponent, h, inject, watchSyncEffect } from 'vue' import { createStore } from 'redux' import { cleanup, render, waitFor } from '@testing-library/vue' -import { ContextKey, provideStore as provideMock, useSelector } from '../src' +import { + ContextKey, + provideStore as provideMock, + shallowEqual, + useSelector, +} from '../src' import type { Ref } from 'vue' import type { Subscription } from '../src/utils/Subscription' import type { TypedUseSelectorComposition } from '../src' @@ -222,6 +227,86 @@ describe('Vue', () => { expect(renderedItems).toEqual([0, 1]) }) }) + + describe('performance optimizations and bail-outs', () => { + it('defaults to ref-equality to prevent unnecessary updates', async () => { + const state = {} + const store = createStore(() => state) + + const Comp = defineComponent(() => { + const value = useSelector((s) => s) + watchSyncEffect(() => { + renderedItems.push(value.value) + }) + return () =>
+ }) + + const App = defineComponent(() => { + provideMock({ store }) + return () => + }) + + render() + + expect(renderedItems.length).toBe(1) + + store.dispatch({ type: '' }) + + await waitFor(() => expect(renderedItems.length).toBe(1)) + }) + + it('allows other equality functions to prevent unnecessary updates', async () => { + interface StateType { + count: number + stable: {} + } + const store = createStore( + ({ count, stable }: StateType = { count: -1, stable: {} }) => ({ + count: count + 1, + stable, + }), + ) + + const Comp = defineComponent(() => { + const value = useSelector( + (s: StateType) => Object.keys(s), + shallowEqual, + ) + watchSyncEffect(() => { + renderedItems.push(value.value) + }) + return () =>
+ }) + + const Comp2 = defineComponent(() => { + const value = useSelector((s: StateType) => Object.keys(s), { + equalityFn: shallowEqual, + }) + watchSyncEffect(() => { + renderedItems.push(value.value) + }) + return () =>
+ }) + + const App = defineComponent(() => { + provideMock({ store }) + return () => ( + <> + + + + ) + }) + + render() + + expect(renderedItems.length).toBe(2) + + store.dispatch({ type: '' }) + + await waitFor(() => expect(renderedItems.length).toBe(2)) + }) + }) }) }) })