Skip to content

Commit

Permalink
chore(useData): enhance flexibility (#3248)
Browse files Browse the repository at this point in the history
  • Loading branch information
tujoworker authored Jan 22, 2024
1 parent d1b03c2 commit eff0caf
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import Context, { ContextState } from '../Context'
*/
import structuredClone from '@ungap/structured-clone'

// SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
const useLayoutEffect =
typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect

export type Path = string
export type UpdateDataValue = (path: Path, data: unknown) => void

Expand Down Expand Up @@ -118,12 +122,28 @@ export default function Provider<Data extends JsonObject>({
// - Validator
const ajvSchemaValidatorRef = useRef<ValidateFunction>()
// - Shared state
const sharedState = useSharedState(id, initialData)
const sharedState = useSharedState<Data>(id)
useMemo(() => {
if (sharedState?.data && !initialData) {
// Update the internal data set, if the shared state changes
if (id && sharedState?.data && !initialData) {
internalDataRef.current = sharedState.data
}
}, [initialData, sharedState.data])
}, [id, initialData, sharedState.data])
useLayoutEffect(() => {
// Update the shared state, if initialData is given
if (id && !sharedState?.data && initialData) {
sharedState.set?.(initialData)
}

// If the shared state changes, update the internal data set
if (
id &&
sharedState?.data &&
sharedState?.data !== internalDataRef.current
) {
internalDataRef.current = sharedState?.data
}
}, [id, initialData, sharedState, sharedState?.data])

const validateData = useCallback(() => {
if (!ajvSchemaValidatorRef.current) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -922,37 +922,153 @@ describe('DataContext.Provider', () => {
it('should rerender provider and its contents', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const { data, update } = Form.useData('update-id', existingData)
const id = React.useId()
const { data, update } = Form.useData(id, existingData)

const increment = React.useCallback(() => {
update('/count', (count) => {
return count + 1
})
}, [update])

countRender++

return (
<Form.Handler id="update-id">
<Field.Number path="/count" showStepControls />
<Form.SubmitButton
onClick={increment}
text={'Increment ' + data.count}
/>
</Form.Handler>
<DataContext.Provider id={id}>
<Field.Number path="/count" />
<Form.SubmitButton onClick={increment} text={data.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const buttonElement = document.querySelector('button')

expect(inputElement).toHaveValue('1')
expect(buttonElement).toHaveTextContent('1')
expect(countRender).toBe(1)

await userEvent.click(
document.querySelector('.dnb-forms-submit-button')
)

expect(inputElement).toHaveValue('2')
expect(buttonElement).toHaveTextContent('2')
expect(countRender).toBe(2)
})

it('should return data given in the context provider after a rerender', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData<{ count: number }>(id)

console.log('data', data)

const increment = React.useCallback(() => {
update('/count', (count) => {
return count + 1
})
}, [update])

countRender++

return (
<DataContext.Provider id={id} data={existingData}>
<Field.Number path="/count" />
<Form.SubmitButton onClick={increment} text={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const buttonElement = document.querySelector('button')

expect(inputElement).toHaveValue('1')
expect(buttonElement).toHaveTextContent('1')
expect(countRender).toBe(2)

await userEvent.click(
document.querySelector('.dnb-forms-submit-button')
)

expect(inputElement).toHaveValue('2')
expect(buttonElement).toHaveTextContent('2')
expect(countRender).toBe(3)
})

it('should update data via useEffect when data is given in useData', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData(id, existingData)

React.useEffect(() => {
update('/count', (count) => count + 1)
}, [update])

countRender++

return (
<DataContext.Provider id={id}>
<Field.Number path="/count" label={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const labelElement = document.querySelector('label')

expect(inputElement).toHaveValue('2')
expect(labelElement).toHaveTextContent('2')
expect(countRender).toBe(2)
})

it('should update data via useEffect when data is given in the context provider', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData<{ count: number }>(id)

React.useEffect(() => {
update('/count', () => 123)
}, [update])

countRender++

return (
<DataContext.Provider id={id} data={existingData}>
<Field.Number path="/count" label={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const labelElement = document.querySelector('label')

expect(inputElement).toHaveValue('123')
expect(labelElement).toHaveTextContent('123')
expect(countRender).toBe(3)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ describe('Form.useData', () => {
expect(B.current.data).toEqual({ key: 'changed value' })
})

it('should rerender when shared state calls "set"', () => {
const { result } = renderHook(() => useData('onSet'))

const { result: sharedState } = renderHook(() =>
useSharedState('onSet')
)

act(() => {
sharedState.current.set({ foo: 'bar' })
})

expect(result.current.data).toEqual({ foo: 'bar' })
})

describe('with mock', () => {
it('should call "set" with initialData on mount if data is not present', () => {
const update = jest.fn()
Expand Down
39 changes: 19 additions & 20 deletions packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useReducer, useRef } from 'react'
import pointer from 'json-pointer'
import { useSharedState } from '../../../../shared/helpers/useSharedState'
import type { Path } from '../../DataContext/Provider'
Expand Down Expand Up @@ -30,34 +30,33 @@ export default function useData<Data>(
data: Data = undefined
): UseDataReturn<Data> {
const initialDataRef = useRef(data)
const sharedState = useSharedState<Data>(id, data)
const sharedStateRef = useRef(null)
const [, forceUpdate] = useReducer(() => ({}), {})
sharedStateRef.current = useSharedState<Data>(id, data, forceUpdate)

const updatePath = useCallback<UseDataReturnUpdate<Data>>(
(path, fn) => {
const existingData = sharedState.data || ({} as Data)
const existingValue = pointer.has(existingData, path)
? pointer.get(existingData, path)
: undefined
const updatePath = useCallback<UseDataReturnUpdate<Data>>((path, fn) => {
const existingData = sharedStateRef.current.data || ({} as Data)
const existingValue = pointer.has(existingData, path)
? pointer.get(existingData, path)
: undefined

// get new value
const newValue = fn(existingValue)
// get new value
const newValue = fn(existingValue)

// update existing data
pointer.set(existingData, path, newValue)
// update existing data
pointer.set(existingData, path, newValue)

// update provider
sharedState.update?.(existingData)
},
[sharedState]
)
// update provider
sharedStateRef.current?.update?.(existingData)
}, [])

// when initial data changes, update the shared state
useEffect(() => {
if (data && data !== initialDataRef.current) {
initialDataRef.current = data
sharedState.update?.(data)
sharedStateRef.current?.update?.(data)
}
}, [data, sharedState])
}, [data])

return { data: sharedState.data, update: updatePath }
return { data: sharedStateRef.current?.data, update: updatePath }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { useSharedState, getOrCreateSharedState } from '../useSharedState'
import { useSharedState, createSharedState } from '../useSharedState'

describe('useSharedState', () => {
it('should create a new shared state if one does not exist', () => {
Expand All @@ -10,7 +10,7 @@ describe('useSharedState', () => {
})

it('should use an existing shared state if one exists', () => {
getOrCreateSharedState('existingId', { test: 'existing' })
createSharedState('existingId', { test: 'existing' })
const { result } = renderHook(() =>
useSharedState('existingId', { test: 'initial' })
)
Expand All @@ -31,11 +31,11 @@ describe('useSharedState', () => {
const { result } = renderHook(() =>
useSharedState('changeId', { test: 'initial' })
)
const sharedState = getOrCreateSharedState('changeId', {
const sharedState = createSharedState('changeId', {
test: 'initial',
})
act(() => {
sharedState.updateSharedState({ test: 'changed' })
sharedState.update({ test: 'changed' })
})
expect(result.current.data).toEqual({ test: 'changed' })
})
Expand All @@ -44,12 +44,12 @@ describe('useSharedState', () => {
const { result, unmount } = renderHook(() =>
useSharedState('unmountId', { test: 'initial' })
)
const sharedState = getOrCreateSharedState('unmountId', {
const sharedState = createSharedState('unmountId', {
test: 'initial',
})
unmount()
act(() => {
sharedState.updateSharedState({ test: 'unmounted' })
sharedState.update({ test: 'unmounted' })
})
expect(result.current.data).toEqual({ test: 'initial' })
})
Expand All @@ -65,6 +65,7 @@ describe('useSharedState', () => {
const { result } = renderHook(() =>
useSharedState(null, { test: 'initial' })
)
expect(result.current.data).toBeUndefined()
act(() => {
result.current.update({ test: 'updated' })
})
Expand All @@ -75,10 +76,52 @@ describe('useSharedState', () => {
const { result, unmount } = renderHook(() =>
useSharedState(null, { test: 'initial' })
)
expect(result.current.data).toBeUndefined()
unmount()
act(() => {
result.current.update({ test: 'unmounted' })
})
expect(result.current.data).toBeUndefined()
})

it('should call onSet when set is called from another hook', () => {
const onSet = jest.fn()

const { result: resultA } = renderHook(() => useSharedState('onSet'))
const { result: resultB } = renderHook(() =>
useSharedState('onSet', undefined, onSet)
)
const { result: resultC } = renderHook(() => useSharedState('onSet'))

resultA.current.set({ foo: 'bar' })

expect(onSet).toHaveBeenCalledTimes(1)
expect(onSet).toHaveBeenCalledWith({ foo: 'bar' })

expect(resultA.current.data).toEqual(undefined)
expect(resultB.current.data).toEqual(undefined)
expect(resultC.current.data).toEqual(undefined)
})

it('should sync all hooks', () => {
const { result: resultA } = renderHook(() => useSharedState('in-sync'))
const { result: resultB } = renderHook(() => useSharedState('in-sync'))

expect(resultA.current.data).toEqual(undefined)
expect(resultB.current.data).toEqual(undefined)

act(() => {
resultA.current.update({ foo: 'bar' })
})

expect(resultA.current.data).toEqual({ foo: 'bar' })
expect(resultB.current.data).toEqual({ foo: 'bar' })

act(() => {
resultB.current.update({ foo: 'baz' })
})

expect(resultA.current.data).toEqual({ foo: 'baz' })
expect(resultB.current.data).toEqual({ foo: 'baz' })
})
})
Loading

0 comments on commit eff0caf

Please sign in to comment.