From d34f454386b6ab025ba08fafd4400c4d698df6b2 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Mon, 8 Jun 2026 13:05:40 -0700 Subject: [PATCH 1/3] fix(next): preserve lazy step registration on stable Signed-off-by: Pranay Prakash --- .../fix-stable-next-lazy-registration.md | 5 + packages/next/src/builder-deferred.test.ts | 128 ++++++++++++++++++ packages/next/src/index.test.ts | 42 ++++++ packages/next/src/index.ts | 24 ++-- workbench/nextjs-turbopack/next.config.ts | 4 +- workbench/nextjs-webpack/next.config.ts | 4 +- 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-stable-next-lazy-registration.md create mode 100644 packages/next/src/builder-deferred.test.ts diff --git a/.changeset/fix-stable-next-lazy-registration.md b/.changeset/fix-stable-next-lazy-registration.md new file mode 100644 index 0000000000..5369a016e2 --- /dev/null +++ b/.changeset/fix-stable-next-lazy-registration.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Preserve deferred step registration with Turbopack content filtering. diff --git a/packages/next/src/builder-deferred.test.ts b/packages/next/src/builder-deferred.test.ts new file mode 100644 index 0000000000..3193995b71 --- /dev/null +++ b/packages/next/src/builder-deferred.test.ts @@ -0,0 +1,128 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getNextBuilderDeferred } from './builder-deferred.js'; +import { + DEFERRED_STEP_COPY_DIR_NAME, + parseDeferredStepSourceMetadata, +} from './step-copy-utils.js'; + +const tempDirs: string[] = []; +// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import. +const originalEval = globalThis.eval; + +afterEach(async () => { + await Promise.all( + tempDirs.map((dir) => rm(dir, { recursive: true, force: true })) + ); + tempDirs.length = 0; + vi.unstubAllGlobals(); +}); + +describe('NextDeferredBuilder', () => { + it('generates route imports for local, transitive, package, and built-in steps', async () => { + const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-')); + tempDirs.push(workingDir); + vi.stubGlobal('eval', (source: string) => { + if (source === 'import("@workflow/builders")') { + return import('@workflow/builders'); + } + return originalEval(source); + }); + + const workflowFile = join(workingDir, 'workflows/example.ts'); + const localStepFile = join(workingDir, 'workflows/local-step.ts'); + const importedStepFile = join(workingDir, 'shared/imported-step.ts'); + const packageStepFile = join( + workingDir, + 'node_modules/example-step-package/index.js' + ); + await mkdir(join(workingDir, 'workflows'), { recursive: true }); + await mkdir(join(workingDir, 'shared'), { recursive: true }); + await mkdir(join(workingDir, 'node_modules/example-step-package'), { + recursive: true, + }); + await writeFile( + workflowFile, + `import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}` + ); + await writeFile( + localStepFile, + `export async function localStep() {\n 'use step';\n}` + ); + await writeFile( + importedStepFile, + `export async function importedStep() {\n 'use step';\n}` + ); + await writeFile( + packageStepFile, + `export async function packageStep() {\n 'use step';\n}` + ); + + const NextDeferredBuilder = await getNextBuilderDeferred(); + const builder = new NextDeferredBuilder({ + dirs: [], + workingDir, + buildTarget: 'next', + workflowsBundlePath: '', + stepsBundlePath: '', + webhookBundlePath: '', + }) as any; + builder.createDeferredStepsManifest = vi.fn(async () => ({})); + + const workflowGeneratedDir = join( + workingDir, + 'app/.well-known/workflow/v1' + ); + await builder.buildStepsFunction({ + workflowGeneratedDir, + discoveredEntries: { + discoveredSteps: [localStepFile, packageStepFile], + discoveredWorkflows: [workflowFile], + discoveredSerdeFiles: [], + }, + }); + + const stepRouteDir = join(workflowGeneratedDir, 'step'); + const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME); + const copiedFileNames = await readdir(copiedStepsDir); + const copiedSources = await Promise.all( + copiedFileNames.map(async (fileName) => ({ + fileName, + source: await readFile(join(copiedStepsDir, fileName), 'utf-8'), + })) + ); + const copiedSourcePaths = copiedSources + .map( + ({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath + ) + .filter((path): path is string => Boolean(path)); + + expect(copiedSourcePaths).toEqual( + expect.arrayContaining([localStepFile, importedStepFile, packageStepFile]) + ); + expect( + copiedSources.some(({ source }) => + source.includes('__builtin_response_array_buffer') + ) + ).toBe(true); + + const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8'); + for (const { fileName } of copiedSources) { + expect(routeCode).toContain( + `import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';` + ); + } + expect(routeCode).toContain( + "export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';" + ); + }); +}); diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 249ebe307c..cd56b11ff5 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => { rmSync(projectDir, { recursive: true, force: true }); } }); + + it('lets Turbopack transform deferred step copies but not generated routes', async () => { + shouldUseDeferredBuilderMock.mockReturnValue(true); + const config = withWorkflow( + {}, + { + workflows: { lazyDiscovery: true }, + } + ); + + const resolvedConfig = await config('phase-production-build', { + defaultConfig: {}, + }); + const condition = (resolvedConfig.turbopack?.rules as any)['*.ts'] + .condition; + const generatedPathCondition = condition.all.find( + (entry: Record) => 'any' in entry + ); + const contentCondition = condition.all.find( + (entry: Record) => 'content' in entry + ); + const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any; + + const matchesPathCondition = (path: string) => + !nonGeneratedPath.not.path.test(path) || + deferredStepCopyPath.path.test(path); + + expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true); + expect( + matchesPathCondition( + '/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts' + ) + ).toBe(true); + expect( + matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js') + ).toBe(false); + expect( + contentCondition.content.test( + `export async function step() {\n 'use step';\n}` + ) + ).toBe(true); + }); }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 8b62aabc49..078fd975a3 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -14,6 +14,11 @@ const workflowSerdeSymbolPattern = /Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/; const workflowSerdeComputedPropertyPattern = /\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/; +const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/; +const deferredStepCopyPathPattern = + /[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]__workflow_step_files__[/\\]/; +const turbopackWorkflowContentPattern = + /(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/; const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); @@ -396,18 +401,21 @@ export function withWorkflow( ...(supportsTurboCondition ? { condition: { - // Use 'all' to combine: must match content AND must NOT be in generated path - // Merge with any existing 'all' conditions from user config + // Merge with any existing 'all' conditions from user config. all: [ ...(existingRules[key]?.condition?.all || []), - // Exclude generated workflow route files from transformation - { not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } }, - // Match files with workflow directives or custom serialization patterns - // Uses backreferences (\2, \3) to ensure matching quote types { - content: - /(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/, + // Deferred step copies are generated source files that must + // still be transformed in step mode. Other generated route + // files have already been transformed and remain excluded. + any: [ + { not: { path: generatedWorkflowPathPattern } }, + { path: deferredStepCopyPathPattern }, + ], }, + // Match files with workflow directives or custom serialization patterns + // Uses backreferences (\2, \3) to ensure matching quote types + { content: turbopackWorkflowContentPattern }, ], }, } diff --git a/workbench/nextjs-turbopack/next.config.ts b/workbench/nextjs-turbopack/next.config.ts index 78df6b2090..99cc5ed334 100644 --- a/workbench/nextjs-turbopack/next.config.ts +++ b/workbench/nextjs-turbopack/next.config.ts @@ -1,5 +1,5 @@ -import type { NextConfig } from 'next'; import path from 'node:path'; +import type { NextConfig } from 'next'; import { withWorkflow } from 'workflow/next'; const turbopackRoot = path.resolve(process.cwd(), '../..'); @@ -16,5 +16,5 @@ const nextConfig: NextConfig = { // export default nextConfig; export default withWorkflow(nextConfig, { - workflows: { lazyDiscovery: true }, + workflows: { lazyDiscovery: false }, }); diff --git a/workbench/nextjs-webpack/next.config.ts b/workbench/nextjs-webpack/next.config.ts index 69ef25c947..9f42587d0b 100644 --- a/workbench/nextjs-webpack/next.config.ts +++ b/workbench/nextjs-webpack/next.config.ts @@ -11,4 +11,6 @@ const nextConfig: NextConfig = { }; // export default nextConfig; -export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } }); +export default withWorkflow(nextConfig, { + workflows: { lazyDiscovery: false }, +}); From 4e4fee408e97bfc5cf05ca600d13913812bd1f79 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Mon, 8 Jun 2026 13:53:52 -0700 Subject: [PATCH 2/3] refactor(next): reuse deferred step directory constant Signed-off-by: Pranay Prakash --- packages/next/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 078fd975a3..6324e6daea 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -6,6 +6,7 @@ import { shouldUseDeferredBuilder, WORKFLOW_DEFERRED_ENTRIES, } from './builder.js'; +import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js'; const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; @@ -15,8 +16,9 @@ const workflowSerdeSymbolPattern = const workflowSerdeComputedPropertyPattern = /\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/; const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/; -const deferredStepCopyPathPattern = - /[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]__workflow_step_files__[/\\]/; +const deferredStepCopyPathPattern = new RegExp( + String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]` +); const turbopackWorkflowContentPattern = /(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/; From 9721981acd598e08a6b4c154894a5ea0fc863e22 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 8 Jun 2026 14:04:25 -0700 Subject: [PATCH 3/3] test: gate deferred dev checks explicitly --- .github/workflows/e2e-community-world.yml | 2 +- .github/workflows/tests.yml | 2 +- packages/core/e2e/dev.test.ts | 14 +++++++++++--- scripts/create-test-matrix.mjs | 2 ++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-community-world.yml b/.github/workflows/e2e-community-world.yml index 678d893356..c549e48d03 100644 --- a/.github/workflows/e2e-community-world.yml +++ b/.github/workflows/e2e-community-world.yml @@ -156,7 +156,7 @@ jobs: SERVICE_TYPE: ${{ inputs.service-type }} WORLD_ID: ${{ inputs.world-id }} DEPLOYMENT_URL: "http://localhost:3000" - DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}' + DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}' - name: Generate E2E summary if: always() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3a0737f4ca..085d21007c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -649,7 +649,7 @@ jobs: NODE_OPTIONS: "--enable-source-maps" APP_NAME: "nextjs-turbopack" DEPLOYMENT_URL: "http://localhost:3000" - DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}' + DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}' - name: Print Next.js server logs if: always() diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 074f867c21..87aee47545 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -9,6 +9,8 @@ export interface DevTestConfig { apiFilePath: string; apiFileImportPath: string; canary?: boolean; + /** Whether the app emits deferred step copy files during dev. */ + supportsDeferredStepCopies?: boolean; /** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */ testWorkflowFile?: string; /** The workflows directory relative to appPath. Defaults to 'workflows' */ @@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) { ); const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts'; const workflowsDir = finalConfig.workflowsDir ?? 'workflows'; - const supportsDeferredStepCopies = generatedStep.includes( - path.join('.well-known', 'workflow', 'v1', 'step', 'route.js') - ); + const supportsDeferredStepCopies = + finalConfig.supportsDeferredStepCopies ?? + generatedStep.includes( + path.join('.well-known', 'workflow', 'v1', 'step', 'route.js') + ); const restoreFiles: Array<{ path: string; content: string }> = []; const fetchWithTimeout = (pathname: string) => { @@ -217,6 +221,10 @@ export async function myNewStep() { if (stepRouteContent.includes('myNewStep')) { return; } + if (!supportsDeferredStepCopies) { + expect(stepRouteContent).toContain('myNewStep'); + return; + } const copiedStepFileNames = await fs.readdir(copiedStepDir); const copiedStepContents = await Promise.all( diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index 08e6316f5b..503ebd8010 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = { generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js', apiFilePath: 'app/api/chat/route.ts', apiFileImportPath: '../../..', + supportsDeferredStepCopies: false, }, 'nextjs-webpack': { generatedStepPath: 'app/.well-known/workflow/v1/step/route.js', generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js', apiFilePath: 'app/api/chat/route.ts', apiFileImportPath: '../../..', + supportsDeferredStepCopies: false, }, nitro: { generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',