From f028f85a4c7c00478cca9fa3a3222253c0a92209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 4 Oct 2026 10:21:37 -0300 Subject: [PATCH 1/3] test(signals): align fixtures with current types and signatures Preserve refreshable accessor types, instantiate deferred gates, and fix imports and call signatures used by the existing regression tests. Keep deliberately invalid callbacks at an explicit runtime-test boundary and keep optional fixture properties absent at runtime. No production, configuration or dependency changes. --- .../adoption-unchanged-key-read-3706.test.ts | 2 +- .../tests/attribution-navigation.test.ts | 2 +- packages/signals/tests/createRoot.test.ts | 5 +++-- packages/signals/tests/diagnostics.test.ts | 5 +++-- .../effect-mainline-ownership-3412.test.ts | 4 ++-- .../tests/held-conditional-effect.test.ts | 10 ++++----- ...eld-derivation-not-a-proposal-3612.test.ts | 10 ++++----- .../tests/held-truth-lane-only.test.ts | 2 +- .../inv4-projection-dispose-shadow.test.ts | 2 +- packages/signals/tests/l2-contract.test.ts | 17 +++++++-------- .../tests/late-pending-equality.test.ts | 2 +- .../recompute-preserves-disposed-3621.test.ts | 9 ++++---- packages/signals/tests/refresh-await.test.ts | 21 ++++++++++--------- .../tests/settle-walk-invariant.test.ts | 2 +- .../tests/spec-async-semantics.test.ts | 4 +++- .../signals/tests/store/async-setter.test.ts | 16 +++++++------- .../tests/store/deep-overlay-rewalk.test.ts | 2 +- .../store/flight-owned-transaction.test.ts | 2 +- .../signals/tests/store/fold-3164.test.ts | 8 +++++-- .../tests/store/fold-slot-identity.test.ts | 2 +- .../optimistic-draft-visibility-3665.test.ts | 5 +++-- .../signals/tests/store/owner-stamp.test.ts | 2 +- .../privatization-observer-identity.test.ts | 2 +- .../signals/tests/store/utilities.test.ts | 8 +++---- .../tests/strict-read-pending-store.test.ts | 7 ++++--- packages/signals/tests/until.test.ts | 5 +++-- .../tests/untracked-async-read.test.ts | 2 +- 27 files changed, 85 insertions(+), 73 deletions(-) diff --git a/packages/signals/tests/adoption-unchanged-key-read-3706.test.ts b/packages/signals/tests/adoption-unchanged-key-read-3706.test.ts index 04ec3ea43..b44c0f984 100644 --- a/packages/signals/tests/adoption-unchanged-key-read-3706.test.ts +++ b/packages/signals/tests/adoption-unchanged-key-read-3706.test.ts @@ -41,7 +41,7 @@ function deferred() { } function board(read: (drag: () => string | undefined, cards: Card[]) => unknown) { - const gates = new Map>(); + const gates = new Map>>(); let move!: (id: string, column: number) => Promise; let setDrag!: (v: string) => void; let drag!: () => string | undefined; diff --git a/packages/signals/tests/attribution-navigation.test.ts b/packages/signals/tests/attribution-navigation.test.ts index 288bcebab..c191cfff6 100644 --- a/packages/signals/tests/attribution-navigation.test.ts +++ b/packages/signals/tests/attribution-navigation.test.ts @@ -755,7 +755,7 @@ describe("interaction — a router that awaited before writing hands the click b const [location, setLocation] = createSignal("/a", { name: "location" }); createRoot(() => createEffect(location, () => {}, { name: "reader" })); flush(); - let first: ReturnType; + let first: ReturnType["attribution"]["currentOrigin"]>; OBSERVE!.attribution.withInteraction({ type: "click", target: "a.first" }, () => { first = OBSERVE!.attribution.currentOrigin(); }); diff --git a/packages/signals/tests/createRoot.test.ts b/packages/signals/tests/createRoot.test.ts index 7608d76a5..53889ea4e 100644 --- a/packages/signals/tests/createRoot.test.ts +++ b/packages/signals/tests/createRoot.test.ts @@ -11,7 +11,8 @@ import { refresh, type Accessor, type Owner, - type Signal + type Signal, + type SourceAccessor } from "../src/index.js"; afterEach(() => flush()); @@ -48,7 +49,7 @@ it("should not resurrect a dependency-free computation queued before disposal (# // resurrecting the node (post-unmount runs, leaked cleanups). let runs = 0; let cleanups = 0; - let read!: Accessor; + let read!: SourceAccessor; let dispose!: () => void; createRoot(d => { diff --git a/packages/signals/tests/diagnostics.test.ts b/packages/signals/tests/diagnostics.test.ts index 47d6aa4e0..7a23ae061 100644 --- a/packages/signals/tests/diagnostics.test.ts +++ b/packages/signals/tests/diagnostics.test.ts @@ -192,7 +192,8 @@ describe("diagnostics", () => { const capture = OBSERVE!.diagnostics.capture(); createRoot(() => { - expect(() => createEffect(() => 1)).toThrow( + // Deliberately omit the required effect callback to exercise the runtime guard. + expect(() => (createEffect as unknown as (compute: () => number) => void)(() => 1)).toThrow( /createEffect requires both a compute function and an effect function/ ); }); @@ -279,7 +280,7 @@ describe("diagnostics console footer", () => { expect(url).toMatch( /^https:\/\/github\.com\/solidjs\/solid\/blob\/main\/.*SKILL\.md#strict_read_untracked$/ ); - expect(DEV!.guideUrl("WIDE_WRITE")).toBe(url.replace(/#.*$/, "#wide_write")); + expect(DEV!.guideUrl("SILENT_HOLD")).toBe(url.replace(/#.*$/, "#silent_hold")); }); afterEach(() => { diff --git a/packages/signals/tests/effect-mainline-ownership-3412.test.ts b/packages/signals/tests/effect-mainline-ownership-3412.test.ts index 7a7d7c5f6..b60041eec 100644 --- a/packages/signals/tests/effect-mainline-ownership-3412.test.ts +++ b/packages/signals/tests/effect-mainline-ownership-3412.test.ts @@ -31,8 +31,8 @@ function setup(opts: { boundary: boolean; on: boolean; unconditional: boolean }) const [show, _setShow] = createSignal(true); setCount = _setCount; setShow = _setShow; - const copy = createMemo(async () => count(), undefined, { name: "copy" }); - const details = createMemo(() => delay(1500, copy()), undefined, { name: "details" }); + const copy = createMemo(async () => count(), { name: "copy" }); + const details = createMemo(() => delay(1500, copy()), { name: "details" }); createRenderEffect( () => String(show()), v => { diff --git a/packages/signals/tests/held-conditional-effect.test.ts b/packages/signals/tests/held-conditional-effect.test.ts index 99697a577..7812c5e02 100644 --- a/packages/signals/tests/held-conditional-effect.test.ts +++ b/packages/signals/tests/held-conditional-effect.test.ts @@ -78,9 +78,9 @@ describe("conditional render effect across a held branch change (#3438)", () => const [show, sS] = createSignal(true, { name: "show" }); setCount = sC; setShow = sS; - const delayedShow = createMemo(() => delay(1000, show()), undefined, { name: "delayedShow" }); + const delayedShow = createMemo(() => delay(1000, show()), { name: "delayedShow" }); // Shape the compiler emits for `{show() ? count() : "hidden"}`. - const c = createMemo(() => !!show(), undefined, { name: "c" }); + const c = createMemo(() => !!show(), { name: "c" }); text(() => `Count: ${count()}`, log, when); text(() => `Show: ${show()}`, log, when); deps = text(() => `Panel: ${c() ? count() : "hidden"}`, log, when); @@ -89,15 +89,15 @@ describe("conditional render effect across a held branch change (#3438)", () => flush(); await settle(); await advanceTo(2000); - expect(deps()).toBe("computed,count]"); + expect(deps()).toBe("c,count]"); setShow(false); await settle(); // The held pass validated only `c`; count stays linked past the tail. - expect(deps()).toBe("computed],count"); + expect(deps()).toBe("c],count"); await advanceTo(2500); setCount(1); await settle(); - expect(deps()).toBe("computed,count]"); + expect(deps()).toBe("c,count]"); await advanceTo(5000); // The `show` flip (2000) is held by delayedShow. Panel's held pass stopped // reading count; before, its trim dropped that edge at the pass while the diff --git a/packages/signals/tests/held-derivation-not-a-proposal-3612.test.ts b/packages/signals/tests/held-derivation-not-a-proposal-3612.test.ts index 45ee67331..f2bf78063 100644 --- a/packages/signals/tests/held-derivation-not-a-proposal-3612.test.ts +++ b/packages/signals/tests/held-derivation-not-a-proposal-3612.test.ts @@ -53,7 +53,7 @@ async function settle() { function setup(opts: { holds?: (a: number) => boolean } = {}) { const holds = opts.holds ?? (a => a !== 1); const views: string[] = []; - const gates: ReturnType[] = []; + const gates: ReturnType>[] = []; let dispose!: () => void; const h = createRoot(d => { dispose = d; @@ -142,7 +142,7 @@ describe("A34 amendment — a held derivation is not a proposal (#3612)", () => it("a prev-reading derivation folds the write: max(prev=5, a=2) = 5", async () => { const views: string[] = []; - const gates: ReturnType[] = []; + const gates: ReturnType>[] = []; let setA!: (v: number) => void; let setHi!: (v: number | ((p: number) => number)) => void; let hi!: () => number; @@ -337,7 +337,7 @@ describe("A34 amendment — a held derivation is not a proposal (#3612)", () => it("compute-phase ownedWrite under T (b=201), then mainline b=101 → a=2 b=101", async () => { const views: string[] = []; - const gates: ReturnType[] = []; + const gates: ReturnType>[] = []; let setA!: (v: number) => void; let setB!: (v: number) => void; let dispose!: () => void; @@ -386,7 +386,7 @@ describe("A34 amendment — a held derivation is not a proposal (#3612)", () => it("an unrelated hold (T holds k; a unchanged; b unstamped): the mainline write commits at once, as today", async () => { const views: string[] = []; - let gate: ReturnType | null = null; + let gate: ReturnType> | null = null; let setK!: (v: number) => void; let setB!: (v: number) => void; let b!: () => number; @@ -431,7 +431,7 @@ describe("A34 amendment — a held derivation is not a proposal (#3612)", () => function setupStore(opts: { holds?: (a: number) => boolean } = {}) { const holds = opts.holds ?? (a => a !== 1); const views: string[] = []; - const gates: ReturnType[] = []; + const gates: ReturnType>[] = []; let dispose!: () => void; const h = createRoot(d => { dispose = d; diff --git a/packages/signals/tests/held-truth-lane-only.test.ts b/packages/signals/tests/held-truth-lane-only.test.ts index 77dbe2c24..6b09492dc 100644 --- a/packages/signals/tests/held-truth-lane-only.test.ts +++ b/packages/signals/tests/held-truth-lane-only.test.ts @@ -586,7 +586,7 @@ describe("held truth is masked from lane passes only (GabbeV's session fold, #31 let mainMemo!: () => string; const dispose = createRoot(d => { [state, setState] = createOptimisticStore<{ session: Session }>( - async function* () { + async function* (): AsyncGenerator<{ session: Session }, void, unknown> { yield { session: { status: "signedOut", account: { email: "g@x" } } }; await authenticated; yield { session: { status: "authenticated", user: { name: "Gabriel" } } }; diff --git a/packages/signals/tests/inv4-projection-dispose-shadow.test.ts b/packages/signals/tests/inv4-projection-dispose-shadow.test.ts index d8388ec05..6c38feb66 100644 --- a/packages/signals/tests/inv4-projection-dispose-shadow.test.ts +++ b/packages/signals/tests/inv4-projection-dispose-shadow.test.ts @@ -38,7 +38,7 @@ it("the flush right after disposing a projection with a held refetch passes the [s] = createStore<{ n: number }>( () => { const v = q(); - return new Promise(r => fetches.push(() => r({ n: v * 10 }))); + return new Promise<{ n: number }>(r => fetches.push(() => r({ n: v * 10 }))); }, { n: -1 } ); diff --git a/packages/signals/tests/l2-contract.test.ts b/packages/signals/tests/l2-contract.test.ts index 403bcf079..4132993b9 100644 --- a/packages/signals/tests/l2-contract.test.ts +++ b/packages/signals/tests/l2-contract.test.ts @@ -123,38 +123,36 @@ function tabs() { createRenderEffect( () => ((tick = getOwner()!), counter()), c => void log.push(`tab1:tick:${c}`), - undefined, { name: "tick" } ); return "tab1"; } const upper = createMemo( () => ((owners[t] = getOwner()!), runs.upper++, label().toUpperCase()), - undefined, { name: `upper${t}` } ); - createEffect(upper, u => void log.push(`tab${t}:effect:${u}`), undefined, { + createEffect(upper, u => void log.push(`tab${t}:effect:${u}`), { name: `effect${t}` }); // Tab 3 is only ever entered from a parked tab 2 — its pass is born // into a future already known to be pending, so a render effect // created BEFORE the async read must stay cold until the swap (O2/A). if (t === 3) - createRenderEffect(label, l => void log.push(`tab3:shell:${l}`), undefined, { + createRenderEffect(label, l => void log.push(`tab3:shell:${l}`), { name: "shell3" }); - const data = createMemo(() => new Promise(r => (resolvers[t] = r)), undefined, { + const data = createMemo(() => new Promise(r => (resolvers[t] = r)), { name: `data${t}` }); - createRenderEffect(data, v => void log.push(`tab${t}:data:${v}`), undefined, { + createRenderEffect(data, v => void log.push(`tab${t}:data:${v}`), { name: `dataEffect${t}` }); return `tab${t}`; }); - createRenderEffect(viewMemo, v => void log.push(`render:${v}`), undefined, { name: "render" }); + createRenderEffect(viewMemo, v => void log.push(`render:${v}`), { name: "render" }); // A derivation of the route: a write that reaches it reaches the future. - const header = createMemo(() => `${theme()}/${tab()}`, undefined, { name: "header" }); - createRenderEffect(header, v => void log.push(`header:${v}`), undefined, { + const header = createMemo(() => `${theme()}/${tab()}`, { name: "header" }); + createRenderEffect(header, v => void log.push(`header:${v}`), { name: "headerEffect" }); // The frame itself, in the compiled shape: one render effect over the @@ -162,7 +160,6 @@ function tabs() { createRenderEffect( () => (show() ? `panel:${tab()}/${counter()}` : "panel:hidden"), v => void log.push(v), - undefined, { name: "panel" } ); return d; diff --git a/packages/signals/tests/late-pending-equality.test.ts b/packages/signals/tests/late-pending-equality.test.ts index 71bd4c93d..9d819d1b4 100644 --- a/packages/signals/tests/late-pending-equality.test.ts +++ b/packages/signals/tests/late-pending-equality.test.ts @@ -33,7 +33,7 @@ it("commits an effect write when a pending source is superseded with its cached } const [version, setVersion] = createSignal(0, { ownedWrite: true }); notify = () => setVersion(value => value + 1); - const data = createMemo(previous => { + const data = createMemo(previous => { version(); return flight && previous !== undefined ? flight : 1; }); diff --git a/packages/signals/tests/recompute-preserves-disposed-3621.test.ts b/packages/signals/tests/recompute-preserves-disposed-3621.test.ts index b9b704d06..7b1593bba 100644 --- a/packages/signals/tests/recompute-preserves-disposed-3621.test.ts +++ b/packages/signals/tests/recompute-preserves-disposed-3621.test.ts @@ -21,7 +21,8 @@ import { flush, isDisposed, refresh, - runWithOwner + runWithOwner, + type SourceAccessor } from "../src/index.js"; const node = (accessor: () => unknown) => (accessor as any)[$REFRESH]; @@ -31,7 +32,7 @@ afterEach(() => flush()); describe("#3621 a memo disposing its own root mid-recompute stays disposed", () => { it("issue repro: isDisposed is true and refresh() does not re-run it", () => { const [n, setN] = createSignal(0); - let memo!: () => number; + let memo!: SourceAccessor; let runs = 0; createRoot(dispose => { @@ -166,7 +167,7 @@ describe("#3621 a memo disposing its own root mid-recompute stays disposed", () it("a memo disposing a parent owner (not its root) mid-compute stays disposed; the root survives", () => { const [n, setN] = createSignal(0); - let memo!: () => number; + let memo!: SourceAccessor; let sibling!: () => number; let runs = 0; let siblingRuns = 0; @@ -213,7 +214,7 @@ describe("#3621 a memo disposing its own root mid-recompute stays disposed", () it("control: disposing after the flush (non-reentrant) is unchanged", () => { const [n, setN] = createSignal(0); - let memo!: () => number; + let memo!: SourceAccessor; let runs = 0; let dispose!: () => void; diff --git a/packages/signals/tests/refresh-await.test.ts b/packages/signals/tests/refresh-await.test.ts index 3dd279c82..57bba5adf 100644 --- a/packages/signals/tests/refresh-await.test.ts +++ b/packages/signals/tests/refresh-await.test.ts @@ -29,7 +29,8 @@ import { createSignal, flush, isPending, - refresh + refresh, + type SourceAccessor } from "../src/index.js"; const tick = () => new Promise(r => setTimeout(r, 0)); @@ -46,7 +47,7 @@ function deferred() { test("sync source: resolves with the re-executed value", async () => { let runs = 0; - let m!: () => number; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -65,7 +66,7 @@ test("sync source: resolves with the re-executed value", async () => { test("async source: resolves with the landed value; the window stays quiet", async () => { const gates: ReturnType>[] = []; - let m!: () => string; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -103,7 +104,7 @@ test("async source: resolves with the landed value; the window stays quiet", asy test("equal-value landing (silent to the graph) still settles the promise", async () => { const gates: ReturnType>[] = []; const effectRuns: string[] = []; - let m!: () => string; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -134,7 +135,7 @@ test("equal-value landing (silent to the graph) still settles the promise", asyn test("failed re-ask rejects with the error", async () => { const gates: ReturnType>[] = []; - let m!: () => string; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -162,7 +163,7 @@ test("failed re-ask rejects with the error", async () => { test("fire-and-forget refresh with a failing re-ask surfaces no unhandled rejection", async () => { const gates: ReturnType>[] = []; - let m!: () => string; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -192,7 +193,7 @@ test("fire-and-forget refresh with a failing re-ask surfaces no unhandled reject test("supersession: waiters from both refreshes deliver the final landing", async () => { const gates: ReturnType>[] = []; - let m!: () => string; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; @@ -264,7 +265,7 @@ test("store target resolves with the node passed (root and nested)", async () => test("yield refresh in an action: staged landing delivers, the override does not", async () => { let server = "v1"; - let m!: () => string; + let m!: SourceAccessor; let opt!: () => string; let setOpt!: (v: string) => void; const views: string[] = []; @@ -315,7 +316,7 @@ test("yield refresh in an action: staged landing delivers, the override does not }); test("disposed target: already quiescent — resolves immediately with the last value", async () => { - let m!: () => number; + let m!: SourceAccessor; const d = createRoot(dispose => { m = createMemo(() => 42); createEffect(m, () => {}); @@ -361,7 +362,7 @@ test("iterable-backed source: resolves at the fresh iteration's first yield", as const streams = [pump(), pump()]; let run = 0; - let m!: () => number; + let m!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; diff --git a/packages/signals/tests/settle-walk-invariant.test.ts b/packages/signals/tests/settle-walk-invariant.test.ts index e3fef746b..0fcf8ae89 100644 --- a/packages/signals/tests/settle-walk-invariant.test.ts +++ b/packages/signals/tests/settle-walk-invariant.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { OBSERVE } from "../src/index.js"; import { settlePendingSource } from "../src/core/async.js"; import { CONFIG_OVERRIDE, NOT_PENDING, STATUS_UNINITIALIZED } from "../src/core/constants.js"; -import type { Computed } from "../src/core/core.js"; +import type { Computed } from "../src/core/types.js"; /** * The SETTLE_WALK_UNINITIALIZED_SOURCE tripwire has no reachable trigger diff --git a/packages/signals/tests/spec-async-semantics.test.ts b/packages/signals/tests/spec-async-semantics.test.ts index de0f9d9b8..d2913df83 100644 --- a/packages/signals/tests/spec-async-semantics.test.ts +++ b/packages/signals/tests/spec-async-semantics.test.ts @@ -408,7 +408,9 @@ describe("A15 (was B3): overlapping transitions settle as one unit", () => { } else { const [c, setC] = createSignal(0); get = c; - write = v => setC(v); + write = v => { + setC(v); + }; } const details = createMemo(() => fetcher.fetch(get())); createRenderEffect( diff --git a/packages/signals/tests/store/async-setter.test.ts b/packages/signals/tests/store/async-setter.test.ts index 5893424ae..f8d29ebf9 100644 --- a/packages/signals/tests/store/async-setter.test.ts +++ b/packages/signals/tests/store/async-setter.test.ts @@ -36,11 +36,11 @@ describe("ASYNC_STORE_SETTER: store setter callbacks are synchronous", () => { it("createStore: an async setter callback throws, after its sync writes committed", () => { const [store, setStore] = createStore({ n: 0 }); expect(() => - setStore(async d => { + setStore((async (d: { n: number }) => { d.n = 1; await sleep(); d.n = 2; - }) + }) as unknown as Parameters[0]) ).toThrow(ASYNC); // The writes before the first await were real — the transaction closed // at the callback's return with them in it, like an effect's side effects @@ -68,9 +68,9 @@ describe("ASYNC_STORE_SETTER: store setter callbacks are synchronous", () => { const [base] = createStore({ n: 0 }); const [, setView] = createOptimisticStore(base); expect(() => - setView(async d => { + setView((async (d: { n: number }) => { d.n = 1; - }) + }) as unknown as Parameters[0]) ).toThrow(ASYNC); }); @@ -85,16 +85,18 @@ describe("ASYNC_STORE_SETTER: store setter callbacks are synchronous", () => { ) ); expect(() => - setDerived(async d => { + setDerived((async (d: { n: number }) => { d.n = 5; - }) + }) as unknown as Parameters[0]) ).toThrow(ASYNC); }); it("the throw goes through the diagnostics channel first", () => { const capture = OBSERVE!.diagnostics.capture(); const [, setStore] = createStore({ n: 0 }); - expect(() => setStore(async () => {})).toThrow(ASYNC); + expect(() => setStore((async () => {}) as unknown as Parameters[0])).toThrow( + ASYNC + ); const events = capture.stop(); expect(events).toHaveLength(1); expect(events[0].code).toBe("ASYNC_STORE_SETTER"); diff --git a/packages/signals/tests/store/deep-overlay-rewalk.test.ts b/packages/signals/tests/store/deep-overlay-rewalk.test.ts index 08b8ee54d..537d443b0 100644 --- a/packages/signals/tests/store/deep-overlay-rewalk.test.ts +++ b/packages/signals/tests/store/deep-overlay-rewalk.test.ts @@ -12,7 +12,7 @@ import { deep, flush, untrack -} from "../../src/index.ts"; +} from "../../src/index.js"; for (const editParent of [false, true]) { it(`deep remains subscribed to descendants after ${editParent ? "parent" : "child"} edit`, () => { diff --git a/packages/signals/tests/store/flight-owned-transaction.test.ts b/packages/signals/tests/store/flight-owned-transaction.test.ts index f7840d665..d78598afb 100644 --- a/packages/signals/tests/store/flight-owned-transaction.test.ts +++ b/packages/signals/tests/store/flight-owned-transaction.test.ts @@ -21,7 +21,7 @@ import { flush, isPending } from "../../src/index.js"; -import type { Store } from "../../src/store.js"; +import type { Store } from "../../src/store/index.js"; const tick = () => new Promise(r => setTimeout(r, 0)); diff --git a/packages/signals/tests/store/fold-3164.test.ts b/packages/signals/tests/store/fold-3164.test.ts index 37bf26615..57dea78e7 100644 --- a/packages/signals/tests/store/fold-3164.test.ts +++ b/packages/signals/tests/store/fold-3164.test.ts @@ -71,7 +71,7 @@ describe("#3164: union-shape landing under a held transaction", () => { createRoot(() => { [state, setState] = createOptimisticStore<{ session: Session }>( - async function* () { + async function* (): AsyncGenerator<{ session: Session }, void, unknown> { yield { session: { status: "signedOut", account: { email: "gabriel@example.com" } } }; await authenticated; yield { session: { status: "authenticated", user: { name: "Gabriel" } } }; @@ -144,7 +144,11 @@ describe("#3164: union-shape landing under a held transaction", () => { let signIn!: () => Promise; createRoot(() => { - [session, setSession] = createOptimistic(async function* () { + [session, setSession] = createOptimistic(async function* (): AsyncGenerator< + Session, + void, + unknown + > { yield { status: "signedOut", account: { email: "gabriel@example.com" } }; await authenticated; yield { status: "authenticated", user: { name: "Gabriel" } }; diff --git a/packages/signals/tests/store/fold-slot-identity.test.ts b/packages/signals/tests/store/fold-slot-identity.test.ts index 68097a315..750cd26d3 100644 --- a/packages/signals/tests/store/fold-slot-identity.test.ts +++ b/packages/signals/tests/store/fold-slot-identity.test.ts @@ -12,7 +12,7 @@ import { flush, snapshot, untrack -} from "../../src/index.ts"; +} from "../../src/index.js"; for (const operation of ["unshift", "reverse", "splice"] as const) { for (const observed of [false, true]) { diff --git a/packages/signals/tests/store/optimistic-draft-visibility-3665.test.ts b/packages/signals/tests/store/optimistic-draft-visibility-3665.test.ts index 3f96554e4..668c2f145 100644 --- a/packages/signals/tests/store/optimistic-draft-visibility-3665.test.ts +++ b/packages/signals/tests/store/optimistic-draft-visibility-3665.test.ts @@ -7,7 +7,8 @@ import { createRoot, createStore, deep, - flush + flush, + type NoFn } from "../../src/index.js"; /** @@ -39,7 +40,7 @@ type Row = { id: string; qty?: number }; * draft. The action's only yield resolves at once; the caller flushes. */ function twoSetters( kind: "plain" | "optimistic", - init: T, + init: NoFn, write: (d: T) => void, read: (d: T) => void, yieldBetween = false diff --git a/packages/signals/tests/store/owner-stamp.test.ts b/packages/signals/tests/store/owner-stamp.test.ts index dd8f4251a..8f670d9cc 100644 --- a/packages/signals/tests/store/owner-stamp.test.ts +++ b/packages/signals/tests/store/owner-stamp.test.ts @@ -104,7 +104,7 @@ describe("ownership stamp (#3360)", () => { }); it("the first commit onto an unowned backing does not report a membership change", () => { - const [s, set] = createStore({ a: 1, b: 2 }); + const [s, set] = createStore<{ a: number; b: number; c?: number }>({ a: 1, b: 2 }); let keyRuns = 0; createRoot(() => { createEffect( diff --git a/packages/signals/tests/store/privatization-observer-identity.test.ts b/packages/signals/tests/store/privatization-observer-identity.test.ts index 147901da7..a080b2206 100644 --- a/packages/signals/tests/store/privatization-observer-identity.test.ts +++ b/packages/signals/tests/store/privatization-observer-identity.test.ts @@ -5,7 +5,7 @@ // and every node on the original target (the observer's subscription among // them) was orphaned: later writes to the ancestor notified nobody. The clone // now registers in the target's own map; identity and subscriptions survive. -import { createRenderEffect, createRoot, createStore, flush, untrack } from "../../src/index.ts"; +import { createRenderEffect, createRoot, createStore, flush, untrack } from "../../src/index.js"; describe("subscriptions after descendant writes", () => { for (const derived of [false, true]) { diff --git a/packages/signals/tests/store/utilities.test.ts b/packages/signals/tests/store/utilities.test.ts index 8ed657f6d..3776b23ad 100644 --- a/packages/signals/tests/store/utilities.test.ts +++ b/packages/signals/tests/store/utilities.test.ts @@ -781,7 +781,7 @@ describe("view descriptors", () => { }); test("a store leaf or a memo source is always an accessor, whatever the store reports", () => { createRoot(() => { - const [store] = createStore({ a: 1 }); + const [store] = createStore<{ a: number; z?: never }>({ a: 1 }); const overStore = merge({ b: 2 }, store); expect(typeof Object.getOwnPropertyDescriptor(overStore, "a")!.get).toBe("function"); expect(Object.getOwnPropertyDescriptor(overStore, "b")!.value).toBe(2); @@ -865,7 +865,7 @@ describe("view descriptors", () => { expect(l7.type).toBe("x"); expect(l7.class).toBeUndefined(); // One pass gives every key with the leaf that owns it. - const keys: PropertyKey[] = [], + const keys: (string | symbol)[] = [], owners: any[] = []; sourceOwners(l5, keys, owners); expect(keys).toEqual(["role", "class", "label", "extra"]); @@ -1159,7 +1159,7 @@ describe("view descriptors", () => { test("through merge() and omit() views, the leaf that owns the key decides", () => { const [sig, setSig] = createSignal("a"); const literal = { as: "button", label: "x" }; - const expr = { + const expr: { readonly as: string; label?: never } = { get as() { return sig(); } @@ -1194,7 +1194,7 @@ describe("view descriptors", () => { expect(isStatic(chainDyn, "as")).toBe(false); }); test("memo sources, stores, and anything reaching them are not static", () => { - const [store] = createStore({ as: "button" }); + const [store] = createStore<{ as: string; label?: never }>({ as: "button" }); const plain = { label: "x" }; // A store answers `as` with a value, but the key can change and even // appear/disappear: never static, whether direct or through a view. diff --git a/packages/signals/tests/strict-read-pending-store.test.ts b/packages/signals/tests/strict-read-pending-store.test.ts index ab5afd99b..445f4b207 100644 --- a/packages/signals/tests/strict-read-pending-store.test.ts +++ b/packages/signals/tests/strict-read-pending-store.test.ts @@ -8,7 +8,8 @@ import { isPending, refresh, untrack, - OBSERVE + OBSERVE, + type Refreshable } from "../src/index.js"; import { NotReadyError } from "../src/core/error.js"; @@ -264,7 +265,7 @@ describe("thenable probe on a store proxy in a strict-read scope", () => { const capture = OBSERVE!.diagnostics.capture(); let fetches = 0; let dispose!: () => void; - let list!: { id: number }[]; + let list!: Refreshable<{ id: number }[]>; createRoot(d => { dispose = d; [list] = createStore( @@ -324,7 +325,7 @@ describe("thenable probe on a store proxy in a strict-read scope", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const capture = OBSERVE!.diagnostics.capture(); let dispose!: () => void; - let list!: { id: number }[]; + let list!: Refreshable<{ id: number }[]>; let fetches = 0; createRoot(d => { dispose = d; diff --git a/packages/signals/tests/until.test.ts b/packages/signals/tests/until.test.ts index 5abfbfe88..0fed8c52a 100644 --- a/packages/signals/tests/until.test.ts +++ b/packages/signals/tests/until.test.ts @@ -33,7 +33,8 @@ import { flush, refresh, TimeoutError, - until + until, + type SourceAccessor } from "../src/index.js"; const tick = () => new Promise(r => setTimeout(r, 0)); @@ -408,7 +409,7 @@ test("authoritative view covers membership: in / Object.keys ignore optimistic s */ test("refresh()ed async source landing mid-hold satisfies until", async () => { const resolvers: ((v: number) => void)[] = []; - let version!: () => number; + let version!: SourceAccessor; let dispose!: () => void; createRoot(d => { dispose = d; diff --git a/packages/signals/tests/untracked-async-read.test.ts b/packages/signals/tests/untracked-async-read.test.ts index 0ff5269f8..ffb186994 100644 --- a/packages/signals/tests/untracked-async-read.test.ts +++ b/packages/signals/tests/untracked-async-read.test.ts @@ -25,7 +25,7 @@ describe("untracked async read after await (#2987)", () => { return "m-value"; }); - const [s] = createSignal, number>(async () => { + const [s] = createSignal(async () => { await Promise.resolve(); m(); // first-ever read of m — untracked, m still loading return 42; From a5d124ac30444d8290d771ebd1ea9dcd6b747c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 4 Oct 2026 10:21:38 -0300 Subject: [PATCH 2/3] test(signals): control the action completion flush window The L2 scheduler no longer exposes activeTransition. Reading that missing property made the old condition pass without observing the intended completion window. Await the action while holding scheduler microtasks, inject the operation, then release the flush. Assert the ordering and restore the mock even when an action stalls until the test timeout. All four regressions reject a mutation that flushes before the operation. --- .../tests/action-completion-race.test.ts | 72 +++++++----- .../signals/tests/action-done-window.test.ts | 109 +++++++++--------- 2 files changed, 100 insertions(+), 81 deletions(-) diff --git a/packages/signals/tests/action-completion-race.test.ts b/packages/signals/tests/action-completion-race.test.ts index 7926a0478..700f841a1 100644 --- a/packages/signals/tests/action-completion-race.test.ts +++ b/packages/signals/tests/action-completion-race.test.ts @@ -1,19 +1,43 @@ -// #2916: when an action's done() restores activeTransition (without adopting -// the ambient batch) and the shared transition is still incomplete, an -// ordinary write in the microtask window before the scheduled flush lands in -// the detached ambient batch. The incomplete-transition stash then replaced -// that batch wholesale, stranding the queued pending node: the write never -// committed and every later write to the same signal stayed frozen (dev -// INV-7). +// #2916: a write between action completion and a pending scheduler flush must +// commit and leave the signal writable, even while another action is open. +// The original stranded pending node violated INV-7 on the next flush. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { action, createSignal, flush } from "../src/index.js"; -import * as scheduler from "../src/core/scheduler.js"; -const tick = () => Promise.resolve(); +afterEach(() => vi.restoreAllMocks()); + +// Hold scheduler flushes while Promise callbacks complete the action. +// Await its result, inject the operation, then release the held callbacks. +// The callbacks can come from joining or completing a transaction. +async function inDoneWindow(done: Promise, complete: () => void, operation: () => void) { + const queued: VoidFunction[] = []; + const events: string[] = []; + const microtask = vi.spyOn(globalThis, "queueMicrotask").mockImplementation(callback => { + queued.push(() => { + events.push("flush"); + callback(); + }); + }); + try { + complete(); + await done; + events.push("done"); + expect(queued.length).toBeGreaterThan(0); + expect(events).toEqual(["done"]); + operation(); + events.push("operation"); + expect(events).toEqual(["done", "operation"]); + while (queued.length) queued.shift()!(); + expect(events[2]).toBe("flush"); + } finally { + microtask.mockRestore(); + while (queued.length) queued.shift()!(); + } +} describe("post-action completion race (#2916)", () => { - it("commits an ambient write made between an action's done() and its scheduled flush", async () => { + it("commits an ambient write after an action completes and before a pending flush", async () => { const [y, setY] = createSignal(0); let resolveA!: () => void; @@ -32,23 +56,17 @@ describe("post-action completion race (#2916)", () => { const aDone = A(); const bDone = B(); - flush(); // stash the shared incomplete transition - - resolveA(); + flush(); + await Promise.resolve(); // drain the initial scheduled flush before interception - // Write in the window where A's done() has restored activeTransition but - // its scheduled flush has not run. The internal read only makes the - // timing deterministic; the write itself is an ordinary application - // write. - let wrote = false; - for (let i = 0; i < 16; i++) { - await tick(); - if (!wrote && scheduler.activeTransition !== null) { - wrote = true; - setY(7); - } - } - expect(wrote).toBe(true); + let bCompleted = false; + void bDone.then(() => { + bCompleted = true; + }); + await inDoneWindow(aDone, resolveA, () => { + expect(bCompleted).toBe(false); + setY(7); + }); resolveB(); await Promise.all([aDone, bDone]); diff --git a/packages/signals/tests/action-done-window.test.ts b/packages/signals/tests/action-done-window.test.ts index f52318af7..cc2abf54e 100644 --- a/packages/signals/tests/action-done-window.test.ts +++ b/packages/signals/tests/action-done-window.test.ts @@ -1,17 +1,6 @@ -/** - * The post-action done() window (#2916 shape): an async-generator action's - * done() runs from an iterator-result microtask, restoring activeTransition - * with no synchronous flush after it. Until the scheduled flush runs, - * globalQueue._batch was a detached ambient batch — so anything registered in - * that window (ordinary writes held by a merged transition, optimistic - * overrides, affects() marks) landed in a batch that nothing ever finalized. - * done() now re-adopts the batch through initTransition, the same path every - * other transition-resumption site uses. - * - * Each test polls microtasks until it observes the restored transition - * (scheduler.activeTransition !== null) and injects its work exactly there. - */ -import { describe, expect, it } from "vitest"; +// #2916: inject work after an async action completes but before a pending +// flush. Assert both the ordering and the eventual write/revert/release. +import { afterEach, describe, expect, it, vi } from "vitest"; import { action, affects, @@ -23,10 +12,40 @@ import { flush, isPending } from "../src/index.js"; -import * as scheduler from "../src/core/scheduler.js"; const tick = () => Promise.resolve(); +afterEach(() => vi.restoreAllMocks()); + +// Hold scheduler flushes while Promise callbacks complete the action. +// Await its result, inject the operation, then release the held callbacks. +// The callbacks can come from joining or completing a transaction. +async function inDoneWindow(done: Promise, complete: () => void, operation: () => void) { + const queued: VoidFunction[] = []; + const events: string[] = []; + const microtask = vi.spyOn(globalThis, "queueMicrotask").mockImplementation(callback => { + queued.push(() => { + events.push("flush"); + callback(); + }); + }); + try { + complete(); + await done; + events.push("done"); + expect(queued.length).toBeGreaterThan(0); + expect(events).toEqual(["done"]); + operation(); + events.push("operation"); + expect(events).toEqual(["done", "operation"]); + while (queued.length) queued.shift()!(); + expect(events[2]).toBe("flush"); + } finally { + microtask.mockRestore(); + while (queued.length) queued.shift()!(); + } +} + describe("post-action done() window", () => { it("a completed action's write survives another action resuming in its done-window", async () => { const [x, setX] = createSignal(0); @@ -55,23 +74,19 @@ describe("post-action done() window", () => { const aDone = A(); flush(); // stash T_A await tick(); - const bDone = B(); // fresh transition T_B (T_A stashed, activeTransition null) + const bDone = B(); // B remains open until its controlled thenable resumes. flush(); // stash T_B - resolveA(); - - // Land in A's done-window and resume B there, so initTransition(T_B) - // merges the restored T_A into T_B. T_A's held write must survive the - // merge and commit when T_B settles. - let resumed = false; - for (let i = 0; i < 16; i++) { - await tick(); - if (!resumed && scheduler.activeTransition !== null && hasResumeB) { - resumed = true; - resumeB(undefined); - } - } - expect(resumed).toBe(true); + await tick(); // drain the initial scheduled flushes + let bCompleted = false; + void bDone.then(() => { + bCompleted = true; + }); + await inDoneWindow(aDone, resolveA, () => { + expect(hasResumeB).toBe(true); + expect(bCompleted).toBe(false); + resumeB(undefined); + }); await Promise.all([aDone, bDone]); await new Promise(r => setTimeout(r, 0)); @@ -103,17 +118,10 @@ describe("post-action done() window", () => { const aDone = A(); flush(); - resolveA(); - - let wrote = false; - for (let i = 0; i < 16; i++) { - await tick(); - if (!wrote && scheduler.activeTransition !== null) { - wrote = true; - setOpt(5); - } - } - expect(wrote).toBe(true); + await tick(); // drain the initial scheduled flush + await inDoneWindow(aDone, resolveA, () => { + setOpt(5); + }); await aDone; await new Promise(r => setTimeout(r, 0)); @@ -143,25 +151,18 @@ describe("post-action done() window", () => { const aDone = A(); flush(); - resolveA(); - - let marked = false; - for (let i = 0; i < 16; i++) { - await tick(); - if (!marked && scheduler.activeTransition !== null) { - marked = true; - affects(count); - } - } - expect(marked).toBe(true); + await tick(); // drain the initial scheduled flush + await inDoneWindow(aDone, resolveA, () => { + affects(count); + }); await aDone; await new Promise(r => setTimeout(r, 0)); flush(); flush(); - // The mark now belongs to the restored transaction and releases at its - // settle; before the fix it landed in the detached ambient batch, where + // The mark must release at settle; before the fix it landed in a + // detached ambient batch, where // (in combination with other pending work) it could leak forever // (isPending stuck true, INV-10 on the next quiescent flush). expect(isPending(() => count())).toBe(false); From 3434fd6db8223c8c6a156683f9612b706ab46f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 4 Oct 2026 10:21:38 -0300 Subject: [PATCH 3/3] test(signals): observe async errors and effect collection Pass the error handler in the effect bundle and assert the received error while retaining the memo's throwing read. Capture the effect owner in the compute phase so the GC regression observes a valid weak target. The seven explicit-GC tests pass. With the related fixture and action test repairs, the test-inclusive TypeScript check has zero diagnostics and the full Signals suite passes 4,892 tests. --- .../tests/flatten-async-iterable.test.ts | 17 ++++++++++------ packages/signals/tests/gc.test.ts | 20 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/signals/tests/flatten-async-iterable.test.ts b/packages/signals/tests/flatten-async-iterable.test.ts index 67c17d20d..ca35e008c 100644 --- a/packages/signals/tests/flatten-async-iterable.test.ts +++ b/packages/signals/tests/flatten-async-iterable.test.ts @@ -255,25 +255,30 @@ describe("promise-of-AsyncIterable flattening (deferred posture)", () => { }); it("surfaces a stream error through the memo", async () => { + const errors: unknown[] = []; + const failure = new Error("stream boom"); const gate = deferred(); let memo!: () => number; const failing: AsyncIterable = { [Symbol.asyncIterator]: () => ({ - next: () => Promise.reject(new Error("stream boom")) + next: () => Promise.reject(failure) }) }; createRoot(() => { memo = createMemo(() => gate.promise.then(() => failing) as unknown as number); - createEffect( - () => memo(), - () => {}, - { error: () => {} } - ); + createEffect(() => memo(), { + effect: () => {}, + error: error => { + errors.push(error); + } + }); }); flush(); gate.resolve(); await tick(); flush(); + expect(errors).toHaveLength(1); + expect(errors[0]).toBe(failure); expect(() => memo()).toThrow("stream boom"); }); diff --git a/packages/signals/tests/gc.test.ts b/packages/signals/tests/gc.test.ts index 4bcdb24af..a77b7e5af 100644 --- a/packages/signals/tests/gc.test.ts +++ b/packages/signals/tests/gc.test.ts @@ -80,9 +80,13 @@ if (global.gc) { ref!: WeakRef; const dispose = createRoot(dispose => { - createEffect($x, () => { - ref = new WeakRef(getOwner()!); - }); + createEffect( + () => { + ref = new WeakRef(getOwner()!); + return $x(); + }, + () => {} + ); return dispose; }); @@ -159,9 +163,13 @@ if (global.gc) { for (const mode of ["latest", "isPending"] as const) { it(`releases an obsolete leaf value after a ${mode}() reader is disposed (#3503)`, async () => { const fixture = createRoot(dispose => { - const [state, setState] = createStore(() => {}, {} as { value?: object }, { - shallow: true - }); + const [state, setState] = createStore<{ value?: object }>( + () => {}, + {}, + { + shallow: true + } + ); return { state, setState, dispose }; }); const ref = (() => {