Repository: alloy-framework/alloy
Pull request #127 (2025-04-20 23:31:07)

Use Scheduler
This introduces a scheduler to handle invoking reactive effects. For now it is merely a queue, which has the effect of making effects FIFO instead of LIFO. Additionally, since the queue is a set keyed by the effect function, we end up deduplicating effects. Consider the following (assuming we're in a component or other code run during rendering):

```js
let obj = reactive({ a: 0, a: 0 });

effect(() => {
  console.log({ a: obj.a, b: obj.b });
});
console.log("1");
obj.a = 1;
console.log("2");
obj.b = 2;
console.log("3");
```

Previously this would log `1, { a: 1, b: 0 }, 2, { a: 1, b: 2 }, 3`, but now logs `1, 2, 3, { a: 1, b: 2}`.

This coalescing effect seems to be a fairly significant optimization. Symbols especially frequently triggered multiple duplicate effects doing a lot of pointless work. Additionally, the scheduler provides an easy way to avoid duplicate work not tied to effects. This is used to make name conflict resolution run once all symbols have been declared in the current effect (so e.g. parameter conflicts will be called once per parameter list, not once per conflicted symbol). With a priority queue this could be updated to be called essentially once per scope by making the conflict resolution low priority.

This change is technically breaking, as if you are changing reactive values/signals after calling render/renderTree/printTree, you will need to call `flushJobs` yourself. I suspect this is only limited to tests (see the various test updates in this PR), and likely only tests in the core framework, so hopefully this breaking change is minor.

Another consequence of this change is that it is somewhat less subtle avoiding infinite loops with recursive effects. This PR allows recursive effects in order to allow patterns like dynamic discovery of types to emit while emitting types (see new test). However, for complex reactive code, you can definitely still get into recursive/mutually recursive effects without trying too hard. See for example the fix to the binder. Generally the fix is to slap an untrack somewhere. I think this is ok under the philosophy that complex reactive code will be mostly limited to the framework. In the future we can consider allowing recursive effects in some places but not others (e.g. in Vue, render effects are allowed to be recursive but manual effects are not).

github-actions[bot]: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@alloy-js/core`

<details>
<summary>Show changes</summary>

### `@alloy-js/core` - _breaking_ [✏️](https://github.com/bterlson/alloy/edit/scheduler/.chronus/changes/scheduler-2025-3-20-16-9-33.md?pr=/alloy-framework/alloy/pull/127)
> Use a scheduler for effects. Effects are no longer run instantly but are queued to run after the current effect finishes. This significantly reduces effect calls due to coalescing multiple updates and also enables more recursive patterns.
</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/3c46c0bf-a579-4010-8741-0b1ed6ac13e8)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/cli</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/cli@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/create</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/create@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/rollup-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/rollup-plugin@127
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@127
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/40851456927"><code>9801b80</code></a>_

bterlson: Validated with TypeSpec JS Client emitter, no regressions, very slight perf improvement (test times dominated by tsp compile)

Patch:
diff --git a/.chronus/changes/scheduler-2025-3-20-16-9-33.md b/.chronus/changes/scheduler-2025-3-20-16-9-33.md
new file mode 100644
index 0000000..478da0a
--- /dev/null
+++ b/.chronus/changes/scheduler-2025-3-20-16-9-33.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/core"
+---
+
+Use a scheduler for effects. Effects are no longer run instantly but are queued to run after the current effect finishes. This significantly reduces effect calls due to coalescing multiple updates and also enables more recursive patterns.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index b5fb803..220c8dd 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -1,6 +1,5 @@
 import {
   computed,
-  effect,
   reactive,
   ref,
   Ref,
@@ -10,8 +9,9 @@ import {
 import { useBinder } from "./context/binder.js";
 import { useMemberScope } from "./context/member-scope.js";
 import { useScope } from "./context/scope.js";
-import { memo, untrack } from "./jsx-runtime.js";
+import { effect, memo, untrack } from "./jsx-runtime.js";
 import { refkey, Refkey } from "./refkey.js";
+import { queueJob, QueueJob } from "./scheduler.js";
 export type Metadata = object;
 
 /**
@@ -464,6 +464,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     OutputScope,
     Map<string, Ref<OutputScope | undefined>>
   >();
+  const deconflictJobs = new Map<OutputScope, Map<string, QueueJob>>();
 
   return binder;
 
@@ -629,16 +630,23 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     addInstanceMembersToSymbol(target);
 
     effect(() => {
+      // rerun when instance member scope changes
       for (const sym of source.instanceMemberScope!.symbols) {
-        if (target.instanceMemberScope!.symbols.has(sym)) {
-          continue;
-        }
+        // but don't want to track anything else
+        untrack(() => {
+          // we don't want to trigger this effect when the symbols
+          // we're instantiating into has changed
+
+          if (target.instanceMemberScope!.symbols.has(sym)) {
+            return;
+          }
 
-        createSymbol({
-          name: sym.name,
-          scope: target.instanceMemberScope!,
-          refkey: [refkey(target.refkeys[0], sym.refkeys[0])],
-          flags: sym.flags | OutputSymbolFlags.InstanceMember,
+          createSymbol({
+            name: sym.name,
+            scope: target.instanceMemberScope!,
+            refkey: [refkey(target.refkeys[0], sym.refkeys[0])],
+            flags: sym.flags | OutputSymbolFlags.InstanceMember,
+          });
         });
       }
     });
@@ -789,7 +797,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
   function deconflict(symbol: OutputSymbol) {
     const scope = symbol.scope;
     const existingNames = [...scope.symbols].filter(
-      (sym) => sym.originalName === symbol.name,
+      (sym) => sym.name === symbol.name,
     );
 
     if (existingNames.length < 2) {
@@ -797,7 +805,13 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     }
 
     if (options.nameConflictResolver) {
-      options.nameConflictResolver(symbol.name, existingNames);
+      queueJob(
+        deconflictJobForScopeAndName(
+          scope,
+          symbol.name,
+          options.nameConflictResolver,
+        ),
+      );
     } else {
       // default disambiguation is first-wins
       for (let i = 1; i < existingNames.length; i++) {
@@ -806,6 +820,31 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     }
   }
 
+  function deconflictJobForScopeAndName(
+    scope: OutputScope,
+    name: string,
+    handler: NameConflictResolver,
+  ) {
+    if (!deconflictJobs.has(scope)) {
+      deconflictJobs.set(scope, new Map());
+    }
+
+    const jobs = deconflictJobs.get(scope)!;
+    if (jobs.has(name)) {
+      return jobs.get(name)!;
+    }
+    const job = () => {
+      const conflictedSymbols = [...scope.symbols].filter(
+        (sym) => sym.name === name,
+      );
+      handler(name, conflictedSymbols);
+      jobs.delete(name);
+    };
+
+    jobs.set(name, job);
+    return job;
+  }
+
   function getSymbolForRefkey<TSymbol extends OutputSymbol>(
     refkey: Refkey,
   ): Ref<TSymbol | undefined> {
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index eadc28f..bd12996 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -5,6 +5,7 @@ import {
   isReactive,
   pauseTracking,
   proxyRefs,
+  ReactiveEffectRunner,
   Ref,
   resetTracking,
   shallowRef,
@@ -14,6 +15,7 @@ import {
 } from "@vue/reactivity";
 import { Refkey } from "./refkey.js";
 import { RenderedTextTree } from "./render.js";
+import { scheduler } from "./scheduler.js";
 
 if ((globalThis as any).ALLOY) {
   throw new Error(
@@ -125,21 +127,29 @@ export function effect<T>(fn: (prev?: T) => T, current?: T) {
     for (let k = 0, len = d.length; k < len; k++) d[k]();
 
     // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    final && stop(c);
+    final && stop(runner);
   };
 
   onCleanup(() => cleanupFn(true));
-  const c = vueEffect(() => {
-    cleanupFn(false);
-
-    const oldContext = globalContext;
-    globalContext = context;
-    try {
-      current = fn(current);
-    } finally {
-      globalContext = oldContext;
-    }
-  }, {});
+  const runner: ReactiveEffectRunner<void> = vueEffect(
+    () => {
+      cleanupFn(false);
+
+      const oldContext = globalContext;
+      globalContext = context;
+      try {
+        current = fn(current);
+      } finally {
+        globalContext = oldContext;
+      }
+    },
+    {
+      scheduler: scheduler(() => runner),
+    },
+  );
+
+  // allow recursive effects (recursive option does nothing, possible bug)
+  (runner as any).effect.flags |= 1 << 5;
 }
 
 /**
diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts
index 5e1c918..5b3c355 100644
--- a/packages/core/src/render.ts
+++ b/packages/core/src/render.ts
@@ -22,6 +22,7 @@ import {
   untrack,
 } from "./jsx-runtime.js";
 import { isRefkey } from "./refkey.js";
+import { flushJobs } from "./scheduler.js";
 const {
   builders: {
     align,
@@ -181,6 +182,7 @@ export function render(
   options?: PrintTreeOptions,
 ): OutputDirectory {
   const tree = renderTree(children);
+  flushJobs();
   let rootDirectory: OutputDirectory | undefined = undefined;
 
   // when passing Output, the first render tree child is the Output component.
@@ -559,6 +561,9 @@ export function printTree(tree: RenderedTextTree, options?: PrintTreeOptions) {
     ),
   };
 
+  // make sure queue is empty
+  flushJobs();
+
   const d = printTreeWorker(tree);
   return doc.printer.printDocToString(d, options as doc.printer.Options)
     .formatted;
diff --git a/packages/core/src/scheduler.ts b/packages/core/src/scheduler.ts
new file mode 100644
index 0000000..818b2ee
--- /dev/null
+++ b/packages/core/src/scheduler.ts
@@ -0,0 +1,24 @@
+import { ReactiveEffectRunner } from "@vue/reactivity";
+
+export interface QueueJob {
+  (): any;
+}
+const queue = new Set<QueueJob>();
+
+export function scheduler(jobGetter: () => ReactiveEffectRunner) {
+  return () => {
+    queueJob(jobGetter());
+  };
+}
+export function queueJob(job: QueueJob) {
+  // the set is serving an important purpose here in deduping the effects we run
+  // (which in effect coalesces multiple update effects together).
+  queue.add(job);
+}
+
+export function flushJobs() {
+  for (const job of queue) {
+    queue.delete(job);
+    job();
+  }
+}


Test patch:
diff --git a/packages/core/test/components/declaration.test.tsx b/packages/core/test/components/declaration.test.tsx
index 621b955..df8a11d 100644
--- a/packages/core/test/components/declaration.test.tsx
+++ b/packages/core/test/components/declaration.test.tsx
@@ -7,6 +7,7 @@ import {
   Scope,
   useBinder,
 } from "../../src/index.js";
+import { flushJobs } from "../../src/scheduler.js";
 import { createTap } from "../../src/tap.js";
 
 it("creates and cleans up a symbol", () => {
@@ -33,5 +34,6 @@ it("creates and cleans up a symbol", () => {
   const subScope = [...binder.globalScope.children][0];
   expect(subScope.symbols.size).toBe(1);
   doDecl.value = false;
+  flushJobs();
   expect(subScope.symbols.size).toBe(0);
 });
diff --git a/packages/core/test/components/list.test.tsx b/packages/core/test/components/list.test.tsx
index 375251b..33be349 100644
--- a/packages/core/test/components/list.test.tsx
+++ b/packages/core/test/components/list.test.tsx
@@ -73,7 +73,6 @@ it("is useful for statements", () => {
       <Statement />
     </List>,
   );
-
   expect(printTree(tree)).toEqual(d`
     console.log(true);
     console.log(true);
diff --git a/packages/core/test/control-flow/for.test.tsx b/packages/core/test/control-flow/for.test.tsx
index 57de60a..cd49028 100644
--- a/packages/core/test/control-flow/for.test.tsx
+++ b/packages/core/test/control-flow/for.test.tsx
@@ -3,6 +3,7 @@ import { d } from "@alloy-js/core/testing";
 import { expect, it } from "vitest";
 import { For } from "../../src/components/For.jsx";
 import { onCleanup, printTree, reactive, renderTree } from "../../src/index.js";
+import { flushJobs } from "../../src/scheduler.js";
 
 it("works", () => {
   const messages = ["hi", "bye"];
@@ -76,6 +77,7 @@ it("doesn't rerender mappers", () => {
   expect(count).toBe(2);
 
   messages.push("maybe");
+  flushJobs();
 
   expect(count).toBe(3);
   expect(printTree(tree)).toBe(d`
@@ -93,7 +95,7 @@ it("doesn't rerender mappers with sets", () => {
   expect(count).toBe(2);
 
   messages.add("maybe");
-
+  flushJobs();
   expect(count).toBe(3);
   expect(printTree(tree)).toBe(d`
     item 0
@@ -116,7 +118,7 @@ it("doesn't rerender mappers with maps", () => {
   expect(count).toBe(2);
 
   messages.set("maybe", "three");
-
+  flushJobs();
   expect(count).toBe(3);
   expect(printTree(tree)).toBe(d`
     item 0
@@ -132,9 +134,11 @@ it("doesn't rerender mappers (with splice)", () => {
   const tree = renderTree(template);
   expect(count).toBe(3);
   messages.splice(1, 1);
+  flushJobs();
   // A sufficiently smart mapJoin would be able to handle this case...
   // but for now we re-render everything after the splice point.
   expect(count).toBe(4);
+
   expect(printTree(tree)).toBe(d`
     item 0
     item 3
@@ -165,12 +169,14 @@ it("cleans up things which end up removed (with push)", () => {
   `);
 
   items.pop();
+  flushJobs();
   expect(cleanups).toEqual(["b"]);
   expect(printTree(tree)).toBe(d`
     Letter a
   `);
 
   items.pop();
+  flushJobs();
   expect(cleanups).toEqual(["b", "a"]);
   expect(printTree(tree)).toBe("");
 });
@@ -200,7 +206,7 @@ it("cleans up things which end up removed (with splice)", () => {
   `);
 
   items.splice(1, 1);
-
+  flushJobs();
   // A sufficiently smart mapJoin would be able to handle this case...
   // but for now we re-render everything after the splice point.
   expect(cleanups).toEqual(["b", "c"]);
diff --git a/packages/core/test/reactivity/circular-reactives.test.tsx b/packages/core/test/reactivity/circular-reactives.test.tsx
new file mode 100644
index 0000000..083ef6d
--- /dev/null
+++ b/packages/core/test/reactivity/circular-reactives.test.tsx
@@ -0,0 +1,32 @@
+import { shallowReactive } from "@vue/reactivity";
+import { expect, it } from "vitest";
+import { For } from "../../src/index.js";
+import { printTree, renderTree } from "../../src/render.js";
+import { d } from "../../testing/render.js";
+
+it("it should work with circular reactives", () => {
+  const items: string[] = shallowReactive([]);
+  let added = false;
+  function MaybeAddString(props: any) {
+    if (!added) {
+      items.push("item " + items.length);
+      added = true;
+    }
+    return <>{props.item}</>;
+  }
+  const template = (
+    <>
+      <For each={items}>
+        {(item) => {
+          return <MaybeAddString item={item} />;
+        }}
+      </For>
+    </>
+  );
+  const tree = renderTree(template);
+  items.push("item start");
+  expect(printTree(tree)).toBe(d`
+    item start
+    item 1
+  `);
+});
diff --git a/packages/core/test/reactivity/cleanup.test.tsx b/packages/core/test/reactivity/cleanup.test.tsx
index 98af2f8..15f8b84 100644
--- a/packages/core/test/reactivity/cleanup.test.tsx
+++ b/packages/core/test/reactivity/cleanup.test.tsx
@@ -2,6 +2,7 @@ import { Children, effect, memo, onCleanup } from "@alloy-js/core/jsx-runtime";
 import { ref } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
 import { renderTree } from "../../src/render.js";
+import { flushJobs } from "../../src/scheduler.js";
 
 describe("memo cleanup", () => {
   it("cleans up when memo value is recomputed", () => {
@@ -19,6 +20,7 @@ describe("memo cleanup", () => {
     expect(callCount).toBe(0);
 
     r.value = 2;
+    flushJobs();
 
     expect(m()).toBe(2);
     expect(callCount).toBe(1);
@@ -40,6 +42,7 @@ describe("effect cleanup", () => {
     expect(cleanedUp).toBe(false);
 
     r.value = 2;
+    flushJobs();
 
     expect(cleanedUp).toBe(true);
   });
@@ -58,6 +61,7 @@ describe("element cleanup", () => {
     const template = <>{el}</>;
     renderTree(template);
     el.value = "";
+    flushJobs();
     expect(cleanedUp).toBe(true);
   });
 
@@ -85,6 +89,7 @@ describe("element cleanup", () => {
     const template = <>{el}</>;
     renderTree(template);
     el.value = "";
+    flushJobs();
     expect(cleanedUpC1).toBe(true);
     expect(cleanedUpC2).toBe(true);
   });
diff --git a/packages/core/test/reactivity/untrack.test.ts b/packages/core/test/reactivity/untrack.test.ts
index 3c8363d..36bd91c 100644
--- a/packages/core/test/reactivity/untrack.test.ts
+++ b/packages/core/test/reactivity/untrack.test.ts
@@ -1,6 +1,7 @@
 import { ref } from "@vue/reactivity";
 import { expect, it } from "vitest";
 import { memo, untrack } from "../../src/jsx-runtime.js";
+import { flushJobs } from "../../src/scheduler.js";
 
 it("ignores signals for dependency tracking", () => {
   const signal = ref(0);
@@ -12,6 +13,7 @@ it("ignores signals for dependency tracking", () => {
   expect(m()).toBe(0);
 
   signal.value = 1;
+  flushJobs();
 
   expect(m()).toBe(0);
 });
@@ -28,6 +30,7 @@ it("doesn't affect signal changes", () => {
   untrack(() => {
     signal.value = 1;
   });
+  flushJobs();
 
   expect(m()).toBe(1);
 });
diff --git a/packages/core/test/rendering/memoization.test.tsx b/packages/core/test/rendering/memoization.test.tsx
index 1d7e31c..aee0b0d 100644
--- a/packages/core/test/rendering/memoization.test.tsx
+++ b/packages/core/test/rendering/memoization.test.tsx
@@ -2,6 +2,7 @@ import { memo } from "@alloy-js/core/jsx-runtime";
 import { ref } from "@vue/reactivity";
 import { expect, it } from "vitest";
 import { renderTree } from "../../src/render.js";
+import { flushJobs } from "../../src/scheduler.js";
 
 it("memoizes child components", () => {
   let renderCount = 0;
@@ -26,5 +27,6 @@ it("memoizes child components", () => {
 
   renderTree(template);
   doThing.value = true;
+  flushJobs();
   expect(renderCount).toBe(2);
 });
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index 143b9ac..4831373 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -8,6 +8,7 @@ import {
   OutputSymbolFlags,
 } from "../src/binder.js";
 import { Refkey, refkey } from "../src/refkey.js";
+import { flushJobs } from "../src/scheduler.js";
 
 it("works", () => {
   const binder = createOutputBinder();
@@ -22,10 +23,11 @@ it("works", () => {
     scope,
   });
 
+  flushJobs();
   expect([...scope.getSymbolNames()]).toEqual(["sym"]);
 
   symbol.name = "bar";
-
+  flushJobs();
   expect([...scope.getSymbolNames()]).toEqual(["bar"]);
 });
 
@@ -354,6 +356,7 @@ describe("instantiating members", () => {
     });
 
     binder.instantiateSymbolInto(rootSymbol, instantiation);
+    flushJobs();
     expect(
       instantiation.flags & OutputSymbolFlags.InstanceMemberContainer,
     ).toBeTruthy();
@@ -377,6 +380,7 @@ describe("instantiating members", () => {
       instantiation.refkeys[0],
       newInstanceMemberRefkey,
     );
+    flushJobs();
     expect(
       instantiation.instanceMemberScope!.symbolsByRefkey.get(newExpectedRefkey),
     ).toBeDefined();
@@ -540,6 +544,7 @@ describe("refkey resolution", () => {
     expect(resolvedSym.value).toBe(undefined);
 
     sym.refkeys[0] = key;
+    flushJobs();
     expect(resolvedSym.value?.targetDeclaration).toBe(sym);
   });
 });
@@ -563,10 +568,11 @@ describe("Deleting symbols", () => {
     expect(resolvedSym.value).toBe(undefined);
 
     sym.refkeys[0] = key;
+    flushJobs();
     expect(resolvedSym.value?.targetDeclaration).toBe(sym);
 
     binder.deleteSymbol(sym);
-
+    flushJobs();
     expect(resolvedSym.value).toBe(undefined);
   });
 
diff --git a/packages/core/test/utils.test.tsx b/packages/core/test/utils.test.tsx
index 0b8d38b..9b1fb50 100644
--- a/packages/core/test/utils.test.tsx
+++ b/packages/core/test/utils.test.tsx
@@ -2,6 +2,7 @@ import { Children } from "@alloy-js/core/jsx-runtime";
 import { computed, ref, triggerRef } from "@vue/reactivity";
 import { describe, expect, it } from "vitest";
 import { renderTree } from "../src/render.js";
+import { flushJobs } from "../src/scheduler.js";
 import { children, join, mapJoin } from "../src/utils.js";
 import "../testing/extend-expect.js";
 
@@ -93,6 +94,7 @@ describe("mapJoin", () => {
     expect(callCount).toBe(2);
     arr.value.push(3);
     triggerRef(arr);
+    flushJobs();
     expect(callCount).toBe(3);
   });
   it("can map a joiner", () => {