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); 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/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 = (() => { 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;