diff --git a/.changeset/chart-props-no-spread.md b/.changeset/chart-props-no-spread.md new file mode 100644 index 000000000..feea8baaf --- /dev/null +++ b/.changeset/chart-props-no-spread.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Eliminate per-instance props spread in `ChartState` diff --git a/packages/layerchart/src/lib/components/Chart/Chart.base.svelte b/packages/layerchart/src/lib/components/Chart/Chart.base.svelte index 8a02d746d..f10fe8da1 100644 --- a/packages/layerchart/src/lib/components/Chart/Chart.base.svelte +++ b/packages/layerchart/src/lib/components/Chart/Chart.base.svelte @@ -29,6 +29,7 @@ import { getSettings } from '$lib/contexts/settings.js'; import { setChartContext } from '$lib/contexts/chart.js'; import { ChartState } from '$lib/states/chart.svelte.js'; + import type { ChartPropsWithoutHTML } from './Chart.shared.svelte.js'; import { isScaleBand } from '$lib/utils/scales.svelte.js'; import { getObjectOrNull } from '$lib/utils/common.js'; import { @@ -71,13 +72,18 @@ let brushXDomain = $state(); let brushYDomain = $state(); - const chartState = new ChartState(() => ({ - ref: refProp, - context: contextProp, - ...props, - xDomain: brushXDomain ?? props.xDomain, - yDomain: brushYDomain ?? props.yDomain, - })); + // Pass the `$props()` proxy directly — `props.X` reads stay reactive and + // don't pay the cost of an `{...props}` spread (recursive `ownKeys` across + // nested rest/spread proxies). Brush selections are supplied as getters so + // the chart's domain calculation can layer them on top of `props.xDomain` + // / `props.yDomain` at the read sites. + const chartState = new ChartState( + props as ChartPropsWithoutHTML, + { + brushXDomain: () => brushXDomain, + brushYDomain: () => brushYDomain, + } + ); let ref = $state(); $effect.pre(() => { diff --git a/packages/layerchart/src/lib/states/chart.svelte.test.ts b/packages/layerchart/src/lib/states/chart.svelte.test.ts index 4519b1114..1238a5e95 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.test.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.test.ts @@ -19,7 +19,7 @@ function createChartState(props: Partial> let state: ChartState; cleanup = $effect.root(() => { - state = new ChartState(() => props as ChartPropsWithoutHTML); + state = new ChartState(props as ChartPropsWithoutHTML); }); // Access derived values after reactive graph is set up diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index 146a2e584..bb49e617d 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -99,11 +99,16 @@ export class ChartState< XScale extends AnyScale = AnyScale, YScale extends AnyScale = AnyScale, > { - // Props getter function - set in constructor - private _propsGetter!: () => ChartPropsWithoutHTML; + // The `$props()` proxy from the host component. Reads on `this.props.X` go + // straight through to the underlying reactive prop — no spread / no derived + // wrapper needed. + props!: ChartPropsWithoutHTML; - // Props - accessed via getter function for fine-grained reactivity - props = $derived(this._propsGetter()); + // Brush-domain overrides. The host component owns the brush state as local + // `$state` and supplies these getters so brush selections take precedence + // over `props.xDomain` / `props.yDomain` when reading the effective domain. + #brushXDomain!: () => BrushDomainType | undefined; + #brushYDomain!: () => BrushDomainType | undefined; // State / contexts geoState: GeoState; @@ -272,8 +277,16 @@ export class ChartState< // Meta data - reactive to props.meta changes meta = $derived(this.props.meta ?? {}); - constructor(propsGetter: () => ChartPropsWithoutHTML) { - this._propsGetter = propsGetter; + constructor( + props: ChartPropsWithoutHTML, + overrides?: { + brushXDomain?: () => BrushDomainType | undefined; + brushYDomain?: () => BrushDomainType | undefined; + } + ) { + this.props = props; + this.#brushXDomain = overrides?.brushXDomain ?? (() => undefined); + this.#brushYDomain = overrides?.brushYDomain ?? (() => undefined); // Create GeoState instance — pass a dimensions getter so projection // is available during SSR (where $effect doesn't run) @@ -402,7 +415,7 @@ export class ChartState< }); // Set up domain motion if motion prop is configured - const motionProp = propsGetter().motion; + const motionProp = props.motion; if (motionProp) { const resolved = parseMotionProp(motionProp); this._xDomainMotion = createControlledMotion([], resolved); @@ -506,7 +519,7 @@ export class ChartState< if (this.props.bandPadding != null && this.valueAxis === 'y') { return scaleBand().padding(this.props.bandPadding); } - return autoScale(this.props.xDomain, this.flatData, this.x); + return autoScale(this.#brushXDomain() ?? this.props.xDomain, this.flatData, this.x); }); _yScaleProp = $derived.by(() => { @@ -517,7 +530,7 @@ export class ChartState< if (this.props.bandPadding != null && this.valueAxis === 'x') { return scaleBand().padding(this.props.bandPadding); } - return autoScale(this.props.yDomain, this.flatData, this.y); + return autoScale(this.#brushYDomain() ?? this.props.yDomain, this.flatData, this.y); }); _zScaleProp = $derived.by(() => { @@ -756,7 +769,10 @@ export class ChartState< } private resolveDomain(axis: 'x' | 'y'): DomainType | undefined { - const domain = axis === 'x' ? this.props.xDomain : this.props.yDomain; + const domain = + axis === 'x' + ? (this.#brushXDomain() ?? this.props.xDomain) + : (this.#brushYDomain() ?? this.props.yDomain); const interval = axis === 'x' ? this.props.xInterval : this.props.yInterval; const explicitBaseline = axis === 'x' ? this.props.xBaseline : this.props.yBaseline; // Use explicit baseline if provided (null means "no baseline"), otherwise auto-derive