From 54574dcfb33ad8b4fb02c32b06dd7a789510b150 Mon Sep 17 00:00:00 2001 From: Dev Agrawal Date: Sat, 4 Apr 2026 22:38:54 -0500 Subject: [PATCH] research --- research/program.md | 44 +++++++ research/run-research-loop.sh | 35 ++++++ research/simplicity-report.mjs | 197 +++++++++++++++++++++++++++++ research/simplicity.md | 118 ++++++++++++++++++ research/simplicity.ts | 221 +++++++++++++++++++++++++++++++++ tests/core/reactivity.bench.ts | 82 ++++++++++++ tests/coreReactivity.test.ts | 98 +++++++++++++++ 7 files changed, 795 insertions(+) create mode 100644 research/program.md create mode 100755 research/run-research-loop.sh create mode 100644 research/simplicity-report.mjs create mode 100644 research/simplicity.md create mode 100644 research/simplicity.ts create mode 100644 tests/core/reactivity.bench.ts create mode 100644 tests/coreReactivity.test.ts diff --git a/research/program.md b/research/program.md new file mode 100644 index 0000000..0ffba64 --- /dev/null +++ b/research/program.md @@ -0,0 +1,44 @@ +Work in @solid/signals/ on the current branch. + +Task: +Find and implement exactly one small simplification in the internal reactive core, limited to src/core/**.ts + +Goal: +Make the code meaningfully simpler while preserving behavior. Simplicity is the primary objective. Performance is a constraint, not the target. + +Constraints: +- no public API changes +- no new dependencies +- no edits outside the allowed files +- one idea only, not a bundle of cleanup changes +- prefer removing complexity over introducing new abstractions +- avoid broad refactors; choose the smallest correct change + +Required process: +1. Read the relevant code in the allowed files and identify one concrete simplification opportunity. +2. Run the tests and bench to get a baseline. +3. Implement the change. +4. Run these verification commands in @solid/signals: + - pnpm test + - pnpm build + - pnpm exec vitest bench --run tests/core/reactivity.bench.ts + - pnpm run research:simplicity + The benchmarks should not show a significantly worse performance than the baseline. +5. Evaluate the result with this keep/discard rule: + - discard if tests fail + - discard if build fails + - discard if the benchmark shows a meaningful regression + - discard if `pnpm run research:simplicity` does not report a positive heuristic score + - discard if the result is not clearly simpler + - keep only if the change is small, behavior-preserving, and obviously simpler +6. Make a clean descriptive git commit describing the simplification made and why + +If your simplification could plausibly affect semantics, add a focused unit test that captures the intended invariant and verify it passes with the final change. + +Use `research/simplicity.md` as the simplicity scoring rubric when deciding whether the result is clearly simpler. + +Output: +- a brief description of the simplification you chose +- the exact files changed +- test/build/bench/simplicity results +- a final keep-or-discard recommendation with a short reason focused on simplicity diff --git a/research/run-research-loop.sh b/research/run-research-loop.sh new file mode 100755 index 0000000..bcc62e9 --- /dev/null +++ b/research/run-research-loop.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(pwd)" +PROMPT_FILE="$ROOT_DIR/research/program.md" +ITERATIONS="${1:-}" +STOP_ON_FAILURE="${STOP_ON_FAILURE:-1}" + +if [[ ! -f "$PROMPT_FILE" ]]; then + printf 'Missing prompt file: %s\n' "$PROMPT_FILE" >&2 + exit 1 +fi + +count=0 + +while :; do + if [[ -n "$ITERATIONS" && "$count" -ge "$ITERATIONS" ]]; then + break + fi + + count=$((count + 1)) + printf '\n[%s] Starting research pass %d\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$count" + + prompt="$(<"$PROMPT_FILE")" + + if ! claude --model opus -p --dangerously-skip-permissions "$prompt"; then + printf '[%s] Research pass %d failed\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$count" >&2 + if [[ "$STOP_ON_FAILURE" == "1" ]]; then + exit 1 + fi + fi +done + +printf '\nCompleted %d research pass(es)\n' "$count" diff --git a/research/simplicity-report.mjs b/research/simplicity-report.mjs new file mode 100644 index 0000000..991a84a --- /dev/null +++ b/research/simplicity-report.mjs @@ -0,0 +1,197 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT_DIR = process.cwd(); +const PROGRAM_PATH = path.join(ROOT_DIR, "research", "program.md"); + +const KEYWORD_BRANCH_REGEX = /\bif\b|\belse\s+if\b|\bswitch\b|\bcase\b|\?[^:]+:/g; +const SPECIAL_CASE_REGEX = /\b(default|fallback|special|edge|boundary|pending|error|null|undefined)\b/g; +const MUTABLE_LOCAL_REGEX = /\b(let|var)\s+[A-Za-z_$][\w$]*/g; +const LOCAL_HELPER_REGEX = /\bfunction\s+[A-Za-z_$][\w$]*\s*\(|\bconst\s+[A-Za-z_$][\w$]*\s*=\s*\([^)]*\)\s*=>/g; +const BOOLEAN_OPERATOR_REGEX = /&&|\|\|/g; +const RETURN_REGEX = /\breturn\b/g; +const TRY_CATCH_REGEX = /\btry\b|\bcatch\b|\bfinally\b/g; + +if (!existsSync(PROGRAM_PATH)) { + console.error(`Missing research program: ${PROGRAM_PATH}`); + process.exit(1); +} + +const allowedFiles = parseAllowedFiles(readFileSync(PROGRAM_PATH, "utf8")); +const changedFiles = getChangedAllowedFiles(allowedFiles); + +if (changedFiles.length === 0) { + console.log( + JSON.stringify( + { + comparedAgainst: "HEAD", + changedFiles: [], + heuristicScore: 0, + message: "No changed allowed files to compare.", + }, + null, + 2, + ), + ); + process.exit(0); +} + +const files = changedFiles.map((filePath) => { + const beforeSource = readGitFile(filePath); + const afterSource = readWorkingFile(filePath); + const before = analyzeSource(beforeSource); + const after = analyzeSource(afterSource); + const delta = diffMetrics(before, after); + + return { + filePath, + before, + after, + delta, + heuristicScore: scoreMetrics(delta), + }; +}); + +const totals = sumMetricDeltas(files.map((file) => file.delta)); + +console.log( + JSON.stringify( + { + comparedAgainst: "HEAD", + changedFiles, + totals, + heuristicScore: scoreMetrics(totals), + files, + }, + null, + 2, + ), +); + +function parseAllowedFiles(programText) { + return programText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("- src/")) + .map((line) => line.slice(2)); +} + +function getChangedAllowedFiles(allowedFiles) { + const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD", "--", ...allowedFiles], { + cwd: ROOT_DIR, + encoding: "utf8", + }); + + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function readGitFile(filePath) { + try { + return execFileSync("git", ["show", `HEAD:${filePath}`], { + cwd: ROOT_DIR, + encoding: "utf8", + }); + } catch { + return ""; + } +} + +function readWorkingFile(filePath) { + const absolutePath = path.join(ROOT_DIR, filePath); + return existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : ""; +} + +function analyzeSource(source) { + const lines = source + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + + return { + lines: lines.length, + branchingCount: countMatches(source, KEYWORD_BRANCH_REGEX), + maxIndentDepth: computeMaxIndentDepth(lines), + mutableLocalCount: countMatches(source, MUTABLE_LOCAL_REGEX), + specialCaseCount: countMatches(source, SPECIAL_CASE_REGEX), + tryCatchCount: countMatches(source, TRY_CATCH_REGEX), + localHelperCount: countMatches(source, LOCAL_HELPER_REGEX), + booleanOperatorCount: countMatches(source, BOOLEAN_OPERATOR_REGEX), + returnCount: countMatches(source, RETURN_REGEX), + }; +} + +function countMatches(source, regex) { + return source.match(regex)?.length ?? 0; +} + +function computeMaxIndentDepth(lines) { + let maxDepth = 0; + for (const line of lines) { + const indent = line.match(/^\s*/)?.[0].length ?? 0; + maxDepth = Math.max(maxDepth, Math.floor(indent / 2)); + } + return maxDepth; +} + +function diffMetrics(before, after) { + return { + lines: after.lines - before.lines, + branchingCount: after.branchingCount - before.branchingCount, + maxIndentDepth: after.maxIndentDepth - before.maxIndentDepth, + mutableLocalCount: after.mutableLocalCount - before.mutableLocalCount, + specialCaseCount: after.specialCaseCount - before.specialCaseCount, + tryCatchCount: after.tryCatchCount - before.tryCatchCount, + localHelperCount: after.localHelperCount - before.localHelperCount, + booleanOperatorCount: after.booleanOperatorCount - before.booleanOperatorCount, + returnCount: after.returnCount - before.returnCount, + }; +} + +function sumMetricDeltas(deltas) { + return deltas.reduce( + (total, delta) => ({ + lines: total.lines + delta.lines, + branchingCount: total.branchingCount + delta.branchingCount, + maxIndentDepth: total.maxIndentDepth + delta.maxIndentDepth, + mutableLocalCount: total.mutableLocalCount + delta.mutableLocalCount, + specialCaseCount: total.specialCaseCount + delta.specialCaseCount, + tryCatchCount: total.tryCatchCount + delta.tryCatchCount, + localHelperCount: total.localHelperCount + delta.localHelperCount, + booleanOperatorCount: total.booleanOperatorCount + delta.booleanOperatorCount, + returnCount: total.returnCount + delta.returnCount, + }), + { + lines: 0, + branchingCount: 0, + maxIndentDepth: 0, + mutableLocalCount: 0, + specialCaseCount: 0, + tryCatchCount: 0, + localHelperCount: 0, + booleanOperatorCount: 0, + returnCount: 0, + }, + ); +} + +function scoreMetrics(delta) { + return ( + scoreDelta(delta.branchingCount, 4) + + scoreDelta(delta.maxIndentDepth, 4) + + scoreDelta(delta.mutableLocalCount, 3) + + scoreDelta(delta.specialCaseCount, 3) + + scoreDelta(delta.tryCatchCount, 4) + + scoreDelta(delta.localHelperCount, 2) + + scoreDelta(delta.booleanOperatorCount, 1) + + scoreDelta(delta.lines, 1) + + scoreDelta(delta.returnCount, -1) + ); +} + +function scoreDelta(delta, weight) { + return delta === 0 ? 0 : delta < 0 ? Math.abs(delta) * weight : -delta * weight; +} diff --git a/research/simplicity.md b/research/simplicity.md new file mode 100644 index 0000000..bc0239a --- /dev/null +++ b/research/simplicity.md @@ -0,0 +1,118 @@ +# Simplicity Scoring + +The research loop should not treat fewer lines as the definition of simplicity. Instead, it should score a candidate with a small heuristic scorecard and combine that with an explicit reviewer judgment. + +## Why This Shape + +- raw LOC is too noisy +- cyclomatic complexity alone misses indirection and state-surface changes +- a simplification should usually reduce branching, nesting, mutable temporaries, or special cases +- a reviewer still needs to confirm that the new code is more direct to understand + +## Inputs + +The scorer compares only the touched functions before and after a candidate change. + +Each touched function should provide: + +- `filePath` +- `name` +- `source` + +The caller should also provide a reviewer judgment: + +- `explanationClarity`: `high | medium | low` +- `semanticDirectness`: `higher | same | lower` +- `confidence`: `high | medium | low` +- `summary`: one-sentence explanation of why the code is simpler or not + +## Heuristics + +For each touched function, `research/simplicity.ts` computes: + +- `lines` +- `branchingCount` +- `maxIndentDepth` +- `mutableLocalCount` +- `specialCaseCount` +- `tryCatchCount` +- `localHelperCount` +- `booleanOperatorCount` +- `returnCount` + +These are intentionally simple source-level heuristics for v1. They are not a full parser-backed semantic metric. + +## Scoring Rules + +The score should reward reductions in: + +- branches +- nesting depth +- mutable locals +- special-case handling +- try/catch flow +- local helper indirection + +It should lightly reward reduced line count. + +It should not automatically penalize extra `return` statements because early returns often simplify code; `returnCount` should be treated as a weak signal only. + +## Acceptance Rule + +A candidate counts as a simplicity improvement only if all are true: + +1. heuristic score is positive +2. reviewer says semantic directness is not lower +3. explanation clarity is not low +4. reviewer confidence is not low + +This is intentionally conservative. The loop should prefer false negatives over accepting noisy simplifications. + +## Example Output + +```json +{ + "touchedFunctionCount": 1, + "totals": { + "lines": -3, + "branchingCount": -1, + "maxIndentDepth": -1, + "mutableLocalCount": 0, + "specialCaseCount": 0, + "tryCatchCount": 0, + "localHelperCount": 0, + "booleanOperatorCount": 0, + "returnCount": 1 + }, + "heuristicScore": 8, + "judgment": { + "explanationClarity": "high", + "semanticDirectness": "higher", + "confidence": "high", + "summary": "This removes one null-guard branch and makes the main path linear." + }, + "improved": true, + "reasons": [ + "fewer branches in touched functions", + "lower nesting depth", + "reviewer judged logic as more direct", + "change is easy to explain in one sentence" + ] +} +``` + +## Loop Integration + +The loop should use the scorer after candidate evaluation and before final accept/reject: + +1. identify touched functions in the allowed files +2. capture their source before and after the candidate +3. run `scoreSimplicity(before, after, judgment)` +4. reject if `improved` is false +5. include the scorecard in the recorded result JSON + +## Important Limits + +- The heuristics are useful only for small local edits. +- They should not be treated as a universal code quality metric. +- If a candidate may change behavior, a focused unit test still matters more than the simplicity score. diff --git a/research/simplicity.ts b/research/simplicity.ts new file mode 100644 index 0000000..4c17a7b --- /dev/null +++ b/research/simplicity.ts @@ -0,0 +1,221 @@ +type JudgmentLevel = "higher" | "same" | "lower"; +type ClarityLevel = "high" | "medium" | "low"; + +export type FunctionSnapshot = { + filePath: string; + name: string; + source: string; +}; + +export type SimplicityJudgment = { + explanationClarity: ClarityLevel; + semanticDirectness: JudgmentLevel; + confidence: ClarityLevel; + summary: string; +}; + +export type FunctionHeuristics = { + lines: number; + branchingCount: number; + maxIndentDepth: number; + mutableLocalCount: number; + specialCaseCount: number; + tryCatchCount: number; + localHelperCount: number; + booleanOperatorCount: number; + returnCount: number; +}; + +export type FunctionDelta = { + filePath: string; + name: string; + before: FunctionHeuristics; + after: FunctionHeuristics; + delta: MetricDelta; +}; + +export type MetricDelta = { + lines: number; + branchingCount: number; + maxIndentDepth: number; + mutableLocalCount: number; + specialCaseCount: number; + tryCatchCount: number; + localHelperCount: number; + booleanOperatorCount: number; + returnCount: number; +}; + +export type SimplicityScorecard = { + touchedFunctionCount: number; + functionDeltas: FunctionDelta[]; + totals: MetricDelta; + heuristicScore: number; + judgment: SimplicityJudgment; + improved: boolean; + reasons: string[]; +}; + +const KEYWORD_BRANCH_REGEX = /\bif\b|\belse\s+if\b|\bswitch\b|\bcase\b|\?[^:]+:/g; +const SPECIAL_CASE_REGEX = /\b(default|fallback|special|edge|boundary|pending|error|null|undefined)\b/g; +const MUTABLE_LOCAL_REGEX = /\b(let|var)\s+[A-Za-z_$][\w$]*/g; +const LOCAL_HELPER_REGEX = /\bfunction\s+[A-Za-z_$][\w$]*\s*\(|\bconst\s+[A-Za-z_$][\w$]*\s*=\s*\([^)]*\)\s*=>/g; +const BOOLEAN_OPERATOR_REGEX = /&&|\|\|/g; +const RETURN_REGEX = /\breturn\b/g; +const TRY_CATCH_REGEX = /\btry\b|\bcatch\b|\bfinally\b/g; + +export function analyzeFunction(source: string): FunctionHeuristics { + const lines = source + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + + return { + lines: lines.length, + branchingCount: countMatches(source, KEYWORD_BRANCH_REGEX), + maxIndentDepth: computeMaxIndentDepth(lines), + mutableLocalCount: countMatches(source, MUTABLE_LOCAL_REGEX), + specialCaseCount: countMatches(source, SPECIAL_CASE_REGEX), + tryCatchCount: countMatches(source, TRY_CATCH_REGEX), + localHelperCount: countMatches(source, LOCAL_HELPER_REGEX), + booleanOperatorCount: countMatches(source, BOOLEAN_OPERATOR_REGEX), + returnCount: countMatches(source, RETURN_REGEX), + }; +} + +export function scoreSimplicity( + before: FunctionSnapshot[], + after: FunctionSnapshot[], + judgment: SimplicityJudgment, +): SimplicityScorecard { + const beforeByKey = new Map(before.map((snapshot) => [makeKey(snapshot), snapshot])); + const afterByKey = new Map(after.map((snapshot) => [makeKey(snapshot), snapshot])); + const keys = [...new Set([...beforeByKey.keys(), ...afterByKey.keys()])].sort(); + const functionDeltas: FunctionDelta[] = []; + + for (const key of keys) { + const beforeSnapshot = beforeByKey.get(key); + const afterSnapshot = afterByKey.get(key); + if (!beforeSnapshot || !afterSnapshot) continue; + const beforeMetrics = analyzeFunction(beforeSnapshot.source); + const afterMetrics = analyzeFunction(afterSnapshot.source); + functionDeltas.push({ + filePath: afterSnapshot.filePath, + name: afterSnapshot.name, + before: beforeMetrics, + after: afterMetrics, + delta: diffMetrics(beforeMetrics, afterMetrics), + }); + } + + const totals = sumMetricDeltas(functionDeltas.map((entry) => entry.delta)); + const heuristicScore = + scoreDelta(totals.branchingCount, 4) + + scoreDelta(totals.maxIndentDepth, 4) + + scoreDelta(totals.mutableLocalCount, 3) + + scoreDelta(totals.specialCaseCount, 3) + + scoreDelta(totals.tryCatchCount, 4) + + scoreDelta(totals.localHelperCount, 2) + + scoreDelta(totals.booleanOperatorCount, 1) + + scoreDelta(totals.lines, 1) + + scoreDelta(totals.returnCount, -1); + + const reasons = explainScore(totals, judgment, functionDeltas.length); + const improved = + heuristicScore > 0 && + judgment.semanticDirectness !== "lower" && + judgment.explanationClarity !== "low" && + judgment.confidence !== "low"; + + return { + touchedFunctionCount: functionDeltas.length, + functionDeltas, + totals, + heuristicScore, + judgment, + improved, + reasons, + }; +} + +function makeKey(snapshot: FunctionSnapshot): string { + return snapshot.filePath + "::" + snapshot.name; +} + +function countMatches(source: string, regex: RegExp): number { + return source.match(regex)?.length ?? 0; +} + +function computeMaxIndentDepth(lines: string[]): number { + let maxDepth = 0; + for (const line of lines) { + const indent = line.match(/^\s*/)?.[0].length ?? 0; + maxDepth = Math.max(maxDepth, Math.floor(indent / 2)); + } + return maxDepth; +} + +function diffMetrics(before: FunctionHeuristics, after: FunctionHeuristics): MetricDelta { + return { + lines: after.lines - before.lines, + branchingCount: after.branchingCount - before.branchingCount, + maxIndentDepth: after.maxIndentDepth - before.maxIndentDepth, + mutableLocalCount: after.mutableLocalCount - before.mutableLocalCount, + specialCaseCount: after.specialCaseCount - before.specialCaseCount, + tryCatchCount: after.tryCatchCount - before.tryCatchCount, + localHelperCount: after.localHelperCount - before.localHelperCount, + booleanOperatorCount: after.booleanOperatorCount - before.booleanOperatorCount, + returnCount: after.returnCount - before.returnCount, + }; +} + +function sumMetricDeltas(deltas: MetricDelta[]): MetricDelta { + return deltas.reduce( + (total, delta) => ({ + lines: total.lines + delta.lines, + branchingCount: total.branchingCount + delta.branchingCount, + maxIndentDepth: total.maxIndentDepth + delta.maxIndentDepth, + mutableLocalCount: total.mutableLocalCount + delta.mutableLocalCount, + specialCaseCount: total.specialCaseCount + delta.specialCaseCount, + tryCatchCount: total.tryCatchCount + delta.tryCatchCount, + localHelperCount: total.localHelperCount + delta.localHelperCount, + booleanOperatorCount: total.booleanOperatorCount + delta.booleanOperatorCount, + returnCount: total.returnCount + delta.returnCount, + }), + { + lines: 0, + branchingCount: 0, + maxIndentDepth: 0, + mutableLocalCount: 0, + specialCaseCount: 0, + tryCatchCount: 0, + localHelperCount: 0, + booleanOperatorCount: 0, + returnCount: 0, + }, + ); +} + +function scoreDelta(delta: number, weight: number): number { + return delta === 0 ? 0 : delta < 0 ? Math.abs(delta) * weight : -delta * weight; +} + +function explainScore( + totals: MetricDelta, + judgment: SimplicityJudgment, + touchedFunctionCount: number, +): string[] { + const reasons: string[] = []; + if (totals.branchingCount < 0) reasons.push("fewer branches in touched functions"); + if (totals.maxIndentDepth < 0) reasons.push("lower nesting depth"); + if (totals.mutableLocalCount < 0) reasons.push("fewer mutable locals"); + if (totals.specialCaseCount < 0) reasons.push("fewer special-case paths"); + if (totals.tryCatchCount < 0) reasons.push("less exception-flow complexity"); + if (totals.localHelperCount < 0) reasons.push("less local indirection"); + if (totals.lines < 0 && reasons.length === 0) reasons.push("smaller touched-function footprint"); + if (judgment.semanticDirectness === "higher") reasons.push("reviewer judged logic as more direct"); + if (judgment.explanationClarity === "high") reasons.push("change is easy to explain in one sentence"); + if (touchedFunctionCount > 1) reasons.push(`spread across ${touchedFunctionCount} touched functions`); + if (reasons.length === 0) reasons.push("no strong simplicity signal detected"); + return reasons; +} diff --git a/tests/core/reactivity.bench.ts b/tests/core/reactivity.bench.ts new file mode 100644 index 0000000..2e12cd7 --- /dev/null +++ b/tests/core/reactivity.bench.ts @@ -0,0 +1,82 @@ +import { bench } from "vitest"; +import { createEffect, createMemo, createRoot, createSignal, flush } from "../../src/index.js"; + +const filter = new RegExp(process.env.FILTER || ".+"); + +function registerBench(title: string, fn: () => void) { + if (filter.test(title)) { + bench(title, fn); + } +} + +registerBench("reactivity: fanout update", () => { + let setSource!: (value: number) => number; + + const dispose = createRoot(dispose => { + const [$source, _setSource] = createSignal(0); + const memos = Array.from({ length: 50 }, (_, index) => createMemo(() => $source() + index)); + const $sink = createMemo(() => { + let total = 0; + for (const memo of memos) total += memo(); + return total; + }); + + setSource = _setSource; + createEffect($sink, () => {}); + return dispose; + }); + + flush(); + setSource(1); + flush(); + dispose(); + flush(); +}); + +registerBench("reactivity: diamond propagation", () => { + let setSource!: (value: number) => number; + + const dispose = createRoot(dispose => { + const [$source, _setSource] = createSignal(0); + const $left = createMemo(() => $source() + 1); + const $right = createMemo(() => $source() + 2); + const $diamond = createMemo(() => $left() + $right()); + + setSource = _setSource; + createEffect($diamond, () => {}); + return dispose; + }); + + flush(); + setSource(1); + flush(); + dispose(); + flush(); +}); + +registerBench("reactivity: dynamic dependency switch", () => { + let setCondition!: (value: boolean) => boolean; + let setLeft!: (value: number) => number; + let setRight!: (value: number) => number; + + const dispose = createRoot(dispose => { + const [$condition, _setCondition] = createSignal(true); + const [$left, _setLeft] = createSignal(0); + const [$right, _setRight] = createSignal(0); + const $selected = createMemo(() => ($condition() ? $left() : $right())); + + setCondition = _setCondition; + setLeft = _setLeft; + setRight = _setRight; + createEffect($selected, () => {}); + return dispose; + }); + + flush(); + setLeft(1); + setCondition(false); + setRight(1); + flush(); + dispose(); + flush(); +}); diff --git a/tests/coreReactivity.test.ts b/tests/coreReactivity.test.ts new file mode 100644 index 0000000..ff1ae4f --- /dev/null +++ b/tests/coreReactivity.test.ts @@ -0,0 +1,98 @@ +import { createEffect, createMemo, createRoot, createSignal, flush } from "../src/index.js"; + +afterEach(() => flush()); + +it("coalesces multiple source writes into one downstream effect run", () => { + const effect = vi.fn(); + let setX!: (value: number) => number; + let setY!: (value: number) => number; + + createRoot(() => { + const [$x, _setX] = createSignal(1); + const [$y, _setY] = createSignal(2); + const $sum = createMemo(() => $x() + $y()); + + setX = _setX; + setY = _setY; + + createEffect($sum, effect); + }); + + flush(); + expect(effect).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenLastCalledWith(3, undefined); + + setX(10); + setY(20); + setX(11); + + expect(effect).toHaveBeenCalledTimes(1); + + flush(); + expect(effect).toHaveBeenCalledTimes(2); + expect(effect).toHaveBeenLastCalledWith(31, 3); +}); + +it("switches dependencies within a batch before notifying downstream effects", () => { + const effect = vi.fn(); + let setCondition!: (value: boolean) => boolean; + let setX!: (value: number) => number; + let setY!: (value: number) => number; + + createRoot(() => { + const [$condition, _setCondition] = createSignal(true); + const [$x, _setX] = createSignal(0); + const [$y, _setY] = createSignal(0); + const $selected = createMemo(() => ($condition() ? $x() : $y())); + + setCondition = _setCondition; + setX = _setX; + setY = _setY; + + createEffect($selected, effect); + }); + + flush(); + expect(effect).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenLastCalledWith(0, undefined); + + setX(1); + setCondition(false); + setY(2); + flush(); + + expect(effect).toHaveBeenCalledTimes(2); + expect(effect).toHaveBeenLastCalledWith(2, 0); + + setX(3); + flush(); + expect(effect).toHaveBeenCalledTimes(2); + + setY(4); + flush(); + expect(effect).toHaveBeenCalledTimes(3); + expect(effect).toHaveBeenLastCalledWith(4, 2); +}); + +it("does not run queued effects after the owning root is disposed", () => { + const effect = vi.fn(); + let dispose!: () => void; + let setX!: (value: number) => number; + + dispose = createRoot(rootDispose => { + const [$x, _setX] = createSignal(0); + setX = _setX; + createEffect($x, effect); + return rootDispose; + }); + + flush(); + expect(effect).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenLastCalledWith(0, undefined); + + setX(1); + dispose(); + flush(); + + expect(effect).toHaveBeenCalledTimes(1); +});