Skip to content

Commit d293833

Browse files
piehmrstork
andauthored
fix: compatability for next@16.1.0-canary.19 (#3300)
Co-authored-by: Mateusz Bocian <mrstork@users.noreply.github.com>
1 parent 35891f5 commit d293833

File tree

8 files changed

+119
-54
lines changed

8 files changed

+119
-54
lines changed

.github/workflows/run-tests.yml

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
runs-on: ubuntu-latest
1717
outputs:
1818
matrix: ${{ steps.set-matrix.outputs.matrix }}
19+
os: ${{ steps.set-matrix.outputs.os }}
1920
steps:
2021
- name: Check PR labels
2122
if: github.event_name == 'pull_request'
@@ -28,25 +29,42 @@ jobs:
2829
repo: context.repo.repo,
2930
issue_number: context.payload.pull_request.number,
3031
});
31-
const shouldTestAllVersions = labels.some(label => label.name === 'autorelease: pending' || label.name === 'test all versions');
32-
if (shouldTestAllVersions) {
33-
return 'all'
32+
33+
const runOnWindows = labels.some(label => label.name === 'test on windows');
34+
35+
let versionsToTest = 'latest';
36+
if (labels.some(label => label.name === 'autorelease: pending' || label.name === 'test all versions')) {
37+
versionsToTest = 'all';
38+
} else if (labels.some(label => label.name === 'test latest and canary')) {
39+
versionsToTest = 'latest-and-canary';
3440
}
3541
36-
return labels.some(label => label.name === 'test latest and canary') ? 'latest-and-canary' : 'latest'
42+
return {
43+
versionsToTest,
44+
runOnWindows
45+
}
3746
- name: Set Next.js versions to test
3847
id: set-matrix
39-
# If this is the nightly build or a release PR then run the full matrix of versions
40-
run: |
41-
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
42-
echo "matrix=${{ github.event.inputs.versions }}" >> $GITHUB_OUTPUT
43-
elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "all" ]; then
44-
echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT
45-
elif [ "${{ steps.check-labels.outputs.result }}" = "latest-and-canary" ]; then
46-
echo "matrix=[\"latest\", \"canary\"]" >> $GITHUB_OUTPUT
47-
else
48-
echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT
49-
fi
48+
uses: actions/github-script@v8
49+
with:
50+
script: |
51+
const { versionsToTest, runOnWindows } = ${{ steps.check-labels.outputs.result }} ?? {}
52+
53+
if ('${{ github.event_name }}' === 'workflow_dispatch') {
54+
core.setOutput('matrix', '${{ github.event.inputs.versions }}');
55+
} else if ('${{ github.event_name }}' === 'schedule' || versionsToTest === 'all') {
56+
core.setOutput('matrix', '["latest", "canary", "15.5.9", "14.2.35", "13.5.1"]');
57+
} else if (versionsToTest === 'latest-and-canary') {
58+
core.setOutput('matrix', '["latest", "canary"]');
59+
} else {
60+
core.setOutput('matrix', '["latest"]');
61+
}
62+
63+
if (runOnWindows) {
64+
core.setOutput('os', '["ubuntu-latest", "windows-2025"]');
65+
} else {
66+
core.setOutput('os', '["ubuntu-latest"]');
67+
}
5068
5169
e2e:
5270
needs: setup
@@ -122,7 +140,7 @@ jobs:
122140
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
123141
NEXT_VERSION: ${{ matrix.version }}
124142
NEXT_RESOLVED_VERSION: ${{ steps.resolve-next-version.outputs.version }}
125-
NODE_OPTIONS: --import ${{ github.workspace }}/tools/fetch-retry.mjs
143+
NODE_OPTIONS: --import file://${{ github.workspace }}/tools/fetch-retry.mjs
126144
- name: Upload blob report to GitHub Actions Artifacts
127145
uses: actions/upload-artifact@v5
128146
if: always()
@@ -137,13 +155,16 @@ jobs:
137155
fail-fast: false
138156
matrix:
139157
shard: [1, 2, 3, 4, 5, 6, 7, 8]
140-
os: [ubuntu-latest]
158+
os: ${{ fromJson(needs.setup.outputs.os) }}
141159
version: ${{ fromJson(needs.setup.outputs.matrix) }}
142160
exclude:
143161
- os: windows-2025
144162
version: "13.5.1"
145163
- os: windows-2025
146-
version: "14.2.15"
164+
version: "14.2.35"
165+
- os: windows-2025
166+
version: "15.5.9"
167+
147168
runs-on: ${{ matrix.os }}
148169
steps:
149170
- uses: actions/checkout@v6
@@ -171,17 +192,8 @@ jobs:
171192
node-version: ${{ steps.decide-node-version.outputs.version }}
172193
cache: "npm"
173194
cache-dependency-path: "**/package-lock.json"
174-
- name: Prefer npm global on windows
175-
if: runner.os == 'Windows'
176-
# On Windows by default PATH prefers corepack bundled with Node.js
177-
# This prepends npm global to PATH to ensure that npm installed global corepack is used instead
178-
run: |
179-
echo "$(npm config get prefix)" >> "$GITHUB_PATH"
180-
shell: bash
181195
- name: setup pnpm/yarn
182196
run: |
183-
# global corepack installation requires --force on Windows, otherwise EEXIST errors occur
184-
npm install -g corepack --force
185197
corepack enable
186198
shell: bash
187199
- name: Install Deno
@@ -217,15 +229,15 @@ jobs:
217229
env:
218230
NEXT_VERSION: ${{ matrix.version }}
219231
NEXT_RESOLVED_VERSION: ${{ steps.resolve-next-version.outputs.version }}
220-
NODE_OPTIONS: --import ${{ github.workspace }}/tools/fetch-retry.mjs
232+
NODE_OPTIONS: --import file://${{ github.workspace }}/tools/fetch-retry.mjs
221233
- name: "Unit and integration tests"
222234
run: npm run test:ci:unit-and-integration -- --shard=${{ matrix.shard }}/8
223235
env:
224236
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
225237
NEXT_VERSION: ${{ matrix.version }}
226238
NEXT_RESOLVED_VERSION: ${{ steps.resolve-next-version.outputs.version }}
227239
TEMP: ${{ github.workspace }}/..
228-
NODE_OPTIONS: --import ${{ github.workspace }}/tools/fetch-retry.mjs
240+
NODE_OPTIONS: --import file://${{ github.workspace }}/tools/fetch-retry.mjs
229241

230242
smoke:
231243
if: always()
@@ -286,7 +298,7 @@ jobs:
286298
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
287299
NEXT_VERSION: ${{ matrix.version }}
288300
NEXT_RESOLVED_VERSION: ${{ steps.resolve-next-version.outputs.version }}
289-
NODE_OPTIONS: --import ${{ github.workspace }}/tools/fetch-retry.mjs
301+
NODE_OPTIONS: --import file://${{ github.workspace }}/tools/fetch-retry.mjs
290302

291303
merge-reports:
292304
if: always()

src/build/content/server.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
writeFile,
1111
} from 'node:fs/promises'
1212
import { createRequire } from 'node:module'
13-
import { dirname, join, relative, sep } from 'node:path'
14-
import { join as posixJoin, sep as posixSep } from 'node:path/posix'
13+
import { dirname, join, sep } from 'node:path'
14+
import { join as posixJoin, relative as posixRelative, sep as posixSep } from 'node:path/posix'
1515

1616
import { trace } from '@opentelemetry/api'
1717
import { wrapTracer } from '@opentelemetry/api/experimental'
@@ -26,7 +26,11 @@ import type { PluginContext, RequiredServerFilesManifest } from '../plugin-conte
2626

2727
const tracer = wrapTracer(trace.getTracer('Next runtime'))
2828

29-
const toPosixPath = (path: string) => path.split(sep).join(posixSep)
29+
const toPosixPath = (path: string) =>
30+
path
31+
.replace(/^\\+\?\\+/, '') // https://github.com/nodejs/node/blob/81e05e124f71b3050cd4e60c95017af975568413/lib/internal/fs/utils.js#L370-L372
32+
.split(sep)
33+
.join(posixSep)
3034

3135
function isError(error: unknown): error is NodeJS.ErrnoException {
3236
return error instanceof Error
@@ -309,17 +313,21 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise<void> =>
309313
await tracer.withActiveSpan('copyNextDependencies', async () => {
310314
const promises: Promise<void>[] = []
311315

312-
const nodeModulesLocationsInStandalone = new Set<string>()
316+
const nodeModulesLocations = new Set<{ source: string; destination: string }>()
313317
const commonFilter = ctx.constants.IS_LOCAL ? undefined : nodeModulesFilter
314318

315-
const dotNextDir = join(ctx.standaloneDir, ctx.nextDistDir)
319+
const dotNextDir = toPosixPath(join(ctx.standaloneDir, ctx.nextDistDir))
320+
321+
const standaloneRootDir = toPosixPath(ctx.standaloneRootDir)
322+
const outputFileTracingRoot = toPosixPath(ctx.outputFileTracingRoot)
316323

317324
await cp(ctx.standaloneRootDir, ctx.serverHandlerRootDir, {
318325
recursive: true,
319326
verbatimSymlinks: true,
320327
force: true,
321-
filter: async (sourcePath: string) => {
322-
if (sourcePath === dotNextDir) {
328+
filter: async (sourcePath: string, destination: string) => {
329+
const posixSourcePath = toPosixPath(sourcePath)
330+
if (posixSourcePath === dotNextDir) {
323331
// copy all except the distDir (.next) folder as this is handled in a separate function
324332
// this will include the node_modules folder as well
325333
return false
@@ -328,18 +336,23 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise<void> =>
328336
if (sourcePath.endsWith('node_modules')) {
329337
// keep track of node_modules as we might need to recreate symlinks
330338
// we are still copying them
331-
nodeModulesLocationsInStandalone.add(sourcePath)
339+
nodeModulesLocations.add({
340+
source: posixSourcePath,
341+
destination: toPosixPath(destination),
342+
})
332343
}
333344

334345
// finally apply common filter if defined
335346
return commonFilter?.(sourcePath) ?? true
336347
},
337348
})
338349

339-
for (const nodeModulesLocationInStandalone of nodeModulesLocationsInStandalone) {
340-
const relativeToRoot = relative(ctx.standaloneRootDir, nodeModulesLocationInStandalone)
341-
const locationInProject = join(ctx.outputFileTracingRoot, relativeToRoot)
342-
const locationInServerHandler = join(ctx.serverHandlerRootDir, relativeToRoot)
350+
for (const {
351+
source: nodeModulesLocationInStandalone,
352+
destination: locationInServerHandler,
353+
} of nodeModulesLocations) {
354+
const relativeToRoot = posixRelative(standaloneRootDir, nodeModulesLocationInStandalone)
355+
const locationInProject = posixJoin(outputFileTracingRoot, relativeToRoot)
343356

344357
promises.push(recreateNodeModuleSymlinks(locationInProject, locationInServerHandler))
345358
}

src/build/plugin-context.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, readFileSync } from 'node:fs'
22
import { readFile } from 'node:fs/promises'
33
import { createRequire } from 'node:module'
4-
import { join, relative, resolve } from 'node:path'
4+
import { join, relative, resolve, sep } from 'node:path'
55
import { join as posixJoin, relative as posixRelative } from 'node:path/posix'
66
import { fileURLToPath } from 'node:url'
77

@@ -88,11 +88,39 @@ export class PluginContext {
8888
* The root directory for output file tracing. Paths inside standalone directory preserve paths of project, relative to this directory.
8989
*/
9090
get outputFileTracingRoot(): string {
91-
return (
91+
// Up until https://github.com/vercel/next.js/pull/86812 we had direct access to computed value of it with following
92+
const outputFileTracingRootFromRequiredServerFiles =
9293
this.requiredServerFiles.config.outputFileTracingRoot ??
9394
// fallback for older Next.js versions that don't have outputFileTracingRoot in the config, but had it in config.experimental
9495
this.requiredServerFiles.config.experimental.outputFileTracingRoot
95-
)
96+
if (outputFileTracingRootFromRequiredServerFiles) {
97+
return outputFileTracingRootFromRequiredServerFiles
98+
}
99+
100+
if (!this.relativeAppDir.includes('..')) {
101+
// For newer Next.js versions outputFileTracingRoot is not written to the output directly anymore, but we can use appDir and relativeAppDir to compute it.
102+
// This assumes that relative app dir will never contain '..' segments. Some monorepos support workspaces outside of the monorepo root (verified with pnpm)
103+
// However Next.js itself have some limits on it:
104+
// - turbopack by default would throw "Module not found: Can't resolve '<name_of_package_outside_of_root>'"
105+
// forcing user to manually set `outputFileTracingRoot` in next.config which will impact `appDir` and `relativeAppDir` preserving the lack of '..' in `relativeAppDir`
106+
// - webpack case depends on wether dependency is marked as external or not:
107+
// - if it's marked as external then standalone while working locally, it would never work when someone tries to deploy it (and not just on Netlify, but also in fully self-hosted scenarios)
108+
// because parts of application would be outside of "standalone" directory
109+
// - if it's not marked as external it will be included in next.js produced chunks
110+
111+
const depth = this.relativeAppDir === '' ? 0 : this.relativeAppDir.split(sep).length
112+
113+
const computedOutputFileTracingRoot = resolve(
114+
this.requiredServerFiles.appDir,
115+
...Array.from<string>({ length: depth }).fill('..'),
116+
)
117+
return computedOutputFileTracingRoot
118+
}
119+
120+
// if relativeAppDir contains '..', we can't actually figure out the outputFileTracingRoot
121+
// so best fallback is to just cwd() which won't work in wild edge cases, but there is no way of getting anything better
122+
// if it's not correct it will cause build failures later when assembling a server handler function
123+
return process.cwd()
96124
}
97125

98126
/**

tests/e2e/middleware.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,5 +660,5 @@ test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr
660660
expect(response.headers.get('x-test-used-middleware')).toBe('true')
661661

662662
// ensure we are testing version before the fix for self hosted
663-
expect(response.headers.get('x-test-used-next-version')).toBe('15.1.9')
663+
expect(response.headers.get('x-test-used-next-version')).toBe('15.1.11')
664664
})

tests/fixtures/after/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
"test": {
1616
"dependencies": {
17-
"next": ">=15.1.9"
17+
"next": "15.1.11"
1818
}
1919
}
2020
}

tests/fixtures/middleware-subrequest-vuln/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
"build": "next build"
99
},
1010
"dependencies": {
11-
"next": "15.1.9",
11+
"next": "15.1.11",
1212
"react": "18.2.0",
1313
"react-dom": "18.2.0"
1414
},
1515
"test": {
1616
"dependencies": {
17-
"next": "15.1.9"
17+
"next": "15.1.11"
1818
}
1919
}
2020
}

tests/utils/fixture.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ export const createFixture = async (fixture: string, ctx: FixtureTestContext) =>
106106
delete globalThis[Symbol.for('next-patch')]
107107
}
108108

109+
// due to changes in https://github.com/vercel/next.js/pull/86591 , this global is specific to instance of application and we to clean it up
110+
// from any previous function invocations that might have run in the same process
111+
delete globalThis[Symbol.for('next.server.manifests')]
112+
109113
ctx.cwd = await mkdtemp(join(tmpdir(), 'opennextjs-netlify-'))
110114
vi.spyOn(process, 'cwd').mockReturnValue(ctx.cwd)
111115

tests/utils/next-version-helpers.mjs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { readFile, writeFile } from 'node:fs/promises'
44

55
import fg from 'fast-glob'
6-
import { coerce, gt, gte, satisfies, valid } from 'semver'
6+
import { coerce, gt, gte, parse as parseSemver, satisfies, valid } from 'semver'
77
import { execaCommand } from 'execa'
88

99
const FUTURE_NEXT_PATCH_VERSION = '16.999.0'
@@ -193,7 +193,7 @@ export async function setNextVersionInFixture(
193193
const nextPeerDependencies = JSON.parse(stdout)
194194

195195
if (updateReact && nextVersionRequiresReact19(checkVersion)) {
196-
// canaries started reporting peerDependencies as `^18.2.0 || 19.0.0-rc-<hash>-<date>`
196+
// canaries started reporting peerDependencies as `^18.2.0 || 19.0.0-rc-<hash>-<date> || ^19.0.0`
197197
// with https://github.com/vercel/next.js/pull/70219 which is valid range for package managers
198198
// but not for @nx/next which checks dependencies and tries to assure that at least React 18 is used
199199
// but the check doesn't handle the alternative in version selector which thinks it's not valid:
@@ -205,14 +205,22 @@ export async function setNextVersionInFixture(
205205
.split('||')
206206
.map((alternative) => {
207207
const selector = alternative.trim()
208-
const coerced = coerce(selector)?.format()
208+
// we need to pick the highest version from alternatives and to handle
209+
// comparison of both range selectors (^) and pinned prerelease version (-rc-<hash>-<date>)
210+
// we need to use couple of tricks:
211+
// 1. we do try to parse semver - this only works for pinned versions and will handle prereleases, it will return null for ranges
212+
// 2. if parsing returns null, we coerce
213+
// this will allow us to preserve prerelease identifiers for comparisons (as coercing prerelease version strip those)
214+
215+
const versionToCompare = (parseSemver(selector) ?? coerce(selector))?.format()
216+
209217
return {
210218
selector,
211-
coerced,
219+
versionToCompare,
212220
}
213221
})
214222
.sort((a, b) => {
215-
return gt(a.coerced, b.coerced) ? -1 : 1
223+
return gt(a.versionToCompare, b.versionToCompare) ? -1 : 1
216224
})[0].selector
217225

218226
const reactVersion =

0 commit comments

Comments
 (0)