mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
7b58e5880c
Full motivation and plan here: https://app.notion.com/p/vercel/Turbopack-pnpm-Global-Virtual-Store-383e06b059c480579403ddfd71cc2d40?source=copy_link The goal is to allow `DiskFileSystem` to traverse outside of it's own root to other configured `DiskFileSystem`s when following symlinks. We may allow traversal in other situations in the future, but this is limited to symlink resolution for now. ## Global Virtual Store The motivation for this is to enable [pnpm's Global Virtual Store feature](https://pnpm.io/global-virtual-store) (and there are other package managers doing this, including nub and bun). We'd expose the ability to manually configure this in `next.config.js`, but we should also auto-configure ourselves for popular package managers (or at least make a best effort to do so, the `PNPM_HOME` semantics can be complicated). The `ignoreIfMissing` option is provided for this situation: We can configure a bunch of roots automatically, and they only actually get set up if they exist, the check for directory existence is cheap. ## NFT changes This requires a couple extensions to the `*.nft.json` file format: https://github.com/vercel/next.js/pull/98469 ## Related Issues - #93556 - https://github.com/pnpm/pnpm/issues/14972
798 lines
25 KiB
TypeScript
798 lines
25 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
|
|
import { createDefineEnv, loadBindings } from 'next/dist/build/swc'
|
|
import type {
|
|
Issue,
|
|
MemoryEvictionMode,
|
|
Project,
|
|
RawEntrypoints,
|
|
StyledString,
|
|
TurbopackResult,
|
|
UpdateInfo,
|
|
} from 'next/dist/build/swc/types'
|
|
import loadConfig from 'next/dist/server/config'
|
|
import path, { join } from 'path'
|
|
import { spawnSync } from 'child_process'
|
|
|
|
function normalizePath(path: string) {
|
|
return path
|
|
.replace(/\[project\].+\/node_modules\//g, '[project]/.../node_modules/')
|
|
.replace(
|
|
/\[project\]\/packages\/next\//g,
|
|
'[project]/.../node_modules/next/'
|
|
)
|
|
}
|
|
|
|
function styledStringToMarkdown(styled: StyledString): string {
|
|
switch (styled.type) {
|
|
case 'text':
|
|
return styled.value
|
|
case 'strong':
|
|
return '**' + styled.value + '**'
|
|
case 'code':
|
|
return '`' + styled.value + '`'
|
|
case 'line':
|
|
return styled.value.map(styledStringToMarkdown).join('')
|
|
case 'stack':
|
|
return styled.value.map(styledStringToMarkdown).join('\n')
|
|
default:
|
|
throw new Error('Unknown StyledString type', styled)
|
|
}
|
|
}
|
|
|
|
function normalizeIssues(issues: Issue[]) {
|
|
return issues
|
|
.map((issue) => ({
|
|
...issue,
|
|
detail:
|
|
issue.detail && normalizePath(styledStringToMarkdown(issue.detail)),
|
|
filePath: issue.filePath && normalizePath(issue.filePath),
|
|
source: issue.source && {
|
|
...issue.source,
|
|
source: normalizePath(issue.source.source.ident),
|
|
},
|
|
}))
|
|
.sort((a, b) => {
|
|
const a_ = JSON.stringify(a)
|
|
const b_ = JSON.stringify(b)
|
|
if (a_ < b_) return -1
|
|
if (a_ > b_) return 1
|
|
return 0
|
|
})
|
|
}
|
|
|
|
function raceIterators<T>(iterators: AsyncIterableIterator<T>[]) {
|
|
const nexts = iterators.map((iterator, i) =>
|
|
iterator.next().then((next) => ({ next, i }))
|
|
)
|
|
return (async function* () {
|
|
while (true) {
|
|
const remaining = nexts.filter((x) => x)
|
|
if (remaining.length === 0) return
|
|
const { next, i } = await Promise.race(remaining)
|
|
if (!next.done) {
|
|
yield next.value
|
|
nexts[i] = iterators[i].next().then((next) => ({ next, i }))
|
|
} else {
|
|
nexts[i] = undefined
|
|
}
|
|
}
|
|
})()
|
|
}
|
|
|
|
async function* filterMapAsyncIterator<T, U>(
|
|
iterator: AsyncIterableIterator<T>,
|
|
transform: (t: T) => U | undefined
|
|
): AsyncGenerator<Awaited<U>> {
|
|
for await (const val of iterator) {
|
|
const mapped = transform(val)
|
|
if (mapped !== undefined) {
|
|
yield mapped
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drains the stream until no value is available for 100ms, then returns the next value.
|
|
*/
|
|
async function drainAndGetNext<T>(stream: AsyncIterableIterator<T>) {
|
|
while (true) {
|
|
const next = stream.next()
|
|
const result = await Promise.race([
|
|
new Promise((r) => setTimeout(() => r({ next }), 100)),
|
|
next.then(() => undefined),
|
|
])
|
|
|
|
if (result) return result
|
|
}
|
|
}
|
|
|
|
function pagesIndexCode(text, props = {}) {
|
|
return `import props from "../lib/props.js";
|
|
export default () => <div>${text}</div>;
|
|
export function getServerSideProps() { return { props: { ...props, ...${JSON.stringify(
|
|
props
|
|
)}} } }`
|
|
}
|
|
|
|
function appPageCode(text) {
|
|
return `import Client from "./client.tsx";
|
|
export default () => <div>${text}<Client /></div>;`
|
|
}
|
|
|
|
describe('next.rs api writeToDisk multiple times', () => {
|
|
const { next } = nextTestSetup({
|
|
skipStart: true,
|
|
files: {
|
|
'pages/index.js': pagesIndexCode('hello world'),
|
|
'lib/props.js': 'export default {}',
|
|
'pages/page-nodejs.js': 'export default () => <div>hello world</div>',
|
|
'pages/page-edge.js':
|
|
'export default () => <div>hello world</div>\nexport const config = { runtime: "experimental-edge" }',
|
|
'pages/api/nodejs.js':
|
|
'export default () => Response.json({ hello: "world" })',
|
|
'pages/api/edge.js':
|
|
'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }',
|
|
'app/layout.tsx':
|
|
'export default function RootLayout({ children }: { children: any }) { return (<html><body>{children}</body></html>)}',
|
|
'app/loading.tsx':
|
|
'export default function Loading() { return <>Loading</> }',
|
|
'app/app/page.tsx': appPageCode('hello world'),
|
|
'app/app/client.tsx':
|
|
'"use client";\nexport default () => <div>hello world</div>',
|
|
'app/app-edge/page.tsx':
|
|
'export default () => <div>hello world</div>\nexport const runtime = "edge"',
|
|
'app/app-nodejs/page.tsx': 'export default () => <div>hello world</div>',
|
|
'app/route-nodejs/route.ts':
|
|
'export function GET() { return Response.json({ hello: "world" }) }',
|
|
'app/route-edge/route.ts':
|
|
'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"',
|
|
'server.js': `
|
|
process.title = 'next.rs api run test';
|
|
const path = require('path');
|
|
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');
|
|
const loadConfig = require('next/dist/server/config');
|
|
const { loadBindings } = require('next/dist/build/swc');
|
|
const { createDefineEnv } = require('next/dist/build/swc');
|
|
async function main() {
|
|
const nextConfig = await loadConfig.default(
|
|
PHASE_DEVELOPMENT_SERVER,
|
|
__dirname
|
|
);
|
|
const bindings = await loadBindings();
|
|
const rootPath = __dirname;
|
|
const distDir = '.next';
|
|
const projectResult = await bindings.turbo.createProject({
|
|
env: {},
|
|
nextConfig: nextConfig,
|
|
rootPath,
|
|
projectPath: '.',
|
|
distDir,
|
|
watch: {
|
|
enable: true,
|
|
},
|
|
dev: true,
|
|
defineEnv: createDefineEnv({
|
|
projectPath: __dirname,
|
|
isTurbopack: true,
|
|
clientRouterFilters: undefined,
|
|
config: nextConfig,
|
|
dev: true,
|
|
distDir: path.join(rootPath, distDir),
|
|
fetchCacheKeyPrefix: undefined,
|
|
hasRewrites: false,
|
|
middlewareMatchers: undefined,
|
|
rewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
},
|
|
}),
|
|
buildId: 'development',
|
|
encryptionKey: '12345',
|
|
previewProps: {
|
|
previewModeId: 'development',
|
|
previewModeEncryptionKey: '12345',
|
|
previewModeSigningKey: '12345',
|
|
},
|
|
browserslistQuery: 'last 2 versions',
|
|
noMangling: false,
|
|
writeRoutesHashesManifest: false,
|
|
currentNodeJsVersion: '18.0.0',
|
|
isPersistentCachingEnabled: false,
|
|
nextVersion: '0.0.0',
|
|
}, {
|
|
turbopackMemoryEviction: 'off',
|
|
});
|
|
if (projectResult.issues.length > 0) {
|
|
throw new Error(
|
|
\`Project initialization failed: \${JSON.stringify(projectResult.issues)}\`
|
|
);
|
|
}
|
|
const project = projectResult.value;
|
|
|
|
const entrypointsSubscription = project.entrypointsSubscribe();
|
|
const entrypoints = (await entrypointsSubscription.next()).value.value;
|
|
|
|
const RUNS = 1000;
|
|
async function compileRoute(route) {
|
|
const endpoint = route.endpoint ?? route.htmlEndpoint ?? route.pages[0].htmlEndpoint;
|
|
if (!endpoint) {
|
|
console.log('unexpected route', route);
|
|
process.exit(1);
|
|
}
|
|
for (let i = 0; i < RUNS; i++) {
|
|
await endpoint.writeToDisk();
|
|
}
|
|
}
|
|
|
|
for (const [name, route] of entrypoints.routes) {
|
|
console.log(name, route.type);
|
|
|
|
console.log('first runs');
|
|
await compileRoute(route);
|
|
gc(true);
|
|
let old = process.memoryUsage();
|
|
const initial = old;
|
|
let stabilized = false;
|
|
for (let j = 0; j < 10; j++) {
|
|
await compileRoute(route);
|
|
gc(true);
|
|
const newMemory = process.memoryUsage();
|
|
const addedMemory = newMemory.rss - old.rss;
|
|
console.log(\`#\${j} \${RUNS} runs add \${addedMemory} bytes of memory, memory since first runs \${newMemory.rss - initial.rss} bytes\`);
|
|
old = newMemory;
|
|
if (addedMemory === 0) {
|
|
console.log('memory usage stabilized');
|
|
stabilized = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!stabilized) {
|
|
console.log('memory usage did not stabilize, failing');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
main()
|
|
.then(() => {
|
|
process.exit(0);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
|
|
`,
|
|
},
|
|
})
|
|
|
|
it('should allow to write to disk multiple times', async () => {
|
|
const result = spawnSync(
|
|
'node',
|
|
['--expose-gc', join(next.testDir, 'server.js')],
|
|
{
|
|
stdio: 'inherit',
|
|
env: {
|
|
...process.env,
|
|
},
|
|
}
|
|
)
|
|
expect(result.status).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('next.rs api', () => {
|
|
const { next } = nextTestSetup({
|
|
skipStart: true,
|
|
files: {
|
|
'pages/index.js': pagesIndexCode('hello world'),
|
|
'lib/props.js': 'export default {}',
|
|
'pages/page-nodejs.js': 'export default () => <div>hello world</div>',
|
|
'pages/page-edge.js':
|
|
'export default () => <div>hello world</div>\nexport const config = { runtime: "experimental-edge" }',
|
|
'pages/api/nodejs.js':
|
|
'export default () => Response.json({ hello: "world" })',
|
|
'pages/api/edge.js':
|
|
'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }',
|
|
'app/layout.tsx':
|
|
'export default function RootLayout({ children }: { children: any }) { return (<html><body>{children}</body></html>)}',
|
|
'app/loading.tsx':
|
|
'export default function Loading() { return <>Loading</> }',
|
|
'app/app/page.tsx': appPageCode('hello world'),
|
|
'app/app/client.tsx':
|
|
'"use client";\nexport default () => <div>hello world</div>',
|
|
'app/app-edge/page.tsx':
|
|
'export default () => <div>hello world</div>\nexport const runtime = "edge"',
|
|
'app/app-nodejs/page.tsx': 'export default () => <div>hello world</div>',
|
|
'app/route-nodejs/route.ts':
|
|
'export function GET() { return Response.json({ hello: "world" }) }',
|
|
'app/route-edge/route.ts':
|
|
'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"',
|
|
},
|
|
})
|
|
|
|
let project: Project
|
|
let projectUpdateSubscription: AsyncIterableIterator<UpdateInfo>
|
|
beforeAll(async () => {
|
|
const nextConfig = await loadConfig(PHASE_DEVELOPMENT_SERVER, next.testDir)
|
|
const bindings = await loadBindings()
|
|
const rootPath = process.env.NEXT_SKIP_ISOLATE
|
|
? path.resolve(__dirname, '../../..')
|
|
: next.testDir
|
|
const distDir = '.next'
|
|
const projectResult = await bindings.turbo.createProject(
|
|
{
|
|
env: {},
|
|
nextConfig: nextConfig,
|
|
rootPath,
|
|
projectPath: path.relative(rootPath, next.testDir) || '.',
|
|
distDir,
|
|
watch: {
|
|
enable: true,
|
|
},
|
|
dev: true,
|
|
defineEnv: createDefineEnv({
|
|
projectPath: next.testDir,
|
|
isTurbopack: true,
|
|
clientRouterFilters: undefined,
|
|
config: nextConfig,
|
|
dev: true,
|
|
distDir: path.join(rootPath, distDir),
|
|
fetchCacheKeyPrefix: undefined,
|
|
hasRewrites: false,
|
|
middlewareMatchers: undefined,
|
|
rewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
},
|
|
}),
|
|
buildId: 'development',
|
|
encryptionKey: '12345',
|
|
previewProps: {
|
|
previewModeId: 'development',
|
|
previewModeEncryptionKey: '12345',
|
|
previewModeSigningKey: '12345',
|
|
},
|
|
browserslistQuery: 'last 2 versions',
|
|
noMangling: false,
|
|
writeRoutesHashesManifest: false,
|
|
currentNodeJsVersion: '18.0.0',
|
|
isPersistentCachingEnabled: false,
|
|
nextVersion: '0.0.0',
|
|
},
|
|
{
|
|
turbopackMemoryEviction: 'off' as MemoryEvictionMode,
|
|
}
|
|
)
|
|
const initializationIssues = normalizeIssues(projectResult.issues)
|
|
if (initializationIssues.length > 0) {
|
|
throw new Error(
|
|
`Project initialization failed:\n${JSON.stringify(initializationIssues, null, 2)}`
|
|
)
|
|
}
|
|
project = projectResult.value
|
|
projectUpdateSubscription = filterMapAsyncIterator(
|
|
project.updateInfoSubscribe(1000),
|
|
(update) => (update.updateType === 'end' ? update.value : undefined)
|
|
)
|
|
})
|
|
|
|
it('should detect the correct routes', async () => {
|
|
const entrypointsSubscription = project.entrypointsSubscribe()
|
|
const entrypoints = await entrypointsSubscription.next()
|
|
expect(entrypoints.done).toBe(false)
|
|
if (!('routes' in entrypoints.value.value)) {
|
|
throw new Error('Entrypoints not available due to compilation errors')
|
|
}
|
|
|
|
expect(Array.from(entrypoints.value.value.routes.keys()).sort()).toEqual([
|
|
'/',
|
|
'/_not-found',
|
|
'/api/edge',
|
|
'/api/nodejs',
|
|
'/app',
|
|
'/app-edge',
|
|
'/app-nodejs',
|
|
'/page-edge',
|
|
'/page-nodejs',
|
|
'/route-edge',
|
|
'/route-nodejs',
|
|
])
|
|
expect(normalizeIssues(entrypoints.value.issues)).toMatchSnapshot('issues')
|
|
await entrypointsSubscription.return()
|
|
})
|
|
|
|
const routes = [
|
|
{
|
|
name: 'root page',
|
|
path: '/',
|
|
type: 'page',
|
|
runtime: 'nodejs',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'pages edge api',
|
|
path: '/api/edge',
|
|
type: 'page-api',
|
|
runtime: 'edge',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'pages Node.js api',
|
|
path: '/api/nodejs',
|
|
type: 'page-api',
|
|
runtime: 'nodejs',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'app edge page',
|
|
path: '/app-edge',
|
|
type: 'app-page',
|
|
runtime: 'edge',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'app Node.js page',
|
|
path: '/app-nodejs',
|
|
type: 'app-page',
|
|
runtime: 'nodejs',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'pages edge page',
|
|
path: '/page-edge',
|
|
type: 'page',
|
|
runtime: 'edge',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'pages Node.js page',
|
|
path: '/page-nodejs',
|
|
type: 'page',
|
|
runtime: 'nodejs',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'app edge route',
|
|
path: '/route-edge',
|
|
type: 'app-route',
|
|
runtime: 'edge',
|
|
config: {},
|
|
},
|
|
{
|
|
name: 'app Node.js route',
|
|
path: '/route-nodejs',
|
|
type: 'app-route',
|
|
runtime: 'nodejs',
|
|
config: {},
|
|
},
|
|
]
|
|
for (const { name, path, type, runtime, config } of routes) {
|
|
// eslint-disable-next-line no-loop-func
|
|
it(`should allow to write ${name} to disk`, async () => {
|
|
const entrypointsSubscribtion = project.entrypointsSubscribe()
|
|
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
|
|
await entrypointsSubscribtion.next()
|
|
).value
|
|
if (!('routes' in entrypoints.value)) {
|
|
throw new Error('Entrypoints not available due to compilation errors')
|
|
}
|
|
|
|
const route = entrypoints.value.routes.get(path)
|
|
entrypointsSubscribtion.return()
|
|
|
|
expect(route.type).toBe(type)
|
|
|
|
switch (route.type) {
|
|
case 'page-api':
|
|
case 'app-route': {
|
|
const result = await route.endpoint.writeToDisk()
|
|
expect(result.value.type).toBe(runtime)
|
|
expect(result.value.config).toEqual(config)
|
|
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
|
|
break
|
|
}
|
|
case 'page': {
|
|
const result = await route.htmlEndpoint.writeToDisk()
|
|
expect(result.value.type).toBe(runtime)
|
|
expect(result.value.config).toEqual(config)
|
|
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
|
|
|
|
const result2 = await route.dataEndpoint.writeToDisk()
|
|
expect(result2.value.type).toBe(runtime)
|
|
expect(result2.value.config).toEqual(config)
|
|
expect(normalizeIssues(result2.issues)).toMatchSnapshot('data issues')
|
|
break
|
|
}
|
|
case 'app-page': {
|
|
const result = await route.pages[0].htmlEndpoint.writeToDisk()
|
|
expect(result.value.type).toBe(runtime)
|
|
expect(result.value.config).toEqual(config)
|
|
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
|
|
|
|
const result2 = await route.pages[0].rscHmrEndpoint.writeToDisk()
|
|
expect(result2.value.type).toBe(runtime)
|
|
expect(result2.value.config).toEqual(config)
|
|
expect(normalizeIssues(result2.issues)).toMatchSnapshot('rsc issues')
|
|
|
|
break
|
|
}
|
|
default: {
|
|
throw new Error('unknown route type')
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const hmrCases: {
|
|
name: string
|
|
path: string
|
|
type: string
|
|
file: string
|
|
content: string
|
|
expectedUpdate: string | false
|
|
expectedServerSideChange: boolean
|
|
}[] = [
|
|
{
|
|
name: 'client-side change on a page',
|
|
path: '/',
|
|
type: 'page',
|
|
file: 'pages/index.js',
|
|
content: pagesIndexCode('hello world2'),
|
|
expectedUpdate: '/pages/index.js',
|
|
expectedServerSideChange: false,
|
|
},
|
|
{
|
|
name: 'server-side change on a page',
|
|
path: '/',
|
|
type: 'page',
|
|
file: 'lib/props.js',
|
|
content: 'export default { some: "prop" }',
|
|
expectedUpdate: false,
|
|
expectedServerSideChange: true,
|
|
},
|
|
{
|
|
name: 'client and server-side change on a page',
|
|
path: '/',
|
|
type: 'page',
|
|
file: 'pages/index.js',
|
|
content: pagesIndexCode('hello world2', { another: 'prop' }),
|
|
expectedUpdate: '/pages/index.js',
|
|
expectedServerSideChange: true,
|
|
},
|
|
{
|
|
name: 'client-side change on a app page',
|
|
path: '/app',
|
|
type: 'app-page',
|
|
file: 'app/app/client.tsx',
|
|
content: '"use client";\nexport default () => <div>hello world2</div>',
|
|
expectedUpdate: '/app/app/client.tsx',
|
|
expectedServerSideChange: false,
|
|
},
|
|
{
|
|
name: 'server-side change on a app page',
|
|
path: '/app',
|
|
type: 'app-page',
|
|
file: 'app/app/page.tsx',
|
|
content: appPageCode('hello world2'),
|
|
expectedUpdate: false,
|
|
expectedServerSideChange: true,
|
|
},
|
|
]
|
|
|
|
for (const {
|
|
name,
|
|
path,
|
|
type,
|
|
file,
|
|
content,
|
|
expectedUpdate,
|
|
expectedServerSideChange,
|
|
} of hmrCases) {
|
|
for (let i = 0; i < 3; i++)
|
|
// eslint-disable-next-line no-loop-func
|
|
it(`should have working HMR on ${name} ${i}`, async () => {
|
|
console.log('start')
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
const entrypointsSubscribtion = project.entrypointsSubscribe()
|
|
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
|
|
await entrypointsSubscribtion.next()
|
|
).value
|
|
if (!('routes' in entrypoints.value)) {
|
|
throw new Error('Entrypoints not available due to compilation errors')
|
|
}
|
|
|
|
const route = entrypoints.value.routes.get(path)
|
|
entrypointsSubscribtion.return()
|
|
|
|
expect(route.type).toBe(type)
|
|
|
|
let serverSideSubscription:
|
|
| AsyncIterableIterator<TurbopackResult<void>>
|
|
| undefined
|
|
switch (route.type) {
|
|
case 'page': {
|
|
await route.htmlEndpoint.writeToDisk()
|
|
serverSideSubscription =
|
|
await route.dataEndpoint.serverChanged(false)
|
|
break
|
|
}
|
|
case 'app-page': {
|
|
await route.pages[0].htmlEndpoint.writeToDisk()
|
|
serverSideSubscription =
|
|
await route.pages[0].rscHmrEndpoint.serverChanged(false)
|
|
break
|
|
}
|
|
default: {
|
|
throw new Error('unknown route type')
|
|
}
|
|
}
|
|
|
|
const result = await project.clientHmrChunkNamesSubscribe().next()
|
|
expect(result.done).toBe(false)
|
|
const chunkNames = result.value.value.chunkNames
|
|
expect(chunkNames).toHaveProperty('length', expect.toBePositive())
|
|
|
|
const subscriptions = chunkNames.map((chunkName) =>
|
|
project.clientHmrEvents(chunkName)
|
|
)
|
|
await Promise.all(
|
|
subscriptions.map(async (subscription) => {
|
|
const result = await subscription.next()
|
|
expect(result.done).toBe(false)
|
|
expect(result.value.value).toHaveProperty(
|
|
'resource',
|
|
expect.toBeObject()
|
|
)
|
|
expect(result.value.value).toHaveProperty('type', 'issues')
|
|
expect(normalizeIssues(result.value.issues)).toEqual([])
|
|
})
|
|
)
|
|
console.log('waiting for events')
|
|
const { next: updateComplete } = await drainAndGetNext(
|
|
projectUpdateSubscription
|
|
)
|
|
const oldContent = await next.readFile(file)
|
|
let ok = false
|
|
try {
|
|
await next.patchFile(file, content)
|
|
let foundUpdates: string[] | false = false
|
|
let foundServerSideChange = false
|
|
let done = false
|
|
const result2 = await Promise.race(
|
|
[
|
|
(async () => {
|
|
const merged = raceIterators(subscriptions)
|
|
for await (const item of merged) {
|
|
if (done) return
|
|
if (item.value.type === 'partial') {
|
|
expect(item.value.instruction).toEqual({
|
|
type: 'ChunkListUpdate',
|
|
merged: [
|
|
expect.objectContaining({
|
|
chunks: expect.toBeObject(),
|
|
entries: expect.toBeObject(),
|
|
}),
|
|
],
|
|
})
|
|
const updates = Object.keys(
|
|
item.value.instruction.merged[0].entries
|
|
)
|
|
expect(updates).not.toBeEmpty()
|
|
|
|
foundUpdates = foundUpdates || []
|
|
foundUpdates.push(
|
|
...Object.keys(item.value.instruction.merged[0].entries)
|
|
)
|
|
}
|
|
}
|
|
})(),
|
|
serverSideSubscription &&
|
|
(async () => {
|
|
for await (const { issues } of serverSideSubscription) {
|
|
if (done) return
|
|
expect(issues).toBeArray()
|
|
foundServerSideChange = true
|
|
}
|
|
})(),
|
|
updateComplete.then(
|
|
(u) => new Promise((r) => setTimeout(() => r(u), 1000))
|
|
),
|
|
new Promise((r) => setTimeout(() => r('timeout'), 30000)),
|
|
].filter((x) => x)
|
|
)
|
|
done = true
|
|
expect(result2).toMatchObject({
|
|
done: false,
|
|
value: {
|
|
duration: expect.toBePositive(),
|
|
tasks: expect.toBePositive(),
|
|
},
|
|
})
|
|
if (typeof expectedUpdate === 'boolean') {
|
|
expect(foundUpdates).toBe(false)
|
|
} else {
|
|
expect(
|
|
typeof foundUpdates === 'boolean'
|
|
? foundUpdates
|
|
: Array.from(new Set(foundUpdates))
|
|
).toEqual([expect.stringContaining(expectedUpdate)])
|
|
}
|
|
expect(foundServerSideChange).toBe(expectedServerSideChange)
|
|
ok = true
|
|
} finally {
|
|
try {
|
|
const { next: updateComplete2 } = await drainAndGetNext(
|
|
projectUpdateSubscription
|
|
)
|
|
await next.patchFile(file, oldContent)
|
|
await updateComplete2
|
|
} catch (e) {
|
|
if (ok) throw e
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
it.skip('should allow to make many HMR updates', async () => {
|
|
console.log('start')
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
const entrypointsSubscribtion = project.entrypointsSubscribe()
|
|
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
|
|
await entrypointsSubscribtion.next()
|
|
).value
|
|
if (!('routes' in entrypoints.value)) {
|
|
throw new Error('Entrypoints not available due to compilation errors')
|
|
}
|
|
|
|
const route = entrypoints.value.routes.get('/')
|
|
entrypointsSubscribtion.return()
|
|
|
|
if (route.type !== 'page') throw new Error('unknown route type')
|
|
await route.htmlEndpoint.writeToDisk()
|
|
|
|
const result = await project.clientHmrChunkNamesSubscribe().next()
|
|
expect(result.done).toBe(false)
|
|
const chunkNames = result.value.value.chunkNames
|
|
|
|
const subscriptions = chunkNames.map((chunkName) =>
|
|
project.clientHmrEvents(chunkName)
|
|
)
|
|
await Promise.all(
|
|
subscriptions.map(async (subscription) => {
|
|
const result = await subscription.next()
|
|
expect(result.done).toBe(false)
|
|
expect(result.value.value).toHaveProperty(
|
|
'resource',
|
|
expect.toBeObject()
|
|
)
|
|
expect(result.value.value).toHaveProperty('type', 'issues')
|
|
})
|
|
)
|
|
const merged = raceIterators(subscriptions)
|
|
|
|
const file = 'pages/index.js'
|
|
let currentContent = await next.readFile(file)
|
|
let nextContent = pagesIndexCode('hello world2')
|
|
|
|
const count = process.env.NEXT_TEST_CI ? 300 : 1000
|
|
for (let i = 0; i < count; i++) {
|
|
await next.patchFile(file, nextContent)
|
|
const content = currentContent
|
|
currentContent = nextContent
|
|
nextContent = content
|
|
|
|
while (true) {
|
|
const { value, done } = await merged.next()
|
|
expect(done).toBe(false)
|
|
if (value.value.type === 'partial') {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}, 300000)
|
|
})
|