Repository: solidjs-community/solid-primitives (ts)

Issue:
<Repeat/> does not update when its times.prop changes
### Describe the bug

```tsx
function Counter() {
  const [count, setCount] = createSignal(2);
  const increment = () => setCount((count) => count + 1);
  const decrement = () => setCount((count) => count - 1);

  return (
    <>
      <button onClick={increment}>increment</button>
      <button onClick={decrement}>decrement</button>
      <Repeat times={count()}>{() => "hallo"}</Repeat>
    </>
  );
}
```

steps to reproduce bug:
- initial render: `hallohallo`
- press decrement: stays `hallohallo`
- press increment: stays `hallohallo`
- only when times becomes 0 it will render empty

[I was able to narrow the bug to the usage of `createMemo` in `<Repeat/>`](https://playground.solidjs.com/anonymous/cfa666cf-17cc-4aca-a091-a19f949bd2a5)

### Minimal Reproduction Link

https://playground.solidjs.com/anonymous/6c6feac1-bd0d-41f1-b6ec-61bca51182e3

Pull request:
fix: repeat must not mutate its result object in place (#785)
Fixes #785

This substantially rewrites `repeat` to be more straight-forward with fewer closures, unlike #786 which is more conservative.

Patch:
diff --git a/.changeset/tender-mirrors-lay.md b/.changeset/tender-mirrors-lay.md
new file mode 100644
index 00000000..b8d7080e
--- /dev/null
+++ b/.changeset/tender-mirrors-lay.md
@@ -0,0 +1,5 @@
+---
+"@solid-primitives/range": patch
+---
+
+repeat now returns clones of its result instead of mutating
diff --git a/packages/range/src/repeat.ts b/packages/range/src/repeat.ts
index d79efa7c..51b21e1e 100644
--- a/packages/range/src/repeat.ts
+++ b/packages/range/src/repeat.ts
@@ -1,4 +1,4 @@
-import { Accessor, JSX, createMemo, createRoot, onCleanup, untrack } from "solid-js";
+import { Accessor, JSX, createMemo, createRoot, onCleanup } from "solid-js";
 import { toFunction } from "./common.js";
 
 /**
@@ -23,60 +23,68 @@ export function repeat<T>(
   mapFn: (i: number) => T,
   options: { fallback?: Accessor<T> } = {},
 ): Accessor<T[]> {
-  let disposers: (() => void)[] = [],
-    items: T[] = [],
-    prevLen = 0;
-
-  onCleanup(() => disposers.forEach(f => f()));
+  let prev: readonly T[] = [];
+  let prevLen: number | undefined;
+  const disposers: (() => void)[] = [];
+  onCleanup(() => {
+    for (let index = 0; index < disposers.length; index++) {
+      disposers[index]!();
+    }
+  });
 
-  const mapLength = (len: number): T[] => {
-    if (len === 0) {
-      disposers.forEach(f => f());
+  // Truncate toward zero and force positive
+  const memoLen = createMemo(() => Math.max(times() | 0, 0));
 
-      if (options.fallback)
-        return createRoot(dispose => {
-          disposers = [dispose];
-          return (items = [options.fallback!()]);
-        });
+  return function mapLength(): T[] {
+    const len = memoLen();
+    if (len === prevLen) return prev as T[];
 
-      disposers = [];
-      return (items = []);
+    // Dispose of fallback or unnecessarry elements
+    if (prevLen === 0) disposers[0]?.();
+    else {
+      for (let index = len; index < disposers.length; index++) {
+        disposers[index]!();
+      }
     }
 
-    if (prevLen === 0) {
-      // after fallback case:
-      if (disposers[0]) disposers[0]();
-      for (let i = 0; i < len; i++) items[i] = createRoot(mapper.bind(void 0, i));
-      return items;
-    }
+    // The following prefers to use `prev.slice` to
+    // preserve any array element kind optimizations
+    // the runtime has made.
 
-    {
-      const diff = prevLen - len;
-      if (diff > 0) {
-        for (let i = prevLen - 1; i >= len; i--) disposers[i]!();
-        items.splice(len, diff);
-        disposers.splice(len, diff);
-        return items;
+    if (len === 0) {
+      const fallback = options.fallback;
+      if (fallback) {
+        // Show fallback if available
+        const next = prev.slice(0, 1);
+        next[0] = createRoot(dispose => {
+          disposers[0] = dispose;
+          return fallback();
+        });
+
+        disposers.length = 1;
+        prevLen = 0;
+        return (prev = next);
+      } else {
+        // Show empty array, otherwise
+        disposers.length = 0;
+        prevLen = 0;
+        return (prev = prev.slice(0, 0));
       }
     }
 
-    for (let i = prevLen; i < len; i++) items[i] = createRoot(mapper.bind(void 0, i));
-    return items;
-  };
+    const next = prev.slice(0, len);
 
-  const mapper = (index: number, dispose: () => void): T => {
-    disposers[index] = dispose;
-    return mapFn(index);
-  };
+    // Create new elements as needed
+    for (let index = prevLen ?? 0; index < len; index++) {
+      next[index] = createRoot(dispose => {
+        disposers[index] = dispose;
+        return mapFn(index);
+      });
+    }
 
-  const memoLen = createMemo(() => Math.floor(Math.max(times(), 0)));
-  return () => {
-    const len = memoLen();
-    return untrack(() => {
-      const newItems = mapLength(len);
-      prevLen = len;
-      return newItems;
-    });
+    disposers.length = len;
+    prevLen = len;
+    return (prev = next);
   };
 }
 


Test patch:
diff --git a/packages/range/test/repeat.test.ts b/packages/range/test/repeat.test.ts
index c23cb369..e98ab580 100644
--- a/packages/range/test/repeat.test.ts
+++ b/packages/range/test/repeat.test.ts
@@ -1,6 +1,6 @@
 import { expect, describe, it } from "vitest";
 import { createComputed, createRoot, createSignal, onCleanup } from "solid-js";
-import { repeat } from "../src/index.js";
+import { Repeat, repeat } from "../src/index.js";
 
 describe("repeat", () => {
   it("maps only added items", () =>
@@ -82,4 +82,52 @@ describe("repeat", () => {
       setLength(3);
       expect(mapped(), "mapped after dispose").toEqual(["fb"]);
     }));
+
+  it("uses fallback when length is initially 0", () =>
+    createRoot(disposer => {
+      const map = repeat(
+        () => 0,
+        i => i,
+        { fallback: () => NaN },
+      );
+      expect(map()).toEqual([NaN]);
+      disposer();
+    }));
+});
+
+describe("<Repeat/>", () => {
+  it("notifies observers on length change", () => {
+    const [length, setLength] = createSignal(3);
+
+    const [dispose, accessor] = createRoot(dispose => {
+      const accessor = Repeat({
+        get times() {
+          return length();
+        },
+        fallback: () => 0,
+        children: () => 1,
+      }) as never as () => {};
+      return [dispose, accessor];
+    });
+
+    let notifications = 0;
+    createComputed(() => {
+      accessor();
+      notifications++;
+    });
+
+    expect(notifications).toEqual(1);
+    setLength(4);
+    expect(notifications).toEqual(2);
+    setLength(0);
+    expect(notifications).toEqual(3);
+    setLength(2);
+    expect(notifications).toEqual(4);
+    setLength(1);
+    expect(notifications).toEqual(5);
+    setLength(1.5);
+    expect(notifications).toEqual(5);
+
+    dispose();
+  });
 });