Skip to content

Commit

Permalink
feat: Add useFleurDomain hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
hanakla committed Apr 2, 2022
1 parent 1a3b4aa commit c943a0e
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 70 deletions.
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { withFleurContext, ContextProp } from './withFleurContext'
export { connectToStores } from './connectToStores'
export { useFleurContext } from './useFleurContext'
export { useStore } from './useStore'
export { useFleurDomain } from './useFleurDomain'
export { FleurContext } from './ComponentReactContext'
50 changes: 50 additions & 0 deletions packages/react/src/useFleurDomain.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<FleurContext value={context}>{children}</FleurContext>
)
}

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()
})
})
77 changes: 77 additions & 0 deletions packages/react/src/useFleurDomain.ts
Original file line number Diff line number Diff line change
@@ -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<S>> = S extends StoreClass<infer R>
? R
: never

export const useFleurDomain = <
S extends StoreClass<any>,
O extends { [k: string]: Operation }
>(
Store: S,
ops: O,
checkEquality:
| ((prev: StateOfStore<S>, next: StateOfStore<S>) => boolean)
| null = isEqual,
): [
StateOfStore<S>,
{ [K in keyof O]: (...args: OperationArgs<O[K]>) => void },
] => {
const {
context: { getStore, executeOperation },
synchronousUpdate,
} = useInternalFleurContext()

const isMounted = useRef<boolean>(false)
const latestState = useRef<StateOfStore<S> | 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]
}
80 changes: 10 additions & 70 deletions packages/react/src/useStore.ts
Original file line number Diff line number Diff line change
@@ -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 = <Mapper extends StoreToPropMapper>(
mapStoresToProps: Mapper,
checkEquality:
Expand All @@ -86,7 +26,7 @@ export const useStore = <Mapper extends StoreToPropMapper>(

const referencedStores = useRef<Set<StoreClass>>(new Set())
const isMounted = useRef<boolean>(false)
const [, rerender] = useReducer(s => s + 1, 0)
const [, rerender] = useReducer((s) => s + 1, 0)

const getStoreInspector = useCallback(
<T extends StoreClass>(storeClass: T) => {
Expand Down Expand Up @@ -131,12 +71,12 @@ export const useStore = <Mapper extends StoreToPropMapper>(
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)
})
}
Expand Down
65 changes: 65 additions & 0 deletions packages/react/src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -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 extends {}>(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 = <T extends (...arg: any[]) => any>(
fn: T,
bounceTime: number,
) => {
let lastExecuteTime = 0

const bouncer = (...args: Parameters<T>) => {
const now = Date.now()

if (now - lastExecuteTime > bounceTime) {
fn(...args)
lastExecuteTime = now
} else {
setTimeout(bouncer, bounceTime, ...args)
}
}

return bouncer
}

0 comments on commit c943a0e

Please sign in to comment.