Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add useFleurDomain hooks #522

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 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 { 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
}