diff --git a/packages/react/src/useFleurDomain.spec.tsx b/packages/react/src/useFleurDomain.spec.tsx new file mode 100644 index 00000000..f422e09d --- /dev/null +++ b/packages/react/src/useFleurDomain.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import Fleur, { AppContext, minOps } from '@fleur/fleur' +import { renderHook } from '@testing-library/react-hooks' +import { FleurContext } from './ComponentReactContext' +import { useFleurDomain } from './useFleurDomain' + +describe('useFleurDomain', () => { + // App + const [TestStore, testOps] = minOps('Test', { + ops: { + a(x, value: string) { + x.commit({ check: true, value }) + }, + b() {}, + }, + initialState: () => ({ check: false, value: '' }), + }) + + const app = new Fleur({ stores: [TestStore] }) + + const wrapperFactory = (context: AppContext) => { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) + } + + it('Should work', async () => { + const context = app.createContext() + const { result, waitForNextUpdate, unmount } = renderHook( + () => useFleurDomain(TestStore, testOps), + { + wrapper: wrapperFactory(context), + }, + ) + + expect(result.current[1].a).toBeInstanceOf(Function) + expect(result.current[1].b).toBeInstanceOf(Function) + expect(result.current[0]).toMatchObject({ + check: false, + value: '', + }) + + result.current[1].a('hello') + await waitForNextUpdate() + + expect(result.current[0]).toMatchObject(context.getStore(TestStore).state) + + unmount() + }) +}) diff --git a/packages/react/src/useFleurDomain.ts b/packages/react/src/useFleurDomain.ts new file mode 100644 index 00000000..c8ef0a93 --- /dev/null +++ b/packages/react/src/useFleurDomain.ts @@ -0,0 +1,77 @@ +import { Operation, OperationArgs, StoreClass } from '@fleur/fleur' +import React, { useCallback, useMemo, useReducer, useRef } from 'react' +import { useInternalFleurContext } from './useFleurContext' +import { + useIsomorphicLayoutEffect, + canUseDOM, + bounce, + isEqual, +} from './utils/utils' + +type StateOfStore> = S extends StoreClass + ? R + : never + +export const useFleurDomain = < + S extends StoreClass, + O extends { [k: string]: Operation } +>( + Store: S, + ops: O, + checkEquality: + | ((prev: StateOfStore, next: StateOfStore) => boolean) + | null = isEqual, +): [ + StateOfStore, + { [K in keyof O]: (...args: OperationArgs) => void }, +] => { + const { + context: { getStore, executeOperation }, + synchronousUpdate, + } = useInternalFleurContext() + + const isMounted = useRef(false) + const latestState = useRef | null>(null) + const [, rerender] = useReducer((s) => s + 1, 0) + + const bindedOps = useMemo( + () => + Object.keys(ops).reduce( + (binded, key) => + Object.assign(binded, { + [key]: (...args: any) => executeOperation(ops[key], ...args), + }), + Object.create(null), + ), + [ + /* ignore ops set changing */ + ], + ) + + const handleStoreMutation = useCallback(() => { + const nextState = getStore(Store).state + + if (checkEquality?.(latestState.current!, nextState)) return + latestState.current = nextState + + rerender() + }, [Store]) + + useIsomorphicLayoutEffect(() => { + isMounted.current = true + + const bounced = + // Synchronous mapping on SSR + canUseDOM && !synchronousUpdate + ? bounce(handleStoreMutation, 10) + : handleStoreMutation + + getStore(Store).on(bounced) + + return () => { + getStore(Store).off(bounced) + } + }, [handleStoreMutation, synchronousUpdate]) + + return [getStore(Store).state, bindedOps] +} diff --git a/packages/react/src/useStore.ts b/packages/react/src/useStore.ts index e3c40030..a7eac3a9 100644 --- a/packages/react/src/useStore.ts +++ b/packages/react/src/useStore.ts @@ -1,75 +1,15 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useReducer, - useRef, -} from 'react' +import { useCallback, useReducer, useRef } from 'react' import { StoreClass, StoreGetter } from '@fleur/fleur' import { useInternalFleurContext } from './useFleurContext' +import { + canUseDOM, + isEqual, + useIsomorphicLayoutEffect, + bounce, +} from './utils/utils' type StoreToPropMapper = (getStore: StoreGetter) => any -const canUseDOM = typeof window !== 'undefined' - -const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect - -const bounce = (fn: () => void, bounceTime: number) => { - let lastExecuteTime = 0 - - const bouncer = () => { - const now = Date.now() - - if (now - lastExecuteTime > bounceTime) { - fn() - lastExecuteTime = now - } else { - setTimeout(bouncer, bounceTime) - } - } - - return bouncer -} - -const hasOwnKey = Object.prototype.hasOwnProperty - -// Object.is polyfill -const is = (x: any, y: any): boolean => { - if (x === y) { - return x !== 0 || y !== 0 || 1 / x === 1 / y - } else { - return x !== x && y !== y - } -} - -/** Shallow equality check */ -const isEqual = (prev: any, next: any) => { - if (is(prev, next)) return true - if (typeof prev !== typeof next) return false - - if (Array.isArray(prev) && Array.isArray(next)) { - if (prev.length !== next.length) return false - - for (const idx in prev) { - if (!is(prev[idx], next[idx])) return false - } - } - - if ( - typeof prev === 'object' && - typeof next === 'object' && - prev !== null && - next !== null - ) { - for (const key in prev) { - if (!hasOwnKey.call(next, key)) continue - if (!is(prev[key], next[key])) return false - } - } - - return false -} - export const useStore = ( mapStoresToProps: Mapper, checkEquality: @@ -86,7 +26,7 @@ export const useStore = ( const referencedStores = useRef>(new Set()) const isMounted = useRef(false) - const [, rerender] = useReducer(s => s + 1, 0) + const [, rerender] = useReducer((s) => s + 1, 0) const getStoreInspector = useCallback( (storeClass: T) => { @@ -131,12 +71,12 @@ export const useStore = ( useIsomorphicLayoutEffect(() => { isMounted.current = true - referencedStores.current.forEach(store => { + referencedStores.current.forEach((store) => { getStore(store).on(bouncedHandleStoreMutation) }) return () => { - referencedStores.current.forEach(store => { + referencedStores.current.forEach((store) => { getStore(store).off(bouncedHandleStoreMutation) }) } diff --git a/packages/react/src/utils/utils.ts b/packages/react/src/utils/utils.ts new file mode 100644 index 00000000..ae6054ee --- /dev/null +++ b/packages/react/src/utils/utils.ts @@ -0,0 +1,65 @@ +import { useEffect, useLayoutEffect } from 'react' + +export const canUseDOM = typeof window !== 'undefined' + +export const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect + +export const hasOwnKey = (o: O, v: string | symbol | number) => + Object.prototype.hasOwnProperty.call(o, v) + +// Object.is polyfill +export const is = (x: any, y: any): boolean => { + if (x === y) { + return x !== 0 || y !== 0 || 1 / x === 1 / y + } else { + return x !== x && y !== y + } +} + +/** Shallow equality check */ +export const isEqual = (prev: any, next: any) => { + if (is(prev, next)) return true + if (typeof prev !== typeof next) return false + + if (Array.isArray(prev) && Array.isArray(next)) { + if (prev.length !== next.length) return false + + for (const idx in prev) { + if (!is(prev[idx], next[idx])) return false + } + } + + if ( + typeof prev === 'object' && + typeof next === 'object' && + prev !== null && + next !== null + ) { + for (const key in prev) { + if (!hasOwnKey.call(next, key)) continue + if (!is(prev[key], next[key])) return false + } + } + + return false +} + +export const bounce = any>( + fn: T, + bounceTime: number, +) => { + let lastExecuteTime = 0 + + const bouncer = (...args: Parameters) => { + const now = Date.now() + + if (now - lastExecuteTime > bounceTime) { + fn(...args) + lastExecuteTime = now + } else { + setTimeout(bouncer, bounceTime, ...args) + } + } + + return bouncer +}