Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
19 changes: 5 additions & 14 deletions src/useSelector.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>,
Expand All @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions src/useSelectors.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>,
T = any,
TEmitted = TActor extends Subscribable<infer Emitted> ? Emitted : never
> = (emitted: TEmitted) => T;

type SelectorObject<
TActor extends ActorRef<any, any>,
T = any,
TEmitted = TActor extends Subscribable<infer Emitted> ? Emitted : never
> = {
selector: SelectorFunction<TActor, T, TEmitted>;
compare?: (a: T, b: T) => boolean;
getSnapshot?: (a: TActor) => TEmitted;
};

type Selector<
TActor extends ActorRef<any, any>,
T = any,
TEmitted = TActor extends Subscribable<infer Emitted> ? Emitted : never
> = SelectorFunction<TActor, T, TEmitted> | SelectorObject<TActor, T, TEmitted>;

type ExtractSelectorsToRefs<
TActor extends ActorRef<any, any>,
TSelectors extends Record<string, Selector<TActor>>
> = {
[Key in keyof TSelectors]: TSelectors[Key] extends Selector<TActor, infer T>
? Ref<T>
: never;
};

function isSelectorObject<TActor extends ActorRef<any, any>>(
selector: Selector<TActor>
): selector is SelectorObject<TActor> {
return typeof selector === 'object';
}

function normalizeSelectors<TActor extends ActorRef<any, any>>(
selectors: Record<string, Selector<TActor>>
) {
return Object.entries(selectors).reduce((acc, [name, selector]) => {
const selectorObject: SelectorObject<TActor> = isSelectorObject(selector)
? selector
: { selector };

acc[name] = {
compare: defaultCompare,
getSnapshot: defaultGetSnapshot,
...selectorObject
};

return acc;
}, {} as Record<string, Required<SelectorObject<TActor>>>);
}

export function useSelectors<
TActor extends ActorRef<any, any>,
TSelectors extends Record<string, Selector<TActor>>
>(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<TActor, TSelectors>
);

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;
}
59 changes: 59 additions & 0 deletions test/UseSelectors.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<template>
<div>
<div data-testid="count">{{ count }}</div>
<button data-testid="other" @click="service.send('OTHER')">Other</button>
<button data-testid="increment" @click="service.send('INCREMENT')">
Increment
</button>
</div>
</template>

<script lang="ts">
import { defineComponent, onMounted, onUpdated } from '@vue/composition-api';
import { assign, createMachine } from 'xstate';
import { useInterpret, useSelectors } from '../src';

const machine = createMachine<{ count: number; other: number }>({
initial: 'active',
context: {
other: 0,
count: 0
},
states: {
active: {}
},
on: {
OTHER: {
actions: assign({ other: (ctx) => ctx.other + 1 })
},
INCREMENT: {
actions: assign({ count: (ctx) => ctx.count + 1 })
}
}
});

// Shim Vue 3 debugging function
function onRenderTracked(callback: () => void) {
onMounted(callback);
onUpdated(callback);
}

export default defineComponent({
emits: ['rerender'],
setup(props, { emit }) {
const service = useInterpret(machine);
const { count } = useSelectors(service, {
count: (state) => state.context.count
});

let rerenders = 0;

onRenderTracked(() => {
rerenders++;
emit('rerender', rerenders);
});

return { service, count };
}
});
</script>
48 changes: 48 additions & 0 deletions test/UseSelectorsCustomFn.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<template>
<div>
<div data-testid="name">{{ name }}</div>
<button
data-testid="sendUpper"
@click="service.send({ type: 'CHANGE', value: 'DAVID' })"
></button>
<button
data-testid="sendOther"
@click="service.send({ type: 'CHANGE', value: 'other' })"
></button>
</div>
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { assign, createMachine } from 'xstate';
import { useInterpret, useSelectors } from '../src';

const machine = createMachine<{ name: string }>({
initial: 'active',
context: {
name: 'david'
},
states: {
active: {}
},
on: {
CHANGE: {
actions: assign({ name: (_, e) => e.value })
}
}
});

export default defineComponent({
setup() {
const service = useInterpret(machine);
const { name } = useSelectors(service, {
name: {
selector: (state) => state.context.name,
compare: (a, b) => a.toUpperCase() === b.toUpperCase()
}
});

return { service, name };
}
});
</script>
49 changes: 49 additions & 0 deletions test/useSelectors.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});