From 646fca7ca671599eb25cb87acf53ddf8468696d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchrisshank=E2=80=9D?= Date: Thu, 12 Aug 2021 18:07:41 -0700 Subject: [PATCH] feat(use-selectors): add useSelectors composable --- src/index.ts | 1 + src/useSelector.ts | 19 ++------ src/useSelectors.ts | 91 +++++++++++++++++++++++++++++++++++ test/UseSelectors.vue | 59 +++++++++++++++++++++++ test/UseSelectorsCustomFn.vue | 48 ++++++++++++++++++ test/useSelectors.test.ts | 49 +++++++++++++++++++ 6 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 src/useSelectors.ts create mode 100644 test/UseSelectors.vue create mode 100644 test/UseSelectorsCustomFn.vue create mode 100644 test/useSelectors.test.ts diff --git a/src/index.ts b/src/index.ts index 7b4748e..25dc469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ export { useService } from './useService'; export { useActor } from './useActor'; export { useInterpret } from './useInterpret'; export { useSelector } from './useSelector'; +export { useSelectors } from './useSelectors'; export { useSpawn } from './useSpawn'; diff --git a/src/useSelector.ts b/src/useSelector.ts index fd80d96..1109ddc 100644 --- a/src/useSelector.ts +++ b/src/useSelector.ts @@ -1,8 +1,8 @@ -import { onMounted, onBeforeUnmount, shallowRef } from '@vue/composition-api'; +import { onBeforeUnmount, shallowRef } from '@vue/composition-api'; import { ActorRef, Subscribable } from 'xstate'; import { defaultGetSnapshot } from './useActor'; -const defaultCompare = (a, b) => a === b; +export const defaultCompare = (a, b) => a === b; export function useSelector< TActor extends ActorRef, @@ -16,24 +16,15 @@ export function useSelector< ) { const selected = shallowRef(selector(getSnapshot(actor))); - const updateSelectedIfChanged = (nextSelected: T) => { + let sub = actor.subscribe((emitted) => { + const nextSelected = selector(emitted); if (!compare(selected.value, nextSelected)) { selected.value = nextSelected; } - }; - - let sub; - onMounted(() => { - const initialSelected = selector(getSnapshot(actor)); - updateSelectedIfChanged(initialSelected); - sub = actor.subscribe((emitted) => { - const nextSelected = selector(emitted); - updateSelectedIfChanged(nextSelected); - }); }); onBeforeUnmount(() => { - sub?.unsubscribe(); + sub.unsubscribe(); }); return selected; diff --git a/src/useSelectors.ts b/src/useSelectors.ts new file mode 100644 index 0000000..a59003b --- /dev/null +++ b/src/useSelectors.ts @@ -0,0 +1,91 @@ +import { onBeforeUnmount, shallowRef, Ref } from '@vue/composition-api'; +import { ActorRef, Subscribable } from 'xstate'; +import { defaultGetSnapshot } from './useActor'; +import { defaultCompare } from './useSelector'; + +type SelectorFunction< + TActor extends ActorRef, + T = any, + TEmitted = TActor extends Subscribable ? Emitted : never +> = (emitted: TEmitted) => T; + +type SelectorObject< + TActor extends ActorRef, + T = any, + TEmitted = TActor extends Subscribable ? Emitted : never +> = { + selector: SelectorFunction; + compare?: (a: T, b: T) => boolean; + getSnapshot?: (a: TActor) => TEmitted; +}; + +type Selector< + TActor extends ActorRef, + T = any, + TEmitted = TActor extends Subscribable ? Emitted : never +> = SelectorFunction | SelectorObject; + +type ExtractSelectorsToRefs< + TActor extends ActorRef, + TSelectors extends Record> +> = { + [Key in keyof TSelectors]: TSelectors[Key] extends Selector + ? Ref + : never; +}; + +function isSelectorObject>( + selector: Selector +): selector is SelectorObject { + return typeof selector === 'object'; +} + +function normalizeSelectors>( + selectors: Record> +) { + return Object.entries(selectors).reduce((acc, [name, selector]) => { + const selectorObject: SelectorObject = isSelectorObject(selector) + ? selector + : { selector }; + + acc[name] = { + compare: defaultCompare, + getSnapshot: defaultGetSnapshot, + ...selectorObject + }; + + return acc; + }, {} as Record>>); +} + +export function useSelectors< + TActor extends ActorRef, + TSelectors extends Record> +>(actor: TActor, selectors: TSelectors) { + const normalizedSelectors = normalizeSelectors(selectors); + + const selected = Object.entries(normalizedSelectors).reduce( + (acc, [name, { selector, getSnapshot }]) => { + acc[name as keyof TSelectors] = shallowRef(selector(getSnapshot(actor))); + return acc; + }, + {} as ExtractSelectorsToRefs + ); + + let sub = actor.subscribe((emitted) => { + Object.entries(normalizedSelectors).forEach( + ([name, { selector, compare }]) => { + const nextSelected = selector(emitted); + if (!compare(selected[name].value, nextSelected)) { + selected[name].value = nextSelected; + } + } + ); + }); + + onBeforeUnmount(() => { + sub?.unsubscribe(); + }); + + return selected; +} diff --git a/test/UseSelectors.vue b/test/UseSelectors.vue new file mode 100644 index 0000000..819c7f8 --- /dev/null +++ b/test/UseSelectors.vue @@ -0,0 +1,59 @@ + + + diff --git a/test/UseSelectorsCustomFn.vue b/test/UseSelectorsCustomFn.vue new file mode 100644 index 0000000..5b22f94 --- /dev/null +++ b/test/UseSelectorsCustomFn.vue @@ -0,0 +1,48 @@ + + + diff --git a/test/useSelectors.test.ts b/test/useSelectors.test.ts new file mode 100644 index 0000000..5063393 --- /dev/null +++ b/test/useSelectors.test.ts @@ -0,0 +1,49 @@ +import { render, fireEvent } from '@testing-library/vue'; +import UseSelectors from './UseSelectors.vue'; +import useSelectorsCustomFn from './UseSelectorsCustomFn.vue'; + +describe('useSelector', () => { + it('only rerenders for selected values', async () => { + const { getByTestId, emitted } = render(UseSelectors); + + const countButton = getByTestId('count'); + const otherButton = getByTestId('other'); + const incrementEl = getByTestId('increment'); + + await fireEvent.click(incrementEl); + expect(countButton.textContent).toBe('1'); + + await fireEvent.click(otherButton); + await fireEvent.click(otherButton); + await fireEvent.click(otherButton); + await fireEvent.click(otherButton); + + await fireEvent.click(incrementEl); + expect(countButton.textContent).toBe('2'); + + expect(emitted()['rerender'].length).toBe(3); + }); + + it('should work with a custom comparison function', async () => { + const { getByTestId } = render(useSelectorsCustomFn); + + const nameEl = getByTestId('name'); + const sendUpperButton = getByTestId('sendUpper'); + const sendOtherButton = getByTestId('sendOther'); + + expect(nameEl.textContent).toEqual('david'); + + await fireEvent.click(sendUpperButton); + + // unchanged due to comparison function + expect(nameEl.textContent).toEqual('david'); + + await fireEvent.click(sendOtherButton); + + expect(nameEl.textContent).toEqual('other'); + + await fireEvent.click(sendUpperButton); + + expect(nameEl.textContent).toEqual('DAVID'); + }); +});