solid-recharts 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +5 -3
  2. package/src/cartesian/Area.tsx +40 -55
  3. package/src/cartesian/Bar.tsx +66 -58
  4. package/src/cartesian/BarStack.tsx +5 -2
  5. package/src/cartesian/Brush.tsx +1 -1
  6. package/src/cartesian/Funnel.tsx +15 -19
  7. package/src/cartesian/Line.tsx +34 -51
  8. package/src/cartesian/LineDrawShape.tsx +8 -6
  9. package/src/cartesian/ReferenceArea.tsx +25 -35
  10. package/src/cartesian/ReferenceDot.tsx +24 -34
  11. package/src/cartesian/ReferenceLine.tsx +25 -34
  12. package/src/cartesian/Scatter.tsx +25 -37
  13. package/src/cartesian/XAxis.tsx +54 -67
  14. package/src/cartesian/YAxis.tsx +53 -66
  15. package/src/cartesian/ZAxis.tsx +27 -40
  16. package/src/chart/SunburstChart.tsx +1 -1
  17. package/src/component/ActivePoints.tsx +0 -9
  18. package/src/component/Dots.tsx +7 -3
  19. package/src/context/ErrorBarContext.tsx +15 -24
  20. package/src/polar/Pie.tsx +36 -48
  21. package/src/polar/PolarAngleAxis.tsx +41 -50
  22. package/src/polar/PolarRadiusAxis.tsx +40 -48
  23. package/src/polar/Radar.tsx +22 -34
  24. package/src/polar/RadialBar.tsx +21 -39
  25. package/src/shape/Curve.tsx +1 -1
  26. package/src/shape/Symbols.tsx +1 -1
  27. package/src/state/SetLegendPayload.ts +19 -27
  28. package/src/state/SetTooltipEntrySettings.tsx +9 -26
  29. package/src/state/cartesianAxisSlice.ts +40 -60
  30. package/src/state/createCollectionActions.ts +51 -0
  31. package/src/state/createSelector.ts +97 -0
  32. package/src/state/externalEventsMiddleware.ts +2 -11
  33. package/src/state/graphicalItemsSlice.ts +27 -40
  34. package/src/state/isEventThrottled.ts +12 -0
  35. package/src/state/keyboardEventsMiddleware.ts +2 -8
  36. package/src/state/layoutSlice.ts +8 -4
  37. package/src/state/legendSlice.ts +24 -26
  38. package/src/state/mouseEventsMiddleware.ts +2 -13
  39. package/src/state/patched.ts +19 -0
  40. package/src/state/referenceElementsSlice.ts +40 -31
  41. package/src/state/registerInStore.ts +68 -0
  42. package/src/state/selectors/areaSelectors.ts +13 -36
  43. package/src/state/selectors/arrayEqualityCheck.ts +32 -7
  44. package/src/state/selectors/axisSelectors.ts +215 -169
  45. package/src/state/selectors/barSelectors.ts +8 -36
  46. package/src/state/selectors/combiners/combineConfiguredScale.ts +1 -1
  47. package/src/state/selectors/combiners/combineRealScaleType.ts +1 -1
  48. package/src/state/selectors/dataSelectors.ts +35 -16
  49. package/src/state/selectors/numberDomainEqualityCheck.ts +16 -8
  50. package/src/state/selectors/selectAxisForGraphicalItem.ts +52 -0
  51. package/src/state/tooltipSlice.ts +93 -64
  52. package/src/util/ActiveShapeUtils.tsx +0 -7
  53. package/src/util/BarUtils.tsx +27 -22
  54. package/src/util/ChartUtils.ts +69 -25
  55. package/src/util/propsAreEqual.ts +3 -33
  56. package/src/util/stacks/stackTypes.ts +1 -1
  57. package/src/util/types.ts +1 -1
  58. package/test/cartesian/CartesianAxis.spec.tsx +1 -1
  59. package/test/cartesian/GraphicalItemClipPath.scale-behavior.spec.tsx +1 -1
  60. package/test/cartesian/ReferenceLine/getEndPoints.spec.ts +1 -1
  61. package/test/cartesian/XAxis/XAxis.timescale.spec.tsx +1 -1
  62. package/test/helper/mockAxes.ts +1 -1
  63. package/test/shape/Curve.spec.tsx +1 -1
  64. package/test/state/createCollectionActions.spec.ts +87 -0
  65. package/test/state/createSelector.spec.ts +158 -0
  66. package/test/state/registerInStore.spec.tsx +101 -0
  67. package/test/state/selectors/combiners/combineConfiguredScale.spec.ts +1 -1
  68. package/test/state/selectors/combiners/combineRealScaleType.spec.ts +1 -1
  69. package/test/state/selectors/selectorMemoization.spec.tsx +136 -0
  70. package/test/util/CartesianUtils/CartesianUtils.spec.ts +1 -1
  71. package/test/util/ChartUtils/ChartUtils.spec.ts +1 -1
  72. package/test/util/ChartUtils/getCateCoordinateOfLine.spec.ts +14 -0
  73. package/test/util/ChartUtils/getStackedData.spec.ts +1 -1
  74. package/test/util/ChartUtils/truncateByDomain.spec.ts +1 -1
  75. package/test/util/scale/createCategoricalInverse.spec.ts +1 -1
  76. package/test-results/.last-run.json +4 -0
  77. package/src/state/reduxDevtoolsJsonStringifyReplacer.ts +0 -15
  78. package/src/util/axisPropsAreEqual.ts +0 -42
  79. package/src/util/resolveDefaultProps.tsx +0 -8
  80. package/src/util/typedDataKey.ts +0 -8
  81. package/test/state/reduxDevtoolsJsonStringifyReplacer.spec.ts +0 -25
  82. package/test/util/axisPropsAreEqual.spec.ts +0 -93
@@ -15,16 +15,8 @@ import {
15
15
  stackOffsetSilhouette,
16
16
  stackOffsetWiggle,
17
17
  stackOrderNone,
18
- } from "victory-vendor/d3-shape";
19
- import {
20
- findEntryInArray,
21
- isNan,
22
- isNotNil,
23
- isNullish,
24
- isNumber,
25
- isNumOrStr,
26
- mathSign,
27
- } from "./DataUtils";
18
+ } from "d3-shape";
19
+ import { isNan, isNotNil, isNullish, isNumber, isNumOrStr, mathSign } from "./DataUtils";
28
20
  import { getSliced } from "./getSliced";
29
21
  import { isWellBehavedNumber } from "./isWellBehavedNumber";
30
22
  import type {
@@ -276,6 +268,32 @@ export const truncateByDomain = <T>(
276
268
  return result;
277
269
  };
278
270
 
271
+ // Value→tick lookup cache, keyed on the ticks array reference. getCateCoordinateOfLine
272
+ // is called once per data point; a linear findEntryInArray scan there is O(points²)
273
+ // across a full series when allowDuplicatedCategory=false (ticks.length ≈ points).
274
+ // The axis-ticks selector memoizes the ticks array, so this map is built once per
275
+ // render and reused for every point lookup, making it O(points). First value wins,
276
+ // mirroring Array.find.
277
+ const tickByValueCache = new WeakMap<ReadonlyArray<TickItem>, Map<unknown, TickItem>>();
278
+ function findTickByValue(ticks: ReadonlyArray<TickItem>, value: unknown): TickItem | undefined {
279
+ // Map uses SameValueZero and would match NaN, while the upstream Array.find
280
+ // lookup uses strict equality (NaN !== NaN). Preserve that fallback behavior.
281
+ if (typeof value === "number" && Number.isNaN(value)) {
282
+ return undefined;
283
+ }
284
+ let map = tickByValueCache.get(ticks);
285
+ if (map == null) {
286
+ map = new Map<unknown, TickItem>();
287
+ for (const tick of ticks) {
288
+ if (tick != null && !map.has(tick.value)) {
289
+ map.set(tick.value, tick);
290
+ }
291
+ }
292
+ tickByValueCache.set(ticks, map);
293
+ }
294
+ return map.get(value);
295
+ }
296
+
279
297
  export function getCateCoordinateOfLine<T>({
280
298
  axis,
281
299
  ticks,
@@ -312,7 +330,7 @@ export function getCateCoordinateOfLine<T>({
312
330
  const key: string | number = axisDataKey;
313
331
  const cellValue = entry[key];
314
332
  if (!isNullish(cellValue)) {
315
- const matchedTick = ticks ? findEntryInArray(ticks, "value", cellValue) : undefined;
333
+ const matchedTick = ticks ? findTickByValue(ticks, cellValue) : undefined;
316
334
 
317
335
  if (matchedTick) {
318
336
  return matchedTick.coordinate + bandSize / 2;
@@ -625,35 +643,61 @@ export const getStackedData = (
625
643
  offsetType: StackOffsetType,
626
644
  ): ReadonlyArray<Series<StackDatum, StackKey>> => {
627
645
  const offsetAccessor: OffsetAccessor = STACK_OFFSET_MAP[offsetType] ?? offsetSign;
646
+ // Track whether any value is a [min, max] tuple during the (unavoidable) value
647
+ // accessor pass, so the ranged-data fixup below can be skipped entirely for the
648
+ // common scalar case instead of running a second full series×points scan.
649
+ let hasRangedValues = false;
628
650
  const stack = shapeStack<StackDatum, StackKey>()
629
651
  .keys(dataKeys)
630
- .value((d, key) => Number(getValueByDataKey(d, key, 0)))
652
+ .value((d, key) => {
653
+ const raw = getValueByDataKey(d, key, 0);
654
+ if (Array.isArray(raw) && raw.length === 2) {
655
+ hasRangedValues = true;
656
+ }
657
+ return Number(raw);
658
+ })
631
659
  .order(stackOrderNone)
632
660
  .offset(offsetAccessor);
633
661
 
634
662
  const result = stack(data);
635
663
 
636
664
  // Ranged data: when a value is a [min, max] tuple, use it directly (no stacking).
637
- result.forEach((s, seriesIndex) => {
638
- s.forEach((point, pointIndex) => {
639
- const value = getValueByDataKey(data[pointIndex], dataKeys[seriesIndex], 0);
640
- if (value != null && Array.isArray(value) && value.length === 2) {
641
- const lo: unknown = value[0];
642
- const hi: unknown = value[1];
643
- if (isNumber(lo) && isNumber(hi)) {
644
- point[0] = lo;
645
- point[1] = hi;
665
+ if (hasRangedValues) {
666
+ result.forEach((s, seriesIndex) => {
667
+ s.forEach((point, pointIndex) => {
668
+ const value = getValueByDataKey(data[pointIndex], dataKeys[seriesIndex], 0);
669
+ if (value != null && Array.isArray(value) && value.length === 2) {
670
+ const lo: unknown = value[0];
671
+ const hi: unknown = value[1];
672
+ if (isNumber(lo) && isNumber(hi)) {
673
+ point[0] = lo;
674
+ point[1] = hi;
675
+ }
646
676
  }
647
- }
677
+ });
648
678
  });
649
- });
679
+ }
650
680
 
651
681
  return result;
652
682
  };
653
683
 
654
684
  const getDomainOfSingle = (data: ReadonlyArray<ReadonlyArray<unknown>>): number[] => {
655
- const flat = data.flat(2).filter(isNumber);
656
- return [Math.min(...flat), Math.max(...flat)];
685
+ // Reduce loop, not `Math.min(...flat)`: spreading a data-sized array throws a V8
686
+ // RangeError past ~100k elements. Empty input yields [Infinity, -Infinity],
687
+ // matching the previous Math.min/Math.max-of-empty behavior.
688
+ let min = Infinity;
689
+ let max = -Infinity;
690
+ for (const value of data.flat(2)) {
691
+ if (isNumber(value)) {
692
+ if (value < min) {
693
+ min = value;
694
+ }
695
+ if (value > max) {
696
+ max = value;
697
+ }
698
+ }
699
+ }
700
+ return [min, max];
657
701
  };
658
702
 
659
703
  const makeDomainFinite = (domain: NumberDomain): NumberDomain => [
@@ -1,16 +1,15 @@
1
1
  // Ported from recharts 3.8.1 src/util/propsAreEqual.ts (commit 7a23854).
2
2
  //
3
3
  // Deviation: recharts imports `shallowEqual` from `react-redux`. A SolidJS port
4
- // has no react-redux, so we inline an equivalent one-level same-value compare.
5
- // react-redux's `shallowEqual` is just `Object.is` applied per own-enumerable
6
- // key (its internal `is` predates and mirrors `Object.is`), so the local
7
- // version below is behavior-identical — NaN compares equal, +0 and -0 do not.
4
+ // has no react-redux, so it reuses the local equivalent in `./ShallowEqual`
5
+ // (one-level same-value compare NaN equal, +0 and -0 distinct).
8
6
  //
9
7
  // NOTE on relevance: recharts uses `propsAreEqual` only as the `React.memo`
10
8
  // comparator on Bar/Area/Line/Scatter/Legend/ReportEventSettings/etc. Solid
11
9
  // components do not re-render, so when those units are ported this comparator
12
10
  // will most likely be dropped or repurposed. The function itself is pure and
13
11
  // spec-tested, so we port it now and revisit its call sites in Tier 2.
12
+ import { shallowEqual } from "./ShallowEqual";
14
13
 
15
14
  const propsToShallowCompare = new Set<string>([
16
15
  "axisLine",
@@ -37,35 +36,6 @@ const propsToShallowCompare = new Set<string>([
37
36
  "throttledEvents",
38
37
  ]);
39
38
 
40
- function isRecord(value: unknown): value is Record<string, unknown> {
41
- return typeof value === "object" && value !== null;
42
- }
43
-
44
- /**
45
- * One-level same-value comparison, equivalent to react-redux's `shallowEqual`.
46
- * Uses `Object.is`, so two NaNs are equal and +0/-0 are not — matching the
47
- * original.
48
- */
49
- function shallowEqual(objA: unknown, objB: unknown): boolean {
50
- if (Object.is(objA, objB)) {
51
- return true;
52
- }
53
- if (!isRecord(objA) || !isRecord(objB)) {
54
- return false;
55
- }
56
- const keysA = Object.keys(objA);
57
- const keysB = Object.keys(objB);
58
- if (keysA.length !== keysB.length) {
59
- return false;
60
- }
61
- for (const key of keysA) {
62
- if (!Object.hasOwn(objB, key) || !Object.is(objA[key], objB[key])) {
63
- return false;
64
- }
65
- }
66
- return true;
67
- }
68
-
69
39
  /**
70
40
  * When comparing two values, returns true if they are the same value or
71
41
  * are both NaN.
@@ -1,5 +1,5 @@
1
1
  // Ported from recharts 3.8.1 src/util/stacks/stackTypes.ts (commit 7a23854).
2
- import { Series } from "victory-vendor/d3-shape";
2
+ import { Series } from "d3-shape";
3
3
  import { StackId } from "../ChartUtils";
4
4
  import { DataKey } from "../types";
5
5
  import { GraphicalItemId } from "../../state/graphicalItemsSlice";
package/src/util/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { JSX } from "@solidjs/web";
2
2
  import type { Element } from "solid-js";
3
- import type * as d3Scales from "victory-vendor/d3-scale";
3
+ import type * as d3Scales from "d3-scale";
4
4
  import type { DotProps } from "../shape/Dot";
5
5
  import type { ExternalMouseEvents } from "../chart/types";
6
6
 
@@ -6,7 +6,7 @@
6
6
  // React.cloneElement an already-evaluated element to inject computed x/y — function form
7
7
  // covers it).
8
8
  import { describe, expect, it } from "vitest";
9
- import { scaleLinear } from "victory-vendor/d3-scale";
9
+ import { scaleLinear } from "d3-scale";
10
10
  import { render } from "@solidjs/testing-library";
11
11
  import { CartesianAxis, Surface } from "../../src";
12
12
  import type { CartesianTickContentProps, TickItem } from "../../src/util/types";
@@ -12,7 +12,7 @@
12
12
  // last spy call holds the current points. `p.payload` is `unknown` in our port
13
13
  // (recharts erases it via `any`), so a runtime guard reads `name`/`x` — no `as`.
14
14
  import { describe, it, expect, beforeEach } from "vitest";
15
- import { scaleLinear, scalePoint } from "victory-vendor/d3-scale";
15
+ import { scaleLinear, scalePoint } from "d3-scale";
16
16
  import { createSignal, flush } from "solid-js";
17
17
  import type { JSX } from "@solidjs/web";
18
18
  import { fireEvent } from "@solidjs/testing-library";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { scaleLinear } from "victory-vendor/d3-scale";
2
+ import { scaleLinear } from "d3-scale";
3
3
  import { getEndPoints, type ReferenceLineSegment } from "../../../src/cartesian/ReferenceLine";
4
4
  import type { CartesianViewBoxRequired } from "../../../src/util/types";
5
5
  import {
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import { createEffect } from "solid-js";
3
3
  // @ts-expect-error d3-time-format ships no types and victory-vendor does not re-export it.
4
4
  import { timeFormat } from "d3-time-format";
5
- import { scaleTime } from "victory-vendor/d3-scale";
5
+ import { scaleTime } from "d3-scale";
6
6
  import { useAppSelector } from "../../../src/state/hooks";
7
7
  import { selectRealScaleType } from "../../../src/state/selectors/axisSelectors";
8
8
  import { Line, LineChart, XAxis, YAxis } from "../../../src";
@@ -1,5 +1,5 @@
1
1
  // Port of recharts 3.8.1 test/helper/mockAxes.ts (commit 7a23854).
2
- import { scaleLinear } from "victory-vendor/d3-scale";
2
+ import { scaleLinear } from "d3-scale";
3
3
  import {
4
4
  BaseAxisWithScale,
5
5
  implicitXAxis,
@@ -5,7 +5,7 @@
5
5
  import { describe, expect, it, test } from "vitest";
6
6
  import { createSignal } from "solid-js";
7
7
  import { render, fireEvent } from "@solidjs/testing-library";
8
- import { curveLinear } from "victory-vendor/d3-shape";
8
+ import { curveLinear } from "d3-shape";
9
9
  import { Surface, Curve } from "../../src";
10
10
  import { getPath, type CurveType } from "../../src/shape/Curve";
11
11
  import { assertNotNull } from "../helper/assertNotNull";
@@ -0,0 +1,87 @@
1
+ // Unit spec for the keyed-collection array algebra (src/state/createCollectionActions.ts).
2
+ // No upstream counterpart — upstream inlines this per slice via immer; this pins
3
+ // the shared contract the array slices (graphicalItems, referenceElements, legend,
4
+ // tooltip) delegate to. The helper is pure over its read/write closures, so this
5
+ // exercises it against a plain array cell — the interface is the test surface, and
6
+ // the "new reference on every write" assertions are the ADR-0002 immutability
7
+ // contract the reference-equality selector caches depend on.
8
+ import { describe, expect, it } from "vitest";
9
+ import { createCollectionActions } from "../../src/state/createCollectionActions";
10
+
11
+ type Item = { id: string; v: number };
12
+
13
+ function harness(initial: ReadonlyArray<Item> = []) {
14
+ let arr: ReadonlyArray<Item> = initial;
15
+ const actions = createCollectionActions<Item>(
16
+ (updater) => {
17
+ const next = updater(arr);
18
+ if (next !== arr) {
19
+ arr = next;
20
+ }
21
+ },
22
+ (it) => it.id,
23
+ );
24
+ return { actions, get: () => arr };
25
+ }
26
+
27
+ describe("createCollectionActions", () => {
28
+ it("add appends and produces a new array reference", () => {
29
+ const { actions, get } = harness();
30
+ const before = get();
31
+ actions.add({ id: "a", v: 1 });
32
+ expect(get()).toEqual([{ id: "a", v: 1 }]);
33
+ expect(get()).not.toBe(before);
34
+ });
35
+
36
+ it("replace swaps the matching element by key (new reference), keeping others and order", () => {
37
+ const { actions, get } = harness([
38
+ { id: "a", v: 1 },
39
+ { id: "b", v: 2 },
40
+ ]);
41
+ const before = get();
42
+ actions.replace({ prev: { id: "a", v: 1 }, next: { id: "a", v: 99 } });
43
+ expect(get()).toEqual([
44
+ { id: "a", v: 99 },
45
+ { id: "b", v: 2 },
46
+ ]);
47
+ expect(get()).not.toBe(before);
48
+ });
49
+
50
+ it("replace is a no-op (same reference) when the key is absent", () => {
51
+ const { actions, get } = harness([{ id: "a", v: 1 }]);
52
+ const before = get();
53
+ actions.replace({ prev: { id: "zzz", v: 0 }, next: { id: "zzz", v: 1 } });
54
+ expect(get()).toBe(before);
55
+ });
56
+
57
+ it("remove filters by key, new reference", () => {
58
+ const { actions, get } = harness([
59
+ { id: "a", v: 1 },
60
+ { id: "b", v: 2 },
61
+ ]);
62
+ const before = get();
63
+ actions.remove({ id: "a", v: 1 });
64
+ expect(get()).toEqual([{ id: "b", v: 2 }]);
65
+ expect(get()).not.toBe(before);
66
+ });
67
+
68
+ it("matches by keyOf, not object identity (nested key supported)", () => {
69
+ type Nested = { settings: { gid: string }; label: string };
70
+ let arr: ReadonlyArray<Nested> = [{ settings: { gid: "g1" }, label: "old" }];
71
+ const actions = createCollectionActions<Nested>(
72
+ (updater) => {
73
+ const next = updater(arr);
74
+ if (next !== arr) {
75
+ arr = next;
76
+ }
77
+ },
78
+ (n) => n.settings.gid,
79
+ );
80
+ // A different object carrying the SAME nested key still matches.
81
+ actions.replace({
82
+ prev: { settings: { gid: "g1" }, label: "whatever" },
83
+ next: { settings: { gid: "g1" }, label: "new" },
84
+ });
85
+ expect(arr).toEqual([{ settings: { gid: "g1" }, label: "new" }]);
86
+ });
87
+ });
@@ -0,0 +1,158 @@
1
+ // Unit spec for the hand-rolled reselect-lite (ADR-0002). No upstream
2
+ // counterpart to port — upstream gets this contract from reselect itself; this
3
+ // spec pins the same contract for our implementation.
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { createMemo, createStore, flush } from "solid-js";
6
+ import { renderHook } from "@solidjs/testing-library";
7
+ import { createSelector } from "../../src/state/createSelector";
8
+
9
+ type TestState = {
10
+ axis: { domain: readonly number[] };
11
+ data: { points: readonly number[] };
12
+ unrelated: { counter: number };
13
+ };
14
+
15
+ const makeState = (): TestState => ({
16
+ axis: { domain: [0, 1] },
17
+ data: { points: [1, 2, 3] },
18
+ unrelated: { counter: 0 },
19
+ });
20
+
21
+ describe("createSelector", () => {
22
+ it("shares one combiner run across repeated calls with the same state and args", () => {
23
+ const combiner = vi.fn((points: readonly number[], axisId: string) => ({
24
+ axisId,
25
+ total: points.reduce((a, b) => a + b, 0),
26
+ }));
27
+ const selector = createSelector(
28
+ [(s: TestState) => s.data.points, (_s: TestState, axisId: string) => axisId],
29
+ combiner,
30
+ );
31
+
32
+ const state = makeState();
33
+ const first = selector(state, "x");
34
+ const second = selector(state, "x");
35
+
36
+ expect(combiner).toHaveBeenCalledTimes(1);
37
+ expect(second).toBe(first);
38
+ });
39
+
40
+ it("keeps separate cache nodes per argument combination (SameValueZero keys)", () => {
41
+ const combiner = vi.fn((points: readonly number[], axisId: string | number) => ({
42
+ axisId,
43
+ count: points.length,
44
+ }));
45
+ const selector = createSelector(
46
+ [(s: TestState) => s.data.points, (_s: TestState, axisId: string | number) => axisId],
47
+ combiner,
48
+ );
49
+
50
+ const state = makeState();
51
+ const zeroNumber = selector(state, 0);
52
+ const zeroString = selector(state, "0");
53
+
54
+ // `0` and `"0"` are distinct axis ids — distinct nodes.
55
+ expect(combiner).toHaveBeenCalledTimes(2);
56
+ expect(selector(state, 0)).toBe(zeroNumber);
57
+ expect(selector(state, "0")).toBe(zeroString);
58
+ expect(combiner).toHaveBeenCalledTimes(2);
59
+ });
60
+
61
+ it("isolates caches per store: alternating stores never thrash each other", () => {
62
+ const combiner = vi.fn((points: readonly number[]) => points.length);
63
+ const selector = createSelector([(s: TestState) => s.data.points], combiner);
64
+
65
+ const a = makeState();
66
+ const b = makeState();
67
+ selector(a);
68
+ selector(b);
69
+ selector(a);
70
+ selector(b);
71
+
72
+ expect(combiner).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("recomputes when an input reference changes, and only then", () => {
76
+ const combiner = vi.fn((domain: readonly number[]) => [...domain]);
77
+ const selector = createSelector([(s: TestState) => s.axis.domain], combiner);
78
+
79
+ const state = makeState();
80
+ const first = selector(state);
81
+ expect(combiner).toHaveBeenCalledTimes(1);
82
+
83
+ // Unrelated branch replaced: input reference unchanged → cache hit.
84
+ state.unrelated = { counter: 1 };
85
+ expect(selector(state)).toBe(first);
86
+ expect(combiner).toHaveBeenCalledTimes(1);
87
+
88
+ // Input path replaced (slice immutability contract) → recompute.
89
+ state.axis = { domain: [0, 2] };
90
+ const second = selector(state);
91
+ expect(combiner).toHaveBeenCalledTimes(2);
92
+ expect(second).toEqual([0, 2]);
93
+ expect(second).not.toBe(first);
94
+ });
95
+
96
+ it("resultEqualityCheck preserves the previous reference on value-equal recomputes", () => {
97
+ const combiner = vi.fn((points: readonly number[]) => points.map((p) => p * 2));
98
+ const selector = createSelector([(s: TestState) => s.data.points], combiner, {
99
+ resultEqualityCheck: (prev, next) =>
100
+ prev.length === next.length && prev.every((v, i) => v === next[i]),
101
+ });
102
+
103
+ const state = makeState();
104
+ const first = selector(state);
105
+
106
+ // New input reference, same values → combiner runs, previous reference survives.
107
+ state.data = { points: [1, 2, 3] };
108
+ expect(selector(state)).toBe(first);
109
+ expect(combiner).toHaveBeenCalledTimes(2);
110
+
111
+ // And the value-equal result is cached: the next call is a pure cache hit.
112
+ expect(selector(state)).toBe(first);
113
+ expect(combiner).toHaveBeenCalledTimes(2);
114
+
115
+ // Genuinely different values → new reference.
116
+ state.data = { points: [4, 5] };
117
+ expect(selector(state)).toEqual([8, 10]);
118
+ expect(selector(state)).not.toBe(first);
119
+ });
120
+
121
+ it("propagates reactively through input reads, including after cache hits", () => {
122
+ const [state, setState] = createStore<TestState>(makeState());
123
+ const combiner = vi.fn((domain: readonly number[]) => domain[1]);
124
+ const selector = createSelector([(s: TestState) => s.axis.domain], combiner);
125
+
126
+ const { result: upper } = renderHook(() => createMemo(() => selector(state)));
127
+ flush();
128
+ expect(upper()).toBe(1);
129
+
130
+ // A cache-hit evaluation in between must not drop the input subscription.
131
+ expect(selector(state)).toBe(1);
132
+
133
+ setState((s) => {
134
+ s.axis = { domain: [0, 5] };
135
+ });
136
+ flush();
137
+ expect(upper()).toBe(5);
138
+ expect(combiner).toHaveBeenCalledTimes(2);
139
+ });
140
+
141
+ it("runs the combiner untracked: state read only inside a combiner does not subscribe", () => {
142
+ const [state, setState] = createStore<TestState>(makeState());
143
+ // Contract violation on purpose: the combiner reads state directly.
144
+ const impure = createSelector([(s: TestState) => s.axis.domain], () => state.unrelated.counter);
145
+
146
+ const { result } = renderHook(() => createMemo(() => impure(state)));
147
+ flush();
148
+ expect(result()).toBe(0);
149
+
150
+ setState((s) => {
151
+ s.unrelated = { counter: 7 };
152
+ });
153
+ flush();
154
+ // No input reference changed and the combiner's read was untracked:
155
+ // the memo must NOT have re-run. This documents the purity contract.
156
+ expect(result()).toBe(0);
157
+ });
158
+ });
@@ -0,0 +1,101 @@
1
+ // Unit spec for the register-while-mounted idiom (src/state/registerInStore.ts).
2
+ // No upstream counterpart — upstream hand-rolls two useLayoutEffects per site;
3
+ // this module concentrates the Solid-port equivalent, so this spec pins the
4
+ // contract every graphical item / axis / reference / tooltip / legend relies on.
5
+ //
6
+ // The interface is the test surface: (compute, {add, replace, remove}). These
7
+ // exercise it with spy handlers and a signal-driven compute — the same seam the
8
+ // 17 call sites cross. The global diagnostics guard in test/setup.ts additionally
9
+ // fails any of these on an unexpected reactive-graph diagnostic (e.g. a
10
+ // STRICT_READ_UNTRACKED, which would fire if the apply phase read a signal), so
11
+ // the add/replace cases double as a check that the two-arg createEffect shape
12
+ // keeps reactive reads in the compute phase only.
13
+ //
14
+ // Not asserted here: that removal runs owner-free (unregisterOnCleanup's
15
+ // runWithOwner(null), guarding SIGNAL_WRITE_IN_OWNED_SCOPE — a store write while a
16
+ // live parent disposes an owned child). That property IS exercised, just not in
17
+ // isolation: every graphical-item / axis component mount+unmount in the component
18
+ // suite writes to the real store on disposal, and the e2e suite covers real
19
+ // router/subtree teardown. A standalone unit attempt writes to a synthetic store
20
+ // during a disposal flush, which trips an unrelated test-harness scheduler
21
+ // artifact ("fn is not a function") rather than the diagnostic — so it would test
22
+ // the harness, not the contract.
23
+ import { describe, expect, it, vi } from "vitest";
24
+ import { createSignal, flush } from "solid-js";
25
+ import { renderHook } from "@solidjs/testing-library";
26
+ import { registerInStore } from "../../src/state/registerInStore";
27
+
28
+ const makeHandlers = () => ({ add: vi.fn(), replace: vi.fn(), remove: vi.fn() });
29
+
30
+ describe("registerInStore", () => {
31
+ it("calls add once on first registration", () => {
32
+ const handlers = makeHandlers();
33
+ renderHook(() => registerInStore(() => ({ id: "a" }), handlers));
34
+ flush();
35
+
36
+ expect(handlers.add).toHaveBeenCalledTimes(1);
37
+ expect(handlers.add).toHaveBeenCalledWith({ id: "a" });
38
+ expect(handlers.replace).not.toHaveBeenCalled();
39
+ expect(handlers.remove).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("calls replace(prev, next) when the computed settings change", () => {
43
+ const handlers = makeHandlers();
44
+ const { result: setN } = renderHook(() => {
45
+ const [n, setN] = createSignal(1);
46
+ registerInStore(() => ({ id: "a", n: n() }), handlers);
47
+ return setN;
48
+ });
49
+ flush();
50
+ expect(handlers.add).toHaveBeenCalledWith({ id: "a", n: 1 });
51
+
52
+ setN(2);
53
+ flush();
54
+
55
+ expect(handlers.replace).toHaveBeenCalledTimes(1);
56
+ expect(handlers.replace).toHaveBeenCalledWith({ id: "a", n: 1 }, { id: "a", n: 2 });
57
+ // add fires exactly once, ever.
58
+ expect(handlers.add).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("skips the tick when compute returns undefined and preserves prev", () => {
62
+ const handlers = makeHandlers();
63
+ const { result: setOn } = renderHook(() => {
64
+ const [on, setOn] = createSignal(true);
65
+ registerInStore(() => (on() ? { id: "a" } : undefined), handlers);
66
+ return setOn;
67
+ });
68
+ flush();
69
+ expect(handlers.add).toHaveBeenCalledTimes(1);
70
+
71
+ setOn(false); // compute → undefined: no write, prev untouched
72
+ flush();
73
+ expect(handlers.replace).not.toHaveBeenCalled();
74
+ expect(handlers.remove).not.toHaveBeenCalled();
75
+
76
+ setOn(true); // valid again: prev survived, so this is a replace — not a second add
77
+ flush();
78
+ expect(handlers.add).toHaveBeenCalledTimes(1);
79
+ expect(handlers.replace).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("calls remove(prev) on unmount", () => {
83
+ const handlers = makeHandlers();
84
+ const { cleanup } = renderHook(() => registerInStore(() => ({ id: "a" }), handlers));
85
+ flush();
86
+
87
+ cleanup();
88
+ expect(handlers.remove).toHaveBeenCalledTimes(1);
89
+ expect(handlers.remove).toHaveBeenCalledWith({ id: "a" });
90
+ });
91
+
92
+ it("does not call remove on unmount when it never registered", () => {
93
+ const handlers = makeHandlers();
94
+ const { cleanup } = renderHook(() => registerInStore(() => undefined, handlers));
95
+ flush();
96
+
97
+ cleanup();
98
+ expect(handlers.add).not.toHaveBeenCalled();
99
+ expect(handlers.remove).not.toHaveBeenCalled();
100
+ });
101
+ });
@@ -1,4 +1,4 @@
1
- import { scaleLinear } from "victory-vendor/d3-scale";
1
+ import { scaleLinear } from "d3-scale";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { assertNotNull } from "../../../helper/assertNotNull";
4
4
  import { AxisRange } from "../../../../src/state/selectors/axisSelectors";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { scaleLog } from "victory-vendor/d3-scale";
2
+ import { scaleLog } from "d3-scale";
3
3
  import { BaseCartesianAxis } from "../../../../src/state/cartesianAxisSlice";
4
4
  import { combineRealScaleType } from "../../../../src/state/selectors/combiners/combineRealScaleType";
5
5