Skip to content

Commit df736b6

Browse files
authored
fix: repeat must not mutate its result object in place (#785) (#787)
2 parents a37052d + 0192f34 commit df736b6

File tree

3 files changed

+106
-45
lines changed

3 files changed

+106
-45
lines changed

.changeset/tender-mirrors-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solid-primitives/range": patch
3+
---
4+
5+
repeat now returns clones of its result instead of mutating

packages/range/src/repeat.ts

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Accessor, JSX, createMemo, createRoot, onCleanup, untrack } from "solid-js";
1+
import { Accessor, JSX, createMemo, createRoot, onCleanup } from "solid-js";
22
import { toFunction } from "./common.js";
33

44
/**
@@ -23,60 +23,68 @@ export function repeat<T>(
2323
mapFn: (i: number) => T,
2424
options: { fallback?: Accessor<T> } = {},
2525
): Accessor<T[]> {
26-
let disposers: (() => void)[] = [],
27-
items: T[] = [],
28-
prevLen = 0;
29-
30-
onCleanup(() => disposers.forEach(f => f()));
26+
let prev: readonly T[] = [];
27+
let prevLen: number | undefined;
28+
const disposers: (() => void)[] = [];
29+
onCleanup(() => {
30+
for (let index = 0; index < disposers.length; index++) {
31+
disposers[index]!();
32+
}
33+
});
3134

32-
const mapLength = (len: number): T[] => {
33-
if (len === 0) {
34-
disposers.forEach(f => f());
35+
// Truncate toward zero and force positive
36+
const memoLen = createMemo(() => Math.max(times() | 0, 0));
3537

36-
if (options.fallback)
37-
return createRoot(dispose => {
38-
disposers = [dispose];
39-
return (items = [options.fallback!()]);
40-
});
38+
return function mapLength(): T[] {
39+
const len = memoLen();
40+
if (len === prevLen) return prev as T[];
4141

42-
disposers = [];
43-
return (items = []);
42+
// Dispose of fallback or unnecessarry elements
43+
if (prevLen === 0) disposers[0]?.();
44+
else {
45+
for (let index = len; index < disposers.length; index++) {
46+
disposers[index]!();
47+
}
4448
}
4549

46-
if (prevLen === 0) {
47-
// after fallback case:
48-
if (disposers[0]) disposers[0]();
49-
for (let i = 0; i < len; i++) items[i] = createRoot(mapper.bind(void 0, i));
50-
return items;
51-
}
50+
// The following prefers to use `prev.slice` to
51+
// preserve any array element kind optimizations
52+
// the runtime has made.
5253

53-
{
54-
const diff = prevLen - len;
55-
if (diff > 0) {
56-
for (let i = prevLen - 1; i >= len; i--) disposers[i]!();
57-
items.splice(len, diff);
58-
disposers.splice(len, diff);
59-
return items;
54+
if (len === 0) {
55+
const fallback = options.fallback;
56+
if (fallback) {
57+
// Show fallback if available
58+
const next = prev.slice(0, 1);
59+
next[0] = createRoot(dispose => {
60+
disposers[0] = dispose;
61+
return fallback();
62+
});
63+
64+
disposers.length = 1;
65+
prevLen = 0;
66+
return (prev = next);
67+
} else {
68+
// Show empty array, otherwise
69+
disposers.length = 0;
70+
prevLen = 0;
71+
return (prev = prev.slice(0, 0));
6072
}
6173
}
6274

63-
for (let i = prevLen; i < len; i++) items[i] = createRoot(mapper.bind(void 0, i));
64-
return items;
65-
};
75+
const next = prev.slice(0, len);
6676

67-
const mapper = (index: number, dispose: () => void): T => {
68-
disposers[index] = dispose;
69-
return mapFn(index);
70-
};
77+
// Create new elements as needed
78+
for (let index = prevLen ?? 0; index < len; index++) {
79+
next[index] = createRoot(dispose => {
80+
disposers[index] = dispose;
81+
return mapFn(index);
82+
});
83+
}
7184

72-
const memoLen = createMemo(() => Math.floor(Math.max(times(), 0)));
73-
return () => {
74-
const len = memoLen();
75-
return untrack(() => {
76-
const newItems = mapLength(len);
77-
prevLen = len;
78-
return newItems;
79-
});
85+
disposers.length = len;
86+
prevLen = len;
87+
return (prev = next);
8088
};
8189
}
8290

packages/range/test/repeat.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, describe, it } from "vitest";
22
import { createComputed, createRoot, createSignal, onCleanup } from "solid-js";
3-
import { repeat } from "../src/index.js";
3+
import { Repeat, repeat } from "../src/index.js";
44

55
describe("repeat", () => {
66
it("maps only added items", () =>
@@ -82,4 +82,52 @@ describe("repeat", () => {
8282
setLength(3);
8383
expect(mapped(), "mapped after dispose").toEqual(["fb"]);
8484
}));
85+
86+
it("uses fallback when length is initially 0", () =>
87+
createRoot(disposer => {
88+
const map = repeat(
89+
() => 0,
90+
i => i,
91+
{ fallback: () => NaN },
92+
);
93+
expect(map()).toEqual([NaN]);
94+
disposer();
95+
}));
96+
});
97+
98+
describe("<Repeat/>", () => {
99+
it("notifies observers on length change", () => {
100+
const [length, setLength] = createSignal(3);
101+
102+
const [dispose, accessor] = createRoot(dispose => {
103+
const accessor = Repeat({
104+
get times() {
105+
return length();
106+
},
107+
fallback: () => 0,
108+
children: () => 1,
109+
}) as never as () => {};
110+
return [dispose, accessor];
111+
});
112+
113+
let notifications = 0;
114+
createComputed(() => {
115+
accessor();
116+
notifications++;
117+
});
118+
119+
expect(notifications).toEqual(1);
120+
setLength(4);
121+
expect(notifications).toEqual(2);
122+
setLength(0);
123+
expect(notifications).toEqual(3);
124+
setLength(2);
125+
expect(notifications).toEqual(4);
126+
setLength(1);
127+
expect(notifications).toEqual(5);
128+
setLength(1.5);
129+
expect(notifications).toEqual(5);
130+
131+
dispose();
132+
});
85133
});

0 commit comments

Comments
 (0)