mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
https://github.com/vercel/next.js/pull/94197 had a bug in the Turbopack case, where it didn't properly trace all necessary files referenced from `cacheHandler` and `cacheHandlers`. If you imported `lodash` in the cache handler, then - `node_modules/.pnpm/[email protected].1/node_modules/lodash/lodash.js` was correctly traced but missing were - `node_modules/.pnpm/[email protected].1/node_modules/lodash/package.json` and - `node_modules/lodash` (the symlink) The problem was that it wasn't including affecting sources of these entrypoints, because it was treating those entrypoints as bundled: ``` SingleModuleGraph::new_inner( entries: Vec<ChunkGroupEntry>, visited_modules: &FxIndexMap<ResolvedVc<Box<dyn Module>>, GraphNodeIndex>, include_traced: bool, include_binding_usage: bool, entries_are_traced: bool, // <------ problematic ) -> Result<Vc<Self>> ``` should really have been ``` SingleModuleGraph::new_inner( entries: &GraphEntries, // contains Vec<ChunkGroupEntry> and traced_modules: Vec<Modules visited_modules: &FxIndexMap<ResolvedVc<Box<dyn Module>>, GraphNodeIndex>, include_traced: bool, include_binding_usage: bool, ) -> Result<Vc<Self>> { ``` because the single `entries_are_traced: bool` can be different per entry.
436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
import glob from 'glob'
|
|
import fs from 'fs-extra'
|
|
import { join } from 'path'
|
|
import cheerio from 'cheerio'
|
|
import { FileRef, nextTestSetup } from 'e2e-utils'
|
|
import {
|
|
createNowRouteMatches,
|
|
fetchViaHTTP,
|
|
findPort,
|
|
initNextServerScript,
|
|
killApp,
|
|
withInvocationId,
|
|
} from 'next-test-utils'
|
|
import { ChildProcess } from 'child_process'
|
|
|
|
describe('required server files app router', () => {
|
|
const { next } = nextTestSetup({
|
|
files: {
|
|
app: new FileRef(join(__dirname, 'app')),
|
|
lib: new FileRef(join(__dirname, 'lib')),
|
|
'cache-handler.js': new FileRef(join(__dirname, 'cache-handler.js')),
|
|
'middleware.js': new FileRef(join(__dirname, 'middleware.js')),
|
|
'data.txt': new FileRef(join(__dirname, 'data.txt')),
|
|
'.env': new FileRef(join(__dirname, '.env')),
|
|
'.env.local': new FileRef(join(__dirname, '.env.local')),
|
|
'.env.production': new FileRef(join(__dirname, '.env.production')),
|
|
},
|
|
nextConfig: {
|
|
cacheHandler: './cache-handler.js',
|
|
cacheMaxMemorySize: 0,
|
|
output: 'standalone',
|
|
},
|
|
dependencies: {
|
|
'is-even': '1.0.0',
|
|
},
|
|
skipStart: true,
|
|
})
|
|
|
|
let server: ChildProcess
|
|
let appPort: number | string
|
|
|
|
beforeAll(async () => {
|
|
// test build against environment with next support
|
|
process.env.NOW_BUILDER = '1'
|
|
process.env.NEXT_PRIVATE_TEST_HEADERS = '1'
|
|
|
|
const { exitCode } = await next.build()
|
|
if (exitCode !== 0) {
|
|
throw new Error(`Failed to build next: ${exitCode}`)
|
|
}
|
|
|
|
await fs.move(
|
|
join(next.testDir, '.next/standalone'),
|
|
join(next.testDir, 'standalone')
|
|
)
|
|
for (const file of await fs.readdir(next.testDir)) {
|
|
if (file !== 'standalone') {
|
|
await fs.remove(join(next.testDir, file))
|
|
console.log('removed', file)
|
|
}
|
|
}
|
|
const files = glob.sync('**/*', {
|
|
cwd: join(next.testDir, 'standalone/.next/server/pages'),
|
|
dot: true,
|
|
})
|
|
|
|
for (const file of files) {
|
|
if (file.endsWith('.json') || file.endsWith('.html')) {
|
|
await fs.remove(join(next.testDir, '.next/server', file))
|
|
}
|
|
}
|
|
|
|
const minimalMode = true
|
|
const testServer = join(next.testDir, 'standalone/server.js')
|
|
await fs.writeFile(
|
|
testServer,
|
|
(await fs.readFile(testServer, 'utf8')).replace(
|
|
'port:',
|
|
`minimalMode: ${minimalMode},port:`
|
|
)
|
|
)
|
|
appPort = await findPort()
|
|
server = await initNextServerScript(
|
|
testServer,
|
|
/- Local:/,
|
|
{
|
|
...process.env,
|
|
...next.env,
|
|
PORT: `${appPort}`,
|
|
},
|
|
undefined,
|
|
{
|
|
cwd: next.testDir,
|
|
}
|
|
)
|
|
})
|
|
afterAll(async () => {
|
|
delete process.env.NOW_BUILDER
|
|
delete process.env.NEXT_PRIVATE_TEST_HEADERS
|
|
if (server) await killApp(server)
|
|
})
|
|
|
|
it('should send the right cache headers for an app route', async () => {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
'/api/test/123',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'x-matched-path': '/api/test/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: '123',
|
|
}).toString(),
|
|
},
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('cache-control')).toBe('s-maxage=31536000')
|
|
})
|
|
|
|
it('should handle optional catchall', async () => {
|
|
let res = await fetchViaHTTP(
|
|
appPort,
|
|
'/optional-catchall/[lang]/[flags]/[[...slug]]',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'x-matched-path': '/optional-catchall/[lang]/[flags]/[[...slug]]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
lang: 'en',
|
|
flags: 'flags',
|
|
slug: 'slug',
|
|
}).toString(),
|
|
},
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
let html = await res.text()
|
|
let $ = cheerio.load(html)
|
|
expect($('body [data-lang]').text()).toBe('en')
|
|
expect($('body [data-slug]').text()).toBe('slug')
|
|
|
|
res = await fetchViaHTTP(
|
|
appPort,
|
|
'/optional-catchall/[lang]/[flags]/[[...slug]]',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'x-matched-path': '/optional-catchall/[lang]/[flags]/[[...slug]]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
lang: 'en',
|
|
flags: 'flags',
|
|
}).toString(),
|
|
},
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
html = await res.text()
|
|
$ = cheerio.load(html)
|
|
expect($('body [data-lang]').text()).toBe('en')
|
|
expect($('body [data-flags]').text()).toBe('flags')
|
|
expect($('body [data-slug]').text()).toBe('')
|
|
})
|
|
|
|
it('should send the right cache headers for an app page', async () => {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
'/test/123',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'x-matched-path': '/test/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: '123',
|
|
}).toString(),
|
|
},
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('cache-control')).toBe(
|
|
's-maxage=3600, stale-while-revalidate=31532400'
|
|
)
|
|
})
|
|
|
|
it('should not fail caching', async () => {
|
|
expect(next.cliOutput).not.toContain('ERR_INVALID_URL')
|
|
})
|
|
|
|
it('should properly handle prerender for bot request', async () => {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
'/isr/first',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'user-agent':
|
|
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
|
'x-matched-path': '/isr/first',
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
const html = await res.text()
|
|
const $ = cheerio.load(html)
|
|
|
|
expect($('#page').text()).toBe('/isr/[slug]')
|
|
|
|
const rscRes = await fetchViaHTTP(
|
|
appPort,
|
|
'/isr/first.rsc',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'user-agent':
|
|
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
|
'x-matched-path': '/isr/first',
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(rscRes.status).toBe(200)
|
|
})
|
|
|
|
it('should properly handle fallback for bot request', async () => {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
'/isr/[slug]',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'user-agent':
|
|
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'new',
|
|
}).toString(),
|
|
'x-matched-path': '/isr/[slug]',
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
const html = await res.text()
|
|
const $ = cheerio.load(html)
|
|
|
|
expect($('#page').text()).toBe('/isr/[slug]')
|
|
|
|
const rscRes = await fetchViaHTTP(
|
|
appPort,
|
|
'/isr/[slug].rsc',
|
|
undefined,
|
|
withInvocationId({
|
|
headers: {
|
|
'user-agent':
|
|
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'new',
|
|
}).toString(),
|
|
'x-matched-path': '/isr/[slug]',
|
|
},
|
|
})
|
|
)
|
|
|
|
expect(rscRes.status).toBe(200)
|
|
})
|
|
|
|
it('should send cache tags in minimal mode for ISR', async () => {
|
|
for (const [path, tags] of [
|
|
[
|
|
'/isr/first',
|
|
'_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/first,isr-page',
|
|
],
|
|
[
|
|
'/isr/second',
|
|
'_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/second,isr-page',
|
|
],
|
|
[
|
|
'/api/isr/first',
|
|
'_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/first,isr-page',
|
|
],
|
|
[
|
|
'/api/isr/second',
|
|
'_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/second,isr-page',
|
|
],
|
|
]) {
|
|
require('console').error('checking', { path, tags })
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
path,
|
|
undefined,
|
|
withInvocationId({
|
|
redirect: 'manual',
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('x-next-cache-tags')).toBe(tags)
|
|
}
|
|
})
|
|
|
|
it('should not send cache tags in minimal mode for SSR', async () => {
|
|
for (const path of [
|
|
'/ssr/first',
|
|
'/ssr/second',
|
|
'/api/ssr/first',
|
|
'/api/ssr/second',
|
|
]) {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
path,
|
|
undefined,
|
|
withInvocationId({
|
|
redirect: 'manual',
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('x-next-cache-tags')).toBeFalsy()
|
|
}
|
|
})
|
|
|
|
it('should not send invalid soft tags to cache handler', async () => {
|
|
for (const path of [
|
|
'/ssr/first',
|
|
'/ssr/second',
|
|
'/api/ssr/first',
|
|
'/api/ssr/second',
|
|
]) {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
path,
|
|
{ hello: 'world' },
|
|
withInvocationId({
|
|
redirect: 'manual',
|
|
})
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('x-next-cache-tags')).toBeFalsy()
|
|
}
|
|
})
|
|
|
|
it('should not override params with query params', async () => {
|
|
const res = await fetchViaHTTP(
|
|
appPort,
|
|
'/search/[key]',
|
|
{ key: 'searchParams', nxtPkey: 'params' },
|
|
withInvocationId({
|
|
headers: {
|
|
'x-matched-path': '/search/[key]',
|
|
},
|
|
})
|
|
)
|
|
|
|
const html = await res.text()
|
|
const $ = cheerio.load(html)
|
|
expect($('dd[data-params]').text()).toBe('params')
|
|
expect($('dd[data-searchParams]').text()).toBe('searchParams')
|
|
})
|
|
|
|
it('should de-dupe HTML/RSC requests for ISR pages', async () => {
|
|
// Create a shared invocation ID for HTML and RSC requests to test de-duplication
|
|
const sharedOpts = withInvocationId()
|
|
|
|
// First request: HTML for ISR page
|
|
const htmlRes = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, {
|
|
...sharedOpts,
|
|
headers: {
|
|
...sharedOpts.headers,
|
|
'x-matched-path': '/isr/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'first',
|
|
}).toString(),
|
|
},
|
|
})
|
|
expect(htmlRes.status).toBe(200)
|
|
const html = await htmlRes.text()
|
|
const $ = cheerio.load(html)
|
|
const timestamp1 = $('#now').text()
|
|
|
|
// Second request: RSC for same page with same x-invocation-id
|
|
const rscRes = await fetchViaHTTP(appPort, '/isr/[slug].rsc', undefined, {
|
|
...sharedOpts,
|
|
headers: {
|
|
...sharedOpts.headers,
|
|
'x-matched-path': '/isr/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'first',
|
|
}).toString(),
|
|
},
|
|
})
|
|
expect(rscRes.status).toBe(200)
|
|
const rscText = await rscRes.text()
|
|
|
|
// Both should have the same timestamp (same cached render)
|
|
expect(rscText).toContain(timestamp1)
|
|
})
|
|
|
|
it('should isolate cache between different ISR request groups', async () => {
|
|
// First group makes a request with its own invocation ID
|
|
const group1Opts = withInvocationId()
|
|
const res1 = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, {
|
|
...group1Opts,
|
|
headers: {
|
|
...group1Opts.headers,
|
|
'x-matched-path': '/isr/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'first',
|
|
}).toString(),
|
|
},
|
|
})
|
|
expect(res1.status).toBe(200)
|
|
const $1 = cheerio.load(await res1.text())
|
|
const data1 = $1('#data').text()
|
|
|
|
// Second group with different x-invocation-id
|
|
const group2Opts = withInvocationId()
|
|
const res2 = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, {
|
|
...group2Opts,
|
|
headers: {
|
|
...group2Opts.headers,
|
|
'x-matched-path': '/isr/[slug]',
|
|
'x-now-route-matches': createNowRouteMatches({
|
|
slug: 'first',
|
|
}).toString(),
|
|
},
|
|
})
|
|
expect(res2.status).toBe(200)
|
|
const $2 = cheerio.load(await res2.text())
|
|
const data2 = $2('#data').text()
|
|
|
|
// Each group should get its own render with different random data
|
|
// since ISR pages fetch fresh data on each render
|
|
expect(data1).not.toBe(data2)
|
|
})
|
|
})
|