From e5064e7002631ce193c91b26e90c34f763958c36 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 29 Jun 2026 08:54:39 +0200 Subject: [PATCH] Fix live debugger curried function instrumentation Arrow expression-body instrumentation rewrote the body's final character to add the outer suffix. When the expression body was itself a function or arrow, that character could also be the inner function's closing brace, so the outer write dropped the inner catch block and produced invalid JavaScript. Append the outer suffix after the expression body instead, preserving the inner function postamble. Add nested and curried regression cases that re-parse transformed output under both namedOnly modes. --- .../live-debugger/src/transform/index.test.ts | 101 ++++++++++++++---- .../live-debugger/src/transform/index.ts | 7 +- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/packages/plugins/live-debugger/src/transform/index.test.ts b/packages/plugins/live-debugger/src/transform/index.test.ts index 48cf0bf92..13bf98558 100644 --- a/packages/plugins/live-debugger/src/transform/index.test.ts +++ b/packages/plugins/live-debugger/src/transform/index.test.ts @@ -294,27 +294,90 @@ describe('transformCode', () => { }); describe('nested functions', () => { - it('should instrument nested functions independently', () => { - const result = transformCode({ - ...BASE_OPTIONS, + const nestedSyntaxCases = [ + { + description: 'nested function declarations', code: 'function outer(a) { function inner(b) { return b; } return inner(a); }', - }); - - expect(result.instrumentedCount).toBe(2); - expect(result.totalFunctions).toBe(2); - }); - - it('should handle nested arrow expression bodies without conflicts', () => { - const result = transformCode({ - ...BASE_OPTIONS, + namedOnly: false, + expectedInstrumentedCount: 2, + expectedTotalFunctions: 2, + }, + { + description: 'arrow returning named function expression', + code: 'const make = (dep) => function named() { return dep; };', + namedOnly: false, + expectedInstrumentedCount: 2, + expectedTotalFunctions: 2, + }, + { + description: 'arrow returning named function expression', + code: 'const make = (dep) => function named() { return dep; };', + namedOnly: true, + expectedInstrumentedCount: 2, + expectedTotalFunctions: 2, + }, + { + description: 'exported arrow returning function expression with inner declaration', + code: 'export const remarkSymbol = (o) => function remarkSymbolPlugin() { const d = o; function add(){ d; } add(); };', + namedOnly: false, + expectedInstrumentedCount: 3, + expectedTotalFunctions: 3, + }, + { + description: 'exported arrow returning function expression with inner declaration', + code: 'export const remarkSymbol = (o) => function remarkSymbolPlugin() { const d = o; function add(){ d; } add(); };', + namedOnly: true, + expectedInstrumentedCount: 3, + expectedTotalFunctions: 3, + }, + { + description: 'triple-nested function expression callback', + code: 'const build = (n) => function useThing() { return cb(function record(){ size(n); }, []); };', + namedOnly: false, + expectedInstrumentedCount: 3, + expectedTotalFunctions: 3, + }, + { + description: 'triple-nested function expression callback', + code: 'const build = (n) => function useThing() { return cb(function record(){ size(n); }, []); };', + namedOnly: true, + expectedInstrumentedCount: 3, + expectedTotalFunctions: 3, + }, + { + description: 'nested arrow expression bodies', code: 'const f = (x) => (y) => x + y;', - }); - - expect(result.instrumentedCount).toBe(2); - // Both functions should be instrumented - const probeMatches = result.code.match(/\$dd_probes/g); - expect(probeMatches?.length).toBe(2); - }); + namedOnly: false, + expectedInstrumentedCount: 2, + expectedTotalFunctions: 2, + }, + { + description: 'curried arrow expression body with anonymous inner arrow skipped', + code: 'const f = (a) => (b) => a + b;', + namedOnly: true, + expectedInstrumentedCount: 1, + expectedTotalFunctions: 2, + }, + ]; + + test.each(nestedSyntaxCases)( + 'should produce valid output for $description with namedOnly $namedOnly', + ({ code, namedOnly, expectedInstrumentedCount, expectedTotalFunctions }) => { + const result = transformCode({ + ...BASE_OPTIONS, + code, + namedOnly, + }); + + const syntaxError = validateSyntax(result.code, '/src/utils.ts'); + const probeMatches = result.code.match(/\$dd_probes/g); + expect(syntaxError).toBeNull(); + expect(result.instrumentedCount).toBe(expectedInstrumentedCount); + expect(result.totalFunctions).toBe(expectedTotalFunctions); + expect(probeMatches?.length).toBe(expectedInstrumentedCount); + expect(result.code).toContain('catch(e)'); + }, + ); }); describe('skipping', () => { diff --git a/packages/plugins/live-debugger/src/transform/index.ts b/packages/plugins/live-debugger/src/transform/index.ts index 65ed1551f..42d540728 100644 --- a/packages/plugins/live-debugger/src/transform/index.ts +++ b/packages/plugins/live-debugger/src/transform/index.ts @@ -526,8 +526,9 @@ function injectInstrumentation(s: MagicStringType, code: string, target: Functio ].join('\n'); // Anchor each injected line to the original expression's location by - // editing the boundary chars of the body. Two updates avoid losing - // per-character mappings of the original expression in between. + // editing the first boundary char and appending after the body. This + // avoids overwriting nested function instrumentation when a curried + // arrow's expression body ends at the inner function's closing brace. // For a single-char body (e.g. `() => 1`) the two ranges would // collide, so we fall back to one update covering the whole body. if (bodyEnd - bodyStart >= 2) { @@ -535,7 +536,7 @@ function injectInstrumentation(s: MagicStringType, code: string, target: Functio ? `${prefix}(${code[bodyStart]}` : prefix + code[bodyStart]; s.update(bodyStart, bodyStart + 1, bodyPrefix); - s.update(bodyEnd - 1, bodyEnd, code[bodyEnd - 1] + suffix); + s.appendRight(bodyEnd, suffix); } else { s.update(bodyStart, bodyEnd, prefix + code.slice(bodyStart, bodyEnd) + suffix); }