Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/next-dev-single-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/next": patch
---

Fix `next dev` sometimes running the workflow build (and starting a second watcher) twice on startup when Next.js resets `process.env` between `next.config` evaluations
66 changes: 66 additions & 0 deletions packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ describe('withWorkflow builder config', () => {
delete process.env.WORKFLOW_LOCAL_DATA_DIR;
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;
delete process.env.WORKFLOW_TARGET_WORLD;
delete (globalThis as Record<symbol, unknown>)[
Symbol.for('@workflow/next/workflowBuilds')
];
});

afterEach(() => {
Expand Down Expand Up @@ -136,6 +139,69 @@ describe('withWorkflow builder config', () => {
);
});

it('builds once per process when Next resets process.env between config evaluations', async () => {
// Next 16 dev re-evaluates next.config (validateTurboNextConfig) after
// startup, and a forced @next/env reload in between (triggered by any env
// change the dev watcher sees, e.g. an `.env*` edit or an app route being
// added) restores process.env to its initial snapshot, dropping
// WORKFLOW_NEXT_PRIVATE_BUILT.
const config = withWorkflow({});

await config('phase-development-server', { defaultConfig: {} });
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;
await config('phase-development-server', { defaultConfig: {} });

expect(buildMock).toHaveBeenCalledOnce();
expect(builderConfigs).toHaveLength(1);
expect(process.env.WORKFLOW_NEXT_PRIVATE_BUILT).toBe('1');
});

it('shares an in-flight build between concurrent config evaluations', async () => {
buildMock.mockImplementationOnce(
() => new Promise<void>((resolve) => setTimeout(resolve, 20))
);
const config = withWorkflow({});

await Promise.all([
config('phase-development-server', { defaultConfig: {} }),
config('phase-development-server', { defaultConfig: {} }),
]);

expect(buildMock).toHaveBeenCalledOnce();
});

it('retries the build on the next config evaluation after a failed build', async () => {
buildMock.mockImplementationOnce(async () => {
throw new Error('boom');
});
const config = withWorkflow({});

await expect(
config('phase-development-server', { defaultConfig: {} })
).rejects.toThrow('boom');
expect(process.env.WORKFLOW_NEXT_PRIVATE_BUILT).toBeUndefined();

await config('phase-development-server', { defaultConfig: {} });

expect(buildMock).toHaveBeenCalledTimes(2);
expect(process.env.WORKFLOW_NEXT_PRIVATE_BUILT).toBe('1');
});

it('builds once when the plugin module is re-imported between config evaluations', async () => {
// Next may re-require next.config (and this plugin) when it re-evaluates
// the config, so the build record must not live in module scope.
await withWorkflow({})('phase-development-server', { defaultConfig: {} });
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;

vi.resetModules();
const { withWorkflow: freshWithWorkflow } = await import('./index.js');
await freshWithWorkflow({})('phase-development-server', {
defaultConfig: {},
});

expect(buildMock).toHaveBeenCalledOnce();
});

it('does not prewarm the SWC plugin cache for the production server', async () => {
const config = withWorkflow({});

Expand Down
40 changes: 38 additions & 2 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ const BASE_PATH_SYMBOL = Symbol.for('@workflow/core/basePath');
const globalConfig = globalThis as typeof globalThis &
Record<symbol, string | undefined>;

// Per-process record of started (or in-flight) workflow builds, keyed by
// working dir. WORKFLOW_NEXT_PRIVATE_BUILT alone is not enough: in `next dev`,
// any env change seen by Next's dev watcher (an `.env*` file changing, or the
// client router filter changing because an app route was added/removed or the
// watcher's first pass landed after the render server started) forces an
// @next/env reload. That restores process.env to its initial snapshot, which
// drops the flag because it's set inside the config function rather than while
// the config module loads. If this happens before Next re-evaluates
// next.config, we'd rebuild and start a second watcher.
// See https://github.com/vercel/workflow/issues/4309.
// It lives on globalThis (not module scope) so it survives the config module
// being re-required, and it's intentionally shared by every copy of
// @workflow/next loaded in the process.
const WORKFLOW_BUILDS_SYMBOL = Symbol.for('@workflow/next/workflowBuilds');

function getWorkflowBuilds(): Map<string, Promise<void>> {
const globalWithBuilds = globalThis as typeof globalThis &
Record<symbol, Map<string, Promise<void>> | undefined>;
globalWithBuilds[WORKFLOW_BUILDS_SYMBOL] ??= new Map();
return globalWithBuilds[WORKFLOW_BUILDS_SYMBOL];
}

// Keep this local: @workflow/next is CommonJS, while @workflow/utils is ESM-only.
function setWorkflowBasePath(basePath: string | undefined): void {
globalConfig[BASE_PATH_SYMBOL] = basePath ?? '';
Expand Down Expand Up @@ -688,9 +710,23 @@ export function withWorkflow(
!process.env.WORKFLOW_NEXT_PRIVATE_BUILT &&
phase !== 'phase-production-server'
) {
const workflowBuilder = await getWorkflowBuilder();
const builds = getWorkflowBuilds();
let build = builds.get(workingDir);
if (!build) {
build = (async () => {
const workflowBuilder = await getWorkflowBuilder();
await workflowBuilder.build();
})();
builds.set(workingDir, build);
// Allow a retry on the next evaluation if the build failed.
build.catch(() => {
if (builds.get(workingDir) === build) {
builds.delete(workingDir);
}
});
}

await workflowBuilder.build();
await build;
process.env.WORKFLOW_NEXT_PRIVATE_BUILT = '1';
}

Expand Down
Loading