From 3b34d5815c972e4158009a2616f7eebdb54f1ef3 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 16 Mar 2026 17:34:49 -0500 Subject: [PATCH 1/2] fix: overlapping edits when transpiling with stripped types. --- package-lock.json | 4 +- package.json | 2 +- src/transpile.ts | 85 ++++++++++++++++++++++++++++++++---------- test/transpile.test.ts | 19 ++++++++++ 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60ac3ee..5976be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/jsx", - "version": "1.9.1", + "version": "1.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/jsx", - "version": "1.9.1", + "version": "1.9.2", "license": "MIT", "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1", diff --git a/package.json b/package.json index bd8b350..665ba25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/jsx", - "version": "1.9.1", + "version": "1.9.2", "description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.", "keywords": [ "jsx runtime", diff --git a/src/transpile.ts b/src/transpile.ts index 144c732..dfd2de3 100644 --- a/src/transpile.ts +++ b/src/transpile.ts @@ -342,6 +342,8 @@ type StripEdit = { replacement?: string } +const MAX_TYPESCRIPT_STRIP_PASSES = 5 + const hasStringProperty = ( value: unknown, key: K, @@ -486,6 +488,45 @@ const applyStripEdits = (magic: MagicString, edits: StripEdit[]) => { return changed } +const stripTypeScriptSyntax = ( + source: string, + sourceType: TranspileSourceType, +): TranspileJsxSourceResult => { + let currentCode = source + let changed = false + + for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) { + const parsed = parseSync( + 'transpile-jsx-source.tsx', + currentCode, + createModuleParserOptions(sourceType), + ) + const error = parsed.errors[0] + if (error) { + throw new Error(formatParserError(error)) + } + + const edits = collectTypeScriptStripEdits(currentCode, parsed.program) + if (!edits.length) { + break + } + + const magic = new MagicString(currentCode) + const passChanged = applyStripEdits(magic, edits) + if (!passChanged) { + break + } + + currentCode = magic.toString() + changed = true + } + + return { + code: currentCode, + changed, + } +} + export function transpileJsxSource( source: string, options: TranspileJsxSourceOptions = {}, @@ -506,32 +547,36 @@ export function transpileJsxSource( throw new Error(formatParserError(firstError)) } - const magic = new MagicString(source) - const stripChanged = - typescriptMode === 'strip' - ? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program)) - : false - const jsxRoots = collectRootJsxNodes(parsed.program) - if (!jsxRoots.length) { + const jsxMagic = new MagicString(source) + + if (jsxRoots.length) { + const builder = new SourceJsxReactBuilder( + source, + createElementRef, + fragmentRef, + typescriptMode === 'strip', + ) + + jsxRoots.sort(compareByRangeStartDesc).forEach(node => { + jsxMagic.overwrite(node.range[0], node.range[1], builder.compile(node)) + }) + } + + const jsxCode = jsxRoots.length ? jsxMagic.toString() : source + const jsxChanged = jsxRoots.length > 0 + + if (typescriptMode !== 'strip') { return { - code: stripChanged ? magic.toString() : source, - changed: stripChanged, + code: jsxCode, + changed: jsxChanged, } } - const builder = new SourceJsxReactBuilder( - source, - createElementRef, - fragmentRef, - typescriptMode === 'strip', - ) - jsxRoots.sort(compareByRangeStartDesc).forEach(node => { - magic.overwrite(node.range[0], node.range[1], builder.compile(node)) - }) + const stripResult = stripTypeScriptSyntax(jsxCode, sourceType) return { - code: magic.toString(), - changed: true, + code: stripResult.code, + changed: jsxChanged || stripResult.changed, } } diff --git a/test/transpile.test.ts b/test/transpile.test.ts index 26e31f8..16da49f 100644 --- a/test/transpile.test.ts +++ b/test/transpile.test.ts @@ -248,4 +248,23 @@ const beta = (right as B) expect(result.code).not.toContain(' as A') expect(result.code).not.toContain(' as B') }) + + it('strips chained TS casts around JSX expressions', () => { + const input = ` +const node = ( as unknown as HTMLElement) +` + + const result = transpileJsxSource(input, { + sourceType: 'script', + typescript: 'strip', + }) + + expect(result.changed).toBe(true) + expect(result.code).toContain( + 'const node = (React.createElement(Checkbox, { "checked": true }))', + ) + expect(result.code).not.toContain(' as unknown') + expect(result.code).not.toContain(' as HTMLElement') + expect(() => new Function(result.code)).not.toThrow() + }) }) From 890cf7ec4e6b22af9b44fc93b72f6b7aa2603b02 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 16 Mar 2026 17:48:22 -0500 Subject: [PATCH 2/2] fix: type stripping loop. --- src/transpile.ts | 22 ++++++++++++++++++++++ test/transpile.test.ts | 13 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/transpile.ts b/src/transpile.ts index dfd2de3..130c400 100644 --- a/src/transpile.ts +++ b/src/transpile.ts @@ -494,6 +494,7 @@ const stripTypeScriptSyntax = ( ): TranspileJsxSourceResult => { let currentCode = source let changed = false + let reachedStripPassLimit = true for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) { const parsed = parseSync( @@ -508,12 +509,14 @@ const stripTypeScriptSyntax = ( const edits = collectTypeScriptStripEdits(currentCode, parsed.program) if (!edits.length) { + reachedStripPassLimit = false break } const magic = new MagicString(currentCode) const passChanged = applyStripEdits(magic, edits) if (!passChanged) { + reachedStripPassLimit = false break } @@ -521,6 +524,25 @@ const stripTypeScriptSyntax = ( changed = true } + if (reachedStripPassLimit) { + const parsed = parseSync( + 'transpile-jsx-source.tsx', + currentCode, + createModuleParserOptions(sourceType), + ) + const error = parsed.errors[0] + if (error) { + throw new Error(formatParserError(error)) + } + + const remainingEdits = collectTypeScriptStripEdits(currentCode, parsed.program) + if (remainingEdits.length) { + throw new Error( + `[jsx] TypeScript strip did not converge after ${MAX_TYPESCRIPT_STRIP_PASSES} passes (${remainingEdits.length} removable TypeScript nodes remain).`, + ) + } + } + return { code: currentCode, changed, diff --git a/test/transpile.test.ts b/test/transpile.test.ts index 16da49f..946ff42 100644 --- a/test/transpile.test.ts +++ b/test/transpile.test.ts @@ -267,4 +267,17 @@ const node = ( as unknown as HTMLElement) expect(result.code).not.toContain(' as HTMLElement') expect(() => new Function(result.code)).not.toThrow() }) + + it('throws a clear error when strip mode does not converge', () => { + const input = ` +const node = ((((((value as A) as B) as C) as D) as E) as F) +` + + expect(() => + transpileJsxSource(input, { + sourceType: 'script', + typescript: 'strip', + }), + ).toThrow(/TypeScript strip did not converge after 5 passes/) + }) })