Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/signals/tests/attribution-feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ afterEach(() => {
attribution.disable();
flush();
vi.restoreAllMocks();
vi.useRealTimers();
});

const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
Expand Down Expand Up @@ -444,6 +445,12 @@ describe("feedback()", () => {
});

it("measures how long each loading boundary showed its fallback, and counts flashes", async () => {
// The show is timed on the engine's clock (`performance.now()`): on the
// wall clock a loaded runner stretched the 20ms wait past the 150ms flash
// window. Faked, the fallback is up for exactly the time advanced.
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"]
});
arm();
const feed = pagedFeed();
const shown: string[] = [];
Expand All @@ -464,12 +471,14 @@ describe("feedback()", () => {
expect(shown).toEqual(["loading…"]);
let [row] = feedback().fallbacks;
expect(row).toMatchObject({ boundary: "boundary", shows: 1, shownMs: 0, flashes: 0 });
await wait(20);
await vi.advanceTimersByTimeAsync(20);
feed.resolve("a");
await until(() => shown.includes("a-p1"), "content");
await vi.advanceTimersByTimeAsync(0);
flush();
expect(shown).toContain("a-p1");
[row] = feedback().fallbacks;
expect(row.shows).toBe(1);
expect(row.shownMs).toBeGreaterThanOrEqual(15);
expect(row.shownMs).toBe(20);
expect(row.worstMs).toBe(row.shownMs);
// Under 150ms: a spinner that flashed.
expect(row.flashes).toBe(1);
Expand Down
6 changes: 5 additions & 1 deletion packages/signals/tests/attribution-lean-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ describe("attribution engine: lean gate", () => {
it("the checks run without a record: a hot scope still warns", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { setN } = chain();
attribution.enable({ log: false, hotRuns: { count: 3, windowMs: 10_000 } });
attribution.enable({
log: false,
hotRuns: { count: 3, windowMs: 10_000 },
hotTime: false
});
for (let i = 1; i <= 4; i++) {
setN(i);
flush();
Expand Down
116 changes: 48 additions & 68 deletions packages/signals/tests/observe-idle-cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,97 +9,77 @@
*
* Relative tripwire, same discipline as heap-mark-incremental: absolute
* wall-clock bounds do not survive CI, so the SAME workload runs against the
* built prod and observe artifacts in one process, interleaved, best-of-k,
* and the observe/prod ratio is what is capped. Both tiers see the same
* machine load, and best-of-k picks the quiet run for each.
* built prod and observe artifacts in one worker thread, as paired samples,
* and the median of the per-pair observe/prod ratios is what is capped. A
* pair sees one machine load for both tiers, so contention largely cancels
* within it; the median drops the pairs it doesn't.
*/
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { describe, expect, test } from "vitest";

type Tier = typeof import("../src/index.js");

// The built artifacts — what apps actually resolve. Gitignored, so the test
// skips when they haven't been built (run `pnpm build`); resolved paths rather
// than literal specifiers so the build's type pass doesn't try to find them.
const here = dirname(fileURLToPath(import.meta.url));
const PROD = resolve(here, "../dist/prod/index.js");
const OBSERVE = resolve(here, "../dist/observe/index.js");
const WORKER = resolve(here, "observe-idle-cost.worker.mjs");

/**
* A graph-heavy workload with no hooks installed: N chains of
* signal → memo → memo → effect, then K write passes that touch every chain,
* then teardown. Reads, writes, recomputes, effect runs and creation all
* cross the observe wiring; nothing observes.
*/
function workload(tier: Tier, N: number, K: number): number {
const { createEffect, createMemo, createRoot, createSignal, flush } = tier;
let ms = 0;
createRoot(dispose => {
const setters: ((v: number) => void)[] = [];
let sink = 0;
for (let i = 0; i < N; i++) {
const [a, setA] = createSignal(i);
const b = createMemo(() => a() * 2);
const c = createMemo(() => b() + 1);
createEffect(
() => c(),
v => {
sink += v;
}
);
setters.push(setA);
}
flush();
const start = performance.now();
for (let k = 1; k <= K; k++) {
for (let i = 0; i < N; i++) setters[i](i + k);
flush();
}
ms = performance.now() - start;
dispose();
if (sink === Infinity) throw new Error("unreachable");
type Samples = {
observeDefined: boolean;
prodDefined: boolean;
prodMs: number[];
observeMs: number[];
};

function measure(): Promise<Samples> {
return new Promise((done, fail) => {
const worker = new Worker(WORKER, {
workerData: {
prodUrl: pathToFileURL(PROD).href,
observeUrl: pathToFileURL(OBSERVE).href,
// ~13ms a sample locally; 2×(warmup + pairs) samples per round.
N: 1000,
K: 40,
warmup: 5,
pairs: 21
}
});
worker.once("message", done);
worker.once("error", fail);
});
return ms;
}

const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[xs.length >> 1];

describe.skipIf(!existsSync(PROD) || !existsSync(OBSERVE))("observe tier idle cost", () => {
test("no hooks installed: the observe artifact runs the same graph within the cap of prod", async () => {
const prod = (await import(PROD)) as Tier;
const observe = (await import(OBSERVE)) as Tier;
expect((prod as any).OBSERVE).toBeUndefined();
expect((observe as any).OBSERVE).toBeDefined();

// ~15ms a sample locally; under a loaded CI worker a full three rounds
// stays well inside the explicit timeout below.
const N = 1000;
const K = 10;
// Warm both (JIT, allocator) before anything is timed.
workload(prod, N, 2);
workload(observe, N, 2);
// Measured 2026-09-16 (M-series, five samples): 1.03–1.09 — the wiring
// is 3–9% on a graph that does nothing but cross it. The cap trips when
// the wiring costs ~3x what it does today (25%), which is the regression
// this exists to catch — a hook site that stopped being a null check.
// Noise: the suite runs this beside other files on worker
// threads, so one tier can draw the busy slots; a round is best-of-k
// interleaved, and a round over the cap is re-measured (a regression is
// over the cap every round, contention is not).
// Measured 2026-10-05 (M-series, Node loading the artifacts directly):
// median pair ratio 0.96–1.05. The cap trips when the wiring costs a
// quarter of the graph's own work, which is the regression this exists
// to catch — a hook site that stopped being a null check. For scale, a
// WeakMap bump per write and per recompute reads 1.23–1.28.
// Noise: the suite runs this beside other files on worker threads, so
// the machine's load shifts under the measurement. Pairing cancels the
// shift a pair sees, the median drops the outliers, and a round over
// the cap is re-measured (a regression is over the cap every round,
// contention is not).
const CAP = 1.25;
let best = Infinity;
let detail = "";
for (let round = 0; round < 3 && best >= CAP; round++) {
let prodMs = Infinity;
let observeMs = Infinity;
for (let i = 0; i < 5; i++) {
prodMs = Math.min(prodMs, workload(prod, N, K));
observeMs = Math.min(observeMs, workload(observe, N, K));
}
const ratio = observeMs / prodMs;
const s = await measure();
expect(s.prodDefined).toBe(false);
expect(s.observeDefined).toBe(true);
const ratio = median(s.observeMs.map((o, i) => o / s.prodMs[i]));
if (ratio < best) {
best = ratio;
detail = `observe ${observeMs.toFixed(1)}ms / prod ${prodMs.toFixed(1)}ms`;
detail = `median pair ratio over ${s.prodMs.length} pairs; observe ${median(
s.observeMs
).toFixed(1)}ms / prod ${median(s.prodMs).toFixed(1)}ms (medians)`;
}
}
expect(best, detail).toBeLessThan(CAP);
Expand Down
84 changes: 84 additions & 0 deletions packages/signals/tests/observe-idle-cost.worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Measurement half of observe-idle-cost.test.ts. Runs in a worker thread so
// Node loads the built artifacts itself: inside the test runner they would go
// through vite's SSR transform, which rewrites every cross-module import of
// the module-preserving builds into a namespace-object property load — a
// cost apps never pay, larger than the one under measurement, and not the
// same for both tiers.
import { parentPort, workerData } from "node:worker_threads";

const { prodUrl, observeUrl, N, K, warmup, pairs } = workerData;
const prod = await import(prodUrl);
const observe = await import(observeUrl);

// This thread's CPU time, in ms: time spent descheduled on a loaded runner
// is wall-clock noise, not cost. Wall clock where Node predates the API.
const clock = process.threadCpuUsage
? () => {
const { user, system } = process.threadCpuUsage();
return (user + system) / 1000;
}
: () => performance.now();

/**
* A graph-heavy workload with no hooks installed: N chains of
* signal → memo → memo → effect, then K write passes that touch every chain,
* then teardown. Reads, writes, recomputes and effect runs cross the observe
* wiring in the timed window; nothing observes.
*/
function workload(tier) {
const { createEffect, createMemo, createRoot, createSignal, flush } = tier;
let ms = 0;
createRoot(dispose => {
const setters = [];
let sink = 0;
for (let i = 0; i < N; i++) {
const [a, setA] = createSignal(i);
const b = createMemo(() => a() * 2);
const c = createMemo(() => b() + 1);
createEffect(
() => c(),
v => {
sink += v;
}
);
setters.push(setA);
}
flush();
const start = clock();
for (let k = 1; k <= K; k++) {
for (let i = 0; i < N; i++) setters[i](i + k);
flush();
}
ms = clock() - start;
dispose();
if (sink === Infinity) throw new Error("unreachable");
});
return ms;
}

// Paired samples: each pair runs the tiers back to back, alternating which
// goes first, so both halves of a pair see the same machine load and neither
// tier owns the slot after (say) a GC of the other's garbage.
function pair(i) {
if (i % 2) {
const o = workload(observe);
return [workload(prod), o];
}
const p = workload(prod);
return [p, workload(observe)];
}

for (let i = 0; i < warmup; i++) pair(i);
const prodMs = [];
const observeMs = [];
for (let i = 0; i < pairs; i++) {
const [p, o] = pair(i);
prodMs.push(p);
observeMs.push(o);
}
parentPort.postMessage({
observeDefined: observe.OBSERVE !== undefined,
prodDefined: prod.OBSERVE !== undefined,
prodMs,
observeMs
});
15 changes: 6 additions & 9 deletions packages/web/test/loading.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -892,10 +892,7 @@ describe("Testing Loading", () => {
// root insert until the fetch landed: the page stayed blank — not even
// content OUTSIDE the boundary mounted — and the fallback never showed.
describe("<Loading> around an optimistic store's first flight", () => {
// Real timers: the fetches below settle on their own schedule.
beforeEach(() => {
vi.useRealTimers();
});
const FETCH_MS = 10;
const wait = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

type Item = { id: number; label: string; votes: number };
Expand All @@ -905,7 +902,7 @@ describe("<Loading> around an optimistic store's first flight", () => {
const [list] = make<Item[]>(async () => {
tick();
const n = ++fetches;
await wait(10);
await wait(FETCH_MS);
return [{ id: 1, label: "Tacos", votes: n - 1 }];
}, [] as Item[]);
return { list, setTick };
Expand Down Expand Up @@ -952,17 +949,17 @@ describe("<Loading> around an optimistic store's first flight", () => {
const { html, dispose, refetch } = mount(createOptimisticStore);
expect(html()).toBe("<h1>Poll</h1><div>Loading...</div>");

await wait(30);
await vi.advanceTimersByTimeAsync(FETCH_MS);
flush();
expect(html()).toBe("<h1>Poll</h1><ul><li>Tacos - 0</li></ul>");

// Refetch: stale-while-revalidate — no fallback, then the new truth.
refetch();
flush();
await wait(2);
await vi.advanceTimersByTimeAsync(FETCH_MS - 1);
flush();
expect(html()).toBe("<h1>Poll</h1><ul><li>Tacos - 0</li></ul>");
await wait(30);
await vi.advanceTimersByTimeAsync(1);
flush();
expect(html()).toBe("<h1>Poll</h1><ul><li>Tacos - 1</li></ul>");
dispose();
Expand All @@ -971,7 +968,7 @@ describe("<Loading> around an optimistic store's first flight", () => {
test("control: createStore(fn, seed) in the same spot", async () => {
const { html, dispose } = mount(createStore);
expect(html()).toBe("<h1>Poll</h1><div>Loading...</div>");
await wait(30);
await vi.advanceTimersByTimeAsync(FETCH_MS);
flush();
expect(html()).toBe("<h1>Poll</h1><ul><li>Tacos - 0</li></ul>");
dispose();
Expand Down
Loading