From fd904c4f5fa76b341ab1d5747dcab82b80295d15 Mon Sep 17 00:00:00 2001 From: Joel Fickson Date: Sat, 7 Nov 2026 16:46:05 +0100 Subject: [PATCH 1/3] [builders] Compile JSX in steps bundles loaded directly at runtime Signed-off-by: Joel Fickson --- .changeset/steps-bundle-compile-jsx.md | 5 + packages/builders/src/base-builder.ts | 10 +- .../builders/src/steps-bundle-jsx.test.ts | 187 ++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .changeset/steps-bundle-compile-jsx.md create mode 100644 packages/builders/src/steps-bundle-jsx.test.ts diff --git a/.changeset/steps-bundle-compile-jsx.md b/.changeset/steps-bundle-compile-jsx.md new file mode 100644 index 0000000000..a9289111d6 --- /dev/null +++ b/.changeset/steps-bundle-compile-jsx.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Compile JSX in project-local files bundled into a directly loaded steps bundle (Nitro dev, Vitest, NestJS ESM), so a step that imports a `.tsx` module no longer fails with `Unexpected token '<'`. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 1d730ed13b..36b60913dc 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1156,7 +1156,15 @@ export const __steps_registered = true; treeShaking: true, keepNames: true, minify: false, - jsx: 'preserve', + // When project-local files are bundled for direct runtime loading (e.g. + // Nitro dev, which imports this file from disk), nothing compiles the + // output afterwards, so JSX in those files must be compiled here. + // 'automatic' still honors the tsconfig's `jsxImportSource` and + // `jsxFactory`; esbuild would otherwise read a tsconfig `"jsx": + // "preserve"` (or no `jsx`) as classic `React.createElement`, which + // needs React in scope. Otherwise the output goes to another bundler, + // so JSX is preserved for it. + jsx: bundleTransitiveLocalStepDependencies ? 'automatic' : 'preserve', logLevel: 'error', // Use tsconfig for path alias resolution. // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases. diff --git a/packages/builders/src/steps-bundle-jsx.test.ts b/packages/builders/src/steps-bundle-jsx.test.ts new file mode 100644 index 0000000000..4d320342bc --- /dev/null +++ b/packages/builders/src/steps-bundle-jsx.test.ts @@ -0,0 +1,187 @@ +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BaseBuilder } from './base-builder.js'; +import type { StandaloneConfig } from './types.js'; + +class TestBuilder extends BaseBuilder { + async build(): Promise { + // no-op + } + + public createSteps( + stepFile: string, + outfile: string, + bundleTransitiveLocalStepDependencies: boolean + ) { + return this.createStepsBundle({ + inputFiles: [stepFile], + outfile, + externalizeNonSteps: true, + bundleTransitiveLocalStepDependencies, + rewriteTsExtensions: true, + // No `discoveredEntries`: discovery builds the import graph that + // transitive bundling uses to find the step's local dependencies. + }); + } +} + +const realTmpdir = realpathSync(tmpdir()); + +function writeFile(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, 'utf-8'); +} + +function createBuilder(workingDir: string): TestBuilder { + const config: StandaloneConfig = { + buildTarget: 'standalone', + workingDir, + dirs: ['.'], + stepsBundlePath: join(workingDir, '.workflow', 'steps.mjs'), + workflowsBundlePath: join(workingDir, '.workflow', 'workflows.mjs'), + webhookBundlePath: join(workingDir, '.workflow', 'webhook.mjs'), + }; + return new TestBuilder(config); +} + +/** + * Imports the bundle in a fresh Node process, the way Nitro dev loads it, and + * runs the registered `render` step. Throws if Node cannot load the file. + */ +function runRenderStep(file: string, cwd: string): unknown { + const stdout = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `await import(${JSON.stringify(pathToFileURL(file).href)}); +const steps = globalThis[Symbol.for('@workflow/core//registeredSteps')]; +console.log(JSON.stringify(await steps.get('step//./src/step//render')('joel')));`, + ], + { encoding: 'utf8', cwd, stdio: 'pipe' } + ); + return JSON.parse(stdout.trim()); +} + +describe('steps bundle JSX (vercel/workflow#5286)', () => { + let testRoot: string; + let outfile: string; + + beforeEach(() => { + testRoot = mkdtempSync(join(realTmpdir, 'workflow-steps-jsx-')); + outfile = join(testRoot, '.workflow', 'steps.mjs'); + writeFile( + join(testRoot, 'node_modules', 'workflow', 'package.json'), + JSON.stringify({ name: 'workflow', version: '1.0.0' }) + ); + writeFile( + join(testRoot, 'node_modules', 'workflow', 'internal', 'builtins.js'), + 'export const __builtins = true;\n' + ); + // Minimal JSX runtimes, so the compiled `jsx-runtime` imports resolve. + for (const pkg of ['react', 'preact']) { + writeFile( + join(testRoot, 'node_modules', pkg, 'package.json'), + JSON.stringify({ + name: pkg, + version: '1.0.0', + type: 'module', + exports: { './jsx-runtime': './jsx-runtime.js' }, + }) + ); + writeFile( + join(testRoot, 'node_modules', pkg, 'jsx-runtime.js'), + `export const jsx = (type, props) => ({ runtime: '${pkg}', type, props });\nexport const jsxs = jsx;\nexport const Fragment = Symbol('Fragment');\n` + ); + } + // A step that reaches a .tsx file through a plain .ts module, the shape + // from the issue (step -> a.ts -> b.tsx). + writeFile( + join(testRoot, 'src', 'b.tsx'), + 'export function Greeting({ name }: { name: string }) {\n return

Hello {name}

;\n}\n' + ); + writeFile( + join(testRoot, 'src', 'a.ts'), + "import { Greeting } from './b';\nexport const greeting = (name: string) => Greeting({ name });\n" + ); + writeFile( + join(testRoot, 'src', 'step.ts'), + "import { greeting } from './a';\nexport async function render(name: string) {\n 'use step';\n return greeting(name);\n}\n" + ); + }); + + afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('compiles JSX in bundled local imports when the output is loaded directly', async () => { + writeFile( + join(testRoot, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { jsx: 'preserve' } }) + ); + + await createBuilder(testRoot).createSteps( + join(testRoot, 'src', 'step.ts'), + outfile, + true + ); + + const generated = readFileSync(outfile, 'utf-8'); + expect(generated).not.toContain('

'); + expect(runRenderStep(outfile, testRoot)).toMatchObject({ + runtime: 'react', + type: 'p', + }); + }); + + it("honors the tsconfig's jsxImportSource", async () => { + writeFile( + join(testRoot, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { jsx: 'react-jsx', jsxImportSource: 'preact' }, + }) + ); + + await createBuilder(testRoot).createSteps( + join(testRoot, 'src', 'step.ts'), + outfile, + true + ); + + const generated = readFileSync(outfile, 'utf-8'); + expect(generated).not.toContain('

'); + expect(runRenderStep(outfile, testRoot)).toMatchObject({ + runtime: 'preact', + type: 'p', + }); + }); + + it('leaves JSX preserved when a later bundler compiles the output', async () => { + // Without transitive bundling, the steps bundle is input to another + // bundler (Nitro/Rollup in prod). A .tsx step file's own JSX stays as-is, + // as it did before. + writeFile( + join(testRoot, 'src', 'step.tsx'), + "export async function render(name: string) {\n 'use step';\n return

Hello {name}

;\n}\n" + ); + + await createBuilder(testRoot).createSteps( + join(testRoot, 'src', 'step.tsx'), + outfile, + false + ); + + expect(readFileSync(outfile, 'utf-8')).toContain('

'); + }); +}); From f4f0bb4f7b2adfee39d09082d829259868d7531d Mon Sep 17 00:00:00 2001 From: Joel Fickson Date: Sat, 7 Nov 2026 16:57:56 +0100 Subject: [PATCH 2/3] [builders] Trim the JSX comment to match surrounding code Signed-off-by: Joel Fickson --- packages/builders/src/base-builder.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 36b60913dc..00966eb5bb 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1156,14 +1156,8 @@ export const __steps_registered = true; treeShaking: true, keepNames: true, minify: false, - // When project-local files are bundled for direct runtime loading (e.g. - // Nitro dev, which imports this file from disk), nothing compiles the - // output afterwards, so JSX in those files must be compiled here. - // 'automatic' still honors the tsconfig's `jsxImportSource` and - // `jsxFactory`; esbuild would otherwise read a tsconfig `"jsx": - // "preserve"` (or no `jsx`) as classic `React.createElement`, which - // needs React in scope. Otherwise the output goes to another bundler, - // so JSX is preserved for it. + // A directly loaded steps bundle (Nitro dev) is not compiled again, so + // compile JSX here. 'automatic' still honors `jsxImportSource`/`jsxFactory`. jsx: bundleTransitiveLocalStepDependencies ? 'automatic' : 'preserve', logLevel: 'error', // Use tsconfig for path alias resolution. From 439309e01a520a982e4048c7a1683aede2e4ab4b Mon Sep 17 00:00:00 2001 From: Joel Fickson Date: Sat, 7 Nov 2026 19:53:53 +0100 Subject: [PATCH 3/3] [builders] Scope the steps bundle JSX comment to jsxImportSource Signed-off-by: Joel Fickson --- packages/builders/src/base-builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 00966eb5bb..e2ddc2b8d9 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1157,7 +1157,7 @@ export const __steps_registered = true; keepNames: true, minify: false, // A directly loaded steps bundle (Nitro dev) is not compiled again, so - // compile JSX here. 'automatic' still honors `jsxImportSource`/`jsxFactory`. + // compile JSX here. 'automatic' still honors the tsconfig's `jsxImportSource`. jsx: bundleTransitiveLocalStepDependencies ? 'automatic' : 'preserve', logLevel: 'error', // Use tsconfig for path alias resolution.