diff --git a/src/area-chart/__tests__/area-chart-use-chart-model.test.tsx b/src/area-chart/__tests__/area-chart-use-chart-model.test.tsx index b610c17878..698c96d912 100644 --- a/src/area-chart/__tests__/area-chart-use-chart-model.test.tsx +++ b/src/area-chart/__tests__/area-chart-use-chart-model.test.tsx @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useImperativeHandle, useRef, useState } from 'react'; +import React, { useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; import { fireEvent, render } from '@testing-library/react'; import { ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; -import { useReaction } from '../../../lib/components/area-chart/async-store'; import { AreaChartProps } from '../../../lib/components/area-chart/interfaces'; import { ChartModel } from '../../../lib/components/area-chart/model'; import useChartModel, { UseChartModelProps } from '../../../lib/components/area-chart/model/use-chart-model'; @@ -60,8 +59,8 @@ function RenderChartModelHook(props: UseChartModelProps) { popoverRef, }); - useReaction(interactions, state => state.highlightedPoint, setHighlightedPoint); - useReaction(interactions, state => state.highlightedX, setHighlightedX); + useLayoutEffect(() => interactions.subscribe(s => s.highlightedPoint, setHighlightedPoint), [interactions]); + useLayoutEffect(() => interactions.subscribe(s => s.highlightedX, setHighlightedX), [interactions]); useImperativeHandle(refs.plot, () => ({ svg: svgRef.current!, diff --git a/src/area-chart/async-store/__tests__/async-store.test.ts b/src/area-chart/async-store/__tests__/async-store.test.ts deleted file mode 100644 index 51eaa617a9..0000000000 --- a/src/area-chart/async-store/__tests__/async-store.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { act, renderHook } from '../../../__tests__/render-hook'; -import AsyncStore, { useReaction, useSelector } from '../index'; - -describe('AreaChart AsyncStore', () => { - it('notifies listeners when selected state is updated', () => { - const store = new AsyncStore({ a: 1, b: 2 }); - - let a = store.get().a; - store.subscribe( - state => state.a, - newState => { - a = newState.a; - } - ); - - store.set(state => ({ ...state, a: state.a + 1 })); - store.set(state => ({ ...state, b: state.b + 1 })); - store.set(state => ({ ...state, a: state.a + 1 })); - - expect(store.get().a).toBe(3); - expect(store.get().b).toBe(3); - expect(a).toBe(3); - }); - - it('allows unsubscribing from updates', () => { - const store = new AsyncStore({ a: 1, b: 2 }); - - let a = store.get().a; - const unsubscribeA = store.subscribe( - state => state.a, - newState => { - a = newState.a; - } - ); - - store.set(state => ({ ...state, a: state.a + 1 })); - unsubscribeA(); - store.set(state => ({ ...state, a: state.a + 1 })); - - expect(store.get().a).toBe(3); - expect(a).toBe(2); - }); - - it('can be used with useReaction to describe effects', () => { - const store = new AsyncStore({ a: 1, b: 2 }); - - const aIncrements: number[] = []; - renderHook(() => - useReaction( - store, - s => s.a, - a => { - aIncrements.push(a); - } - ) - ); - - act(() => store.set(state => ({ ...state, a: state.a + 1 }))); - act(() => store.set(state => ({ ...state, a: state.a + 1 }))); - - expect(aIncrements).toEqual([2, 3]); - }); - - it('can be used with useSelector to make state from selected properties', () => { - const store = new AsyncStore({ a: 1, b: 2 }); - - const { result } = renderHook(() => useSelector(store, s => s.a)); - expect(result.current).toEqual(1); - - act(() => store.set(state => ({ ...state, a: state.a + 1 }))); - expect(result.current).toEqual(2); - - act(() => store.set(state => ({ ...state, a: state.a + 1 }))); - expect(result.current).toEqual(3); - }); -}); diff --git a/src/area-chart/async-store/index.ts b/src/area-chart/async-store/index.ts deleted file mode 100644 index 6e7328e848..0000000000 --- a/src/area-chart/async-store/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { useLayoutEffect, useState } from 'react'; -import { unstable_batchedUpdates } from 'react-dom'; - -import { usePrevious } from '../../internal/hooks/use-previous'; - -type Selector = (state: S) => R; -type Listener = (state: S, prevState: S) => void; - -export interface ReadonlyAsyncStore { - get(): S; - subscribe(selector: Selector, listener: Listener): () => void; - unsubscribe(listener: Listener): void; -} - -export default class AsyncStore implements ReadonlyAsyncStore { - _state: S; - _listeners: [Selector, Listener][] = []; - - constructor(state: S) { - this._state = state; - } - - get(): S { - return this._state; - } - - set(cb: (state: S) => S): void { - const prevState = this._state; - const newState = cb(prevState); - - this._state = newState; - - unstable_batchedUpdates(() => { - for (const [selector, listener] of this._listeners) { - if (selector(prevState) !== selector(newState)) { - listener(newState, prevState); - } - } - }); - } - - subscribe(selector: Selector, listener: Listener): () => void { - this._listeners.push([selector, listener]); - - return () => this.unsubscribe(listener); - } - - unsubscribe(listener: Listener): void { - for (let index = 0; index < this._listeners.length; index++) { - const [, storedListener] = this._listeners[index]; - - if (storedListener === listener) { - this._listeners.splice(index, 1); - break; - } - } - } -} - -export function useReaction(store: ReadonlyAsyncStore, selector: Selector, effect: Listener): void { - useLayoutEffect( - () => { - const unsubscribe = store.subscribe(selector, (newState, prevState) => - effect(selector(newState), selector(prevState)) - ); - return unsubscribe; - }, - // ignoring selector and effect as they are expected to stay constant - // eslint-disable-next-line react-hooks/exhaustive-deps - [store] - ); -} - -export function useSelector(store: ReadonlyAsyncStore, selector: Selector): R { - const [state, setState] = useState(selector(store.get())); - - useReaction(store, selector, newState => { - setState(newState); - }); - - // When store changes we need the state to be updated synchronously to avoid inconsistencies. - const prevStore = usePrevious(store); - if (prevStore !== null && prevStore !== store) { - return selector(store.get()); - } - - return state; -} diff --git a/src/area-chart/chart-container.tsx b/src/area-chart/chart-container.tsx index 74c887b090..a9c82f8c52 100644 --- a/src/area-chart/chart-container.tsx +++ b/src/area-chart/chart-container.tsx @@ -10,8 +10,8 @@ import InlineStartLabels from '../internal/components/cartesian-chart/inline-sta import LabelsMeasure from '../internal/components/cartesian-chart/labels-measure'; import ChartPlot from '../internal/components/chart-plot'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; +import { useSelector } from '../internal/utils/async-store'; import useContainerWidth from '../internal/utils/use-container-width'; -import { useSelector } from './async-store'; import AreaChartPopover from './elements/chart-popover'; import AreaDataSeries from './elements/data-series'; import AreaHighlightedPoint from './elements/highlighted-point'; diff --git a/src/area-chart/elements/area-chart-legend.tsx b/src/area-chart/elements/area-chart-legend.tsx index c386f8bdcb..be2792e7fe 100644 --- a/src/area-chart/elements/area-chart-legend.tsx +++ b/src/area-chart/elements/area-chart-legend.tsx @@ -3,7 +3,7 @@ import React, { memo, useMemo } from 'react'; import ChartLegend from '../../internal/components/chart-legend'; -import { useSelector } from '../async-store'; +import { useSelector } from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; import { ChartModel } from '../model'; diff --git a/src/area-chart/elements/data-series.tsx b/src/area-chart/elements/data-series.tsx index 1e04cc7d8a..c6d8ff4ea6 100644 --- a/src/area-chart/elements/data-series.tsx +++ b/src/area-chart/elements/data-series.tsx @@ -4,7 +4,7 @@ import React, { memo } from 'react'; import clsx from 'clsx'; import { useUniqueId } from '../../internal/hooks/use-unique-id'; -import { useSelector } from '../async-store'; +import { useSelector } from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; import { ChartModel } from '../model'; import AreaSeries from './area-series'; diff --git a/src/area-chart/elements/highlighted-point.tsx b/src/area-chart/elements/highlighted-point.tsx index df22a20b5f..b7891ad4ee 100644 --- a/src/area-chart/elements/highlighted-point.tsx +++ b/src/area-chart/elements/highlighted-point.tsx @@ -3,7 +3,7 @@ import React, { forwardRef, memo } from 'react'; import HighlightedPoint from '../../internal/components/cartesian-chart/highlighted-point'; -import { useSelector } from '../async-store'; +import { useSelector } from '../../internal/utils/async-store'; import { ChartModel } from '../model'; export default memo(forwardRef(AreaHighlightedPoint)); diff --git a/src/area-chart/elements/use-highlight-details.ts b/src/area-chart/elements/use-highlight-details.ts index 015024f08d..b030f75708 100644 --- a/src/area-chart/elements/use-highlight-details.ts +++ b/src/area-chart/elements/use-highlight-details.ts @@ -3,7 +3,7 @@ import { useInternalI18n } from '../../i18n/context'; import { CartesianChartProps } from '../../internal/components/cartesian-chart/interfaces'; import { ChartSeriesDetailItem } from '../../internal/components/chart-series-details'; -import { useSelector } from '../async-store'; +import { useSelector } from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; import { ChartModel } from '../model'; diff --git a/src/area-chart/elements/vertical-marker.tsx b/src/area-chart/elements/vertical-marker.tsx index 52721e8ae9..59bfd7d288 100644 --- a/src/area-chart/elements/vertical-marker.tsx +++ b/src/area-chart/elements/vertical-marker.tsx @@ -3,7 +3,7 @@ import React, { memo } from 'react'; import VerticalMarker from '../../internal/components/cartesian-chart/vertical-marker'; -import { useSelector } from '../async-store'; +import { useSelector } from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; import { ChartModel } from '../model'; diff --git a/src/area-chart/model/index.ts b/src/area-chart/model/index.ts index 7089a1aeec..6c1c6579ad 100644 --- a/src/area-chart/model/index.ts +++ b/src/area-chart/model/index.ts @@ -6,7 +6,7 @@ import { XDomain, YDomain } from '../../internal/components/cartesian-chart/inte import { ChartScale, NumericChartScale } from '../../internal/components/cartesian-chart/scales'; import { ChartPlotRef } from '../../internal/components/chart-plot'; import { ChartSeriesMarkerType } from '../../internal/components/chart-series-marker'; -import { ReadonlyAsyncStore } from '../async-store'; +import { ReadonlyAsyncStore } from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; export interface ChartModel { diff --git a/src/area-chart/model/interactions-store.ts b/src/area-chart/model/interactions-store.ts index 3d31463618..67ca98ca77 100644 --- a/src/area-chart/model/interactions-store.ts +++ b/src/area-chart/model/interactions-store.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import AsyncStore from '../async-store'; +import AsyncStore from '../../internal/utils/async-store'; import { AreaChartProps } from '../interfaces'; import { ChartModel } from './index'; diff --git a/src/area-chart/model/use-chart-model.ts b/src/area-chart/model/use-chart-model.ts index 1f62ad6fab..cad1aa8274 100644 --- a/src/area-chart/model/use-chart-model.ts +++ b/src/area-chart/model/use-chart-model.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { MouseEvent, RefObject, useEffect, useMemo, useRef } from 'react'; +import React, { MouseEvent, RefObject, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { nodeContains } from '@cloudscape-design/component-toolkit/dom'; import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; @@ -13,7 +13,6 @@ import { circleIndex } from '../../internal/utils/circle-index'; import handleKey from '../../internal/utils/handle-key'; import { nodeBelongs } from '../../internal/utils/node-belongs'; import { throttle } from '../../internal/utils/throttle'; -import { useReaction } from '../async-store'; import { AreaChartProps } from '../interfaces'; import computeChartProps from './compute-chart-props'; import createSeriesDecorator from './create-series-decorator'; @@ -373,7 +372,11 @@ export default function useChartModel({ ]); // Notify client when series highlight change. - useReaction(model.interactions, state => state.highlightedSeries, setHighlightedSeries); + setHighlightedSeries = useStableCallback(setHighlightedSeries); + useLayoutEffect( + () => model.interactions.subscribe(state => state.highlightedSeries, setHighlightedSeries), + [model.interactions, setHighlightedSeries] + ); // Update interactions store when series highlight in a controlled way. useEffect(() => { diff --git a/src/internal/utils/__tests__/async-store.test.ts b/src/internal/utils/__tests__/async-store.test.ts new file mode 100644 index 0000000000..324f92b4d8 --- /dev/null +++ b/src/internal/utils/__tests__/async-store.test.ts @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { act, renderHook } from '../../../__tests__/render-hook'; +import AsyncStore, { useSelector } from '../async-store'; + +describe('AsyncStore', () => { + test('subscribers are notified when selected state is updated', () => { + const store = new AsyncStore({ west: 1, east: 2 }); + + let west = store.get().west; + store.subscribe( + state => state.west, + nextWest => { + west += nextWest; + } + ); + + let east = store.get().east * 2; + store.subscribe( + state => state.east, + nextEast => { + east += nextEast * 2; + } + ); + + store.set(state => ({ ...state, west: state.west + 1 })); + store.set(state => ({ ...state, east: state.east + 1 })); + store.set(state => ({ ...state, west: state.west + 1 })); + + expect(store.get().west).toBe(3); + expect(west).toBe(1 + 2 + 3); + + expect(store.get().east).toBe(3); + expect(east).toBe(4 + 6); + }); + + test('subscribers can unsubscribe from updates', () => { + const store = new AsyncStore({ west: 1, east: 2 }); + + let west = store.get().west; + const unsubscribeWest = store.subscribe( + state => state.west, + nextWest => { + west = nextWest; + } + ); + + store.set(state => ({ ...state, west: state.west + 1 })); + unsubscribeWest(); + store.set(state => ({ ...state, west: state.west + 1 })); + + expect(store.get().west).toBe(3); + expect(west).toBe(2); + }); +}); + +describe('useSelector', () => { + test('selected state updates cause subscribed component to re-render', () => { + const store = new AsyncStore({ west: 1, east: 2 }); + + const { result } = renderHook(() => useSelector(store, s => s.west)); + expect(result.current).toEqual(1); + + act(() => store.set(state => ({ ...state, west: state.west + 1 }))); + expect(result.current).toEqual(2); + + act(() => store.set(state => ({ ...state, west: state.west + 1 }))); + expect(result.current).toEqual(3); + }); +}); diff --git a/src/internal/utils/async-store.ts b/src/internal/utils/async-store.ts new file mode 100644 index 0000000000..24e8c6d07a --- /dev/null +++ b/src/internal/utils/async-store.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect, useState } from 'react'; + +import { usePrevious } from '../../internal/hooks/use-previous'; + +type Selector = (state: S) => R; +type Listener = (state: R, prevState: R) => void; + +/** + * Async store utility can be used to distribute component state without using React context. + * The state can be represented by an object of any shape. Components can subscribe to state changes + * to be notified when the change of the entire state or particular properties occur. + * + * function WestSideComponent({ store }) { + * const westValue = useSelector(store, state => state.west) + * return
{westValue}
; + * } + * + * function EastSideComponent({ store }) { + * const eastValue = useSelector(store, state => state.east) + * return
{eastValue}
; + * } + * + * function SidesComponent() { + * const store = new AsyncStore<{ west: number, east: number }>({ west: 0, east: 0 }); + * return ( + * <> + * + * + * <> + * ); + * } + */ +export interface ReadonlyAsyncStore { + get(): S; + subscribe(selector: Selector, listener: Listener): () => void; + unsubscribe(listener: Listener): void; +} + +export default class AsyncStore implements ReadonlyAsyncStore { + #state: S; + #listeners: [Selector, Listener][] = []; + + constructor(state: S) { + this.#state = state; + } + + get(): S { + return this.#state; + } + + set(cb: (state: S) => S): void { + const prevState = this.#state; + const nextState = cb(prevState); + + this.#state = nextState; + + for (const [selector, listener] of this.#listeners) { + const nextSelected = selector(nextState); + const prevSelected = selector(prevState); + if (nextSelected !== prevSelected) { + listener(nextSelected, prevSelected); + } + } + } + + subscribe(selector: Selector, listener: Listener): () => void { + this.#listeners.push([selector, listener]); + return () => this.unsubscribe(listener); + } + + unsubscribe(listener: Listener): void { + for (let index = 0; index < this.#listeners.length; index++) { + const [, storedListener] = this.#listeners[index]; + + if (storedListener === listener) { + this.#listeners.splice(index, 1); + break; + } + } + } +} + +/** + * Synchronizes selected state to React state. + * + * const eastValue = useSelector(store, state => state.east); + */ +export function useSelector(store: ReadonlyAsyncStore, selector: Selector): R { + const [state, setState] = useState(() => selector(store.get())); + + useLayoutEffect(() => { + setState(selector(store.get())); + return store.subscribe(selector, setState); + // Not including selector because it is expected to be stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store]); + + // When store changes we need the state to be updated synchronously to avoid inconsistencies. + const prevStore = usePrevious(store); + if (prevStore !== null && prevStore !== store) { + return selector(store.get()); + } + + return state; +} diff --git a/src/table/sticky-columns/use-sticky-columns.ts b/src/table/sticky-columns/use-sticky-columns.ts index 9d959c096e..9236ffbdc6 100644 --- a/src/table/sticky-columns/use-sticky-columns.ts +++ b/src/table/sticky-columns/use-sticky-columns.ts @@ -7,7 +7,7 @@ import clsx from 'clsx'; import { useResizeObserver, useStableCallback } from '@cloudscape-design/component-toolkit/internal'; import { getLogicalBoundingClientRect, getScrollInlineStart } from '@cloudscape-design/component-toolkit/internal'; -import AsyncStore, { ReadonlyAsyncStore } from '../../area-chart/async-store'; +import AsyncStore, { ReadonlyAsyncStore } from '../../internal/utils/async-store'; import { CellOffsets, StickyColumnsCellState, @@ -94,9 +94,7 @@ export function useStickyColumns({ } }; - const unsubscribe = store.subscribe(selector, (newState, prevState) => - updateWrapperStyles(selector(newState), selector(prevState)) - ); + const unsubscribe = store.subscribe(selector, (newState, prevState) => updateWrapperStyles(newState, prevState)); return unsubscribe; }, [store, hasStickyColumns]); @@ -196,7 +194,7 @@ export function useStickyCellStyles({ // set up a new subscription to the store's updates if (cellElement) { unsubscribeRef.current = stickyColumns.store.subscribe(selector, (newState, prevState) => { - updateCellStyles(selector(newState), selector(prevState)); + updateCellStyles(newState, prevState); }); } },