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.
- package/package.json +5 -3
- package/src/cartesian/Area.tsx +40 -55
- package/src/cartesian/Bar.tsx +66 -58
- package/src/cartesian/BarStack.tsx +5 -2
- package/src/cartesian/Brush.tsx +1 -1
- package/src/cartesian/Funnel.tsx +15 -19
- package/src/cartesian/Line.tsx +34 -51
- package/src/cartesian/LineDrawShape.tsx +8 -6
- package/src/cartesian/ReferenceArea.tsx +25 -35
- package/src/cartesian/ReferenceDot.tsx +24 -34
- package/src/cartesian/ReferenceLine.tsx +25 -34
- package/src/cartesian/Scatter.tsx +25 -37
- package/src/cartesian/XAxis.tsx +54 -67
- package/src/cartesian/YAxis.tsx +53 -66
- package/src/cartesian/ZAxis.tsx +27 -40
- package/src/chart/SunburstChart.tsx +1 -1
- package/src/component/ActivePoints.tsx +0 -9
- package/src/component/Dots.tsx +7 -3
- package/src/context/ErrorBarContext.tsx +15 -24
- package/src/polar/Pie.tsx +36 -48
- package/src/polar/PolarAngleAxis.tsx +41 -50
- package/src/polar/PolarRadiusAxis.tsx +40 -48
- package/src/polar/Radar.tsx +22 -34
- package/src/polar/RadialBar.tsx +21 -39
- package/src/shape/Curve.tsx +1 -1
- package/src/shape/Symbols.tsx +1 -1
- package/src/state/SetLegendPayload.ts +19 -27
- package/src/state/SetTooltipEntrySettings.tsx +9 -26
- package/src/state/cartesianAxisSlice.ts +40 -60
- package/src/state/createCollectionActions.ts +51 -0
- package/src/state/createSelector.ts +97 -0
- package/src/state/externalEventsMiddleware.ts +2 -11
- package/src/state/graphicalItemsSlice.ts +27 -40
- package/src/state/isEventThrottled.ts +12 -0
- package/src/state/keyboardEventsMiddleware.ts +2 -8
- package/src/state/layoutSlice.ts +8 -4
- package/src/state/legendSlice.ts +24 -26
- package/src/state/mouseEventsMiddleware.ts +2 -13
- package/src/state/patched.ts +19 -0
- package/src/state/referenceElementsSlice.ts +40 -31
- package/src/state/registerInStore.ts +68 -0
- package/src/state/selectors/areaSelectors.ts +13 -36
- package/src/state/selectors/arrayEqualityCheck.ts +32 -7
- package/src/state/selectors/axisSelectors.ts +215 -169
- package/src/state/selectors/barSelectors.ts +8 -36
- package/src/state/selectors/combiners/combineConfiguredScale.ts +1 -1
- package/src/state/selectors/combiners/combineRealScaleType.ts +1 -1
- package/src/state/selectors/dataSelectors.ts +35 -16
- package/src/state/selectors/numberDomainEqualityCheck.ts +16 -8
- package/src/state/selectors/selectAxisForGraphicalItem.ts +52 -0
- package/src/state/tooltipSlice.ts +93 -64
- package/src/util/ActiveShapeUtils.tsx +0 -7
- package/src/util/BarUtils.tsx +27 -22
- package/src/util/ChartUtils.ts +69 -25
- package/src/util/propsAreEqual.ts +3 -33
- package/src/util/stacks/stackTypes.ts +1 -1
- package/src/util/types.ts +1 -1
- package/test/cartesian/CartesianAxis.spec.tsx +1 -1
- package/test/cartesian/GraphicalItemClipPath.scale-behavior.spec.tsx +1 -1
- package/test/cartesian/ReferenceLine/getEndPoints.spec.ts +1 -1
- package/test/cartesian/XAxis/XAxis.timescale.spec.tsx +1 -1
- package/test/helper/mockAxes.ts +1 -1
- package/test/shape/Curve.spec.tsx +1 -1
- package/test/state/createCollectionActions.spec.ts +87 -0
- package/test/state/createSelector.spec.ts +158 -0
- package/test/state/registerInStore.spec.tsx +101 -0
- package/test/state/selectors/combiners/combineConfiguredScale.spec.ts +1 -1
- package/test/state/selectors/combiners/combineRealScaleType.spec.ts +1 -1
- package/test/state/selectors/selectorMemoization.spec.tsx +136 -0
- package/test/util/CartesianUtils/CartesianUtils.spec.ts +1 -1
- package/test/util/ChartUtils/ChartUtils.spec.ts +1 -1
- package/test/util/ChartUtils/getCateCoordinateOfLine.spec.ts +14 -0
- package/test/util/ChartUtils/getStackedData.spec.ts +1 -1
- package/test/util/ChartUtils/truncateByDomain.spec.ts +1 -1
- package/test/util/scale/createCategoricalInverse.spec.ts +1 -1
- package/test-results/.last-run.json +4 -0
- package/src/state/reduxDevtoolsJsonStringifyReplacer.ts +0 -15
- package/src/util/axisPropsAreEqual.ts +0 -42
- package/src/util/resolveDefaultProps.tsx +0 -8
- package/src/util/typedDataKey.ts +0 -8
- package/test/state/reduxDevtoolsJsonStringifyReplacer.spec.ts +0 -25
- package/test/util/axisPropsAreEqual.spec.ts +0 -93
package/src/util/ChartUtils.ts
CHANGED
|
@@ -15,16 +15,8 @@ import {
|
|
|
15
15
|
stackOffsetSilhouette,
|
|
16
16
|
stackOffsetWiggle,
|
|
17
17
|
stackOrderNone,
|
|
18
|
-
} from "
|
|
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 ?
|
|
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) =>
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
|
5
|
-
//
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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";
|
package/test/helper/mockAxes.ts
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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 "
|
|
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
|
|