From eff0cafaf18cfe4546c20241c0300693e3a5d1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Mon, 22 Jan 2024 08:55:24 +0100 Subject: [PATCH] chore(useData): enhance flexibility (#3248) --- .../forms/DataContext/Provider/Provider.tsx | 26 +++- .../Provider/__tests__/Provider.test.tsx | 132 ++++++++++++++++-- .../Form/hooks/__tests__/useData.test.tsx | 14 ++ .../extensions/forms/Form/hooks/useData.tsx | 39 +++--- .../helpers/__tests__/useSharedState.test.ts | 55 +++++++- .../src/shared/helpers/useSharedState.tsx | 80 +++++++---- 6 files changed, 280 insertions(+), 66 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 02b17bfd0b2..702ed7450ba 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -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 @@ -118,12 +122,28 @@ export default function Provider({ // - Validator const ajvSchemaValidatorRef = useRef() // - Shared state - const sharedState = useSharedState(id, initialData) + const sharedState = useSharedState(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) { diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index c3dc9a6451c..cc02648f922 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -922,8 +922,11 @@ 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) => { @@ -931,28 +934,141 @@ describe('DataContext.Provider', () => { }) }, [update]) + countRender++ + return ( - - - - + + + + ) } render() 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 ( + + + + + ) + } + + render() + + 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 ( + + + + ) + } + + render() + + 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 ( + + + + ) + } + + render() + + const inputElement = document.querySelector('input') + const labelElement = document.querySelector('label') + + expect(inputElement).toHaveValue('123') + expect(labelElement).toHaveTextContent('123') + expect(countRender).toBe(3) }) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx index 2ccc384e84c..afb5d22bf6b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/__tests__/useData.test.tsx @@ -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() diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx index 193610982aa..da2b87e4fa9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx @@ -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' @@ -30,34 +30,33 @@ export default function useData( data: Data = undefined ): UseDataReturn { const initialDataRef = useRef(data) - const sharedState = useSharedState(id, data) + const sharedStateRef = useRef(null) + const [, forceUpdate] = useReducer(() => ({}), {}) + sharedStateRef.current = useSharedState(id, data, forceUpdate) - const updatePath = useCallback>( - (path, fn) => { - const existingData = sharedState.data || ({} as Data) - const existingValue = pointer.has(existingData, path) - ? pointer.get(existingData, path) - : undefined + const updatePath = useCallback>((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 } } diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts index 234e089f97e..f7f49e7072b 100644 --- a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts @@ -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', () => { @@ -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' }) ) @@ -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' }) }) @@ -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' }) }) @@ -65,6 +65,7 @@ describe('useSharedState', () => { const { result } = renderHook(() => useSharedState(null, { test: 'initial' }) ) + expect(result.current.data).toBeUndefined() act(() => { result.current.update({ test: 'updated' }) }) @@ -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' }) + }) }) diff --git a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx index 1fcc89dff1f..8ce8e36ad91 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx @@ -1,46 +1,52 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer } from 'react' type SharedStateId = string type Subscriber = () => void interface SharedStateInstance { data: Data - getSharedState: () => Data - updateSharedState: (newData: Partial) => void - subscribeToSharedState: (subscriber: Subscriber) => void - unsubscribeFromSharedState: (subscriber: Subscriber) => void + get: () => Data + set: (newData: Partial) => void + update: (newData: Partial) => void + subscribe: (subscriber: Subscriber) => void + unsubscribe: (subscriber: Subscriber) => void } const sharedStates: Record> = {} -export function getOrCreateSharedState( +export function createSharedState( id: SharedStateId, initialData: Data ): SharedStateInstance { if (!sharedStates[id]) { let subscribers: Subscriber[] = [] - const getSharedState = () => sharedStates[id].data + const get = () => sharedStates[id].data - const updateSharedState = (newData: Partial) => { + const set = (newData: Partial) => { sharedStates[id].data = { ...sharedStates[id].data, ...newData } + } + + const update = (newData: Partial) => { + set(newData) subscribers.forEach((subscriber) => subscriber()) } - const subscribeToSharedState = (subscriber: Subscriber) => { + const subscribe = (subscriber: Subscriber) => { subscribers.push(subscriber) } - const unsubscribeFromSharedState = (subscriber: Subscriber) => { + const unsubscribe = (subscriber: Subscriber) => { subscribers = subscribers.filter((sub) => sub !== subscriber) } sharedStates[id] = { data: initialData ? { ...initialData } : undefined, - getSharedState, - updateSharedState, - subscribeToSharedState, - unsubscribeFromSharedState, + get, + set, + update, + subscribe, + unsubscribe, } as SharedStateInstance } else if ( sharedStates[id].data === undefined && @@ -54,41 +60,57 @@ export function getOrCreateSharedState( export function useSharedState( id: SharedStateId, - initialData: Data + initialData: Data = undefined, + onSet = null ) { + const [, forceUpdate] = useReducer(() => ({}), {}) + const sharedState = useMemo( - () => id && getOrCreateSharedState(id, initialData), + () => id && createSharedState(id, initialData), [id, initialData] ) - const [data, setData] = useState(sharedState?.getSharedState?.()) + const sharedFunc = useMemo( + () => id && createSharedState(id + 'func', { onSet }), + [id, onSet] + ) const update = useCallback( (newData: Data) => { - if (!id) { - return + if (id) { + sharedState.update(newData) } - - sharedState?.updateSharedState?.(newData) }, [id, sharedState] ) + const set = useCallback( + (newData: Data) => { + if (id && !sharedState.get()) { + sharedState.set(newData) + sharedFunc.get()?.onSet?.(newData) + } + }, + [id, sharedState, sharedFunc] + ) + useEffect(() => { if (!id) { return } - const updateState = () => { - const existingData = sharedState.getSharedState() - setData(existingData) - } - - sharedState.subscribeToSharedState(updateState) + sharedState.subscribe(forceUpdate) return () => { - sharedState.unsubscribeFromSharedState(updateState) + sharedState.unsubscribe(forceUpdate) } }, [id, sharedState]) - return { data, update } + useEffect(() => { + // Set the onSet function in case it is not set yet + if (id && onSet && !sharedFunc.get()?.onSet) { + sharedFunc.set({ onSet }) + } + }, [id, onSet, sharedFunc]) + + return { data: sharedState?.get?.(), update, set } }