Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9f8c803
fix(next): converge workflow HMR rebuilds
NathanColosimo Aug 11, 2026
f80c04e
fix(next): rediscover directive file changes
NathanColosimo Aug 18, 2026
7a0424f
test(next): await HMR scheduler idle
NathanColosimo Aug 18, 2026
f9336a2
test(next): classify directive edits as full
NathanColosimo Aug 18, 2026
26ccbf1
refactor(next): simplify HMR convergence
NathanColosimo Aug 19, 2026
0052815
style(next): order HMR test imports
NathanColosimo Aug 19, 2026
9505c6b
fix(next): close remaining HMR consistency gaps
NathanColosimo Aug 26, 2026
8294a78
fix(builders): preserve discovery helper API
NathanColosimo Aug 26, 2026
c599903
fix(builders): preserve legacy import graph
NathanColosimo Aug 26, 2026
10d21d0
fix(builders): preserve discovery result compatibility
NathanColosimo Aug 26, 2026
4147163
fix(next): rebuild non-source workflow inputs
NathanColosimo Aug 26, 2026
703cf38
fix(next): keep HMR rebuilds minimal
NathanColosimo Aug 26, 2026
ce5a4a2
fix(next): bound full rebuild convergence
NathanColosimo Aug 26, 2026
ac08470
perf(next): precompute affected workflow files
NathanColosimo Aug 28, 2026
694b773
fix(next): honor selected source directory
NathanColosimo Aug 28, 2026
4d207be
fix(next): match active entrypoint conventions
NathanColosimo Aug 28, 2026
2150e2b
fix(next): classify Rspack entrypoints
NathanColosimo Aug 28, 2026
8b0bfea
Merge remote-tracking branch 'origin/main' into codex/fix-next-hmr-bu…
NathanColosimo Aug 28, 2026
6ccd1da
Merge remote-tracking branch 'origin/main' into peter/wt-3333
VaguelySerious Sep 14, 2026
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
6 changes: 6 additions & 0 deletions .changeset/fix-next-hmr-build-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/next': patch
'@workflow/builders': patch
---

Keep Next.js development rebuilds consistent with source and import-graph changes.
58 changes: 43 additions & 15 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import {
createWorkflowEntrypointOptionsCode,
createWorkflowRouteHandlersCode,
} from './constants.js';
import { importParents as legacyImportParents } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import {
type CompleteDiscoveredEntries,
type DiscoveredEntries,
fastDiscoverEntries,
} from './fast-discovery.js';
Expand Down Expand Up @@ -390,7 +392,7 @@ export abstract class BaseBuilder {
* This cache is invalidated automatically when the inputs array reference changes
* (e.g., when files are added/removed during watch mode).
*/
private discoveredEntries: WeakMap<string[], DiscoveredEntries> =
private discoveredEntries: WeakMap<string[], CompleteDiscoveredEntries> =
new WeakMap();

public clearDiscoveredEntriesCache(): void {
Expand Down Expand Up @@ -513,17 +515,18 @@ export abstract class BaseBuilder {
inputs: string[],
outdir: string,
tsconfigPath?: string
): Promise<DiscoveredEntries> {
): Promise<CompleteDiscoveredEntries> {
const previousResult = this.discoveredEntries.get(inputs);

if (previousResult) {
return previousResult;
}
const state: DiscoveredEntries = {
const state: CompleteDiscoveredEntries = {
discoveredSteps: new Set(),
discoveredWorkflows: new Set(),
discoveredSerdeFiles: new Set(),
discoveredFiles: new Set(),
importParents: new Map(),
};

const discoverStart = Date.now();
Expand Down Expand Up @@ -1172,6 +1175,7 @@ export const __steps_registered = true;
createPseudoPackagePlugin(),
createSwcPlugin({
mode: 'step',
importParents: discovered.importParents,
entriesToBundle: normalizedEntriesToBundle,
outdir: outfile ? dirname(outfile) : undefined,
projectRoot: this.transformProjectRoot,
Expand Down Expand Up @@ -1411,6 +1415,7 @@ export const __steps_registered = true;
createPseudoPackagePlugin(),
createSwcPlugin({
mode: 'workflow',
importParents: discovered.importParents,
projectRoot: this.transformProjectRoot,
moduleSpecifierRoot: this.moduleSpecifierRoot,
workflowManifest,
Expand Down Expand Up @@ -1668,18 +1673,24 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
stepsContext?: esbuild.BuildContext;
interimBundleCtx?: esbuild.BuildContext;
bundleFinal?: (interimBundleResult: string) => Promise<void>;
discoveredEntries: DiscoveredEntries;
discoveredEntries: CompleteDiscoveredEntries;
stepsManifest: WorkflowManifest;
workflowsManifest: WorkflowManifest;
}> {
this.startWorkflowBuildTimer();
const effectiveDiscoveredEntries =
discoveredEntries ??
(await this.discoverEntries(
inputFiles,
dirname(flowOutfile),
tsconfigPath
));
const effectiveDiscoveredEntries: CompleteDiscoveredEntries =
discoveredEntries === undefined
? await this.discoverEntries(
inputFiles,
dirname(flowOutfile),
tsconfigPath
)
: {
...discoveredEntries,
importParents:
discoveredEntries.importParents ?? legacyImportParents,
discoveredFiles: discoveredEntries.discoveredFiles ?? new Set(),
};

// 1. Build step registrations bundle (used as separate file for
// bundleFinalOutput: false, or read back for inline content when true)
Expand Down Expand Up @@ -1712,10 +1723,28 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
outfile: tempWorkflowOutfile,
format,
bundleFinalOutput: false,
includeMetafile: this.config.watch,
tsconfigPath,
discoveredEntries: effectiveDiscoveredEntries,
});

if (this.config.watch) {
const metafile = workflowsResult.interimBundleMetafile;
if (!metafile) {
throw new Error('Invariant: expected workflow build inputs');
}
for (const input of Object.keys(metafile.inputs)) {
const file = resolve(this.config.workingDir, input).replace(/\\/g, '/');
if (
input !== 'virtual-entry.js' &&
!file.includes('/node_modules/') &&
!file.includes('/.pnpm/')
) {
effectiveDiscoveredEntries.discoveredFiles.add(file);
}
}
}

const workflowVMCode = workflowsResult.interimBundleText;
if (!workflowVMCode) {
throw new Error('createWorkflowsBundle did not return interimBundleText');
Expand Down Expand Up @@ -1879,10 +1908,8 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
// Discover serde files from the input files' dependency tree for cross-context class registration.
// Classes need to be registered in the client bundle so they can be serialized
// when passing data to workflows via start() and deserialized when receiving workflow results.
const { discoveredSerdeFiles } = await this.discoverEntries(
inputFiles,
outputDir
);
const discoveredEntries = await this.discoverEntries(inputFiles, outputDir);
const { discoveredSerdeFiles } = discoveredEntries;

// Identify serde files that aren't in the inputFiles (deduplicated)
const inputFilesNormalized = new Set(
Expand Down Expand Up @@ -1966,6 +1993,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr
plugins: [
createSwcPlugin({
mode: 'step',
importParents: discoveredEntries.importParents,
projectRoot: this.transformProjectRoot,
moduleSpecifierRoot: this.moduleSpecifierRoot,
onAfterTransform: this.config.onAfterTransform,
Expand Down
15 changes: 12 additions & 3 deletions packages/builders/src/discover-entries-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ vi.mock('./apply-swc-transform.js', () => ({

import {
createDiscoverEntriesPlugin,
importParents,
importGraphHasChild,
importParents as legacyImportParents,
parentHasChild,
} from './discover-entries-esbuild-plugin.js';

const importParents = new Map<string, Set<string>>();

const realTmpdir = realpathSync(tmpdir());

function normalizeSlashes(path: string): string {
Expand All @@ -41,6 +44,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => {
beforeEach(() => {
testRoot = mkdtempSync(join(realTmpdir, 'workflow-discover-plugin-'));
importParents.clear();
legacyImportParents.clear();
applySwcTransformMock.mockReset();
applySwcTransformMock.mockImplementation(
async (filename: string, source: string) => {
Expand Down Expand Up @@ -104,6 +108,7 @@ describe('createDiscoverEntriesPlugin projectRoot', () => {
discoveredSteps: new Set<string>(),
discoveredWorkflows: new Set<string>(),
discoveredSerdeFiles: new Set<string>(),
importParents,
};

const result = await esbuild.build({
Expand Down Expand Up @@ -205,16 +210,20 @@ describe('createDiscoverEntriesPlugin projectRoot', () => {
const normalizedSerde = normalizeSlashes(serdeFile);

// entry.ts -> bare-pkg/index.js should be tracked
const entryChildren = importParents.get(normalizedEntry);
const entryChildren = legacyImportParents.get(normalizedEntry);
expect(entryChildren).toBeDefined();
expect(entryChildren!.has(normalizedPkgIndex)).toBe(true);

// bare-pkg/index.js -> bare-pkg/serde.js should be tracked
const pkgChildren = importParents.get(normalizedPkgIndex);
const pkgChildren = legacyImportParents.get(normalizedPkgIndex);
expect(pkgChildren).toBeDefined();
expect(pkgChildren!.has(normalizedSerde)).toBe(true);

// parentHasChild should transitively find serde.js from entry.ts
expect(parentHasChild(normalizedEntry, normalizedSerde)).toBe(true);

expect(
importGraphHasChild(legacyImportParents, normalizedEntry, normalizedSerde)
).toBe(true);
});
});
32 changes: 24 additions & 8 deletions packages/builders/src/discover-entries-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ function isGeneratedBuildArtifactPath(filePath: string): boolean {
);
}

// parent -> children relationship (a file can import multiple files)
export const importParents = new Map<string, Set<string>>();
export type ImportParents = Map<string, Set<string>>;

// Kept for compatibility with the exported discovery helpers. BaseBuilder
// replaces it with the last completed discovery graph.
export const importParents: ImportParents = new Map();

// check if a parent has a child in its import chain
// e.g. if a dependency needs to be bundled because it has
// a 'use workflow/'use step' directive in it
export function parentHasChild(
export function importGraphHasChild(
importGraph: ImportParents,
parent: string,
childToFind: string,
{
Expand All @@ -71,15 +75,15 @@ export function parentHasChild(
const excluded = new Set(excludedRoots);
const queue: string[] = [parent];

while (queue.length > 0) {
const current = queue.shift()!;
for (let index = 0; index < queue.length; index++) {
const current = queue[index];

if (visited.has(current)) {
continue;
}
visited.add(current);

const children = importParents.get(current);
const children = importGraph.get(current);
if (!children) {
continue;
}
Expand All @@ -98,17 +102,29 @@ export function parentHasChild(
return false;
}

export function parentHasChild(
parent: string,
childToFind: string,
options: { excludedRoots?: Iterable<string> } = {}
): boolean {
return importGraphHasChild(importParents, parent, childToFind, options);
}

export function createDiscoverEntriesPlugin(
state: {
discoveredSteps: Set<string>;
discoveredWorkflows: Set<string>;
discoveredSerdeFiles: Set<string>;
importParents?: ImportParents;
},
projectRoot?: string
): Plugin {
return {
name: 'discover-entries-esbuild-plugin',
setup(build) {
const currentImportParents = state.importParents ?? importParents;
build.onStart(() => currentImportParents.clear());

// Track parent→child import relationships for ALL imports (not just
// those with file extensions) so that `parentHasChild()` can correctly
// identify transitive parents of serde/step files even when the
Expand All @@ -123,10 +139,10 @@ export function createDiscoverEntriesPlugin(
const normalizedImporter = args.importer.replace(/\\/g, '/');
const normalizedResolved = resolved.replace(/\\/g, '/');
// A file can import multiple files, so we store a Set of children
let children = importParents.get(normalizedImporter);
let children = currentImportParents.get(normalizedImporter);
if (!children) {
children = new Set<string>();
importParents.set(normalizedImporter, children);
currentImportParents.set(normalizedImporter, children);
}
children.add(normalizedResolved);
}
Expand Down
Loading
Loading