Skip to content

Commit

Permalink
chore: add useWeakSharedState for internal use (#4404)
Browse files Browse the repository at this point in the history
When using `useWeakSharedState` it will clean up data when each
subscriber has unmounted (when using `id` to sync components with each
other).

The term "Weak" is inherited from
[WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap).
  • Loading branch information
tujoworker authored Dec 13, 2024
1 parent f977ebc commit 85ad8a4
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createSharedState,
SharedStateId,
createReferenceKey,
useWeakSharedState,
} from '../useSharedState'
import { createContext } from 'react'

Expand Down Expand Up @@ -44,9 +45,7 @@ describe('useSharedState', () => {
const { result } = renderHook(() =>
useSharedState(identifier, { test: 'initial' })
)
const sharedState = createSharedState(identifier, {
test: 'initial',
})
const sharedState = createSharedState(identifier)
act(() => {
sharedState.update({ test: 'changed' })
})
Expand Down Expand Up @@ -101,13 +100,14 @@ describe('useSharedState', () => {
const { result, unmount } = renderHook(() =>
useSharedState(identifier, { test: 'initial' })
)
const sharedState = createSharedState(identifier, {
test: 'initial',
})
const sharedState = createSharedState(identifier)

unmount()

act(() => {
sharedState.update({ test: 'unmounted' })
})

expect(result.current.data).toEqual({ test: 'initial' })
})

Expand Down Expand Up @@ -253,6 +253,56 @@ describe('useSharedState', () => {
})
})

describe('useWeakSharedState', () => {
it('should delete the shared state when all components have been unmounted', () => {
const identifier = {}

const { unmount: unmountA } = renderHook(() =>
useWeakSharedState(identifier, { test: 'initial' })
)
const { unmount: unmountB } = renderHook(() =>
useWeakSharedState(identifier)
)

const getStateOf = (identifier) => {
return createSharedState(identifier).get()
}

expect(getStateOf(identifier)).toEqual({ test: 'initial' })
expect(getStateOf(identifier)).toEqual({ test: 'initial' })

unmountA()
unmountB()

expect(getStateOf(identifier)).toEqual(undefined)
expect(getStateOf(identifier)).toEqual(undefined)
})

it('when not using weak, should not delete the shared state when all components have been unmounted', () => {
const identifier = {}

const { unmount: unmountA } = renderHook(() =>
useSharedState(identifier, { test: 'initial' })
)
const { unmount: unmountB } = renderHook(() =>
useSharedState(identifier)
)

const getStateOf = (identifier) => {
return createSharedState(identifier).get()
}

expect(getStateOf(identifier)).toEqual({ test: 'initial' })
expect(getStateOf(identifier)).toEqual({ test: 'initial' })

unmountA()
unmountB()

expect(getStateOf(identifier)).toEqual({ test: 'initial' })
expect(getStateOf(identifier)).toEqual({ test: 'initial' })
})
})

describe('createReferenceKey', () => {
it('should return the same object for the same references', () => {
const ref1 = {}
Expand Down
47 changes: 39 additions & 8 deletions packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export type SharedStateId =
| React.Context<any>
| Record<string, unknown>

/**
* The shared state will be deleted when all components have been unmounted.
*/
export function useWeakSharedState<
Data,
> /** The identifier for the shared state. */(
id: SharedStateId | undefined,
/** The initial data for the shared state. */
initialData: Data = undefined,
/** Optional callback function to be called when the shared state is set from another instance/component. */
onChange = null
) {
return useSharedState<Data>(id, initialData, onChange, { weak: true })
}

/**
* Custom hook that provides shared state functionality.
*/
Expand All @@ -28,7 +43,12 @@ export function useSharedState<Data>(
/** The initial data for the shared state. */
initialData: Data = undefined,
/** Optional callback function to be called when the shared state is set from another instance/component. */
onChange = null
onChange = null,
/** Optional configuration options. */
{
/** When set to `true`, the shared state will be deleted when all components have been unmounted. */
weak = false,
} = {}
) {
const [, forceUpdate] = useReducer(() => ({}), {})
const hasMountedRef = useMounted()
Expand Down Expand Up @@ -125,8 +145,12 @@ export function useSharedState<Data>(

return () => {
sharedState.unsubscribe(forceRerender)

if (weak && sharedState.subscribersRef.current.length === 0) {
sharedState.update(undefined)
}
}
}, [forceRerender, id, onChange, sharedState])
}, [forceRerender, id, onChange, sharedState, weak])

useEffect(() => {
// Set the onChange function in case it is not set yet
Expand All @@ -153,6 +177,7 @@ export interface SharedStateReturn<Data = undefined> {
set: (newData: Partial<Data>) => void
extend: (newData: Partial<Data>, opts?: Options) => void
update: (newData: Partial<Data>, opts?: Options) => void
subscribersRef?: { current: Subscriber[] }
}

interface SharedStateInstance<Data> extends SharedStateReturn<Data> {
Expand Down Expand Up @@ -185,10 +210,12 @@ export function createSharedState<Data>(
} = {}
): SharedStateInstance<Data> {
if (!sharedStates.get(id)) {
let subscribers: Subscriber[] = []
const subscribersRef = {
current: [] as Subscriber[],
}

const sync = (opts: Options = {}) => {
subscribers.forEach((subscriber) => {
subscribersRef.current.forEach((subscriber) => {
const syncNow = opts.preventSyncOfSameInstance
? shouldSync?.(subscriber) !== false
: true
Expand All @@ -201,7 +228,8 @@ export function createSharedState<Data>(
const get = () => sharedStates.get(id).data

const set = (newData: Partial<Data>) => {
sharedStates.get(id).data = { ...newData }
sharedStates.get(id).data =
newData === undefined ? undefined : { ...newData }
}

const update = (newData: Partial<Data>, opts?: Options) => {
Expand All @@ -218,13 +246,15 @@ export function createSharedState<Data>(
}

const subscribe = (subscriber: Subscriber) => {
if (!subscribers.includes(subscriber)) {
subscribers.push(subscriber)
if (!subscribersRef.current.includes(subscriber)) {
subscribersRef.current.push(subscriber)
}
}

const unsubscribe = (subscriber: Subscriber) => {
subscribers = subscribers.filter((sub) => sub !== subscriber)
subscribersRef.current = subscribersRef.current.filter(
(sub) => sub !== subscriber
)
}

sharedStates.set(id, {
Expand All @@ -236,6 +266,7 @@ export function createSharedState<Data>(
subscribe,
unsubscribe,
hadInitialData: Boolean(initialData),
subscribersRef,
} as SharedStateInstance<Data>)

if (initialData) {
Expand Down

0 comments on commit 85ad8a4

Please sign in to comment.