From cd91af8ff7b2e26e01de6a7507f4bb3bb2c23058 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Wed, 9 Sep 2026 15:13:28 +0200 Subject: [PATCH 1/6] Developer playground: Add lambda views before and after optimisation, including diffs Signed-off-by: Florian Hammerschmidt --- CHANGELOG.md | 1 + compiler/core/lam_compile_main.ml | 6 +- compiler/core/lam_compile_main.mli | 4 + compiler/jsoo/jsoo_playground_main.ml | 25 +- packages/dev-playground/package.json | 2 +- .../scripts/lambda-diff.test.mjs | 241 ++++++++++++++++++ packages/dev-playground/src/Bindings.res | 1 + packages/dev-playground/src/CompilerApi.res | 14 +- packages/dev-playground/src/CompilerApi.resi | 1 + packages/dev-playground/src/LambdaDiff.res | 161 ++++++++++++ .../dev-playground/src/LambdaInlineDiff.res | 220 ++++++++++++++++ packages/dev-playground/src/LambdaView.res | 116 +++++++++ packages/dev-playground/src/Main.res | 35 ++- packages/dev-playground/src/styles.css | 100 ++++++++ packages/playground/playground_test.cjs | 21 ++ 15 files changed, 929 insertions(+), 19 deletions(-) create mode 100644 packages/dev-playground/scripts/lambda-diff.test.mjs create mode 100644 packages/dev-playground/src/LambdaDiff.res create mode 100644 packages/dev-playground/src/LambdaInlineDiff.res create mode 100644 packages/dev-playground/src/LambdaView.res diff --git a/CHANGELOG.md b/CHANGELOG.md index c2774158f0f..7091f433944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,7 @@ #### :rocket: New Feature +- Add Before optimization, After optimization, and Diff views to the developer playground's Lambda tab, with a dependency-free, work-limited diff, inline highlighting of changed Lambda tokens, and optimized Lambda output in playground API v9. - Support UTF-16 surrogate-pair escapes such as `"\uD83D\uDE00"` in ordinary string literals. https://github.com/rescript-lang/rescript/pull/8606 - Support dynamic imports of external bindings annotated with `@scope`; the generated import follows the complete property path. These imports were previously rejected. https://github.com/rescript-lang/rescript/pull/8582 - Add `@res.hoistedFunction` for emitting nested module functions as flat JavaScript exports. https://github.com/rescript-lang/rescript/pull/8402 diff --git a/compiler/core/lam_compile_main.ml b/compiler/core/lam_compile_main.ml index cada1a101d8..1c27804fc4d 100644 --- a/compiler/core/lam_compile_main.ml +++ b/compiler/core/lam_compile_main.ml @@ -254,7 +254,8 @@ let required_modules (lam : Lambda.t) : unit Lam_module_ident.Hash_set.t = collect lam; required -let compile (output_prefix : string) export_idents hoisted (lam : Lambda.t) = +let compile ?on_optimized_lambda (output_prefix : string) export_idents hoisted + (lam : Lambda.t) = let debug_ir = !((Js_config.current ()).debug_ir) in let diagnostics = if debug_ir then Some (Ir_diagnostics.create ~output_prefix) else None @@ -329,6 +330,9 @@ let compile (output_prefix : string) export_idents hoisted (lam : Lambda.t) = lam in + (* Capture the final whole-term optimization result before export grouping + and JavaScript lowering. The callback is absent in normal compilation. *) + Option.iter (fun capture -> capture lam) on_optimized_lambda; let ({Lam_coercion.groups} as coerced_input), meta = Lam_coercion.coerce_and_group_big_lambda meta lam in diff --git a/compiler/core/lam_compile_main.mli b/compiler/core/lam_compile_main.mli index c62ff0dca4e..cbff2fbf5a2 100644 --- a/compiler/core/lam_compile_main.mli +++ b/compiler/core/lam_compile_main.mli @@ -28,6 +28,7 @@ *) val compile : + ?on_optimized_lambda:(Lambda.t -> unit) -> string -> Ident.t list -> Lambda.hoisted_function list -> @@ -35,6 +36,9 @@ val compile : J.deps_program (** For toplevel, [filename] is [""] which is the same as {!Env.get_unit_name ()} + + [on_optimized_lambda], when supplied, observes the whole-term Lambda after + optimization and before export grouping and JavaScript lowering. *) val lambda_as_module : J.deps_program -> string -> unit diff --git a/compiler/jsoo/jsoo_playground_main.ml b/compiler/jsoo/jsoo_playground_main.ml index f19f783442c..b66c568ffb3 100644 --- a/compiler/jsoo/jsoo_playground_main.ml +++ b/compiler/jsoo/jsoo_playground_main.ml @@ -53,8 +53,9 @@ * v6: Added `config.experimental_features` and `config.jsx_preserve_mode` to the BundleConfig. * v7: Added debug dump output APIs for developer playground tooling. * v8: Added genType and source map configuration and compilation outputs. + * v9: Added optimized Lambda output to debug compilation. * *) -let api_version = "8" +let api_version = "9" module Js = Js_of_ocaml.Js @@ -670,8 +671,18 @@ module Compile = struct let {Translmod.lambda; exports; hoisted_functions} = Translmod.transl_implementation modulename typed_tree in + let optimized_lambda = ref None in + let on_optimized_lambda = + if include_debug_outputs then + Some + (fun lambda -> + optimized_lambda := + Some (Printer.to_string Printlambda.lambda lambda)) + else None + in let lambda_output = - Lam_compile_main.compile "" exports hoisted_functions lambda + Lam_compile_main.compile ?on_optimized_lambda "" exports + hoisted_functions lambda in let js_code, source_map = render_javascript ~module_system ~filename ~source:str ~source_map_mode @@ -728,7 +739,15 @@ module Compile = struct ("lam", inject @@ Js.string lam); |] in - Js.Unsafe.obj (Array.concat [attrs; debug_attrs; gentype_attrs]) + let optimized_lambda_attrs = + match !optimized_lambda with + | None -> [||] + | Some output -> + Js.Unsafe.[|("lambda_optimized", inject @@ Js.string output)|] + in + Js.Unsafe.obj + (Array.concat + [attrs; debug_attrs; gentype_attrs; optimized_lambda_attrs]) else Js.Unsafe.obj attrs with e -> ( match e with diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json index d4ca941b9f3..d37873ebe66 100644 --- a/packages/dev-playground/package.json +++ b/packages/dev-playground/package.json @@ -9,7 +9,7 @@ "prepare-pages-site": "node scripts/prepare-pages-site.mjs", "res:build": "rescript", "res:watch": "rescript -w", - "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs", + "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs scripts/lambda-diff.test.mjs", "dev": "vite --host 127.0.0.1", "build": "rescript && vite build", "preview": "vite preview --host 127.0.0.1" diff --git a/packages/dev-playground/scripts/lambda-diff.test.mjs b/packages/dev-playground/scripts/lambda-diff.test.mjs new file mode 100644 index 00000000000..b1874157c8d --- /dev/null +++ b/packages/dev-playground/scripts/lambda-diff.test.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + compare, + maxCharacters, + maxLines, + sections, +} from "../src/LambdaDiff.res.mjs"; +import * as Inline from "../src/LambdaInlineDiff.res.mjs"; + +function changes(before, after) { + const result = compare(before, after); + assert.equal(result.TAG, "Changes"); + const lines = result._0; + for (const [side, text] of [ + ["before", before], + ["after", after], + ]) { + const original = lines.filter(line => line[side] !== undefined); + assert.equal(original.map(line => line.text).join("\n"), text); + assert.deepEqual( + original.map(line => line[side]), + original.map((_, index) => index + 1), + ); + } + return lines; +} + +test("identical dumps, including empty dumps, need no diff", () => { + assert.equal(compare("", ""), "Identical"); + assert.equal(compare("(makeblock 42)\n", "(makeblock 42)\n"), "Identical"); +}); + +test("replacement retains before and after line numbers", () => { + assert.deepEqual( + changes("(let\n unused/1\n result)\n", "(let\n result)\n"), + [ + { kind: "Same", text: "(let", before: 1, after: 1 }, + { kind: "Removed", text: " unused/1", before: 2, after: undefined }, + { kind: "Same", text: " result)", before: 3, after: 2 }, + { kind: "Same", text: "", before: 4, after: 3 }, + ], + ); + assert.deepEqual( + changes("before", "after").map(line => line.kind), + ["Removed", "Added"], + ); +}); + +test("insertions, deletions, empty inputs and final newlines are lossless", () => { + for (const [before, after] of [ + ["", "(block 42)"], + ["(block 42)", ""], + ["a\nb", "a\ninsert\nb"], + ["a\nb", "insert\na\nb\nend"], + ["a\n", "a"], + ["a", "a\n"], + ["\n\n", "\n"], + ]) + changes(before, after); +}); + +test("repeated Lambda lines align deterministically without dropping text", () => { + const before = "(let\n x/1\n (let\n x/1\n ))"; + const after = "(let\n (let\n x/1\n ))"; + assert.equal( + changes(before, after).filter(line => line.kind !== "Same").length, + 1, + ); + assert.deepEqual(compare(before, after), compare(before, after)); + changes("x/1", "x/2"); // Identifier changes must not be normalized away. +}); + +test("exhaustive small dumps reconstruct both inputs", () => { + const dumps = [""]; + for (let length = 1; length <= 4; length++) { + for (let bits = 0; bits < 2 ** length; bits++) { + dumps.push( + Array.from({ length }, (_, i) => + (bits >> i) & 1 ? "(x)" : "(y)", + ).join("\n"), + ); + } + } + for (const before of dumps) { + for (const after of dumps) { + if (before !== after) changes(before, after); + } + } +}); + +test("bounds comparison work and output size", () => { + assert.equal(compare("a\n".repeat(1100), "b\n".repeat(1100)), "TooLarge"); + assert.equal(compare("a".repeat(maxCharacters), "b"), "TooLarge"); + assert.equal(compare("\n".repeat(maxLines), ""), "TooLarge"); +}); + +test("trims common edges before applying the comparison budget", () => { + const edge = "(unchanged)\n".repeat(2000); + const lines = changes(`${edge}before\n${edge}`, `${edge}after\n${edge}`); + assert.equal(lines.filter(line => line.kind !== "Same").length, 2); +}); + +test("collapsed regions preserve every line and keep context around changes", () => { + const context = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"); + const lines = changes( + `${context}\nold\n${context}\nold\n${context}`, + `${context}\nnew\n${context}\nnew\n${context}`, + ); + const grouped = sections(lines); + assert.deepEqual( + grouped.flatMap(section => section._0), + lines, + ); + const hidden = grouped.filter(section => section.TAG === "Collapsed"); + assert.deepEqual( + hidden.map(section => section._0.length), + [17, 14, 17], + ); + for (const section of hidden) { + assert.ok(section._0.every(line => line.kind === "Same")); + } + assert.ok( + sections(changes("a\nb\nc", "a\nB\nc")).every( + section => section.TAG === "Visible", + ), + ); +}); + +function inlineChanges(before, after) { + const lines = changes(before, after); + const highlights = Inline.highlight(lines); + for (const line of lines) { + const parts = Inline.forLine(highlights, line); + if (parts) assert.equal(parts.map(part => part.text).join(""), line.text); + if (line.kind === "Same") assert.equal(parts, undefined); + } + return highlights; +} + +const changedText = parts => + parts.filter(part => part.changed).map(part => part.text); + +test("inline diff highlights changed Lambda tokens, not the shared expression", () => { + const result = inlineChanges(" (+ value/1 10)", " (+ value/1 20)"); + assert.deepEqual(changedText(result.before[1]), ["10"]); + assert.deepEqual(changedText(result.after[1]), ["20"]); + const renamed = inlineChanges("(apply value/123 42)", "(apply value/124 42)"); + assert.deepEqual(changedText(renamed.before[1]), ["value/123"]); + assert.deepEqual(changedText(renamed.after[1]), ["value/124"]); +}); + +test("inline diff preserves whitespace, Unicode, and escaped quoted constants", () => { + const result = inlineChanges( + ' (apply print/1 "a \\"quoted\\" ๐Ÿ™‚")', + '\t(apply print/1 "b \\"quoted\\" ๐Ÿ™‚")', + ); + assert.deepEqual(changedText(result.before[1]), ['"a \\"quoted\\" ๐Ÿ™‚"']); + assert.deepEqual(changedText(result.after[1]), ['"b \\"quoted\\" ๐Ÿ™‚"']); + const whitespace = inlineChanges( + " (apply cafรฉ/1 42)", + "\t(apply cafรฉ/1 42)", + ); + assert.deepEqual(changedText(whitespace.before[1]), []); + assert.deepEqual(changedText(whitespace.after[1]), []); +}); + +test("inline diff handles token insertion, deletion and repeated tokens", () => { + const result = inlineChanges("(apply f/1 x/2 x/2)", "(apply f/1 x/2)"); + assert.deepEqual(changedText(result.before[1]), ["x/2"]); + assert.deepEqual(changedText(result.after[1]), []); + const inserted = inlineChanges("(apply f/1 x/2)", "(apply f/1 x/2 42)"); + assert.deepEqual(changedText(inserted.before[1]), []); + assert.deepEqual(changedText(inserted.after[1]), ["42"]); +}); + +test("inline pairing skips unmatched lines in an uneven changed block", () => { + const result = inlineChanges( + "(unused z/9)\n (+ x/1 10)\n (- y/2 30)", + " (+ x/1 20)\n (- y/2 40)", + ); + assert.equal(result.before[1], undefined); + assert.deepEqual(changedText(result.before[2]), ["10"]); + assert.deepEqual(changedText(result.after[1]), ["20"]); + assert.deepEqual(changedText(result.before[3]), ["30"]); + assert.deepEqual(changedText(result.after[2]), ["40"]); +}); + +test("inline pairing prefers a better later match over a weaker first match", () => { + const result = inlineChanges( + "(apply other/1 10)\n(apply f/2 20)", + "(apply f/2 30)", + ); + assert.equal(result.before[1], undefined); + assert.deepEqual(changedText(result.before[2]), ["20"]); + assert.deepEqual(changedText(result.after[1]), ["30"]); +}); + +test("unrelated expressions and one-sided changes retain only line highlighting", () => { + for (const [before, after] of [ + [" (foo/1 10)", " (bar/2 20)"], + ["((", "))"], + ["(a)\n(b)", "(a)"], + ["(a)", "(a)\n(b)"], + ]) { + assert.deepEqual(inlineChanges(before, after), { before: {}, after: {} }); + } +}); + +test("inline highlighting falls back for oversized blocks, lines and token counts", () => { + const largeBlock = Array.from( + { length: Inline.maxBlockLines }, + (_, i) => `(apply f/${i} 10)`, + ).join("\n"); + assert.deepEqual( + inlineChanges(largeBlock, largeBlock.replaceAll(" 10)", " 20)")), + { before: {}, after: {} }, + ); + for (const body of [ + "x".repeat(Inline.maxLineCharacters), + "x ".repeat(Inline.maxTokens), + ]) { + assert.deepEqual( + inlineChanges(`(apply ${body} 10)`, `(apply ${body} 20)`), + { before: {}, after: {} }, + ); + } +}); + +test("inline token work is budgeted across blocks and resets for each dump", () => { + const body = "x ".repeat(110); + const before = Array.from( + { length: 30 }, + (_, i) => `(apply ${body} 10)\nseparator/${i}`, + ).join("\n"); + const after = before.replaceAll(" 10)", " 20)"); + const result = inlineChanges(before, after); + assert.ok(Object.keys(result.before).length > 0); + assert.ok(Object.keys(result.before).length < 30); + assert.deepEqual(inlineChanges(before, after), result); +}); diff --git a/packages/dev-playground/src/Bindings.res b/packages/dev-playground/src/Bindings.res index edf48d15251..ecca8b32b79 100644 --- a/packages/dev-playground/src/Bindings.res +++ b/packages/dev-playground/src/Bindings.res @@ -80,6 +80,7 @@ module CompileResult = { @get external parsetree: compileResult => option = "parsetree" @get external typedtree: compileResult => option = "typedtree" @get external lambda: compileResult => option = "lambda" + @get external lambdaOptimized: compileResult => option = "lambda_optimized" @get external gentype: compileResult => option = "gentype" @get external sourceMap: compileResult => option = "source_map" @get external errors: compileResult => option> = "errors" diff --git a/packages/dev-playground/src/CompilerApi.res b/packages/dev-playground/src/CompilerApi.res index 1715921e14d..798999609cb 100644 --- a/packages/dev-playground/src/CompilerApi.res +++ b/packages/dev-playground/src/CompilerApi.res @@ -62,6 +62,7 @@ type success = { parsetree: string, typedtree: string, lambda: string, + lambdaOptimized: option, gentype: option, sourceMap: option, warnings: array, @@ -415,9 +416,20 @@ let normalize = (compileOutput, elapsedMs): compileResult => { } let gentype = compileOutput->CompileResult.gentype + let lambdaOptimized = compileOutput->CompileResult.lambdaOptimized let sourceMap = compileOutput->CompileResult.sourceMap - Ok({jsCode, parsetree, typedtree, lambda, gentype, sourceMap, warnings, time: elapsedMs}) + Ok({ + jsCode, + parsetree, + typedtree, + lambda, + lambdaOptimized, + gentype, + sourceMap, + warnings, + time: elapsedMs, + }) | _ => Error(failureFromCompileOutput(compileOutput, elapsedMs)) } diff --git a/packages/dev-playground/src/CompilerApi.resi b/packages/dev-playground/src/CompilerApi.resi index 5535d15e04d..ee857fc33d2 100644 --- a/packages/dev-playground/src/CompilerApi.resi +++ b/packages/dev-playground/src/CompilerApi.resi @@ -25,6 +25,7 @@ type success = { parsetree: string, typedtree: string, lambda: string, + lambdaOptimized: option, gentype: option, sourceMap: option, warnings: array, diff --git a/packages/dev-playground/src/LambdaDiff.res b/packages/dev-playground/src/LambdaDiff.res new file mode 100644 index 00000000000..14848a4bfe4 --- /dev/null +++ b/packages/dev-playground/src/LambdaDiff.res @@ -0,0 +1,161 @@ +type kind = Same | Removed | Added +type line = {kind: kind, text: string, before: option, after: option} +type result = Identical | TooLarge | Changes(array) + +// Bound both comparison work and output size. Compiler dumps can greatly +// exceed the source size; never allocate an unbounded quadratic table. +let maxCells = 1000000 +let maxCharacters = 1000000 +let maxLines = 20000 + +// Line diff: strip identical prefixes/suffixes, then find the longest common +// subsequence (LCS) of exact lines in the remaining middle. Each table cell +// stores how many lines can still match from that pair of positions: equal +// lines take 1 + the diagonal cell; otherwise take the larger of skipping a +// line on either side. Walking the table emits Same/Removed/Added lines with +// their original line numbers (ties prefer removal for deterministic output). +// The middle costs O(rows * columns) time and space, so the limits above return +// TooLarge before allocating an excessive table. LambdaInlineDiff separately +// refines changed blocks; it never changes this line alignment or source text. +let compare = (before, after) => { + if before === after { + Identical + } else if String.length(before) + String.length(after) > maxCharacters { + TooLarge + } else { + let left = before->String.split("\n") + let right = after->String.split("\n") + let n = Array.length(left) + let m = Array.length(right) + let prefix = ref(0) + while ( + prefix.contents < n && + prefix.contents < m && + left->Array.getUnsafe(prefix.contents) === right->Array.getUnsafe(prefix.contents) + ) { + prefix := prefix.contents + 1 + } + let suffix = ref(0) + while ( + suffix.contents < n - prefix.contents && + suffix.contents < m - prefix.contents && + left->Array.getUnsafe(n - suffix.contents - 1) === + right->Array.getUnsafe(m - suffix.contents - 1) + ) { + suffix := suffix.contents + 1 + } + let rows = n - prefix.contents - suffix.contents + let columns = m - prefix.contents - suffix.contents + if n + m > maxLines || (rows + 1) * (columns + 1) > maxCells { + TooLarge + } else { + // Longest common subsequence on the changed middle only. Keeping the + // exact printer text avoids hiding meaningful identifier changes. + let stride = columns + 1 + let table = Array.make(~length=(rows + 1) * stride, 0) + let i = ref(rows - 1) + while i.contents >= 0 { + let j = ref(columns - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + table[cell] = if ( + left->Array.getUnsafe(prefix.contents + i.contents) === + right->Array.getUnsafe(prefix.contents + j.contents) + ) { + 1 + table->Array.getUnsafe(cell + stride + 1) + } else { + Math.Int.max(table->Array.getUnsafe(cell + stride), table->Array.getUnsafe(cell + 1)) + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let lines: array = [] + let x = ref(0) + let y = ref(0) + let same = () => { + lines->Array.push({ + kind: Same, + text: left->Array.getUnsafe(x.contents), + before: Some(x.contents + 1), + after: Some(y.contents + 1), + }) + x := x.contents + 1 + y := y.contents + 1 + } + while x.contents < prefix.contents { + same() + } + while x.contents < n - suffix.contents || y.contents < m - suffix.contents { + if ( + x.contents < n - suffix.contents && + y.contents < m - suffix.contents && + left->Array.getUnsafe(x.contents) === right->Array.getUnsafe(y.contents) + ) { + same() + } else if ( + x.contents < n - suffix.contents && + (y.contents >= m - suffix.contents || + table->Array.getUnsafe( + (x.contents - prefix.contents + 1) * stride + y.contents - prefix.contents, + ) >= + table->Array.getUnsafe( + (x.contents - prefix.contents) * stride + y.contents - prefix.contents + 1, + )) + ) { + lines->Array.push({ + kind: Removed, + text: left->Array.getUnsafe(x.contents), + before: Some(x.contents + 1), + after: None, + }) + x := x.contents + 1 + } else { + lines->Array.push({ + kind: Added, + text: right->Array.getUnsafe(y.contents), + before: None, + after: Some(y.contents + 1), + }) + y := y.contents + 1 + } + } + while x.contents < n { + same() + } + Changes(lines) + } + } +} + +type section = Visible(array) | Collapsed(array) + +let sections = lines => { + let result = [] + let index = ref(0) + let length = Array.length(lines) + while index.contents < length { + let start = index.contents + if (lines->Array.getUnsafe(start)).kind !== Same { + while index.contents < length && (lines->Array.getUnsafe(index.contents)).kind !== Same { + index := index.contents + 1 + } + result->Array.push(Visible(Array.slice(lines, ~start, ~end=index.contents))) + } else { + while index.contents < length && (lines->Array.getUnsafe(index.contents)).kind === Same { + index := index.contents + 1 + } + let end_ = index.contents + let head = start === 0 ? start : Math.Int.min(start + 3, end_) + let tail = end_ === length ? end_ : Math.Int.max(head, end_ - 3) + if tail - head <= 3 { + result->Array.push(Visible(Array.slice(lines, ~start, ~end=end_))) + } else { + result->Array.push(Visible(Array.slice(lines, ~start, ~end=head))) + result->Array.push(Collapsed(Array.slice(lines, ~start=head, ~end=tail))) + result->Array.push(Visible(Array.slice(lines, ~start=tail, ~end=end_))) + } + } + } + result +} diff --git a/packages/dev-playground/src/LambdaInlineDiff.res b/packages/dev-playground/src/LambdaInlineDiff.res new file mode 100644 index 00000000000..049a63d85f2 --- /dev/null +++ b/packages/dev-playground/src/LambdaInlineDiff.res @@ -0,0 +1,220 @@ +// Inline refinement runs only inside consecutive Removed/Added line blocks. +// For each candidate line pair, an LCS of non-whitespace Lambda tokens marks +// the shared tokens. Pairing requires shared atoms (not just punctuation) to +// cover at least half the atom count of the larger line. A second dynamic +// programming table chooses the highest-scoring order-preserving line pairs, +// allowing unmatched lines on either side. Unmatched tokens in chosen pairs +// receive stronger shading; whitespace and the original text stay intact. +// Oversized or dissimilar pairs keep whole-line highlighting. Token comparison +// cells share a budget across the entire dump, and block size caps line-pairing +// work, so this refinement cannot introduce unbounded quadratic comparisons. +type part = {text: string, changed: bool} +type highlights = {before: Dict.t>, after: Dict.t>} +type tokens = {all: array, significant: array, atoms: int} +type pair = {before: array, after: array, score: int} + +// One budget for the entire dump, not a fresh quadratic budget per line. +let maxCells = 250000 +let maxBlockLines = 32 +let maxLineCharacters = 4096 +let maxTokens = 128 + +let isSpace = text => String.trim(text) === "" +let isAtom = text => !isSpace(text) && !RegExp.test(/^[()[\]{},;]+$/, text) + +let tokenize = text => { + if String.length(text) > maxLineCharacters { + None + } else { + // Keep stamped identifiers and operators intact. Quoted constants include + // escapes; whitespace is retained verbatim but does not affect alignment. + let all = + text + ->String.match(/"(?:\\.|[^"\\])*"|\s+|[()[\]{},;]|[^\s()[\]{},;"]+|"/g) + ->Option.getOr([]) + ->Array.keepSome + let significant = all->Array.filter(token => !isSpace(token)) + if Array.length(significant) > maxTokens { + None + } else { + Some({all, significant, atoms: significant->Array.filter(isAtom)->Array.length}) + } + } +} + +let parts = (tokens, matched) => { + let index = ref(0) + tokens.all->Array.map(text => { + let changed = if isSpace(text) { + false + } else { + let changed = !(matched->Array.getUnsafe(index.contents)) + index := index.contents + 1 + changed + } + {text, changed} + }) +} + +let compareTokens = (left, right, budget) => { + let n = Array.length(left.significant) + let m = Array.length(right.significant) + let stride = m + 1 + let cells = (n + 1) * stride + if left.atoms === 0 || right.atoms === 0 || cells > budget.contents { + None + } else { + budget := budget.contents - cells + let table = Array.make(~length=cells, 0) + let i = ref(n - 1) + while i.contents >= 0 { + let j = ref(m - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + table[cell] = if ( + left.significant->Array.getUnsafe(i.contents) === + right.significant->Array.getUnsafe(j.contents) + ) { + 1 + table->Array.getUnsafe(cell + stride + 1) + } else { + Math.Int.max(table->Array.getUnsafe(cell + stride), table->Array.getUnsafe(cell + 1)) + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let leftMatched = Array.make(~length=n, false) + let rightMatched = Array.make(~length=m, false) + let shared = ref(0) + let x = ref(0) + let y = ref(0) + while x.contents < n && y.contents < m { + let token = left.significant->Array.getUnsafe(x.contents) + if token === right.significant->Array.getUnsafe(y.contents) { + leftMatched[x.contents] = true + rightMatched[y.contents] = true + if isAtom(token) { + shared := shared.contents + 1 + } + x := x.contents + 1 + y := y.contents + 1 + } else if ( + table->Array.getUnsafe((x.contents + 1) * stride + y.contents) >= + table->Array.getUnsafe(x.contents * stride + y.contents + 1) + ) { + x := x.contents + 1 + } else { + y := y.contents + 1 + } + } + // Parentheses/indentation alone must not make unrelated expressions a pair. + let largest = Math.Int.max(left.atoms, right.atoms) + if shared.contents === 0 || shared.contents * 2 < largest { + None + } else { + Some({ + before: parts(left, leftMatched), + after: parts(right, rightMatched), + score: shared.contents * 100 / largest, + }) + } + } +} + +let highlight = (lines: array): highlights => { + let result = {before: Dict.make(), after: Dict.make()} + let budget = ref(maxCells) + let index = ref(0) + while index.contents < Array.length(lines) { + if (lines->Array.getUnsafe(index.contents)).kind === Same { + index := index.contents + 1 + } else { + let start = index.contents + while ( + index.contents < Array.length(lines) && + (lines->Array.getUnsafe(index.contents)).kind !== Same + ) { + index := index.contents + 1 + } + if index.contents - start <= maxBlockLines && budget.contents > 0 { + let block = Array.slice(lines, ~start, ~end=index.contents) + let before = block->Array.filter(line => line.kind === Removed) + let after = block->Array.filter(line => line.kind === Added) + let n = Array.length(before) + let m = Array.length(after) + if n > 0 && m > 0 { + let left = before->Array.map(line => tokenize(line.text)) + let right = after->Array.map(line => tokenize(line.text)) + let candidates = Array.make(~length=n * m, None) + for i in 0 to n - 1 { + for j in 0 to m - 1 { + candidates[ + i * m + j + ] = switch (left->Array.getUnsafe(i), right->Array.getUnsafe(j)) { + | (Some(left), Some(right)) => compareTokens(left, right, budget) + | _ => None + } + } + } + // Best ordered pairing, allowing either side to skip whole lines. + let stride = m + 1 + let scores = Array.make(~length=(n + 1) * stride, 0) + let i = ref(n - 1) + while i.contents >= 0 { + let j = ref(m - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + let skip = Math.Int.max( + scores->Array.getUnsafe(cell + stride), + scores->Array.getUnsafe(cell + 1), + ) + scores[cell] = switch candidates->Array.getUnsafe(i.contents * m + j.contents) { + | Some(pair) => + Math.Int.max(skip, pair.score + scores->Array.getUnsafe(cell + stride + 1)) + | None => skip + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let x = ref(0) + let y = ref(0) + while x.contents < n && y.contents < m { + let cell = x.contents * stride + y.contents + switch candidates->Array.getUnsafe(x.contents * m + y.contents) { + | Some(pair) + if pair.score + scores->Array.getUnsafe(cell + stride + 1) === + scores->Array.getUnsafe(cell) => + switch ( + (before->Array.getUnsafe(x.contents)).before, + (after->Array.getUnsafe(y.contents)).after, + ) { + | (Some(before), Some(after)) => + Dict.set(result.before, Int.toString(before), pair.before) + Dict.set(result.after, Int.toString(after), pair.after) + | _ => () + } + x := x.contents + 1 + y := y.contents + 1 + | _ => + if scores->Array.getUnsafe(cell + stride) >= scores->Array.getUnsafe(cell + 1) { + x := x.contents + 1 + } else { + y := y.contents + 1 + } + } + } + } + } + } + } + result +} + +let forLine = (highlights: highlights, line: LambdaDiff.line) => + switch line.kind { + | Same => None + | Removed => + line.before->Option.flatMap(number => Dict.get(highlights.before, Int.toString(number))) + | Added => line.after->Option.flatMap(number => Dict.get(highlights.after, Int.toString(number))) + } diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res new file mode 100644 index 00000000000..698a41a1d91 --- /dev/null +++ b/packages/dev-playground/src/LambdaView.res @@ -0,0 +1,116 @@ +type mode = Before | After | Diff + +let label = mode => + switch mode { + | Before => "Before optimization" + | After => "After optimization" + | Diff => "Diff" + } + +let renderLine = (highlights, line: LambdaDiff.line) => { + let (className, marker) = switch line.kind { + | Same => ("lambda-diff-line", " ") + | Removed => ("lambda-diff-line lambda-diff-removed", "โˆ’") + | Added => ("lambda-diff-line lambda-diff-added", "+") + } + let number = number => number->Option.map(value => Int.toString(value))->Option.getOr("") + + {View.text(number(line.before))} + {View.text(number(line.after))} + {View.text(marker)} + + {switch LambdaInlineDiff.forLine(highlights, line) { + | None => View.text(line.text) + | Some(parts) => + View.fragment( + parts->Array.map(part => + part.changed + ? {View.text(part.text)} + : View.text(part.text) + ), + ) + }} + {View.text("\n")} + + +} + +let renderLines = (highlights, lines) => + View.fragment(Array.map(lines, line => renderLine(highlights, line))) + +let renderDiff = result => + switch result { + | LambdaDiff.Identical => +

+ {View.text("No changes in the printed Lambda after optimization.")} +

+ | TooLarge => +

+ {View.text( + "This Lambda comparison exceeds the diff work limit. Use Before optimization and After optimization to inspect the full dumps.", + )} +

+ | Changes(lines) => + let highlights = LambdaInlineDiff.highlight(lines) +
+

+ {View.text( + "โˆ’ removed from Before ยท + added in After. Stronger shading marks changed tokens in similar lines. Line numbers show Before / After.", + )} +

+ {View.fragment( + LambdaDiff.sections(lines)->Array.map(section => + switch section { + | Visible(lines) => +
 {renderLines(highlights, lines)} 
+ | Collapsed(lines) => +
+ + {View.text(`Show ${Array.length(lines)->Int.toString} unchanged lines`)} + +
 {renderLines(highlights, lines)} 
+
+ } + ), + )} +
+ } + +@jsx.component +let make = (~before, ~after: option, ~mode: Signal.t) => { + let difference = Lazy.make(() => + switch after { + | Some(after) => LambdaDiff.compare(before, after) + | None => LambdaDiff.Identical + } + ) +
+
+ {View.fragment( + [Before, After, Diff]->Array.map(tab => + + ), + )} +
+ {View.tracked(() => + switch (Signal.get(mode), after) { + | (Before, _) =>
 {View.text(before)} 
+ | (After | Diff, None) => +

+ {View.text( + "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", + )} +

+ | (After, Some(after)) =>
 {View.text(after)} 
+ | (Diff, Some(_)) => renderDiff(Lazy.get(difference)) + } + )} +
+} diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index 67ab9e94f05..608f9af53b0 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -812,6 +812,7 @@ module App = { let make = () => { let source = Signal.make(defaultSource) let activeTab = Signal.make(JavaScript) + let lambdaMode = Signal.make(LambdaView.Before) let mappedSourcePosition: Signal.t> = Signal.make(None) let mappedGeneratedPosition: Signal.t> = Signal.make(None) let status = Signal.make(Loading) @@ -1323,19 +1324,27 @@ module App = { {View.signalText(() => resultSummary(Signal.get(compileResult)))}
-
-                {View.tracked(() => {
-                  let selectedTab = Signal.get(activeTab)
-                  interactiveOutputNode(
-                    Signal.get(compileResult),
-                    Signal.get(source),
-                    selectedTab,
-                    Signal.get(mappedGeneratedPosition),
-                    revealOriginalMapping,
-                    () => Signal.set(activeTab, SourceMap),
-                  )
-                })}
-              
+ {View.tracked(() => { + let selectedTab = Signal.get(activeTab) + let snapshot = Signal.get(compileResult) + switch (selectedTab, snapshot) { + | (Lambda, Some({result: Ok(result)})) => + + | _ => +
+                    {interactiveOutputNode(
+                      snapshot,
+                      Signal.get(source),
+                      selectedTab,
+                      Signal.get(mappedGeneratedPosition),
+                      revealOriginalMapping,
+                      () => Signal.set(activeTab, SourceMap),
+                    )}
+                  
+ } + })}
diff --git a/packages/dev-playground/src/styles.css b/packages/dev-playground/src/styles.css index 24ca30a37f0..818a806bbf0 100644 --- a/packages/dev-playground/src/styles.css +++ b/packages/dev-playground/src/styles.css @@ -468,6 +468,106 @@ button { background: var(--playground-panel); } +.lambda-subtabs { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 16px; + background: var(--playground-panel); + border-bottom: 1px solid var(--playground-border-soft); +} + +.lambda-subtab { + padding: 6px 10px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--playground-text-secondary); +} + +.lambda-subtab:hover, +.lambda-subtab-active { + color: var(--playground-text-primary); + background: var(--playground-hover); +} + +.lambda-subtab-active { + border-color: var(--fire); +} + +.lambda-subtab:focus-visible { + outline: 2px solid var(--fire); + outline-offset: 1px; +} + +.lambda-notice { + margin: 0; + padding: 12px 18px; +} + +.lambda-diff-block { + margin: 0; + color: var(--playground-text-primary); + font: 13px / 1.55 var(--font-mono); + tab-size: 2; +} + +.lambda-diff-line { + display: grid; + grid-template-columns: 5ch 5ch 2ch minmax(0, 1fr); +} + +.lambda-diff-number { + color: var(--gray-60); + text-align: right; + padding-right: 1ch; + user-select: none; +} + +.lambda-diff-text { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lambda-diff-removed { + background: rgba(230, 72, 79, 0.12); +} + +.lambda-diff-added { + background: rgba(56, 139, 114, 0.15); +} + +.lambda-diff-token { + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.lambda-diff-removed .lambda-diff-token { + background: rgba(230, 72, 79, 0.38); +} + +.lambda-diff-added .lambda-diff-token { + background: rgba(56, 139, 114, 0.45); +} + +.lambda-diff-removed .lambda-diff-marker { + color: var(--fire); +} + +.lambda-diff-added .lambda-diff-marker { + color: var(--turtle-dark); +} + +.lambda-diff-context > summary { + cursor: pointer; + padding: 6px 18px; + background: var(--playground-hover); +} + .result-meta { min-height: 36px; padding: 9px 16px; diff --git a/packages/playground/playground_test.cjs b/packages/playground/playground_test.cjs index cf0726b3f5a..82dd9dcd9a1 100644 --- a/packages/playground/playground_test.cjs +++ b/packages/playground/playground_test.cjs @@ -173,3 +173,24 @@ assert.match(typeErrorResult.errors[0].fullMsg, /\u001b\[/); const syntaxErrorResult = compiler.rescript.compile("let = 1"); assert.equal(syntaxErrorResult.type, "syntax_error"); assert.match(syntaxErrorResult.errors[0].fullMsg, /\u001b\[/); + +assert.equal(rescript_compiler.api_version, "9"); +const lambdaSource = "let compute = x => { let unused = x + 1; x }"; +const lambdaResult = compiler.rescript.compileWithDebug(lambdaSource); +assert.equal(lambdaResult.type, "success"); +assert.match(lambdaResult.lambda, /unused\//); +assert.equal(typeof lambdaResult.lambda_optimized, "string"); +assert.doesNotMatch(lambdaResult.lambda_optimized, /unused\//); +assert.match(lambdaResult.lambda_optimized, /function x\/\d+ x\/\d+/); + +const regularLambdaResult = compiler.rescript.compile(lambdaSource); +assert.equal(regularLambdaResult.type, "success"); +assert.equal(regularLambdaResult.js_code, lambdaResult.js_code); +assert.equal(regularLambdaResult.lambda_optimized, undefined); + +const nextLambdaResult = compiler.rescript.compileWithDebug("let answer = 42"); +assert.equal(nextLambdaResult.type, "success"); +assert.equal(nextLambdaResult.lambda_optimized.trim(), "(makeblock module/exports 42)"); +assert.doesNotMatch(nextLambdaResult.lambda_optimized, /compute/); + +console.log("-- Playground optimized Lambda test complete --"); From 59af03d42b914fc5e53d3c87a2710b52ad834f27 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Wed, 9 Sep 2026 15:18:59 +0200 Subject: [PATCH 2/6] Changelog Signed-off-by: Florian Hammerschmidt --- CHANGELOG.md | 1 - changes/developer-playground-lambda-diff.internal.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changes/developer-playground-lambda-diff.internal.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7091f433944..c2774158f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,7 +122,6 @@ #### :rocket: New Feature -- Add Before optimization, After optimization, and Diff views to the developer playground's Lambda tab, with a dependency-free, work-limited diff, inline highlighting of changed Lambda tokens, and optimized Lambda output in playground API v9. - Support UTF-16 surrogate-pair escapes such as `"\uD83D\uDE00"` in ordinary string literals. https://github.com/rescript-lang/rescript/pull/8606 - Support dynamic imports of external bindings annotated with `@scope`; the generated import follows the complete property path. These imports were previously rejected. https://github.com/rescript-lang/rescript/pull/8582 - Add `@res.hoistedFunction` for emitting nested module functions as flat JavaScript exports. https://github.com/rescript-lang/rescript/pull/8402 diff --git a/changes/developer-playground-lambda-diff.internal.md b/changes/developer-playground-lambda-diff.internal.md new file mode 100644 index 00000000000..8ca23ac832b --- /dev/null +++ b/changes/developer-playground-lambda-diff.internal.md @@ -0,0 +1 @@ +- Add before/after optimization views and inline diffs to the developer playground's Lambda tab, with optimized Lambda output in playground API v9. https://github.com/rescript-lang/rescript/pull/8636 From a2beab04f2814031808689dc883953fbb8a42fb9 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Wed, 14 Oct 2026 23:02:14 +0200 Subject: [PATCH 3/6] Cache playground Lambda diffs and bound token work by block Keep the Lambda view mounted, cache its rendered comparison by input text, and render collapsed context only when expanded. Preserve expansion for unchanged regions across edits. Count shared edges once in the line budget, prioritize atoms in token alignment, and admit whole changed blocks before charging the shared token budget. Cover the algorithm and rendering regressions, and document the API v9 capture boundary. Signed-off-by: Florian Hammerschmidt --- CONTRIBUTING.md | 12 ++ packages/dev-playground/package.json | 2 +- .../scripts/lambda-diff.test.mjs | 130 +++++++++++++++++- .../dev-playground/scripts/runtime-loader.mjs | 10 ++ packages/dev-playground/src/LambdaDiff.res | 14 +- .../dev-playground/src/LambdaInlineDiff.res | 31 +++-- packages/dev-playground/src/LambdaView.res | 129 ++++++++++++----- packages/dev-playground/src/Main.res | 37 ++--- 8 files changed, 296 insertions(+), 69 deletions(-) create mode 100644 packages/dev-playground/scripts/runtime-loader.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4093cff331f..8963c651531 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -428,6 +428,18 @@ $ node > eval(result.js_code); ``` +### Lambda debug outputs (API v9) + +`comp.rescript.compileWithDebug(source)` returns `lambda` before the backend's +Lambda optimization passes and `lambda_optimized` after the whole-term passes, +before export grouping and JavaScript lowering. Both use the same Lambda printer +so their text can be compared directly. JavaScript lowering performs further +transformations; this snapshot is not the final JavaScript IR. + +Regular `comp.rescript.compile(source)` does not capture these debug outputs. +Older bundles do not provide `lambda_optimized`; consumers should treat it as +optional when supporting multiple API versions. + ### Testing the Playground bundle Run `yarn workspace playground test` for a quick sanity check to see if all the build artifacts are working together correctly. When releasing the playground bundle, the test will always be executed before publishing to catch regressions. diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json index d37873ebe66..01310c4112e 100644 --- a/packages/dev-playground/package.json +++ b/packages/dev-playground/package.json @@ -9,7 +9,7 @@ "prepare-pages-site": "node scripts/prepare-pages-site.mjs", "res:build": "rescript", "res:watch": "rescript -w", - "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs scripts/lambda-diff.test.mjs", + "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs scripts/lambda-diff.test.mjs", "dev": "vite --host 127.0.0.1", "build": "rescript && vite build", "preview": "vite preview --host 127.0.0.1" diff --git a/packages/dev-playground/scripts/lambda-diff.test.mjs b/packages/dev-playground/scripts/lambda-diff.test.mjs index b1874157c8d..29b7ee0c5a5 100644 --- a/packages/dev-playground/scripts/lambda-diff.test.mjs +++ b/packages/dev-playground/scripts/lambda-diff.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { register } from "node:module"; import test from "node:test"; +import * as Signal from "xote/src/Signal.res.mjs"; import { compare, maxCharacters, @@ -8,6 +10,11 @@ import { } from "../src/LambdaDiff.res.mjs"; import * as Inline from "../src/LambdaInlineDiff.res.mjs"; +// Match vite.config.js: Xote and the playground use the published v12 runtime. +register(new URL("./runtime-loader.mjs", import.meta.url)); +const LambdaView = await import("../src/LambdaView.res.mjs"); +const { renderToString } = await import("xote/src/SSR.res.mjs"); + function changes(before, after) { const result = compare(before, after); assert.equal(result.TAG, "Changes"); @@ -96,7 +103,7 @@ test("bounds comparison work and output size", () => { }); test("trims common edges before applying the comparison budget", () => { - const edge = "(unchanged)\n".repeat(2000); + const edge = "(unchanged)\n".repeat(5000); const lines = changes(`${edge}before\n${edge}`, `${edge}after\n${edge}`); assert.equal(lines.filter(line => line.kind !== "Same").length, 2); }); @@ -108,6 +115,7 @@ test("collapsed regions preserve every line and keep context around changes", () `${context}\nnew\n${context}\nnew\n${context}`, ); const grouped = sections(lines); + assert.ok(grouped.every(section => section._0.length > 0)); assert.deepEqual( grouped.flatMap(section => section._0), lines, @@ -174,6 +182,12 @@ test("inline diff handles token insertion, deletion and repeated tokens", () => assert.deepEqual(changedText(inserted.after[1]), ["42"]); }); +test("inline alignment prioritizes shared atoms over repeated punctuation", () => { + const result = inlineChanges("((a", "a(("); + assert.deepEqual(changedText(result.before[1]), ["(", "("]); + assert.deepEqual(changedText(result.after[1]), ["(", "("]); +}); + test("inline pairing skips unmatched lines in an uneven changed block", () => { const result = inlineChanges( "(unused z/9)\n (+ x/1 10)\n (- y/2 30)", @@ -239,3 +253,117 @@ test("inline token work is budgeted across blocks and resets for each dump", () assert.ok(Object.keys(result.before).length < 30); assert.deepEqual(inlineChanges(before, after), result); }); + +test("an oversized candidate block leaves the token budget for later changes", () => { + const block = Array.from( + { length: 16 }, + (_, i) => `(apply f/${i} ${"x ".repeat(100)}10)`, + ).join("\n"); + const result = inlineChanges( + `${block}\nseparator\n(+ value/1 10)`, + `${block.replaceAll("10)", "20)")}\nseparator\n(+ value/1 20)`, + ); + assert.deepEqual(Object.keys(result.before), ["18"]); + assert.deepEqual(changedText(result.before[18]), ["10"]); + assert.deepEqual(changedText(result.after[18]), ["20"]); +}); + +function elements(node) { + switch (node.TAG) { + case "Element": + return [node, ...node.children.flatMap(elements)]; + case "Fragment": + return node._0.flatMap(elements); + case "SignalFragment": + return Signal.peek(node._0).flatMap(elements); + case "LazyComponent": + return elements(node._0()); + case "Keyed": + return elements(node.child); + default: + return []; + } +} + +function lambdaView(contextLines = 20) { + const context = "unchanged\n".repeat(contextLines); + const input = Signal.make({ + before: `${context}old\n${context}`, + after: `${context}new\n${context}`, + }); + const mode = Signal.make("Before"); + const visible = Signal.make(true); + const view = LambdaView.make({ + input: () => Signal.get(input), + mode, + visible: () => Signal.get(visible), + }); + const html = () => renderToString(() => view); + const diff = () => + elements(view).find( + node => + node.tag === "div" && + renderToString(() => node).startsWith('
'), + ); + return { input, mode, visible, view, html, diff }; +} + +test("Lambda view defers diff work and does not render collapsed lines", () => { + const { mode, html, diff } = lambdaView(5000); + assert.equal(diff(), undefined); + Signal.set(mode, "Diff"); + assert.ok(diff()); + assert.equal( + (html().match(/class="lambda-diff-line(?: |")/g) ?? []).length, + 8, + ); + assert.equal((html().match(/
{ + const { input, mode, visible, diff } = lambdaView(); + Signal.set(mode, "Diff"); + const initial = diff(); + assert.ok(initial); + Signal.set(input, { ...Signal.peek(input) }); + assert.equal(diff(), initial); + Signal.set(mode, "Before"); + Signal.set(input, { ...Signal.peek(input), after: "changed while hidden" }); + assert.equal(diff(), initial); + Signal.set(mode, "Diff"); + assert.notEqual(diff(), initial); + const changed = diff(); + Signal.set(visible, false); + Signal.set(visible, true); + assert.equal(diff(), changed); +}); + +test("expanded unchanged regions survive edits to a changed line", () => { + const { input, mode, view, html } = lambdaView(); + Signal.set(mode, "Diff"); + const summary = elements(view).find(node => node.tag === "summary"); + const click = summary.events.find(([name]) => name === "click")[1]; + let prevented = false; + click({ + preventDefault: () => { + prevented = true; + }, + }); + assert.equal(prevented, true); + const openDetails = () => + (html().match(/]* open(?: |>|=)/g) ?? []).length; + assert.equal(openDetails(), 1); + Signal.set(input, { + ...Signal.peek(input), + after: Signal.peek(input).after.replace("new", "edited"), + }); + assert.equal(openDetails(), 1); +}); + +test("older bundles retain the optimized Lambda availability notice", () => { + const { input, mode, html } = lambdaView(); + Signal.set(input, { ...Signal.peek(input), after: undefined }); + Signal.set(mode, "Diff"); + assert.match(html(), /does not provide optimized Lambda/); + assert.doesNotMatch(html(), /class="lambda-diff"/); +}); diff --git a/packages/dev-playground/scripts/runtime-loader.mjs b/packages/dev-playground/scripts/runtime-loader.mjs new file mode 100644 index 00000000000..86e57d78a26 --- /dev/null +++ b/packages/dev-playground/scripts/runtime-loader.mjs @@ -0,0 +1,10 @@ +// Keep Node rendering tests on the same runtime as vite.config.js. +export function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@rescript/runtime/")) { + return nextResolve( + new URL(`../node_modules/${specifier}`, import.meta.url).href, + context, + ); + } + return nextResolve(specifier, context); +} diff --git a/packages/dev-playground/src/LambdaDiff.res b/packages/dev-playground/src/LambdaDiff.res index 14848a4bfe4..5e04cf8225e 100644 --- a/packages/dev-playground/src/LambdaDiff.res +++ b/packages/dev-playground/src/LambdaDiff.res @@ -46,7 +46,11 @@ let compare = (before, after) => { } let rows = n - prefix.contents - suffix.contents let columns = m - prefix.contents - suffix.contents - if n + m > maxLines || (rows + 1) * (columns + 1) > maxCells { + + // Shared edges appear once in the output, not once for each input. + if ( + n + m - prefix.contents - suffix.contents > maxLines || (rows + 1) * (columns + 1) > maxCells + ) { TooLarge } else { // Longest common subsequence on the changed middle only. Keeping the @@ -151,9 +155,13 @@ let sections = lines => { if tail - head <= 3 { result->Array.push(Visible(Array.slice(lines, ~start, ~end=end_))) } else { - result->Array.push(Visible(Array.slice(lines, ~start, ~end=head))) + if head > start { + result->Array.push(Visible(Array.slice(lines, ~start, ~end=head))) + } result->Array.push(Collapsed(Array.slice(lines, ~start=head, ~end=tail))) - result->Array.push(Visible(Array.slice(lines, ~start=tail, ~end=end_))) + if tail < end_ { + result->Array.push(Visible(Array.slice(lines, ~start=tail, ~end=end_))) + } } } } diff --git a/packages/dev-playground/src/LambdaInlineDiff.res b/packages/dev-playground/src/LambdaInlineDiff.res index 049a63d85f2..c7cbf33e23e 100644 --- a/packages/dev-playground/src/LambdaInlineDiff.res +++ b/packages/dev-playground/src/LambdaInlineDiff.res @@ -1,13 +1,14 @@ // Inline refinement runs only inside consecutive Removed/Added line blocks. -// For each candidate line pair, an LCS of non-whitespace Lambda tokens marks +// For each candidate line pair, an LCS prioritizing Lambda atoms marks // the shared tokens. Pairing requires shared atoms (not just punctuation) to // cover at least half the atom count of the larger line. A second dynamic // programming table chooses the highest-scoring order-preserving line pairs, // allowing unmatched lines on either side. Unmatched tokens in chosen pairs // receive stronger shading; whitespace and the original text stay intact. // Oversized or dissimilar pairs keep whole-line highlighting. Token comparison -// cells share a budget across the entire dump, and block size caps line-pairing -// work, so this refinement cannot introduce unbounded quadratic comparisons. +// cells share a budget across the entire dump. Admit whole blocks before doing +// token comparisons, so an oversized block cannot consume the budget partially +// and starve later, smaller changes. Block size also caps line-pairing work. type part = {text: string, changed: bool} type highlights = {before: Dict.t>, after: Dict.t>} type tokens = {all: array, significant: array, atoms: int} @@ -71,11 +72,10 @@ let compareTokens = (left, right, budget) => { let j = ref(m - 1) while j.contents >= 0 { let cell = i.contents * stride + j.contents - table[cell] = if ( - left.significant->Array.getUnsafe(i.contents) === - right.significant->Array.getUnsafe(j.contents) - ) { - 1 + table->Array.getUnsafe(cell + stride + 1) + let token = left.significant->Array.getUnsafe(i.contents) + table[cell] = if token === right.significant->Array.getUnsafe(j.contents) { + // One shared atom outweighs every possible punctuation match. + (isAtom(token) ? maxTokens + 1 : 1) + table->Array.getUnsafe(cell + stride + 1) } else { Math.Int.max(table->Array.getUnsafe(cell + stride), table->Array.getUnsafe(cell + 1)) } @@ -121,6 +121,15 @@ let compareTokens = (left, right, budget) => { } } +let tokenRows = tokens => + tokens->Array.reduce(0, (sum, tokens) => + sum + + switch tokens { + | Some({significant, atoms}) if atoms > 0 => Array.length(significant) + 1 + | Some(_) | None => 0 + } + ) + let highlight = (lines: array): highlights => { let result = {before: Dict.make(), after: Dict.make()} let budget = ref(maxCells) @@ -142,9 +151,9 @@ let highlight = (lines: array): highlights => { let after = block->Array.filter(line => line.kind === Added) let n = Array.length(before) let m = Array.length(after) - if n > 0 && m > 0 { - let left = before->Array.map(line => tokenize(line.text)) - let right = after->Array.map(line => tokenize(line.text)) + let left = before->Array.map(line => tokenize(line.text)) + let right = after->Array.map(line => tokenize(line.text)) + if n > 0 && m > 0 && tokenRows(left) * tokenRows(right) <= budget.contents { let candidates = Array.make(~length=n * m, None) for i in 0 to n - 1 { for j in 0 to m - 1 { diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res index 698a41a1d91..540357086a9 100644 --- a/packages/dev-playground/src/LambdaView.res +++ b/packages/dev-playground/src/LambdaView.res @@ -1,4 +1,12 @@ type mode = Before | After | Diff +type input = {before: string, after: option} + +let sameInput = (left, right) => + switch (left, right) { + | (None, None) => true + | (Some(left), Some(right)) => left.before === right.before && left.after === right.after + | (None, Some(_)) | (Some(_), None) => false + } let label = mode => switch mode { @@ -38,7 +46,7 @@ let renderLine = (highlights, line: LambdaDiff.line) => { let renderLines = (highlights, lines) => View.fragment(Array.map(lines, line => renderLine(highlights, line))) -let renderDiff = result => +let renderDiff = (contextState, result) => switch result { | LambdaDiff.Identical =>

@@ -52,39 +60,83 @@ let renderDiff = result =>

| Changes(lines) => let highlights = LambdaInlineDiff.highlight(lines) + let previous = contextState.contents + let current = Dict.make() + let occurrences = Dict.make() + let sections = LambdaDiff.sections(lines)->Array.map(section => + switch section { + | Visible(lines) =>
 {renderLines(highlights, lines)} 
+ | Collapsed(lines) => + let text = lines->Array.map(line => line.text)->Array.join("\n") + let occurrence = Dict.get(occurrences, text)->Option.getOr(0) + Dict.set(occurrences, text, occurrence + 1) + let key = `${occurrence->Int.toString}:${text}` + let expanded = switch Dict.get(previous, key) { + | Some(expanded) => expanded + | None => Signal.make(false) + } + Dict.set(current, key, expanded) +
Signal.get(expanded) ? Some("") : None)]} + > + { + event->Event.preventDefault + Signal.update(expanded, open_ => !open_) + }} + > + {View.text(`Show ${Array.length(lines)->Int.toString} unchanged lines`)} + + {View.tracked(() => + Signal.get(expanded) + ?
 {renderLines(highlights, lines)} 
+ : View.empty() + )} +
+ } + ) + // Retain expansion only for regions still present in this comparison. + contextState := current

{View.text( "โˆ’ removed from Before ยท + added in After. Stronger shading marks changed tokens in similar lines. Line numbers show Before / After.", )}

- {View.fragment( - LambdaDiff.sections(lines)->Array.map(section => - switch section { - | Visible(lines) => -
 {renderLines(highlights, lines)} 
- | Collapsed(lines) => -
- - {View.text(`Show ${Array.length(lines)->Int.toString} unchanged lines`)} - -
 {renderLines(highlights, lines)} 
-
- } - ), - )} + {View.fragment(sections)}
} @jsx.component -let make = (~before, ~after: option, ~mode: Signal.t) => { - let difference = Lazy.make(() => - switch after { - | Some(after) => LambdaDiff.compare(before, after) - | None => LambdaDiff.Identical +let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { + let contextState = ref(Dict.make()) + let unavailable = () => +

+ {View.text( + "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", + )} +

+ // Cache the complete rendered diff by text, including inline highlights and + // sections. Hidden tabs keep their last result and defer new comparison work. + let cached = ref((None, View.empty())) + let difference = Computed.make(() => { + let (previous, rendered) = cached.contents + let current = visible() && Signal.get(mode) === Diff ? input() : previous + if sameInput(previous, current) { + rendered + } else { + let rendered = switch current { + | Some({before, after: Some(after)}) => + renderDiff(contextState, LambdaDiff.compare(before, after)) + | Some({after: None}) => unavailable() + | None => View.empty() + } + cached := (current, rendered) + rendered } - ) -
+ }) +
visible() ? "lambda-view" : "lambda-view hidden-panel"}>
{View.fragment( [Before, After, Diff]->Array.map(tab => @@ -99,18 +151,23 @@ let make = (~before, ~after: option, ~mode: Signal.t) => { ), )}
- {View.tracked(() => - switch (Signal.get(mode), after) { - | (Before, _) =>
 {View.text(before)} 
- | (After | Diff, None) => -

- {View.text( - "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", - )} -

- | (After, Some(after)) =>
 {View.text(after)} 
- | (Diff, Some(_)) => renderDiff(Lazy.get(difference)) - } - )} +
 Signal.get(mode) === Before ? "output" : "output hidden-panel"}>
+      {View.signalText(() => input()->Option.map(value => value.before)->Option.getOr(""))}
+    
+
Signal.get(mode) === After ? "" : "hidden-panel"}> + {View.tracked(() => + switch input()->Option.flatMap(value => value.after) { + | Some(after) =>
 {View.text(after)} 
+ | None => unavailable() + } + )} +
+
Signal.get(mode) === Diff ? "" : "hidden-panel"}> + {View.tracked(() => { + // Reuse the DOM too when reactive dependencies are reevaluated. + let child = Signal.get(difference) + View.Keyed({key: "lambda-diff", identity: Obj.magic(child), child}) + })} +
} diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index 608f9af53b0..398a9a4bb13 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -818,6 +818,13 @@ module App = { let status = Signal.make(Loading) let compilerInfo: Signal.t> = Signal.make(None) let compileResult: Signal.t> = Signal.make(None) + let lambdaInput = () => + switch Signal.get(compileResult) { + | Some({result: Ok(result)}) => + Some({LambdaView.before: result.lambda, after: result.lambdaOptimized}) + | Some({result: Error(_)}) | None => None + } + let lambdaVisible = () => Signal.get(activeTab) === Lambda && lambdaInput()->Option.isSome let config = Signal.make(CompilerApi.defaultConfig) let visibleTabNodes: Signal.t> = Obj.magic( Computed.make(() => @@ -1324,27 +1331,23 @@ module App = { {View.signalText(() => resultSummary(Signal.get(compileResult)))}
- {View.tracked(() => { - let selectedTab = Signal.get(activeTab) - let snapshot = Signal.get(compileResult) - switch (selectedTab, snapshot) { - | (Lambda, Some({result: Ok(result)})) => - - | _ => -
-                    {interactiveOutputNode(
-                      snapshot,
+              
+              
 lambdaVisible() ? "output hidden-panel" : "output"}>
+                {View.tracked(() =>
+                  if lambdaVisible() {
+                    View.empty()
+                  } else {
+                    interactiveOutputNode(
+                      Signal.get(compileResult),
                       Signal.get(source),
-                      selectedTab,
+                      Signal.get(activeTab),
                       Signal.get(mappedGeneratedPosition),
                       revealOriginalMapping,
                       () => Signal.set(activeTab, SourceMap),
-                    )}
-                  
- } - })} + ) + } + )} +
From 8eac328c9e5b2d4321b3737303c8fd3b2fd369c2 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 13:36:41 +0200 Subject: [PATCH 4/6] Defer hidden Lambda updates and debounce comparisons (#8636) Keep the Before and After dumps unchanged while the Lambda view is hidden. Opening the view reads the latest compiler result. While Diff is open, wait 200 ms after the latest changed result before replacing its comparison; opening Diff remains immediate. Leaving Diff or unmounting cancels its timer. Review: Address cknitt's two non-blocking follow-ups. Regression tests cover hidden panels catching up, consecutive edits, cancellation on tab switches, and expanded context surviving the delayed update. Validation: All 36 developer playground tests, the native compiler suites, the browser compiler bundle tests, production Vite build, OCaml/ReScript/JS format checks, and changelog validation pass after rebasing onto b7c67d2d8. Signed-off-by: Florian Hammerschmidt --- ...veloper-playground-lambda-diff.internal.md | 2 +- .../scripts/lambda-diff.test.mjs | 59 ++++++++++++++++++- packages/dev-playground/src/LambdaView.res | 55 +++++++++++++---- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/changes/developer-playground-lambda-diff.internal.md b/changes/developer-playground-lambda-diff.internal.md index 8ca23ac832b..e20c50b6b7b 100644 --- a/changes/developer-playground-lambda-diff.internal.md +++ b/changes/developer-playground-lambda-diff.internal.md @@ -1 +1 @@ -- Add before/after optimization views and inline diffs to the developer playground's Lambda tab, with optimized Lambda output in playground API v9. https://github.com/rescript-lang/rescript/pull/8636 +- Add before/after optimization views and inline diffs to the developer playground's Lambda tab, with optimized Lambda output in playground API v9, deferred hidden-panel updates, and debounced comparisons while editing. https://github.com/rescript-lang/rescript/pull/8636 diff --git a/packages/dev-playground/scripts/lambda-diff.test.mjs b/packages/dev-playground/scripts/lambda-diff.test.mjs index 29b7ee0c5a5..c860dab69e9 100644 --- a/packages/dev-playground/scripts/lambda-diff.test.mjs +++ b/packages/dev-playground/scripts/lambda-diff.test.mjs @@ -338,8 +338,60 @@ test("Lambda view retains its diff across identical compiles and tab switches", assert.equal(diff(), changed); }); -test("expanded unchanged regions survive edits to a changed line", () => { - const { input, mode, view, html } = lambdaView(); +test("Lambda panels retain their text while hidden and catch up when shown", () => { + const { input, visible, html } = lambdaView(); + assert.match(html(), /old/); + Signal.set(visible, false); + Signal.set(input, { before: "hidden before", after: "hidden after" }); + assert.doesNotMatch(html(), /hidden before|hidden after/); + assert.match(html(), /old/); + Signal.set(visible, true); + assert.match(html(), /hidden before/); + assert.match(html(), /hidden after/); +}); + +test("Lambda Diff debounces edits and renders the latest comparison", t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { input, mode, diff } = lambdaView(); + Signal.set(mode, "Diff"); + const initial = diff(); + Signal.set(input, { ...Signal.peek(input), after: "first edit" }); + t.mock.timers.tick(199); + assert.equal(diff(), initial); + Signal.set(input, { ...Signal.peek(input), after: "latest edit" }); + t.mock.timers.tick(199); + assert.equal(diff(), initial); + t.mock.timers.tick(1); + assert.notEqual(diff(), initial); + const html = renderToString(() => diff()); + assert.match(html, /latest edit/); + assert.doesNotMatch(html, /first edit/); +}); + +test("leaving Lambda Diff cancels pending work and reopening catches up", t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const hidePanel of [false, true]) { + const { input, mode, visible, diff } = lambdaView(); + Signal.set(mode, "Diff"); + const initial = diff(); + Signal.set(input, { ...Signal.peek(input), after: "pending edit" }); + if (hidePanel) Signal.set(visible, false); + else Signal.set(mode, "Before"); + t.mock.timers.tick(200); + assert.equal(diff(), initial); + if (hidePanel) Signal.set(visible, true); + else Signal.set(mode, "Diff"); + assert.notEqual(diff(), initial); + assert.match( + renderToString(() => diff()), + /pending edit/, + ); + } +}); + +test("expanded unchanged regions survive edits to a changed line", t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { input, mode, view, html, diff } = lambdaView(); Signal.set(mode, "Diff"); const summary = elements(view).find(node => node.tag === "summary"); const click = summary.events.find(([name]) => name === "click")[1]; @@ -353,10 +405,13 @@ test("expanded unchanged regions survive edits to a changed line", () => { const openDetails = () => (html().match(/]* open(?: |>|=)/g) ?? []).length; assert.equal(openDetails(), 1); + const initial = diff(); Signal.set(input, { ...Signal.peek(input), after: Signal.peek(input).after.replace("new", "edited"), }); + t.mock.timers.tick(200); + assert.notEqual(diff(), initial); assert.equal(openDetails(), 1); }); diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res index 540357086a9..e3daa661ddc 100644 --- a/packages/dev-playground/src/LambdaView.res +++ b/packages/dev-playground/src/LambdaView.res @@ -23,9 +23,9 @@ let renderLine = (highlights, line: LambdaDiff.line) => { } let number = number => number->Option.map(value => Int.toString(value))->Option.getOr("") - {View.text(number(line.before))} - {View.text(number(line.after))} - {View.text(marker)} + {View.text(number(line.before))} + {View.text(number(line.after))} + {View.text(marker)} {switch LambdaInlineDiff.forLine(highlights, line) { | None => View.text(line.text) @@ -33,7 +33,7 @@ let renderLine = (highlights, line: LambdaDiff.line) => { View.fragment( parts->Array.map(part => part.changed - ? {View.text(part.text)} + ? {View.text(part.text)} : View.text(part.text) ), ) @@ -49,9 +49,7 @@ let renderLines = (highlights, lines) => let renderDiff = (contextState, result) => switch result { | LambdaDiff.Identical => -

- {View.text("No changes in the printed Lambda after optimization.")} -

+

{View.text("No changes in the printed Lambda after optimization.")}

| TooLarge =>

{View.text( @@ -65,7 +63,7 @@ let renderDiff = (contextState, result) => let occurrences = Dict.make() let sections = LambdaDiff.sections(lines)->Array.map(section => switch section { - | Visible(lines) =>

 {renderLines(highlights, lines)} 
+ | Visible(lines) =>
{renderLines(highlights, lines)}
| Collapsed(lines) => let text = lines->Array.map(line => line.text)->Array.join("\n") let occurrence = Dict.get(occurrences, text)->Option.getOr(0) @@ -90,7 +88,7 @@ let renderDiff = (contextState, result) => {View.tracked(() => Signal.get(expanded) - ?
 {renderLines(highlights, lines)} 
+ ?
{renderLines(highlights, lines)}
: View.empty() )} @@ -117,12 +115,41 @@ let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", )}

+ let shownInput = ref(None) + let panelInput = Computed.make(() => { + if visible() { + shownInput := input() + } + shownInput.contents + }) + let diffInput = Signal.make(None) + let wasActive = ref(false) + Effect.run(() => { + let active = visible() && Signal.get(mode) === Diff + let entering = active && !wasActive.contents + wasActive := active + if active { + let current = input() + if entering { + // Opening Diff is immediate; edits wait for a short pause in typing. + Signal.set(diffInput, current) + None + } else if sameInput(Signal.peek(diffInput), current) { + None + } else { + let timer = Window.setTimeout(() => Signal.set(diffInput, current), 200) + Some(() => Window.clearTimeout(timer)) + } + } else { + None + } + }) // Cache the complete rendered diff by text, including inline highlights and // sections. Hidden tabs keep their last result and defer new comparison work. let cached = ref((None, View.empty())) let difference = Computed.make(() => { let (previous, rendered) = cached.contents - let current = visible() && Signal.get(mode) === Diff ? input() : previous + let current = Signal.get(diffInput) if sameInput(previous, current) { rendered } else { @@ -152,12 +179,14 @@ let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { )}
 Signal.get(mode) === Before ? "output" : "output hidden-panel"}>
-      {View.signalText(() => input()->Option.map(value => value.before)->Option.getOr(""))}
+      {View.signalText(() =>
+        Signal.get(panelInput)->Option.map(value => value.before)->Option.getOr("")
+      )}
     
Signal.get(mode) === After ? "" : "hidden-panel"}> {View.tracked(() => - switch input()->Option.flatMap(value => value.after) { - | Some(after) =>
 {View.text(after)} 
+ switch Signal.get(panelInput)->Option.flatMap(value => value.after) { + | Some(after) =>
{View.text(after)}
| None => unavailable() } )} From 93fce703fd34b350e619cebd278e0bacbfe83dc3 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 13:44:45 +0200 Subject: [PATCH 5/6] Reuse hidden Lambda panels after compile errors (#8636) Give the cached panel input an identity equality check so repeated errors on the Lambda tab do not notify the unchanged Before and After panels. Review: Address cknitt's final non-blocking follow-up. The regression test reproduces the After panel being rebuilt without this check, verifies reuse across repeated errors, and checks recovery when compilation succeeds. Validation: All 37 developer playground tests, the production Vite build, and the changed files' ReScript/JavaScript format checks pass. Signed-off-by: Florian Hammerschmidt --- .../scripts/lambda-diff.test.mjs | 24 +++++++++++++++++++ packages/dev-playground/src/LambdaView.res | 15 +++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/dev-playground/scripts/lambda-diff.test.mjs b/packages/dev-playground/scripts/lambda-diff.test.mjs index c860dab69e9..f8f7df53d83 100644 --- a/packages/dev-playground/scripts/lambda-diff.test.mjs +++ b/packages/dev-playground/scripts/lambda-diff.test.mjs @@ -350,6 +350,30 @@ test("Lambda panels retain their text while hidden and catch up when shown", () assert.match(html(), /hidden after/); }); +test("Lambda panels reuse their snapshot across repeated compile errors", () => { + const result = Signal.make({ + lambda: { before: "before", after: "after" }, + }); + const view = LambdaView.make({ + input: () => Signal.get(result).lambda, + visible: () => Signal.get(result).lambda !== undefined, + mode: Signal.make("Before"), + }); + const afterPanel = () => elements(view).filter(node => node.tag === "pre")[1]; + const initial = afterPanel(); + assert.ok(initial); + for (let compile = 0; compile < 2; compile++) { + Signal.set(result, { lambda: undefined }); + assert.equal(afterPanel(), initial); + } + Signal.set(result, { lambda: { before: "fixed", after: "recovered" } }); + assert.notEqual(afterPanel(), initial); + assert.match( + renderToString(() => view), + /recovered/, + ); +}); + test("Lambda Diff debounces edits and renders the latest comparison", t => { t.mock.timers.enable({ apis: ["setTimeout"] }); const { input, mode, diff } = lambdaView(); diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res index e3daa661ddc..15d1d7a599a 100644 --- a/packages/dev-playground/src/LambdaView.res +++ b/packages/dev-playground/src/LambdaView.res @@ -116,12 +116,15 @@ let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { )}

let shownInput = ref(None) - let panelInput = Computed.make(() => { - if visible() { - shownInput := input() - } - shownInput.contents - }) + let panelInput = Computed.make( + ~equals=(left, right) => left === right, + () => { + if visible() { + shownInput := input() + } + shownInput.contents + }, + ) let diffInput = Signal.make(None) let wasActive = ref(false) Effect.run(() => { From 8bbffec3ee4e0d03e126b8b33f5377301a09ab5e Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 20 Oct 2026 14:33:19 +0200 Subject: [PATCH 6/6] Publish Lambda panel snapshots only while visible (#8636) Keep the visible panel snapshot in a signal updated by an owned effect. Repeated compile errors leave the signal untouched, so neither hidden panel is invalidated. Showing the Lambda tab publishes the latest input. Why: The locked rescript-signals 3.1.1 still invalidates downstream computeds despite panelInput's equality check. The local install used 3.1.4 and missed this behavior. Reproduced the CI failure with 3.1.1; the existing panel-reuse regression now passes on the locked version. Validation: All 37 playground tests pass with Node 22.23.3 and the locked rescript-signals 3.1.1. The playground compiler build, production Vite build, ReScript formatting, and diff checks pass. Signed-off-by: Florian Hammerschmidt --- packages/dev-playground/src/LambdaView.res | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res index 15d1d7a599a..4f67e6dc776 100644 --- a/packages/dev-playground/src/LambdaView.res +++ b/packages/dev-playground/src/LambdaView.res @@ -115,16 +115,13 @@ let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", )}

- let shownInput = ref(None) - let panelInput = Computed.make( - ~equals=(left, right) => left === right, - () => { - if visible() { - shownInput := input() - } - shownInput.contents - }, - ) + let panelInput = Signal.make(None) + Effect.run(() => { + if visible() { + Signal.set(panelInput, input()) + } + None + }) let diffInput = Signal.make(None) let wasActive = ref(false) Effect.run(() => {