From 8c35da9a72bf067a275335d0391ce9ab85ed8a12 Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 28 Aug 2024 11:29:44 -0700 Subject: [PATCH] Add initial support for xstate/store/solid (#5056) * Add initial SolidJS support with `useSelector` function * Include changeset for xstate/store/solid * Remove test which doesn't test `useSelector` * Remove unnecessary types * Improve typing and remove unused import * Clean imports --- .changeset/mean-peaches-decide.md | 24 ++ babel.config.js | 2 +- packages/xstate-store/README.md | 21 ++ packages/xstate-store/package.json | 24 +- packages/xstate-store/solid/package.json | 4 + packages/xstate-store/src/solid.ts | 80 +++++++ packages/xstate-store/test/solid.test.tsx | 259 ++++++++++++++++++++++ 7 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 .changeset/mean-peaches-decide.md create mode 100644 packages/xstate-store/solid/package.json create mode 100644 packages/xstate-store/src/solid.ts create mode 100644 packages/xstate-store/test/solid.test.tsx diff --git a/.changeset/mean-peaches-decide.md b/.changeset/mean-peaches-decide.md new file mode 100644 index 0000000000..fe108bbfe4 --- /dev/null +++ b/.changeset/mean-peaches-decide.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': minor +--- + +You can now use the xstate/store package with SolidJS. + +Import `useSelector` from `@xstate/store/solid`. Select the data you want via `useSelector(…)` and send events using `store.send(eventObject)`: + +```tsx +import { donutStore } from './donutStore.ts'; +import { useSelector } from '@xstate/store/solid'; + +function DonutCounter() { + const donutCount = useSelector(donutStore, (state) => state.context.donuts); + + return ( +
+ +
+ ); +} +``` diff --git a/babel.config.js b/babel.config.js index 952c9eebef..e329d14836 100644 --- a/babel.config.js +++ b/babel.config.js @@ -49,7 +49,7 @@ module.exports = { ] }, { - test: /\/xstate-solid\//, + test: /\/xstate-solid\/|solid\.test\.tsx$/, presets: ['babel-preset-solid'] } ], diff --git a/packages/xstate-store/README.md b/packages/xstate-store/README.md index c9f2c0335a..dd39e352a1 100644 --- a/packages/xstate-store/README.md +++ b/packages/xstate-store/README.md @@ -74,6 +74,27 @@ function DonutCounter() { } ``` +## Usage with SolidJS + +Import `useSelector` from `@xstate/store/solid`. Select the data you want via `useSelector(…)` and send events using `store.send(eventObject)`: + +```tsx +import { donutStore } from './donutStore.ts'; +import { useSelector } from '@xstate/store/solid'; + +function DonutCounter() { + const donutCount = useSelector(donutStore, (state) => state.context.donuts); + + return ( +
+ +
+ ); +} +``` + ## Usage with Immer XState Store makes it really easy to integrate with immutable update libraries like [Immer](https://github.com/immerjs/immer) or [Mutative](https://github.com/unadlib/mutative). Pass the `produce` function into `createStoreWithProducer(producer, …)`, and update `context` in transition functions using the convenient pseudo-mutative API: diff --git a/packages/xstate-store/package.json b/packages/xstate-store/package.json index c2d0ac0a72..b9393eeb6f 100644 --- a/packages/xstate-store/package.json +++ b/packages/xstate-store/package.json @@ -30,18 +30,27 @@ "import": "./react/dist/xstate-store-react.cjs.mjs", "default": "./react/dist/xstate-store-react.cjs.js" }, + "./solid": { + "types": { + "import": "./solid/dist/xstate-store-solid.cjs.mjs", + "default": "./solid/dist/xstate-store-solid.cjs.js" + }, + "module": "./solid/dist/xstate-store-solid.esm.js", + "import": "./solid/dist/xstate-store-solid.cjs.mjs", + "default": "./solid/dist/xstate-store-solid.cjs.js" + }, "./package.json": "./package.json" }, "sideEffects": false, "files": [ "dist", - "react" + "react", + "solid" ], "repository": { "type": "git", "url": "git+ssh://git@github.com/statelyai/xstate.git" }, - "scripts": {}, "bugs": { "url": "https://github.com/statelyai/xstate/issues" }, @@ -53,21 +62,28 @@ "immer": "^10.0.2", "react": "^18.0.0", "react-dom": "^18.0.0", + "solid-js": "^1.7.6", + "solid-testing-library": "^0.3.0", "xstate": "^5.17.4" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.2.0", + "solid-js": "^1.7.6" }, "peerDependenciesMeta": { "react": { "optional": true + }, + "solid-js": { + "optional": true } }, "preconstruct": { "umdName": "XStateStore", "entrypoints": [ "./index.ts", - "./react.ts" + "./react.ts", + "./solid.ts" ] } } diff --git a/packages/xstate-store/solid/package.json b/packages/xstate-store/solid/package.json new file mode 100644 index 0000000000..d3dcfeb9f0 --- /dev/null +++ b/packages/xstate-store/solid/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/xstate-store-solid.cjs.js", + "module": "dist/xstate-store-solid.esm.js" +} diff --git a/packages/xstate-store/src/solid.ts b/packages/xstate-store/src/solid.ts new file mode 100644 index 0000000000..34529c4173 --- /dev/null +++ b/packages/xstate-store/src/solid.ts @@ -0,0 +1,80 @@ +/* @jsxImportSource solid-js */ +import { createEffect, createSignal, onCleanup } from 'solid-js'; +import type { Store, SnapshotFromStore } from './types'; + +function defaultCompare(a: T | undefined, b: T) { + return a === b; +} + +function useSelectorWithCompare, T>( + selector: (snapshot: SnapshotFromStore) => T, + compare: (a: T | undefined, b: T) => boolean +): (snapshot: SnapshotFromStore) => T { + let previous: T | undefined; + + return (state): T => { + const next = selector(state); + + if (previous === undefined || !compare(previous, next)) { + previous = next; + } + + return previous; + }; +} + +/** + * Creates a selector which subscribes to the store and selects a value from the + * store's snapshot, using an optional comparison function. + * + * @example + * + * ```tsx + * import { donutStore } from './donutStore.ts'; + * import { useSelector } from '@xstate/store/solid'; + * + * function DonutCounter() { + * const donutCount = useSelector(donutStore, (state) => state.context.donuts); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + * + * @param store The store, created from `createStore(…)` + * @param selector A function which takes in the snapshot and returns a selected + * value from it + * @param compare An optional function which compares the selected value to the + * previously selected value + * @returns A read-only signal of the selected value + */ +export function useSelector, T>( + store: TStore, + selector: (snapshot: SnapshotFromStore) => T, + compare: (a: T | undefined, b: T) => boolean = defaultCompare +): () => T { + const selectorWithCompare = useSelectorWithCompare(selector, compare); + const [selectedValue, setSelectedValue] = createSignal( + selectorWithCompare(store.getSnapshot() as SnapshotFromStore) + ); + + createEffect(() => { + const subscription = store.subscribe(() => { + const newValue = selectorWithCompare( + store.getSnapshot() as SnapshotFromStore + ); + setSelectedValue(() => newValue); + }); + + onCleanup(() => { + subscription.unsubscribe(); + }); + }); + + return selectedValue; +} diff --git a/packages/xstate-store/test/solid.test.tsx b/packages/xstate-store/test/solid.test.tsx new file mode 100644 index 0000000000..f690da0a91 --- /dev/null +++ b/packages/xstate-store/test/solid.test.tsx @@ -0,0 +1,259 @@ +/* @jsxImportSource solid-js */ +import type { Accessor, Component } from 'solid-js'; +import { createRenderEffect, createSignal } from 'solid-js'; +import { fireEvent, render, screen } from 'solid-testing-library'; +import { createStore } from '../src/index.ts'; +import { useSelector } from '../src/solid.ts'; + +/** A function that tracks renders caused by the given accessors changing */ +const useRenderTracker = (...accessors: Accessor[]) => { + const [renders, setRenders] = createSignal(0); + + createRenderEffect(() => { + accessors.forEach((s) => s()); + setRenders((p) => p + 1); + }); + + return renders; +}; + +/** A commonly reused store for testing selector behaviours. */ +const createCounterStore = () => + createStore( + { count: 0, other: 0 }, + { + increment: { count: ({ count }) => count + 1 }, + other: { other: ({ other }) => other + 1 } + } + ); + +describe('Solid.js integration', () => { + describe('useSelector', () => { + // Ensure selectors react and result in renders as expected + it('should minimize rerenders', () => { + const store = createCounterStore(); + + render(() => ); + + const counterLabel = screen.getByTestId('counter-label-value'); + const containerRenders = screen.getByTestId('counter-container-renders'); + const labelRenders = screen.getByTestId('counter-label-renders'); + const incrementButton = screen.getByTestId('increment-button'); + const otherButton = screen.getByTestId('other-button'); + + expect(containerRenders.textContent).toBe('1'); + expect(labelRenders.textContent).toBe('1'); + expect(counterLabel.textContent).toBe('0'); + + fireEvent.click(incrementButton); + + expect(counterLabel.textContent).toBe('1'); + expect(containerRenders.textContent).toBe('1'); + expect(labelRenders.textContent).toBe('2'); + + fireEvent.click(otherButton); + fireEvent.click(otherButton); + fireEvent.click(otherButton); + fireEvent.click(otherButton); + + expect(labelRenders.textContent).toBe('2'); + + fireEvent.click(incrementButton); + + expect(counterLabel.textContent).toBe('2'); + expect(containerRenders.textContent).toBe('1'); + expect(labelRenders.textContent).toBe('3'); + }); + + // Validate the expected behaviours of default and custom comparison functions + it('can use a custom comparison function', async () => { + const INITIAL_ITEMS = [1, 2]; + const DIFFERENT_ITEMS = [3, 4]; + const INITIAL_ITEMS_STRING = INITIAL_ITEMS.join(','); + const DIFFERENT_ITEMS_STRING = DIFFERENT_ITEMS.join(','); + + const store = createStore( + { items: INITIAL_ITEMS }, + { + same: { items: () => [...INITIAL_ITEMS] }, + different: { items: () => DIFFERENT_ITEMS } + } + ); + + const ItemList: Component<{ + itemStore: typeof store; + name: string; + comparison?: (a: number[] | undefined, b: number[]) => boolean; + }> = ({ itemStore, name, comparison }) => { + const items = useSelector( + itemStore, + (s) => s.context.items, + comparison + ); + const renders = useRenderTracker(items); + + return ( +
+
{renders()}
+
+ {items().join(',')} +
+
+ ); + }; + + const Container = () => { + return ( + <> + + JSON.stringify(a) === JSON.stringify(b)} + /> + + + + + ); + }; + + render(() => ); + + const defaultRendersDiv = await screen.findByTestId( + 'default-selector-renders' + ); + const customRendersDiv = await screen.findByTestId( + 'custom-selector-renders' + ); + + const defaultItemsDiv = await screen.findByTestId( + 'default-selector-items' + ); + const customItemsDiv = await screen.findByTestId('custom-selector-items'); + const differentButton = await screen.findByTestId('different'); + const sameButton = await screen.findByTestId('same'); + + expect(defaultItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('1'); + + expect(customItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(customRendersDiv.textContent).toBe('1'); + + fireEvent.click(sameButton); + + // Expect a rerender for default selector + expect(defaultItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('2'); + + // Expect no rerender for custom selector + expect(customItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('2'); + expect(customRendersDiv.textContent).toBe('1'); + + fireEvent.click(differentButton); + + // Expect a rerender for both selectors + expect(defaultItemsDiv.textContent).toBe(DIFFERENT_ITEMS_STRING); + expect(customItemsDiv.textContent).toBe(DIFFERENT_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('3'); + expect(customRendersDiv.textContent).toBe('2'); + + fireEvent.click(sameButton); + + // Expect a rerender for both selectors + expect(defaultItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(customItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('4'); + expect(customRendersDiv.textContent).toBe('3'); + + // Only default comparison selector should rerender + fireEvent.click(sameButton); + fireEvent.click(sameButton); + + // Expect only default selector to cause rerenders + expect(defaultItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(customItemsDiv.textContent).toBe(INITIAL_ITEMS_STRING); + expect(defaultRendersDiv.textContent).toBe('6'); + expect(customRendersDiv.textContent).toBe('3'); + }); + + it('should allow batched updates', () => { + const store = createCounterStore(); + + const BatchCounter = () => { + const count = useSelector(store, (s) => s.context.count); + + return ( +
{ + store.send({ type: 'increment' }); + store.send({ type: 'increment' }); + }} + > + {count()} +
+ ); + }; + + render(() => ); + + const countDiv = screen.getByTestId('count'); + + expect(countDiv.textContent).toEqual('0'); + + fireEvent.click(countDiv); + + expect(countDiv.textContent).toEqual('2'); + }); + }); +}); + +type CounterStore = ReturnType; + +// Used to help track renders caused by selector updates +const CounterLabel: Component<{ store: CounterStore }> = ({ store }) => { + const count = useSelector(store, (s) => s.context.count); + const renders = useRenderTracker(count); + + return ( + <> +
{count()}
; +
{renders()}
; + + ); +}; + +const Counter: Component<{ store: CounterStore }> = ({ store }) => { + const renders = useRenderTracker(); + + return ( +
+
{renders()}
; + +
+ ); +};