diff --git a/projects/angular-redux/src/lib/utils/shallowEqual.ts b/projects/angular-redux/src/lib/utils/shallowEqual.ts new file mode 100644 index 0000000..a662707 --- /dev/null +++ b/projects/angular-redux/src/lib/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.call(objB, keysA[i]) || + !is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false + } + } + + return true +} diff --git a/projects/angular-redux/src/public-api.ts b/projects/angular-redux/src/public-api.ts index 279f7a9..da62d3c 100644 --- a/projects/angular-redux/src/public-api.ts +++ b/projects/angular-redux/src/public-api.ts @@ -7,3 +7,4 @@ export * from './lib/inject-selector'; export * from './lib/inject-store'; export * from './lib/provide-redux'; export * from "./lib/provider" +export * from "./lib/utils/shallowEqual" diff --git a/projects/angular-redux/src/tests/inject-selector.spec.ts b/projects/angular-redux/src/tests/inject-selector.spec.ts index 03ce29c..5a450d9 100644 --- a/projects/angular-redux/src/tests/inject-selector.spec.ts +++ b/projects/angular-redux/src/tests/inject-selector.spec.ts @@ -1,4 +1,4 @@ -import {InjectSelector, injectSelector, provideRedux, ReduxProvider} from '../public-api' +import {InjectSelector, injectSelector, provideRedux, ReduxProvider, shallowEqual} from '../public-api' import {Component, effect, inject} from "@angular/core"; import {render, waitFor} from "@testing-library/angular"; import {AnyAction, createStore, Store} from "redux"; @@ -193,3 +193,144 @@ describe('injectSelector lifecycle interactions', () => { ) }) }); + +describe('performance optimizations and bail-outs', () => { + it('defaults to ref-equality to prevent unnecessary updates', async () => { + const state = {} + const store = createStore(() => state) + + @Component({ + selector: "app-root", + standalone: true, + template: "
" + }) + class Comp { + value = injectSelector((s) => s) + _test = effect(() => { + renderedItems.push(this.value()) + }) + } + + await render(Comp, { + providers: [provideRedux({store})] + }) + + + 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, + }), + ) + + @Component({ + selector: "app-comp", + standalone: true, + template: "
" + }) + class Comp { + value = injectSelector( + (s: StateType) => Object.keys(s), + shallowEqual, + ) + _test = effect(() => { + renderedItems.push(this.value()) + }) + } + + @Component({ + selector: "app-other", + standalone: true, + template: "
" + }) + class Comp2 { + value = injectSelector((s: StateType) => Object.keys(s), { + equalityFn: shallowEqual, + }) + _test = effect(() => { + renderedItems.push(this.value()) + }) + } + + @Component({ + selector: "app-root", + standalone: true, + imports: [Comp, Comp2], + template: ` + + + ` + }) + class App { + } + + await render(App, { + providers: [provideRedux({store})] + }) + + expect(renderedItems.length).toBe(2) + + store.dispatch({type: ''}) + + await waitFor(() => + expect(renderedItems.length).toBe(2) + ) + }); + + it('calls selector exactly once on mount and on update', async () => { + interface StateType { + count: number + } + + const store = createStore(({count}: StateType = {count: 0}) => ({ + count: count + 1, + })) + + const selector = jest.fn((s: StateType) => { + return s.count + }) + const renderedItems: number[] = [] + + + @Component({ + selector: "app-root", + standalone: true, + template: "
" + }) + class Comp { + value = injectSelector(selector) + _test = effect(() => { + renderedItems.push(this.value()) + }) + } + + await render(Comp, { + providers: [provideRedux({store})] + }) + + expect(selector).toHaveBeenCalledTimes(1) + expect(renderedItems.length).toEqual(1) + + store.dispatch({type: ''}) + + await waitFor(() => + expect(selector).toHaveBeenCalledTimes(2) + ) + expect(renderedItems.length).toEqual(2) + }); +});