diff --git a/.changepacks/changepack_log_a2YdR0dEoHiYJ22qe39PI.json b/.changepacks/changepack_log_a2YdR0dEoHiYJ22qe39PI.json new file mode 100644 index 00000000..bfa5aeb6 --- /dev/null +++ b/.changepacks/changepack_log_a2YdR0dEoHiYJ22qe39PI.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Prevent incomplete CSS in Turbopack production builds by prewarming application and external global style entries", + "date": "2026-08-23T15:00:44.533442300Z" +} diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 0aade216..dbdf00d3 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -1146,6 +1146,68 @@ describe('coordinator per-bucket completion', () => { coordinator.close() }) + it('serves complete production CSS from the prewarmed sheet before late loaders run', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) + getCssSpy.mockReturnValue('prewarmed-css') + const canonicalMap = { 'src/late.tsx': 'src/page.tsx' } + const { coordinator, port } = await startAndGetPort( + makeOptions({ + canonicalMap, + expectedBaseFiles: ['src/page.tsx', 'src/late.tsx'], + prewarmedFiles: ['src/page.tsx', 'src/late.tsx'], + quietMs: 5000, + }), + ) + + // The first loader POST establishes the file-number -> bucket mapping. + // The late member has not POSTed, but its atoms already exist because the + // plugin synchronously prewarmed it before starting the coordinator. + await extract(port, 'src/page.tsx') + expect(codeExtractSpy).toHaveBeenCalledTimes(1) + + const t0 = Date.now() + const [bucketCss, baseCss] = await Promise.all([ + httpRequest( + port, + 'GET', + '/css?fileNum=1&importMainCss=true&waitForIdle=true', + ), + httpRequest(port, 'GET', '/css?waitForIdle=true'), + ]) + const elapsed = Date.now() - t0 + + expect(bucketCss.body).toBe('prewarmed-css') + expect(baseCss.body).toBe('prewarmed-css') + expect(elapsed).toBeLessThan(1000) + // If the prewarmed files did not seed completion, both requests would wait + // for src/late.tsx (or the five-second quiet fallback). + expect(codeExtractSpy).toHaveBeenCalledTimes(1) + + coordinator.close() + }) + + it('serves prewarmed singleCss before any source loader runs', async () => { + getCssSpy.mockReturnValue('prewarmed-single-css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ + singleCss: true, + expectedBaseFiles: ['src/page.tsx', 'src/late.tsx'], + prewarmedFiles: ['src/page.tsx', 'src/late.tsx'], + quietMs: 5000, + }), + ) + + const t0 = Date.now() + const res = await httpRequest(port, 'GET', '/css?waitForIdle=true') + + expect(res.status).toBe(200) + expect(res.body).toBe('prewarmed-single-css') + expect(Date.now() - t0).toBeLessThan(1000) + expect(codeExtractSpy).not.toHaveBeenCalled() + + coordinator.close() + }) + // T5: the deterministic wait blocks base css until a still-missing // expectedBaseFile arrives — even after the idle threshold elapses with // nothing in flight. This is exactly the gap-between-waves case the old idle @@ -1183,11 +1245,9 @@ describe('coordinator per-bucket completion', () => { coordinator.close() }) - // T7: a phantom bucket member (its import edges were erased by the bundler, - // e.g. a type imported without the `type` keyword, or an unused import) can - // never extract. Once the bundler goes fully quiet the wait must conclude - // the member is a phantom and serve — via console.info, NOT the scary - // partial-CSS warn — long before the wall-clock backstop. + // T7: legacy callers that do not prewarm still fail open after the quiet + // window when a graph member never reports, rather than hanging forever. + // Production plugin builds take the deterministic prewarmed path instead. it('serves a bucket via the quiet exit when a member is never compiled', async () => { codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) getCssSpy.mockReturnValue('bucket-css') @@ -1219,8 +1279,8 @@ describe('coordinator per-bucket completion', () => { coordinator.close() }) - // T8: a phantom expectedBaseFile resolves the base-css wait via the same - // quiet exit instead of stalling until maxWaitMs. + // T8: the same legacy quiet fallback applies to an expected base file when + // the caller did not seed prewarmed completion state. it('serves base css via the quiet exit when an expectedBaseFile is never compiled', async () => { codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) getCssSpy.mockReturnValue('base-css') diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index b84478ec..19d60c89 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -17,6 +17,7 @@ import { import * as coordinatorModule from '../coordinator' import { DevupUI } from '../plugin' +type CodeExtractResult = ReturnType type NextWebpackConfig = Parameters< NonNullable['webpack']> >[0] @@ -38,6 +39,18 @@ function setNodeEnv(value: string): void { process.env.NODE_ENV = value } +function createCodeExtractResult(contents: string): CodeExtractResult { + return { + css: '', + code: contents, + cssFile: '', + map: undefined, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + } as unknown as CodeExtractResult +} + let existsSyncSpy: ReturnType let mkdirSyncSpy: ReturnType let readFileSyncSpy: ReturnType @@ -54,6 +67,7 @@ let importFileMapSpy: ReturnType let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType let exportFileMapSpy: ReturnType +let codeExtractSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let startCoordinatorSpy: ReturnType @@ -91,6 +105,9 @@ beforeEach(() => { exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue( JSON.stringify({}), ) + codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( + (_path: string, contents: string) => createCodeExtractResult(contents), + ) devupUIWebpackPluginSpy = spyOn( webpackPluginModule, 'DevupUIWebpackPlugin', @@ -126,6 +143,7 @@ afterEach(() => { exportSheetSpy.mockRestore() exportClassMapSpy.mockRestore() exportFileMapSpy.mockRestore() + codeExtractSpy.mockRestore() devupUIWebpackPluginSpy.mockRestore() startCoordinatorSpy.mockRestore() }) @@ -488,6 +506,7 @@ describe('DevupUINextPlugin', () => { coordinatorPortFile: join('df', 'coordinator.port'), canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), + prewarmedFiles: expect.any(Array), }) }) it('should create theme.d.ts file', async () => { @@ -676,7 +695,9 @@ describe('DevupUINextPlugin', () => { coordinatorPortFile: join('df', 'coordinator.port'), canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), + prewarmedFiles: [], }) + expect(codeExtractSpy).not.toHaveBeenCalled() // Verify initial CSS file is written expect(writeFileSyncSpy).toHaveBeenCalledWith( @@ -710,14 +731,52 @@ describe('DevupUINextPlugin', () => { importGraphModule, 'computeFileRoutes', ).mockReturnValue({ 'src/app/page.tsx': [0] }) + const events: string[] = [] + codeExtractSpy.mockImplementation( + (filename: string, contents: string) => { + events.push(`extract:${filename}`) + return createCodeExtractResult(contents) + }, + ) + startCoordinatorSpy.mockImplementation(() => { + events.push('startCoordinator') + return { close: mock() as () => void } + }) try { DevupUI({}) expect(startCoordinatorSpy).toHaveBeenCalledWith( expect.objectContaining({ expectedBaseFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], + prewarmedFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], }), ) + expect(codeExtractSpy).toHaveBeenCalledTimes(2) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/app/page.tsx', + '{}', + '@devup-ui/react', + expect.any(String), + false, + false, + true, + expect.anything(), + ) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/lazy/panel.tsx', + '{}', + '@devup-ui/react', + expect.any(String), + false, + false, + true, + expect.anything(), + ) + expect(events).toEqual([ + 'extract:src/app/page.tsx', + 'extract:src/lazy/panel.tsx', + 'startCoordinator', + ]) // the static-only route map is not consulted outside atom-hoist mode expect(routesSpy).not.toHaveBeenCalled() } finally { @@ -726,6 +785,88 @@ describe('DevupUINextPlugin', () => { } }) + it('prewarms source candidates hidden from the route closure', () => { + process.env.TURBOPACK = '1' + const page = resolve('src/app/page.tsx') + const templateTarget = resolve('src/demos/template-target.tsx') + const graphSpy = spyOn( + importGraphModule, + 'buildStaticImportGraph', + ).mockReturnValue({ + files: [page, templateTarget], + fileSet: new Set([page, templateTarget]), + staticImports: new Map([ + [page, new Set()], + [templateTarget, new Set()], + ]), + staticImporters: new Map([ + [page, new Set()], + [templateTarget, new Set()], + ]), + dynamicTargets: new Set(), + dynamicImports: new Map([ + [page, new Set()], + [templateTarget, new Set()], + ]), + externalImports: new Map([ + [page, new Set()], + [templateTarget, new Set()], + ]), + }) + const compiledSpy = spyOn( + importGraphModule, + 'computeCompiledFiles', + ).mockReturnValue(['src/app/page.tsx']) + try { + DevupUI({}) + + expect(startCoordinatorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + expectedBaseFiles: ['src/app/page.tsx'], + prewarmedFiles: [ + 'src/app/page.tsx', + 'src/demos/template-target.tsx', + ], + }), + ) + expect(codeExtractSpy).toHaveBeenCalledTimes(2) + } finally { + graphSpy.mockRestore() + compiledSpy.mockRestore() + } + }) + + it('prewarms the same complete file set in singleCss mode', () => { + process.env.TURBOPACK = '1' + const compiledSpy = spyOn( + importGraphModule, + 'computeCompiledFiles', + ).mockReturnValue(['src/app/page.tsx', 'src/app/card.tsx']) + try { + DevupUI({}, { singleCss: true }) + + expect(codeExtractSpy).toHaveBeenCalledTimes(2) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/app/card.tsx', + '{}', + '@devup-ui/react', + expect.any(String), + true, + false, + true, + expect.anything(), + ) + expect(startCoordinatorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + singleCss: true, + prewarmedFiles: ['src/app/card.tsx', 'src/app/page.tsx'], + }), + ) + } finally { + compiledSpy.mockRestore() + } + }) + it('does not enable atom hoisting when atomHoist option is unset', () => { process.env.TURBOPACK = '1' const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( diff --git a/packages/next-plugin/src/__tests__/prewarm.test.ts b/packages/next-plugin/src/__tests__/prewarm.test.ts new file mode 100644 index 00000000..9486a1a7 --- /dev/null +++ b/packages/next-plugin/src/__tests__/prewarm.test.ts @@ -0,0 +1,122 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import type { StaticImportGraph } from '@devup-ui/plugin-utils' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { collectProductionPrewarmFiles } from '../prewarm' + +describe('collectProductionPrewarmFiles', () => { + let cwd: string + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'devup-ui-next-prewarm-')) + writeFileSync(join(cwd, 'package.json'), '{"private":true}') + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + function writePackage( + name: string, + exports: Record | string, + files: Record, + ): void { + const packageDir = join(cwd, 'node_modules', ...name.split('/')) + mkdirSync(packageDir, { recursive: true }) + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ name, exports }), + ) + for (const [filename, contents] of Object.entries(files)) { + const path = join(packageDir, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + } + } + + function makeGraph(files: string[], specifiers: string[]): StaticImportGraph { + const source = files[0] ?? join(cwd, 'src/app/page.tsx') + return { + files, + fileSet: new Set(files), + staticImports: new Map(files.map((file) => [file, new Set()])), + staticImporters: new Map(files.map((file) => [file, new Set()])), + dynamicTargets: new Set(), + dynamicImports: new Map(files.map((file) => [file, new Set()])), + externalImports: new Map([[source, new Set(specifiers)]]), + } + } + + it('includes all source candidates and ESM entries accepted by the loader', () => { + writePackage( + '@devup-ui/reset-css', + { + '.': { import: './dist/index.mjs', require: './dist/index.cjs' }, + }, + { 'dist/index.cjs': '', 'dist/index.mjs': '' }, + ) + writePackage('@devup-editor/editor', './index.js', { 'index.js': '' }) + writePackage( + '@acme/ui', + { '.': { import: './index.mjs', require: './index.cjs' } }, + { 'index.cjs': '', 'index.mjs': '' }, + ) + writePackage('design-system', './index.js', { 'index.js': '' }) + writePackage('@devup-ui/cjs-only', './index.cjs', { 'index.cjs': '' }) + writePackage('@devup-ui/data', './data.json', { 'data.json': '{}' }) + + const page = join(cwd, 'src/app/page.tsx') + const templateTarget = join(cwd, 'src/demos/template-target.tsx') + const files = collectProductionPrewarmFiles({ + cwd, + graph: makeGraph( + [page, templateTarget], + [ + '', + '@broken', + '#internal', + 'node:fs', + 'react', + '@devup-ui/reset-css', + '@devup-editor/editor', + '@acme/ui', + 'design-system', + '@devup-ui/cjs-only', + '@devup-ui/data', + '@devup-ui/missing', + ], + ), + expectedBaseFiles: ['src/app/page.tsx'], + libPackage: '@acme/ui', + include: ['design-system'], + }) + + expect(files).toEqual( + [ + 'node_modules/@acme/ui/index.mjs', + 'node_modules/@devup-editor/editor/index.js', + 'node_modules/@devup-ui/reset-css/dist/index.mjs', + 'node_modules/design-system/index.js', + 'src/app/page.tsx', + 'src/demos/template-target.tsx', + ].sort(), + ) + }) + + it('normalizes an absolute expected file without source or package imports', () => { + const page = join(cwd, 'src/app/page.tsx') + + expect( + collectProductionPrewarmFiles({ + cwd, + graph: makeGraph([], []), + expectedBaseFiles: [page], + libPackage: '@', + include: [], + }), + ).toEqual(['src/app/page.tsx']) + }) +}) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 5bc998f0..31866fdd 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -28,16 +28,20 @@ export interface CoordinatorOptions { */ canonicalMap: Record /** - * Route-reachable runtime source files (cwd-relative POSIX), i.e. exactly the - * files the bundler will compile and POST to `/extract`. Used to resolve the - * base-css `/css` wait DETERMINISTICALLY — block until every one of these has - * been extracted, instead of guessing completion from an idle gap. Comes from - * `computeFileRoutes` (already type-filtered and orphan-free), so it can never - * contain a phantom file the bundler skips. Empty when no routes are detected - * (e.g. pages-router) or the best-effort pre-pass failed, in which case the - * legacy idle heuristic below is the fallback. + * Route-reachable source graph closure (cwd-relative POSIX). Used both as the + * production prewarm target and as the deterministic base-css completion + * signal. It may include imports Turbopack later erases, but prewarming still + * extracts those files into the sheet. Empty when no routes are detected or + * the best-effort graph pass failed, in which case the legacy idle heuristic + * below is the fallback. */ expectedBaseFiles?: string[] + /** + * Files synchronously extracted before a production Turbopack build starts. + * They seed completion tracking because their atoms already exist in the + * shared WASM sheet even though their loaders have not POSTed `/extract` yet. + */ + prewarmedFiles?: string[] /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -45,16 +49,11 @@ export interface CoordinatorOptions { */ idleThresholdMs?: number /** - * Full-quiet window (ms) after which a wait with still-missing members - * concludes those members will NEVER be compiled by the bundler and serves - * the CSS. Member sets come from the static import graph, which can - * over-approximate the bundle: an edge whose bindings the bundler erases - * (a type imported without the `type` keyword, or an unused import) keeps - * the member in the graph while the bundler never runs a loader for it. - * Once at least one extraction happened and NOTHING has been in flight for - * this window, the module graph is exhausted — serving now is complete for - * the actual bundle (a never-compiled file contributes no runtime markup). - * Defaults to 10000. Exposed for tests; the plugin omits it. + * Legacy fail-open window (ms) used when completion was not prewarmed and a + * graph member never reports. A quiet window cannot prove that Turbopack is + * finished scheduling loaders, so the production plugin avoids depending on + * this heuristic by extracting its complete route closure up front. Defaults + * to 10000. Exposed for tests; the plugin omits it. */ quietMs?: number /** @@ -162,13 +161,10 @@ let idleThresholdMs = 2500 let quietMs = 10_000 let maxWaitMs = 60_000 -// The bundler invokes the extract loader for every compilable source file it -// discovers. Once at least one extraction happened and nothing has been in -// flight for a full quiet window, the module graph is exhausted: a member that -// still has not reported will never be compiled (its only import edges were -// erased at build time — see CoordinatorOptions.quietMs). Serving then is -// complete for the ACTUAL bundle, so waits use this as an early exit instead -// of stalling until the wall-clock backstop. +// Legacy fail-open signal for callers that could not prewarm a deterministic +// file set. It only observes extraction traffic; it is NOT a Turbopack +// compilation-complete signal. Production builds seed `extractedFiles` with +// their prewarmed route closure and therefore do not depend on this path. function bundlerQuiet(now: number): boolean { return ( totalExtractions > 0 && @@ -179,10 +175,8 @@ function bundlerQuiet(now: number): boolean { } function baseFilesComplete(): boolean { - // Deterministic: the base sheet is complete once every route-reachable runtime - // file has been extracted. Each `/extract` (success OR failure) adds its file - // to `extractedFiles`, and `expectedBaseFiles` is phantom-free, so this is a - // device-independent superset check — no idle gap to guess. + // Deterministic: the base sheet is complete once every route-reachable file + // has been extracted by either the production prewarm or `/extract`. if (expectedBaseFiles.size === 0) return false for (const file of expectedBaseFiles) { if (!extractedFiles.has(file)) return false @@ -207,9 +201,8 @@ function waitForBase(): Promise { resolve() return } - // The graph over-approximated: some expected file's import edges were - // erased by the bundler, so it will never extract. Once the bundler has - // gone fully quiet the sheet is complete for the actual bundle. + // Legacy fail-open for a caller that supplied expected files without + // prewarming them. The Next plugin's production path completes above. if (expectedBaseFiles.size > 0 && bundlerQuiet(now)) { resolve() return @@ -286,18 +279,14 @@ function waitForBucket(bucket: string): Promise { return } const now = Date.now() - // A bucket's member set comes from the import graph (`canonicalMap`), - // which excludes type-only edges (`import type` / `export type` / - // all-inline-type specifier lists) — but it CANNOT statically see - // bundler usage-based elision (a type imported without the `type` - // keyword, or an unused import). Such phantom members never POST - // /extract. Once the bundler has gone fully quiet, conclude the - // remaining members are phantoms and serve: the sheet is complete for - // the actual bundle, since a never-compiled file renders no markup. + // Legacy fail-open for a caller that did not seed the bucket through + // prewarming. Quiet time alone cannot distinguish an erased import from + // a later Turbopack extraction wave; production builds complete via the + // allExtracted branch above. if (bundlerQuiet(now)) { const missing = [...members].filter((m) => !extractedFiles.has(m)) console.info( - `[devup-ui] coordinator: bucket "${bucket}" member(s) were never compiled by the bundler (likely type-only or unused imports, erased at build time): ${missing.join(', ')}; CSS is complete for the compiled bundle`, + `[devup-ui] coordinator: serving bucket "${bucket}" through the legacy quiet fallback; treating unreported member(s) as erased imports: ${missing.join(', ')}`, ) resolve() return @@ -342,6 +331,7 @@ export function startCoordinator(options: CoordinatorOptions): { bucketToMembers = buildBucketToMembers(options.canonicalMap) expectedBaseFiles = new Set(options.expectedBaseFiles ?? []) extractedFiles.clear() + for (const file of options.prewarmedFiles ?? []) extractedFiles.add(file) fileNumToBucket.clear() server = createServer(async (req, res) => { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index f0540b0c..4dca4ff7 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -5,7 +5,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' -import { join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, @@ -17,8 +17,10 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + type StaticImportGraph, } from '@devup-ui/plugin-utils' import { + codeExtract, exportClassMap, exportFileMap, exportSheet, @@ -42,6 +44,7 @@ import { import { type NextConfig } from 'next' import { startCoordinator } from './coordinator' +import { collectProductionPrewarmFiles } from './prewarm' type DevupUiNextPluginOptions = Omit< Partial, @@ -142,6 +145,7 @@ export function DevupUI( // coordinator shares this WASM instance, so it applies to every /extract. const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + const watch = process.env.NODE_ENV === 'development' // Hoisted out of the try so the coordinator can receive it for per-bucket // completion. Stays `{}` if the best-effort pre-pass fails. let canonicalMap: Record = {} @@ -149,12 +153,14 @@ export function DevupUI( // deterministic base-css completion signal handed to the coordinator. Stays // `[]` (idle fallback) when no routes are detected or the pre-pass fails. let expectedBaseFiles: string[] = [] + let staticGraph: StaticImportGraph | undefined try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') const cwd = process.cwd() // One scan+parse of the source tree, shared by all three consumers below. const graph = buildStaticImportGraph(srcDir, tsconfigPath) + staticGraph = graph // Atom hoisting owns the shared-chunk decision, so collapse runs WITHOUT // the file-level @global hoist (DEVUP_HOIST_V) in atom mode. const hoistV = atomMode @@ -208,6 +214,44 @@ export function DevupUI( // merge) and atom hoisting stays off. } + // Turbopack can request a CSS module before it has scheduled every source + // loader. Waiting for a quiet window is not a compilation-complete signal: + // a CSS request can itself hold up the next extraction wave. In one-shot + // builds, extract every source candidate plus accepted external package + // entries synchronously first. The wider set covers template imports, MDX + // dependencies and package-level globalCss (notably reset-css) that the + // route graph cannot represent. Loader-time extraction uses the same + // keys/options and is idempotent. + const prewarmedFiles: string[] = [] + if (!watch && staticGraph) { + const cwd = process.cwd() + const prewarmFiles = collectProductionPrewarmFiles({ + cwd, + graph: staticGraph, + expectedBaseFiles, + libPackage, + include, + }) + for (const filename of prewarmFiles) { + const resourcePath = resolve(cwd, filename) + const relCssDir = `./${relative( + dirname(resourcePath), + cssDir, + ).replaceAll('\\', '/')}` + codeExtract( + filename, + readFileSync(resourcePath, 'utf-8'), + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases as unknown as Record, + ) + prewarmedFiles.push(filename) + } + } + // create devup-ui.css file writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) @@ -231,6 +275,7 @@ export function DevupUI( coordinatorPortFile, canonicalMap, expectedBaseFiles, + prewarmedFiles, }) // Cleanup on exit @@ -255,7 +300,7 @@ export function DevupUI( { loader: '@devup-ui/next-plugin/css-loader', options: { - watch: process.env.NODE_ENV === 'development', + watch, coordinatorPortFile, sheetFile, classMapFile, @@ -288,7 +333,7 @@ export function DevupUI( defaultSheet, defaultClassMap, defaultFileMap, - watch: process.env.NODE_ENV === 'development', + watch, singleCss, // for turbopack, load theme is required on loader theme, diff --git a/packages/next-plugin/src/prewarm.ts b/packages/next-plugin/src/prewarm.ts new file mode 100644 index 00000000..03354ff5 --- /dev/null +++ b/packages/next-plugin/src/prewarm.ts @@ -0,0 +1,105 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { extname, join, relative, resolve } from 'node:path' + +import type { StaticImportGraph } from '@devup-ui/plugin-utils' + +const EXTRACTABLE_EXTENSION = /\.(?:tsx?|jsx?|mjs)$/ + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('#') || specifier.startsWith('node:')) { + return undefined + } + + const [first, second] = specifier.split('/') + if (!first) return undefined + if (!first.startsWith('@')) return first + return second ? `${first}/${second}` : undefined +} + +function isPrewarmPackage( + packageName: string, + libPackage: string, + include: string[], +): boolean { + const configuredPackage = packageNameFromSpecifier(libPackage) + return ( + packageName.startsWith('@devup-ui/') || + packageName.startsWith('@devup-editor/') || + packageName === configuredPackage || + include.some( + (included) => packageName === packageNameFromSpecifier(included), + ) + ) +} + +function preferEsmFile(filename: string): string { + if (extname(filename) !== '.cjs') return filename + const esmFilename = `${filename.slice(0, -4)}.mjs` + return existsSync(esmFilename) ? esmFilename : filename +} + +function toKey(cwd: string, filename: string): string { + return relative(cwd, resolve(cwd, filename)).replaceAll('\\', '/') +} + +export interface CollectProductionPrewarmFilesOptions { + cwd: string + graph: StaticImportGraph + expectedBaseFiles: string[] + libPackage: string + include: string[] +} + +/** + * Build the deterministic production extraction set used before Turbopack can + * request its first CSS module. + * + * `computeCompiledFiles` is intentionally route-aware, but a bundler can also + * compile files hidden behind template imports / MDX and package entries that + * live outside `srcDir`. Prewarming every extractable source file plus the + * external package entries accepted by the loader makes the first snapshot + * independent of Turbopack scheduling. Per-file mode still only imports chunks + * that the bundler reaches; single-CSS mode intentionally contains the whole + * application stylesheet. + */ +export function collectProductionPrewarmFiles({ + cwd, + graph, + expectedBaseFiles, + libPackage, + include, +}: CollectProductionPrewarmFilesOptions): string[] { + const resolvedCwd = resolve(cwd) + const files = new Set( + expectedBaseFiles.map((filename) => toKey(resolvedCwd, filename)), + ) + + for (const filename of graph.files) { + files.add(toKey(resolvedCwd, filename)) + } + + const externalSpecifiers = new Set() + for (const specifiers of graph.externalImports?.values() ?? []) { + for (const specifier of specifiers) externalSpecifiers.add(specifier) + } + + const requireFromProject = createRequire(join(resolvedCwd, 'package.json')) + for (const specifier of [...externalSpecifiers].sort()) { + const packageName = packageNameFromSpecifier(specifier) + if (!packageName || !isPrewarmPackage(packageName, libPackage, include)) { + continue + } + + try { + const filename = preferEsmFile(requireFromProject.resolve(specifier)) + if (!EXTRACTABLE_EXTENSION.test(filename)) continue + files.add(toKey(resolvedCwd, filename)) + } catch { + // Resolution is best-effort, matching the static graph pre-pass. The + // loader remains the fallback for packages resolved only by Turbopack. + } + } + + return [...files].sort() +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index 4278b9c4..0110a2c2 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -951,6 +951,29 @@ describe('buildStaticImportGraph sharing', () => { computeFileReach({ cwd, srcDir }), ) }) + + it('records bare runtime imports that resolve outside the source graph', () => { + writeFixture( + 'src/app/page.tsx', + [ + "import '@devup-ui/reset-css'", + "import type { ReactNode } from 'react'", + "import { type Metadata } from 'next'", + "import './local'", + "export const load = () => import('@devup-ui/components')", + ].join('\n'), + ) + writeFixture('src/app/local.tsx', 'export const local = true\n') + + const graph = buildStaticImportGraph(srcDir) + + expect(graph.externalImports?.get(join(srcDir, 'app/page.tsx'))).toEqual( + new Set(['@devup-ui/reset-css', '@devup-ui/components']), + ) + expect(graph.externalImports?.get(join(srcDir, 'app/local.tsx'))).toEqual( + new Set(), + ) + }) }) describe('planAtomHoist', () => { diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index dbb5de16..ae3a39fe 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -100,6 +100,12 @@ export interface StaticImportGraph { * is itself unreachable would be treated as compiled. */ dynamicImports: Map> + /** + * file -> bare runtime import specifiers that resolve outside `srcDir`. + * Bundler plugins use this to prewarm explicitly included package entries + * (for example `@devup-ui/reset-css`) before their first CSS snapshot. + */ + externalImports?: Map> } /** @@ -125,18 +131,29 @@ export function buildStaticImportGraph( const staticImports = new Map>() const dynamicImports = new Map>() const dynamicTargets = new Set() + const externalImports = new Map>() for (const file of files) { staticImporters.set(file, new Set()) staticImports.set(file, new Set()) dynamicImports.set(file, new Set()) + externalImports.set(file, new Set()) } for (const file of files) { const imports = parseImports(file, readFileSync(file, 'utf-8')) for (const importRef of imports) { const target = resolveImport(importRef.specifier, file, context) - if (!target) continue + if (!target) { + if ( + !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ) { + externalImports.get(file)?.add(importRef.specifier) + } + continue + } if (importRef.kind === 'dynamic') { dynamicTargets.add(target) dynamicImports.get(file)?.add(target) @@ -154,6 +171,7 @@ export function buildStaticImportGraph( staticImporters, dynamicTargets, dynamicImports, + externalImports, } }