+
+
+
+
+
+ >
+ )
+}
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/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
}
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
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
}