diff --git a/.changepacks/changepack_log_next_root_app.json b/.changepacks/changepack_log_next_root_app.json new file mode 100644 index 000000000..601ac9fa3 --- /dev/null +++ b/.changepacks/changepack_log_next_root_app.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Next apps with `app/` or `pages/` at the project root (no `src/`) are prewarmed and waited on like `src/` apps, so Turbopack production builds no longer fall back to a 2.5 s idle wait before serving the base CSS. Source scans skip dot directories such as `.next` and `.git`", + "date": "2026-11-15T00:00:00.000Z" +} diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 66270541c..639d7cfe3 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -439,6 +439,28 @@ describe('DevupUINextPlugin', () => { }) }) + it('reads routes from the project root when it holds app or pages', () => { + // Given: a Next app with `app/` at the root and no `src/`. + existsSyncSpy.mockImplementation( + (path: fs.PathLike) => String(path) === resolve('app'), + ) + const graphSpy = spyOn( + importGraphModule, + 'buildStaticImportGraph', + ).mockReturnValue(createSingleFileGraph(resolve('app/page.tsx'))) + try { + // When + DevupUI({}).webpack!(createWebpackConfig(), createWebpackContext()) + // Then + expect(graphSpy).toHaveBeenCalledWith( + process.cwd(), + resolve('tsconfig.json'), + ) + } finally { + graphSpy.mockRestore() + } + }) + it('should apply webpack plugin with dev', async () => { const ret = DevupUI({}) @@ -1027,6 +1049,26 @@ export const box = style({ color: 'red' })` expect.objectContaining({ sourceMap: true }), ) }) + it('keeps configuring Turbopack when the import graph cannot be read', () => { + process.env.TURBOPACK = '1' + process.env.DEVUP_UI_PROFILE = '1' + const graphSpy = spyOn( + importGraphModule, + 'buildStaticImportGraph', + ).mockImplementation(() => { + throw new Error('unreadable') + }) + const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) + try { + expect(DevupUI({}).turbopack?.rules).toBeDefined() + expect(profileSpy).toHaveBeenCalledWith( + expect.stringContaining('"failed":true'), + ) + } finally { + profileSpy.mockRestore() + graphSpy.mockRestore() + } + }) it('should create theme.d.ts file', async () => { process.env.TURBOPACK = '1' existsSyncSpy.mockReturnValue(true) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 4ce23d33e..aa92f663e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -125,6 +125,16 @@ export function resetTurboSetupCacheForTesting(): void { productionTurboSetupOwner = `${performance.timeOrigin}-${Math.random()}` } +/** + * The directory Next reads routes from: the project root when it holds `app` + * or `pages`, otherwise `src`, as Next itself decides. + */ +function sourceDir(cwd: string): string { + return ['app', 'pages'].some((dir) => existsSync(resolve(cwd, dir))) + ? cwd + : resolve(cwd, 'src') +} + /** * Devup UI Next Plugin * @param config @@ -302,7 +312,7 @@ export function DevupUI( let staticGraph: StaticImportGraph | undefined const graphStartedAt = profileStart() try { - const srcDir = resolve(process.cwd(), 'src') + const srcDir = sourceDir(process.cwd()) const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') const cwd = process.cwd() // One scan+parse of the source tree, shared by all three consumers below. @@ -638,7 +648,7 @@ export function DevupUI( ) if (!_options.dev && productionPrewarmFiles === undefined) { const cwd = process.cwd() - const srcDir = resolve(cwd, 'src') + const srcDir = sourceDir(cwd) const tsconfigPath = resolve(cwd, 'tsconfig.json') const graph = buildStaticImportGraph(srcDir, tsconfigPath) productionPrewarmFiles = collectProductionPrewarmFiles({ diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts index e64ad5716..09ddb34e3 100644 --- a/packages/plugin-utils/src/__tests__/numbering.test.ts +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -24,6 +24,7 @@ describe('collectNumberedFiles', () => { write('src/a.tsx') write('src/nested/c.ts') write('src/readme.md') + write('src/.cache/skipped.tsx') write('node_modules/@acme/ui/Button.tsx') write('node_modules/@acme/ui/node_modules/dep/skipped.tsx') }) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 13197350d..7e0393047 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -610,7 +610,8 @@ export function compareCodePoints(a: string, b: string): number { /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path - * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS + * (deterministic order). Skips `node_modules`, dot directories such as + * `.next` and `.git`, test/spec files, and non-JS/TS * files — the SAME filter `buildCanonicalMap` uses internally, so a plugin can * pre-warm the extractor over exactly the file set the canonical map was built * from. Returns absolute paths. @@ -635,7 +636,8 @@ export function listSourceFiles( for (const entry of entries) { const entryPath = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name === 'node_modules') continue + if (entry.name === 'node_modules' || entry.name.startsWith('.')) + continue visit(entryPath) continue }