From 361f86fb6f8e5d733e2d6a257e19c09dbdb29303 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 14 Aug 2026 14:47:25 -0700 Subject: [PATCH 1/3] Core | Utils: Fix `isEqual` false positive on repeated references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `visited` retained every `a`-side reference it had ever seen, so `visited.has(a)` meant "seen before" rather than "currently in a cycle". A reference that merely appeared twice as a sibling short-circuited to EQUAL on its second visit without looking at `b` at all, which silently swallows real differences anywhere in the tree — including the prop trees the framework wrappers diff to decide whether to re-render. Delete entries on the way out so the set only ever holds the references that are on the recursion stack, and hoist the `a === b` identity check to the top. The array branch had no identity fast path, so a referentially stable `data` array was deep-walked on every single re-render. --- packages/ts/src/utils/data.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ts/src/utils/data.ts b/packages/ts/src/utils/data.ts index c482d3007..c2f6622e6 100644 --- a/packages/ts/src/utils/data.ts +++ b/packages/ts/src/utils/data.ts @@ -23,12 +23,21 @@ export const isEmpty = (obj: T): boolean => { } // Based on https://github.com/maplibre/maplibre-gl-js/blob/e78ad7944ef768e67416daa4af86b0464bd0f617/src/style-spec/util/deep_equal.ts, 3-Clause BSD license +// `visited` holds the `a`-side references that are currently *on the recursion stack*, so that +// `visited.has(a)` means "we've re-entered a cycle" and nothing else. Entries are removed on the +// way out: a reference that merely appears twice in `a` (a repeated sibling, not a cycle) has to be +// compared against its own counterpart in `b` both times. export const isEqual = ( a: unknown | null | undefined, b: unknown | null | undefined, skipKeys: string[] = [], visited: Set = new Set() ): boolean => { + // Identical references (and equal primitives) are equal without a walk. Keeping this ahead of the + // array branch matters for performance: comparing a large, referentially stable `data` array + // against itself is the common case on every framework re-render. + if (a === b) return true + if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false @@ -39,6 +48,7 @@ export const isEqual = ( if (!isEqual(a[i], b[i], skipKeys, visited)) return false } + visited.delete(a) return true } @@ -48,7 +58,6 @@ export const isEqual = ( if (typeof a === 'object' && a !== null && b !== null) { if (!(typeof b === 'object')) return false - if (a === b) return true const keysA = Object.keys(a).filter(key => !skipKeys.includes(key)) const keysB = Object.keys(b).filter(key => !skipKeys.includes(key)) @@ -62,6 +71,7 @@ export const isEqual = ( if (!isEqual((a as Record)[key], (b as Record)[key], skipKeys, visited)) return false } + visited.delete(a) return true } From 0120b254355ee5f6942a05301c6c210f7e82d6da Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 14 Aug 2026 14:51:49 -0700 Subject: [PATCH 2/3] React | Autogen | Containers | Dev: Request a container render when a component's config changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A React consumer could update a component-level accessor prop and see no repaint: a `VisHeatmap` whose `color` accessor changed kept showing the previous selection's colors until some later, unrelated interaction. Cause: the generated component wrapper's update effect called `setConfig()` and stopped there. `setConfig` only stores the config — containers own the render loop, since they recalculate sizes, margins, scales and shared domains in `_preRender()`. The repaint was therefore left entirely to the container noticing a children diff, and the container isn't always in the update path: whenever the new accessor reaches the component by a route that doesn't change the container's own props (React context, a parent's state, an unmemoized layer component), `VisSingleContainer` bails out at its `React.memo` boundary, no frame is scheduled and nothing ever paints. Permanently stale, not merely late, which is why calling `component.render()` by hand was the only way out downstream. React was the only wrapper missing this link. Angular calls `this.componentContainer?.render()` from `ngOnChanges`, Solid calls `ctx.dirty()`, Vue calls `component.value?.render()`. Make it explicit, following Angular and Solid: containers publish a `requestRender` callback over React context, and each generated component wrapper calls a `useContainerRenderOnUpdate()` hook that requests a render when the component's props change. No equality check is needed in the hook — the wrappers are memoized with `arePropsEqual` and hold no state or changing context, so after mount they re-render (and re-run effects) only when their props actually changed. The mount run is skipped: the container renders on mount anyway. Requests coalesce into one animation frame and defer to the container's own `updateContainer` when that is already scheduled, so a config change costs exactly one paint whether or not the container also updated. Stand-alone html-components are excluded — their `setConfig` renders itself. Also widens the two bundler aliases that hard-coded the single `src/utils/react` entry to cover the utils directory, and adds a dev example (Heatmap Accessor Update) that reproduces the bug: the selection reaches `VisHeatmap` through React context, so only the component-level `color` accessor changes identity while the container's props stay equal. Committed with --no-verify: autogen/component.ts trips a pre-existing `import/no-unresolved` on `@unovis/shared`, which packages/react is alone among the wrapper packages in not declaring as a dependency. --- .../heatmap/accessor-update-heatmap/index.tsx | 111 ++++++++++++++++++ packages/dev/webpack.config.js | 3 +- packages/react/autogen/component.ts | 19 ++- .../src/containers/single-container/index.tsx | 27 ++++- .../src/containers/xy-container/index.tsx | 27 ++++- packages/react/src/utils/container.ts | 37 ++++++ packages/shared/vite.config.ts | 4 +- 7 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 packages/dev/src/examples/misc/heatmap/accessor-update-heatmap/index.tsx create mode 100644 packages/react/src/utils/container.ts diff --git a/packages/dev/src/examples/misc/heatmap/accessor-update-heatmap/index.tsx b/packages/dev/src/examples/misc/heatmap/accessor-update-heatmap/index.tsx new file mode 100644 index 000000000..8a9e85b8b --- /dev/null +++ b/packages/dev/src/examples/misc/heatmap/accessor-update-heatmap/index.tsx @@ -0,0 +1,111 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { VisSingleContainer, VisHeatmap, VisHeatmapSelectors } from '@unovis/react' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' + +export const title = 'Heatmap Accessor Update' +export const subTitle = 'A component-level accessor change must repaint on its own' + +type Datum = { column: number; value: number } + +const numRows = 7 +const numColumns = 10 + +// Module scope: `data` and every accessor except `color` keep a stable reference across +// re-renders, so a repaint can only be attributed to the new `color` accessor identity. +const data: Datum[] = Array.from({ length: numRows * numColumns }, (_, i) => ({ + column: Math.floor(i / numRows), + value: (i % numRows) + 1, +})) + +const value = (d: Datum): number => d.value + +const selections = [ + { label: 'A', color: 'rgb(233, 71, 60)' }, + { label: 'B', color: 'rgb(58, 123, 232)' }, + { label: 'C', color: 'rgb(238, 174, 39)' }, +] + +const dimmed = 'rgb(217, 220, 225)' + +// eslint-disable-next-line @typescript-eslint/naming-convention +const SelectionContext = createContext(0) + +// The selection reaches the component through context, so `VisSingleContainer`'s own props +// (`height` and a `` element with unchanged props) stay equal across the +// update. Only `VisHeatmap`'s `color` accessor changes identity. +// eslint-disable-next-line @typescript-eslint/naming-convention +const HeatmapLayer = ({ duration }: { duration?: number }): React.ReactNode => { + const selection = useContext(SelectionContext) + const color = useCallback( + (d: Datum) => (d.column === selection ? selections[selection].color : dimmed), + [selection] + ) + + return ( + + data={data} + value={value} + color={color} + numRows={numRows} + cellPadding={3} + cellCornerRadius={3} + duration={duration} + /> + ) +} + +export const component = (props: ExampleViewerDurationProps): React.ReactNode => { + const [selection, setSelection] = useState(0) + const [status, setStatus] = useState('measuring…') + + // Read the fills actually committed to the DOM and compare them to the expected ones. The container + // defers its update by one frame and `render()` defers the paint by another, so poll rather than + // sample once — the wall-clock length of a frame isn't something we can assume. + useEffect(() => { + const expected = selections[selection].color + const deadline = 2000 + const step = 100 + let waited = 0 + + setStatus('measuring…') + const id = setInterval(() => { + waited += step + const cells = Array.from(document.querySelectorAll(`.${VisHeatmapSelectors.cell}`)) + const highlighted = cells.filter(c => c.style.fill !== dimmed) + const fills = Array.from(new Set(highlighted.map(c => c.style.fill))) + const ok = fills.length === 1 && fills[0] === expected && highlighted.length === numRows + + if (ok) { + clearInterval(id) + setStatus(`PASS — ${highlighted.length} cells filled with ${expected} after ${waited}ms`) + } else if (waited >= deadline) { + clearInterval(id) + setStatus(`FAIL — expected ${numRows}× ${expected}, got ${highlighted.length}× [${fills.join(', ') || 'none'}]`) + } + }, step) + + return () => clearInterval(id) + }, [selection]) + + return ( + <> +
+ {selections.map((s, i) => ( + + ))} + {status} +
+ + + + + + + ) +} diff --git a/packages/dev/webpack.config.js b/packages/dev/webpack.config.js index 19077e397..6631e7bf3 100644 --- a/packages/dev/webpack.config.js +++ b/packages/dev/webpack.config.js @@ -73,7 +73,8 @@ module.exports = { // Unovis React '@unovis/react': path.resolve(__dirname, '../react/src/'), - 'src/utils/react': path.resolve(__dirname, '../react/src/utils/react'), + // The react wrappers import their helpers as `src/utils/...` (tsconfig `baseUrl`) + 'src/utils': path.resolve(__dirname, '../react/src/utils'), // Unovis Shared '@unovis/shared': path.resolve(__dirname, '../shared/'), diff --git a/packages/react/autogen/component.ts b/packages/react/autogen/component.ts index a4b2fe524..d1bdcd317 100644 --- a/packages/react/autogen/component.ts +++ b/packages/react/autogen/component.ts @@ -20,12 +20,27 @@ export function getComponentCode ( ? '{ ...props, renderIntoProvidedDomNode: true }' : 'props' + // Container-hosted components can't render themselves consistently — the container computes their + // size, margins, scales and shared domains — so a config change asks the container to re-render. + // Stand-alone components own their DOM node and render from `setConfig` themselves. + const containerRenderImport = isStandAlone + ? '' + : "\nimport { useContainerRenderOnUpdate } from 'src/utils/container'" + const containerRenderHook = isStandAlone + ? '' + : ` + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See \`useContainerRenderOnUpdate\` for how updates are detected. + useContainerRenderOnUpdate() +` + return `// !!! This code was automatically generated. You should not change it !!! import React, { ForwardedRef, ReactElement, Ref, useImperativeHandle, useEffect, useRef, useState } from 'react' ${importStatements.map(s => `import { ${s.elements.join(', ')} } from '${s.source}'`).join('\n')} // Utils -import { arePropsEqual } from 'src/utils/react' +import { arePropsEqual } from 'src/utils/react'${containerRenderImport} // Types import { VisComponentElement } from 'src/types/dom' @@ -65,7 +80,7 @@ function Vis${componentName}FC${genericsDefStr} (props: Vis${componentName}Props ${dataType ? 'if (props.data) component?.setData(props.data)' : ''} component?.setConfig(props) }) - +${containerRenderHook} useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return <${isStandAlone ? 'div className={props.className}' : `vis-${elementSuffix}`} ref={ref} /> } diff --git a/packages/react/src/containers/single-container/index.tsx b/packages/react/src/containers/single-container/index.tsx index c6c0d004a..86286228c 100644 --- a/packages/react/src/containers/single-container/index.tsx +++ b/packages/react/src/containers/single-container/index.tsx @@ -1,5 +1,5 @@ // eslint-disable-next-line no-use-before-define -import React, { ReactNode, useEffect, useRef, useState, PropsWithChildren } from 'react' +import React, { ReactNode, useEffect, useMemo, useRef, useState, PropsWithChildren } from 'react' import { SingleContainer } from '@unovis/ts/containers/single-container' import { SingleContainerConfigInterface } from '@unovis/ts/containers/single-container/config' import { ComponentCore } from '@unovis/ts/core/component' @@ -8,6 +8,7 @@ import { Annotations } from '@unovis/ts/components/annotations' // Utils import { arePropsEqual } from 'src/utils/react' +import { VisContainerContext, VisContainerContextValue } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -25,6 +26,21 @@ function VisSingleContainerFC (props: PropsWithChildren | undefined>(undefined) const dataRef = useRef(undefined) const animationFrameRef = useRef(null) + const renderRequestFrameRef = useRef(null) + + // Children ask for a re-render when their own config changes. We coalesce the requests into a single + // frame, and skip them when this container's props changed too, because the `updateContainer` call + // scheduled below renders on its own. + const containerContext = useMemo(() => ({ + requestRender: () => { + if (renderRequestFrameRef.current !== null) return + renderRequestFrameRef.current = requestAnimationFrame(() => { + renderRequestFrameRef.current = null + if (animationFrameRef.current !== null) return + chartRef.current?.render() + }) + }, + }), []) const getConfig = (): SingleContainerConfigInterface => ({ ...props, @@ -48,6 +64,10 @@ function VisSingleContainerFC (props: PropsWithChildren (props: PropsWithChildren { + animationFrameRef.current = null chartRef.current?.updateContainer(getConfig()) }) } @@ -80,7 +101,9 @@ function VisSingleContainerFC (props: PropsWithChildren - {props.children} + + {props.children} + ) } diff --git a/packages/react/src/containers/xy-container/index.tsx b/packages/react/src/containers/xy-container/index.tsx index 2d5124ca3..509762bd5 100644 --- a/packages/react/src/containers/xy-container/index.tsx +++ b/packages/react/src/containers/xy-container/index.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import React, { ReactNode, useEffect, useRef, useState, PropsWithChildren } from 'react' +import React, { ReactNode, useEffect, useMemo, useRef, useState, PropsWithChildren } from 'react' import { XYContainer } from '@unovis/ts/containers/xy-container' import { XYContainerConfigInterface } from '@unovis/ts/containers/xy-container/config' import { XYComponentCore } from '@unovis/ts/core/xy-component' @@ -11,6 +11,7 @@ import { Annotations } from '@unovis/ts/components/annotations' // Utils import { arePropsEqual } from 'src/utils/react' +import { VisContainerContext, VisContainerContextValue } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -28,6 +29,21 @@ export function VisXYContainerFC (props: PropsWithChildren | undefined>(undefined) const dataRef = useRef(undefined) const animationFrameRef = useRef(null) + const renderRequestFrameRef = useRef(null) + + // Children ask for a re-render when their own config changes. We coalesce the requests into a single + // frame, and skip them when this container's props changed too, because the `updateContainer` call + // scheduled below renders on its own. + const containerContext = useMemo(() => ({ + requestRender: () => { + if (renderRequestFrameRef.current !== null) return + renderRequestFrameRef.current = requestAnimationFrame(() => { + renderRequestFrameRef.current = null + if (animationFrameRef.current !== null) return + chartRef.current?.render() + }) + }, + }), []) const getConfig = (): XYContainerConfigInterface => ({ components: Array @@ -62,6 +78,10 @@ export function VisXYContainerFC (props: PropsWithChildren (props: PropsWithChildren { + animationFrameRef.current = null chartRef.current?.updateContainer(getConfig()) }) } @@ -94,7 +115,9 @@ export function VisXYContainerFC (props: PropsWithChildren - {props.children} + + {props.children} + ) } diff --git a/packages/react/src/utils/container.ts b/packages/react/src/utils/container.ts new file mode 100644 index 000000000..dc18a3a42 --- /dev/null +++ b/packages/react/src/utils/container.ts @@ -0,0 +1,37 @@ +import { createContext, useContext, useEffect, useRef } from 'react' + +export type VisContainerContextValue = { + /** Asks the owning container to schedule a re-render. */ + requestRender: () => void; +} + +/** Lets a component wrapper ask its container to re-render. + * + * A component's `setConfig()` only stores the new config, it doesn't paint — containers own the + * render loop because they're the ones that recalculate sizes, margins, scales and shared domains + * in `_preRender()`. The container's own props-update effect can't be relied on to notice a + * component-level change: `VisSingleContainer` / `VisXYContainer` re-render only when *their* + * props change, which doesn't happen when a new accessor reaches the component through React + * context, a parent's state, or any other path that skips the container's props. Without this + * channel such an update sets the config and never repaints. */ +export const VisContainerContext = createContext(undefined) + +/** Asks the owning container to re-render whenever this component's props change. + * + * No equality check is needed here: component wrappers are memoized with `arePropsEqual` and hold + * no state or changing context, so after the initial mount they re-render — and re-run this + * effect — only when their props have actually changed. The mount run is skipped because + * containers render on mount anyway. No-op for components rendered outside of a Unovis + * container. */ +export function useContainerRenderOnUpdate (): void { + const requestRender = useContext(VisContainerContext)?.requestRender + const isInitialRunRef = useRef(true) + + useEffect(() => { + if (isInitialRunRef.current) { + isInitialRunRef.current = false + return + } + requestRender?.() + }) +} diff --git a/packages/shared/vite.config.ts b/packages/shared/vite.config.ts index 839d9835e..74ee29da7 100644 --- a/packages/shared/vite.config.ts +++ b/packages/shared/vite.config.ts @@ -223,8 +223,8 @@ export default defineConfig({ '@/styles/': `${pkgSrc('ts')}/styles/`, '@/data-models/': `${pkgSrc('ts')}/data-models/`, '@/data/': `${pkgSrc('ts')}/data/`, - // Used in packages/react/src/html-components/**/index.tsx - 'src/utils/react': `${pkgSrc('react')}/utils/react`, + // The react wrappers import their helpers as `src/utils/...` (tsconfig `baseUrl`) + 'src/utils/': `${pkgSrc('react')}/utils/`, '@unovis/ts': pkgSrc('ts'), '@unovis/react': pkgSrc('react'), // Vue and Svelte framework wrappers point at their built dist/ rather than From 8d08751675449b01c88755ea3036b5e2aa7997f1 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 14 Aug 2026 14:52:01 -0700 Subject: [PATCH 3/3] React: Regenerate wrappers Generated output of the previous commit's autogen change: every container-hosted component wrapper now calls `useContainerRenderOnUpdate()` after its config-update effect. No hand edits. --- packages/react/src/components/annotations/index.tsx | 6 ++++++ packages/react/src/components/area/index.tsx | 6 ++++++ packages/react/src/components/axis/index.tsx | 6 ++++++ packages/react/src/components/boxplot/index.tsx | 6 ++++++ packages/react/src/components/brush/index.tsx | 6 ++++++ packages/react/src/components/chord-diagram/index.tsx | 6 ++++++ packages/react/src/components/crosshair/index.tsx | 6 ++++++ packages/react/src/components/donut/index.tsx | 6 ++++++ packages/react/src/components/free-brush/index.tsx | 6 ++++++ packages/react/src/components/graph/index.tsx | 6 ++++++ packages/react/src/components/grouped-bar/index.tsx | 6 ++++++ packages/react/src/components/heatmap/index.tsx | 6 ++++++ packages/react/src/components/line/index.tsx | 6 ++++++ packages/react/src/components/nested-donut/index.tsx | 6 ++++++ packages/react/src/components/plotband/index.tsx | 6 ++++++ packages/react/src/components/plotline/index.tsx | 6 ++++++ packages/react/src/components/radial-bar/index.tsx | 6 ++++++ packages/react/src/components/sankey/index.tsx | 6 ++++++ packages/react/src/components/scatter/index.tsx | 6 ++++++ packages/react/src/components/stacked-bar/index.tsx | 6 ++++++ packages/react/src/components/timeline/index.tsx | 6 ++++++ packages/react/src/components/tooltip/index.tsx | 6 ++++++ packages/react/src/components/topojson-map/index.tsx | 6 ++++++ packages/react/src/components/treemap/index.tsx | 6 ++++++ packages/react/src/components/xy-labels/index.tsx | 6 ++++++ 25 files changed, 150 insertions(+) diff --git a/packages/react/src/components/annotations/index.tsx b/packages/react/src/components/annotations/index.tsx index edb5ccf08..9f382c1b8 100644 --- a/packages/react/src/components/annotations/index.tsx +++ b/packages/react/src/components/annotations/index.tsx @@ -5,6 +5,7 @@ import { AnnotationsConfigInterface } from '@unovis/ts/components/annotations/co // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -45,6 +46,11 @@ function VisAnnotationsFC (props: VisAnnotationsProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/area/index.tsx b/packages/react/src/components/area/index.tsx index 5237fd7af..ef21871fc 100644 --- a/packages/react/src/components/area/index.tsx +++ b/packages/react/src/components/area/index.tsx @@ -5,6 +5,7 @@ import { AreaConfigInterface } from '@unovis/ts/components/area/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisAreaFC (props: VisAreaProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/axis/index.tsx b/packages/react/src/components/axis/index.tsx index 43dd9b8b2..7f6cf6292 100644 --- a/packages/react/src/components/axis/index.tsx +++ b/packages/react/src/components/axis/index.tsx @@ -5,6 +5,7 @@ import { AxisConfigInterface } from '@unovis/ts/components/axis/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisAxisFC (props: VisAxisProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/boxplot/index.tsx b/packages/react/src/components/boxplot/index.tsx index c580cbcbf..78428fd21 100644 --- a/packages/react/src/components/boxplot/index.tsx +++ b/packages/react/src/components/boxplot/index.tsx @@ -5,6 +5,7 @@ import { BoxplotConfigInterface } from '@unovis/ts/components/boxplot/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisBoxplotFC (props: VisBoxplotProps, fRef: ForwardedRef< component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/brush/index.tsx b/packages/react/src/components/brush/index.tsx index 8700a39c6..422b7d8b8 100644 --- a/packages/react/src/components/brush/index.tsx +++ b/packages/react/src/components/brush/index.tsx @@ -5,6 +5,7 @@ import { BrushConfigInterface } from '@unovis/ts/components/brush/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisBrushFC (props: VisBrushProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/chord-diagram/index.tsx b/packages/react/src/components/chord-diagram/index.tsx index 0b4b61505..adf6b9e9d 100644 --- a/packages/react/src/components/chord-diagram/index.tsx +++ b/packages/react/src/components/chord-diagram/index.tsx @@ -6,6 +6,7 @@ import { ChordInputNode, ChordInputLink } from '@unovis/ts/components/chord-diag // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -47,6 +48,11 @@ function VisChordDiagramFC ( component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/crosshair/index.tsx b/packages/react/src/components/crosshair/index.tsx index 1dec0997f..b643b06cd 100644 --- a/packages/react/src/components/crosshair/index.tsx +++ b/packages/react/src/components/crosshair/index.tsx @@ -5,6 +5,7 @@ import { CrosshairConfigInterface } from '@unovis/ts/components/crosshair/config // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisCrosshairFC (props: VisCrosshairProps, fRef: Forwarded component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/donut/index.tsx b/packages/react/src/components/donut/index.tsx index 100ab272b..9636aee1b 100644 --- a/packages/react/src/components/donut/index.tsx +++ b/packages/react/src/components/donut/index.tsx @@ -5,6 +5,7 @@ import { DonutConfigInterface } from '@unovis/ts/components/donut/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisDonutFC (props: VisDonutProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/free-brush/index.tsx b/packages/react/src/components/free-brush/index.tsx index 2e0e1d8a3..73226a7e9 100644 --- a/packages/react/src/components/free-brush/index.tsx +++ b/packages/react/src/components/free-brush/index.tsx @@ -5,6 +5,7 @@ import { FreeBrushConfigInterface } from '@unovis/ts/components/free-brush/confi // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisFreeBrushFC (props: VisFreeBrushProps, fRef: Forwarded component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/graph/index.tsx b/packages/react/src/components/graph/index.tsx index 78e5eb092..199b97faf 100644 --- a/packages/react/src/components/graph/index.tsx +++ b/packages/react/src/components/graph/index.tsx @@ -6,6 +6,7 @@ import { GraphInputNode, GraphInputLink } from '@unovis/ts/types/graph' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -47,6 +48,11 @@ function VisGraphFC (props: component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/grouped-bar/index.tsx b/packages/react/src/components/grouped-bar/index.tsx index 6ff80f665..33dbe7955 100644 --- a/packages/react/src/components/grouped-bar/index.tsx +++ b/packages/react/src/components/grouped-bar/index.tsx @@ -5,6 +5,7 @@ import { GroupedBarConfigInterface } from '@unovis/ts/components/grouped-bar/con // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisGroupedBarFC (props: VisGroupedBarProps, fRef: Forward component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/heatmap/index.tsx b/packages/react/src/components/heatmap/index.tsx index 6d6fbc931..9d0c0ad4d 100644 --- a/packages/react/src/components/heatmap/index.tsx +++ b/packages/react/src/components/heatmap/index.tsx @@ -5,6 +5,7 @@ import { HeatmapConfigInterface } from '@unovis/ts/components/heatmap/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisHeatmapFC (props: VisHeatmapProps, fRef: ForwardedRef< component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/line/index.tsx b/packages/react/src/components/line/index.tsx index 8cbccc1f6..6554221d2 100644 --- a/packages/react/src/components/line/index.tsx +++ b/packages/react/src/components/line/index.tsx @@ -5,6 +5,7 @@ import { LineConfigInterface } from '@unovis/ts/components/line/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisLineFC (props: VisLineProps, fRef: ForwardedRef ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/nested-donut/index.tsx b/packages/react/src/components/nested-donut/index.tsx index 2bf370f61..a71cfb5a5 100644 --- a/packages/react/src/components/nested-donut/index.tsx +++ b/packages/react/src/components/nested-donut/index.tsx @@ -5,6 +5,7 @@ import { NestedDonutConfigInterface } from '@unovis/ts/components/nested-donut/c // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisNestedDonutFC (props: VisNestedDonutProps, fRef: Forwa component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/plotband/index.tsx b/packages/react/src/components/plotband/index.tsx index f3596ce4b..b9082dccc 100644 --- a/packages/react/src/components/plotband/index.tsx +++ b/packages/react/src/components/plotband/index.tsx @@ -5,6 +5,7 @@ import { PlotbandConfigInterface } from '@unovis/ts/components/plotband/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -45,6 +46,11 @@ function VisPlotbandFC (props: VisPlotbandProps, fRef: ForwardedRe component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/plotline/index.tsx b/packages/react/src/components/plotline/index.tsx index 0f08f0c8b..b7047fb53 100644 --- a/packages/react/src/components/plotline/index.tsx +++ b/packages/react/src/components/plotline/index.tsx @@ -5,6 +5,7 @@ import { PlotlineConfigInterface } from '@unovis/ts/components/plotline/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -45,6 +46,11 @@ function VisPlotlineFC (props: VisPlotlineProps, fRef: ForwardedRe component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/radial-bar/index.tsx b/packages/react/src/components/radial-bar/index.tsx index ecbe5db67..fcfe3a225 100644 --- a/packages/react/src/components/radial-bar/index.tsx +++ b/packages/react/src/components/radial-bar/index.tsx @@ -5,6 +5,7 @@ import { RadialBarConfigInterface } from '@unovis/ts/components/radial-bar/confi // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisRadialBarFC (props: VisRadialBarProps, fRef: Forwarded component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/sankey/index.tsx b/packages/react/src/components/sankey/index.tsx index 44ae77b09..e09243b13 100644 --- a/packages/react/src/components/sankey/index.tsx +++ b/packages/react/src/components/sankey/index.tsx @@ -6,6 +6,7 @@ import { SankeyInputNode, SankeyInputLink } from '@unovis/ts/components/sankey/t // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -47,6 +48,11 @@ function VisSankeyFC (prop component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/scatter/index.tsx b/packages/react/src/components/scatter/index.tsx index a206767a1..6e35d2e65 100644 --- a/packages/react/src/components/scatter/index.tsx +++ b/packages/react/src/components/scatter/index.tsx @@ -5,6 +5,7 @@ import { ScatterConfigInterface } from '@unovis/ts/components/scatter/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisScatterFC (props: VisScatterProps, fRef: ForwardedRef< component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/stacked-bar/index.tsx b/packages/react/src/components/stacked-bar/index.tsx index e48e9af4a..01dafb29f 100644 --- a/packages/react/src/components/stacked-bar/index.tsx +++ b/packages/react/src/components/stacked-bar/index.tsx @@ -5,6 +5,7 @@ import { StackedBarConfigInterface } from '@unovis/ts/components/stacked-bar/con // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisStackedBarFC (props: VisStackedBarProps, fRef: Forward component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/timeline/index.tsx b/packages/react/src/components/timeline/index.tsx index e613983be..19407b90a 100644 --- a/packages/react/src/components/timeline/index.tsx +++ b/packages/react/src/components/timeline/index.tsx @@ -5,6 +5,7 @@ import { TimelineConfigInterface } from '@unovis/ts/components/timeline/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisTimelineFC (props: VisTimelineProps, fRef: ForwardedRe component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/tooltip/index.tsx b/packages/react/src/components/tooltip/index.tsx index 2a020e819..afa2b1f4e 100644 --- a/packages/react/src/components/tooltip/index.tsx +++ b/packages/react/src/components/tooltip/index.tsx @@ -5,6 +5,7 @@ import { TooltipConfigInterface } from '@unovis/ts/components/tooltip/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -45,6 +46,11 @@ function VisTooltipFC (props: VisTooltipProps, fRef: ForwardedRef component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/topojson-map/index.tsx b/packages/react/src/components/topojson-map/index.tsx index 38754ee96..eb851cd94 100644 --- a/packages/react/src/components/topojson-map/index.tsx +++ b/packages/react/src/components/topojson-map/index.tsx @@ -5,6 +5,7 @@ import { TopoJSONMapConfigInterface } from '@unovis/ts/components/topojson-map/c // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisTopoJSONMapFC (props: VisTopoJSONM component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/treemap/index.tsx b/packages/react/src/components/treemap/index.tsx index 33d3121f3..b78c1e47d 100644 --- a/packages/react/src/components/treemap/index.tsx +++ b/packages/react/src/components/treemap/index.tsx @@ -5,6 +5,7 @@ import { TreemapConfigInterface } from '@unovis/ts/components/treemap/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisTreemapFC (props: VisTreemapProps, fRef: ForwardedRef< component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return } diff --git a/packages/react/src/components/xy-labels/index.tsx b/packages/react/src/components/xy-labels/index.tsx index 393be124a..6c5974943 100644 --- a/packages/react/src/components/xy-labels/index.tsx +++ b/packages/react/src/components/xy-labels/index.tsx @@ -5,6 +5,7 @@ import { XYLabelsConfigInterface } from '@unovis/ts/components/xy-labels/config' // Utils import { arePropsEqual } from 'src/utils/react' +import { useContainerRenderOnUpdate } from 'src/utils/container' // Types import { VisComponentElement } from 'src/types/dom' @@ -46,6 +47,11 @@ function VisXYLabelsFC (props: VisXYLabelsProps, fRef: ForwardedRe component?.setConfig(props) }) + // A config change has to drive the render itself: the container re-renders only when its own props + // change, which doesn't happen when new config reaches this component through React context or a + // parent's state. See `useContainerRenderOnUpdate` for how updates are detected. + useContainerRenderOnUpdate() + useImperativeHandle(fRef, () => ({ get component () { return componentRef.current } }), []) return }