diff --git a/.changeset/next-hmr-e2e.md b/.changeset/next-hmr-e2e.md new file mode 100644 index 0000000000..549a57f7d4 --- /dev/null +++ b/.changeset/next-hmr-e2e.md @@ -0,0 +1,4 @@ +--- +--- + +Exercise Next.js HMR convergence against stable and canary releases. diff --git a/.github/actions/setup-next-canary/action.yml b/.github/actions/setup-next-canary/action.yml new file mode 100644 index 0000000000..390e3cf96d --- /dev/null +++ b/.github/actions/setup-next-canary/action.yml @@ -0,0 +1,25 @@ +name: 'Setup Next.js Canary' +description: 'Install a Next.js canary and rebuild the workspace packages.' + +inputs: + app-name: + description: 'Workbench app name' + required: true + next-version: + description: 'Next.js canary version' + required: true + +runs: + using: 'composite' + steps: + - shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + NEXT_VERSION: ${{ inputs.next-version }} + run: | + jq --arg version "$NEXT_VERSION" '.devDependencies.next=$version' packages/next/package.json > packages/next/package.json.new + mv packages/next/package.json.new packages/next/package.json + jq --arg version "$NEXT_VERSION" '.dependencies.next=$version' "workbench/$APP_NAME/package.json" > "workbench/$APP_NAME/package.json.new" + mv "workbench/$APP_NAME/package.json.new" "workbench/$APP_NAME/package.json" + pnpm install --no-frozen-lockfile + pnpm turbo run build --filter='!./workbench/*' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 18ca523641..7f8f51c790 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +env: + NEXT_CANARY_VERSION: 16.4.0-canary.10 + jobs: ci-scope: name: Detect CI Scope @@ -1099,12 +1102,10 @@ jobs: - name: Setup canary if: ${{ matrix.app.canary }} - env: - APP_NAME: ${{ matrix.app.name }} - run: | - cat packages/next/package.json | jq '.dependencies.next|="16.3.1-canary.17"' > packages/next/package.json.new && mv packages/next/package.json.new packages/next/package.json - cat "workbench/$APP_NAME/package.json" | jq '.dependencies.next|="16.3.1-canary.17"' > "workbench/$APP_NAME/package.json.new" && mv "workbench/$APP_NAME/package.json.new" "workbench/$APP_NAME/package.json" - pnpm install --no-frozen-lockfile + uses: ./.github/actions/setup-next-canary + with: + app-name: ${{ matrix.app.name }} + next-version: ${{ env.NEXT_CANARY_VERSION }} - name: Download shared package builds if: ${{ !matrix.app.canary }} @@ -1113,10 +1114,6 @@ jobs: name: e2e-package-build-artifacts path: packages - - name: Run Initial Build for Next.js canary - if: ${{ matrix.app.canary }} - run: pnpm turbo run build --filter='!./workbench/*' - - name: Prepare workbench path id: prepare-workbench uses: ./.github/actions/prepare-workbench-path @@ -1186,12 +1183,10 @@ jobs: - name: Setup canary if: ${{ matrix.app.canary }} - env: - APP_NAME: ${{ matrix.app.name }} - run: | - cat packages/next/package.json | jq '.dependencies.next|="16.3.1-canary.17"' > packages/next/package.json.new && mv packages/next/package.json.new packages/next/package.json - cat "workbench/$APP_NAME/package.json" | jq '.dependencies.next|="16.3.1-canary.17"' > "workbench/$APP_NAME/package.json.new" && mv "workbench/$APP_NAME/package.json.new" "workbench/$APP_NAME/package.json" - pnpm install --no-frozen-lockfile + uses: ./.github/actions/setup-next-canary + with: + app-name: ${{ matrix.app.name }} + next-version: ${{ env.NEXT_CANARY_VERSION }} - name: Download shared package builds if: ${{ !matrix.app.canary }} @@ -1200,10 +1195,6 @@ jobs: name: e2e-package-build-artifacts path: packages - - name: Run Initial Build for Next.js canary - if: ${{ matrix.app.canary }} - run: pnpm turbo run build --filter='!./workbench/*' - - name: Prepare workbench path id: prepare-workbench uses: ./.github/actions/prepare-workbench-path @@ -1302,12 +1293,10 @@ jobs: - name: Setup canary if: ${{ matrix.app.canary }} - env: - APP_NAME: ${{ matrix.app.name }} - run: | - cat packages/next/package.json | jq '.dependencies.next|="16.3.1-canary.17"' > packages/next/package.json.new && mv packages/next/package.json.new packages/next/package.json - cat "workbench/$APP_NAME/package.json" | jq '.dependencies.next|="16.3.1-canary.17"' > "workbench/$APP_NAME/package.json.new" && mv "workbench/$APP_NAME/package.json.new" "workbench/$APP_NAME/package.json" - pnpm install --no-frozen-lockfile + uses: ./.github/actions/setup-next-canary + with: + app-name: ${{ matrix.app.name }} + next-version: ${{ env.NEXT_CANARY_VERSION }} - name: Download shared package builds if: ${{ !matrix.app.canary }} @@ -1316,10 +1305,6 @@ jobs: name: e2e-package-build-artifacts path: packages - - name: Run Initial Build for Next.js canary - if: ${{ matrix.app.canary }} - run: pnpm turbo run build --filter='!./workbench/*' - - name: Setup PostgreSQL Database run: ./packages/world-postgres/bin/setup.js diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 1363117fb2..7cec1bb3bd 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1017,6 +1017,11 @@ ${apiFileContent}` workflowsDir, 'hmr-fuzz-import-helper.ts' ), + buildInput: path.join( + appPath, + workflowsDir, + 'hmr-fuzz-build-input.json' + ), addedWorkflow: path.join( appPath, workflowsDir, @@ -1034,7 +1039,7 @@ ${apiFileContent}` await Promise.all([ fs.writeFile( files.workflow, - `import { HmrFuzzBox } from './hmr-fuzz-serde'; + `import { HmrFuzzBox, hmrFuzzSerdeStep } from './hmr-fuzz-serde'; import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; import { hmrFuzzStep } from './hmr-fuzz-step'; import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper'; @@ -1045,7 +1050,8 @@ export async function hmrFuzzWorkflow() { const workflowValue = hmrFuzzWorkflowHelper( new HmrFuzzBox(hmrFuzzSharedHelper('workflow-${iteration}')) ); - return { stepValue, workflowValue }; + const roundTripped = await hmrFuzzSerdeStep(new HmrFuzzBox(workflowValue)); + return { stepValue, workflowValue: roundTripped.label }; } ` ), @@ -1098,6 +1104,11 @@ export async function hmrFuzzStep() { return new HmrFuzzBox(value.label); } } + +export async function hmrFuzzSerdeStep(value: HmrFuzzBox) { + 'use step'; + return value; +} ` ), fs.writeFile( @@ -1153,14 +1164,16 @@ ${apiFileContent}` >(workflow, []); return await run.returnValue; }; + type ExpectedWorkflowResult = + | { kind: 'step'; value: string } + | { kind: 'workflow'; value: string } + | { kind: 'both'; stepValue: string; workflowValue: string }; const expectWorkflowResult = async ({ description, - stepValue, - workflowValue, + expected, }: { description: string; - stepValue?: string; - workflowValue?: string; + expected: ExpectedWorkflowResult; }) => { await pollUntil({ description, @@ -1168,11 +1181,22 @@ ${apiFileContent}` intervalMs: 500, check: async () => { const result = await runWorkflow(); - if (stepValue) { - expect(result.stepValue).toContain(stepValue); - } - if (workflowValue) { - expect(result.workflowValue).toContain(workflowValue); + switch (expected.kind) { + case 'step': + expect(result.stepValue).toContain(expected.value); + return; + case 'workflow': + expect(result.workflowValue).toContain(expected.value); + return; + case 'both': + expect(result.stepValue).toContain(expected.stepValue); + expect(result.workflowValue).toContain( + expected.workflowValue + ); + return; + default: + expected satisfies never; + throw new Error('Unknown workflow result expectation'); } }, }); @@ -1188,7 +1212,11 @@ ${apiFileContent}` file: files.step, kind: 'step', expectedLogCounts: expectedHotRebuild, - expectedStepValue: (iteration: number) => `step-only-${iteration}`, + expectedResult: (iteration: number) => + ({ + kind: 'step', + value: `step-only-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: ( iteration: number ) => `import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; @@ -1204,8 +1232,11 @@ export async function hmrFuzzStep() { file: files.stepHelper, kind: 'workflow', expectedLogCounts: expectedHotRebuild, - expectedStepValue: (iteration: number) => - `step-helper-only-${iteration}`, + expectedResult: (iteration: number) => + ({ + kind: 'step', + value: `step-helper-only-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: ( iteration: number ) => `export function hmrFuzzStepHelper() { @@ -1217,11 +1248,14 @@ export async function hmrFuzzStep() { file: files.workflow, kind: 'workflow', expectedLogCounts: expectedHotRebuild, - expectedWorkflowValue: (iteration: number) => - `workflow-body-${iteration}`, + expectedResult: (iteration: number) => + ({ + kind: 'workflow', + value: `workflow-body-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: ( iteration: number - ) => `import { HmrFuzzBox } from './hmr-fuzz-serde'; + ) => `import { HmrFuzzBox, hmrFuzzSerdeStep } from './hmr-fuzz-serde'; import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; import { hmrFuzzStep } from './hmr-fuzz-step'; import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper'; @@ -1232,7 +1266,8 @@ export async function hmrFuzzWorkflow() { const workflowValue = hmrFuzzWorkflowHelper( new HmrFuzzBox(hmrFuzzSharedHelper('workflow-body-${iteration}')) ); - return { stepValue, workflowValue }; + const roundTripped = await hmrFuzzSerdeStep(new HmrFuzzBox(workflowValue)); + return { stepValue, workflowValue: roundTripped.label }; } `, }, @@ -1240,8 +1275,11 @@ export async function hmrFuzzWorkflow() { file: files.workflowHelper, kind: 'workflow', expectedLogCounts: expectedHotRebuild, - expectedWorkflowValue: (iteration: number) => - `workflow-helper-body-${iteration}`, + expectedResult: (iteration: number) => + ({ + kind: 'workflow', + value: `workflow-helper-body-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: ( iteration: number ) => `import { HmrFuzzBox } from './hmr-fuzz-serde'; @@ -1255,10 +1293,12 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { file: files.sharedHelper, kind: 'workflow', expectedLogCounts: expectedHotRebuild, - expectedStepValue: (iteration: number) => - `shared-body-${iteration}`, - expectedWorkflowValue: (iteration: number) => - `shared-body-${iteration}`, + expectedResult: (iteration: number) => + ({ + kind: 'both', + stepValue: `shared-body-${iteration}`, + workflowValue: `shared-body-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: ( iteration: number ) => `export function hmrFuzzSharedHelper(value: string) { @@ -1270,6 +1310,11 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { file: files.serde, kind: 'serde', expectedLogCounts: expectedHotRebuild, + expectedResult: (iteration: number) => + ({ + kind: 'workflow', + value: `serde-body-${iteration}`, + }) satisfies ExpectedWorkflowResult, source: (iteration: number) => `export class HmrFuzzBox { static classId = 'HmrFuzzBox'; @@ -1283,39 +1328,25 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { return new HmrFuzzBox(value.label); } } + +export async function hmrFuzzSerdeStep(value: HmrFuzzBox) { + 'use step'; + return value; +} `, }, ] as const; - // Next canary has been flaky for transitive workflow-helper execution - // updates; stable still covers that HMR path. - const casesToRun = finalConfig.canary - ? cases.filter((testCase) => testCase.file !== files.workflowHelper) - : cases; - - for (let index = 0; index < casesToRun.length; index++) { + for (let index = 0; index < cases.length; index++) { const iteration = index + 1; - const testCase = casesToRun[index]; + const testCase = cases[index]; const previousSnapshot = snapshot; const logCursor = await readDevServerLogCursor(); await fs.writeFile(testCase.file, testCase.source(iteration)); - // Next canary can keep executing a stale workflow bundle after the - // workflow hot-rebuild completed. Stable still covers execution - // correctness; canary keeps covering classification/log/artifact - // behavior for these changes. - if (!(finalConfig.canary && testCase.kind === 'workflow')) { - await expectWorkflowResult({ - description: `${testCase.kind} HMR update to affect workflow execution`, - stepValue: - 'expectedStepValue' in testCase - ? testCase.expectedStepValue(iteration) - : undefined, - workflowValue: - 'expectedWorkflowValue' in testCase - ? testCase.expectedWorkflowValue(iteration) - : undefined, - }); - } + await expectWorkflowResult({ + description: `${testCase.kind} HMR update to affect workflow execution`, + expected: testCase.expectedResult(iteration), + }); if (testCase.kind === 'skip') { await expectHmrLogCounts(logCursor, testCase.expectedLogCounts); @@ -1324,7 +1355,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { } snapshot = await waitForGeneratedArtifactStability(); - if (testCase.kind === 'workflow') { + if (testCase.kind === 'workflow' || testCase.kind === 'step') { expect(snapshot.stepMtimeMs).toBe(previousSnapshot.stepMtimeMs); } else { expect(snapshot.stepMtimeMs).toBeGreaterThanOrEqual( @@ -1337,7 +1368,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { const expectedFullRediscovery: ExpectedHmrLogCounts = { full: { kind: 'range', min: 1, max: 3 }, }; - const fullCases = [ + const rebuildCases = [ { description: 'workflow import graph change', expectedLogCounts: expectedFullRediscovery, @@ -1345,7 +1376,7 @@ export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) { await fs.writeFile( files.workflow, `import { hmrFuzzImportedValue } from './hmr-fuzz-import-helper'; -import { HmrFuzzBox } from './hmr-fuzz-serde'; +import { HmrFuzzBox, hmrFuzzSerdeStep } from './hmr-fuzz-serde'; import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; import { hmrFuzzStep } from './hmr-fuzz-step'; import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper'; @@ -1356,19 +1387,87 @@ export async function hmrFuzzWorkflow() { const workflowValue = hmrFuzzWorkflowHelper( new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzImportedValue)) ); - return { stepValue, workflowValue }; + const roundTripped = await hmrFuzzSerdeStep(new HmrFuzzBox(workflowValue)); + return { stepValue, workflowValue: roundTripped.label }; } ` ); }, assert: async () => { - if (finalConfig.canary) { - return; - } await expectWorkflowResult({ description: 'workflow import graph full rediscovery to affect execution', - workflowValue: 'imported-stable', + expected: { kind: 'workflow', value: 'imported-stable' }, + }); + }, + }, + { + description: 'new workflow dependency body change', + expectedLogCounts: expectedHotRebuild, + write: async () => { + await fs.writeFile( + files.importHelper, + "export const hmrFuzzImportedValue = 'imported-updated';\n" + ); + }, + assert: async () => { + await expectWorkflowResult({ + description: + 'new workflow dependency body change to affect execution', + expected: { kind: 'workflow', value: 'imported-updated' }, + }); + }, + }, + { + description: 'non-source workflow dependency added', + expectedLogCounts: expectedFullRediscovery, + write: async () => { + await fs.writeFile( + files.buildInput, + JSON.stringify({ value: 'json-stable' }) + ); + await fs.writeFile( + files.workflow, + `import hmrFuzzBuildInput from './hmr-fuzz-build-input.json'; +import { HmrFuzzBox, hmrFuzzSerdeStep } from './hmr-fuzz-serde'; +import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; +import { hmrFuzzStep } from './hmr-fuzz-step'; +import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper'; + +export async function hmrFuzzWorkflow() { + 'use workflow'; + const stepValue = await hmrFuzzStep(); + const workflowValue = hmrFuzzWorkflowHelper( + new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzBuildInput.value)) + ); + const roundTripped = await hmrFuzzSerdeStep(new HmrFuzzBox(workflowValue)); + return { stepValue, workflowValue: roundTripped.label }; +} +` + ); + }, + assert: async () => { + await expectWorkflowResult({ + description: + 'non-source dependency rediscovery to affect execution', + expected: { kind: 'workflow', value: 'json-stable' }, + }); + }, + }, + { + description: 'non-source workflow dependency body change', + expectedLogCounts: expectedFullRediscovery, + write: async () => { + await fs.writeFile( + files.buildInput, + JSON.stringify({ value: 'json-updated' }) + ); + }, + assert: async () => { + await expectWorkflowResult({ + description: + 'non-source dependency body change to affect execution', + expected: { kind: 'workflow', value: 'json-updated' }, }); }, }, @@ -1413,8 +1512,8 @@ export async function hmrFuzzAddedStep() { write: async (iteration: number) => { await fs.writeFile( files.workflow, - `import { hmrFuzzImportedValue } from './hmr-fuzz-import-helper'; -import { HmrFuzzBox } from './hmr-fuzz-serde'; + `import hmrFuzzBuildInput from './hmr-fuzz-build-input.json'; +import { HmrFuzzBox, hmrFuzzSerdeStep } from './hmr-fuzz-serde'; import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper'; import { hmrFuzzStep } from './hmr-fuzz-step'; import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper'; @@ -1423,9 +1522,10 @@ export async function hmrFuzzWorkflow() { 'use workflow'; const stepValue = await hmrFuzzStep(); const workflowValue = hmrFuzzWorkflowHelper( - new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzImportedValue)) + new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzBuildInput.value)) ); - return { stepValue, workflowValue }; + const roundTripped = await hmrFuzzSerdeStep(new HmrFuzzBox(workflowValue)); + return { stepValue, workflowValue: roundTripped.label }; } export async function hmrFuzzAddedWorkflow() { @@ -1514,12 +1614,12 @@ ${apiFileContent}` }, ] as const; - for (let index = 0; index < fullCases.length; index++) { - const fullCase = fullCases[index]; + for (let index = 0; index < rebuildCases.length; index++) { + const rebuildCase = rebuildCases[index]; const logCursor = await readDevServerLogCursor(); - await fullCase.write(index + 1); - await fullCase.assert(index + 1); - await expectHmrLogCounts(logCursor, fullCase.expectedLogCounts); + await rebuildCase.write(index + 1); + await rebuildCase.assert(); + await expectHmrLogCounts(logCursor, rebuildCase.expectedLogCounts); snapshot = await waitForGeneratedArtifactStability(); } diff --git a/packages/core/e2e/route-bundle-isolation.test.ts b/packages/core/e2e/route-bundle-isolation.test.ts index 882429d39b..40147d7548 100644 --- a/packages/core/e2e/route-bundle-isolation.test.ts +++ b/packages/core/e2e/route-bundle-isolation.test.ts @@ -98,7 +98,8 @@ describe('route bundle isolation (o2flow hook.resume regression)', () => { // module.exports may resolve asynchronously (Turbopack async modules) Promise.resolve(m) .then(async (mod) => { - const POST = mod.routeModule?.userland?.POST; + await mod.routeModule.ensureUserland(); + const POST = mod.routeModule.userland.POST; if (typeof POST !== 'function') { report({ harnessError: 'route bundle did not expose routeModule.userland.POST',