diff --git a/.changepacks/changepack_log_9JC2YOMwT_fB8O1o4sq4f.json b/.changepacks/changepack_log_9JC2YOMwT_fB8O1o4sq4f.json new file mode 100644 index 00000000..90f802dd --- /dev/null +++ b/.changepacks/changepack_log_9JC2YOMwT_fB8O1o4sq4f.json @@ -0,0 +1 @@ +{"changes":{"packages/next-plugin/package.json":"Patch","packages/plugin-utils/package.json":"Patch"},"note":"Fix import type issue","date":"2026-07-02T01:43:47.655413Z"} \ No newline at end of file diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 07e14914..0aade216 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -1183,6 +1183,72 @@ 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. + 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') + const infoSpy = spyOn(console, 'info').mockReturnValue(undefined) + const warnSpy = spyOn(console, 'warn').mockReturnValue(undefined) + const canonicalMap = { 'src/phantom.tsx': 'src/bucket.tsx' } + const { coordinator, port } = await startAndGetPort( + makeOptions({ canonicalMap, quietMs: 100, maxWaitMs: 10_000 }), + ) + await extract(port, 'src/bucket.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?fileNum=1&importMainCss=true&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + expect(res.body).toBe('bucket-css') + // Resolved by the quiet exit (~100ms), NOT the 10s wall-clock backstop. + expect(elapsed).toBeLessThan(5000) + expect(infoSpy).toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalled() + + infoSpy.mockRestore() + warnSpy.mockRestore() + coordinator.close() + }) + + // T8: a phantom expectedBaseFile resolves the base-css wait via the same + // quiet exit instead of stalling until maxWaitMs. + 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') + const { coordinator, port } = await startAndGetPort( + makeOptions({ + expectedBaseFiles: ['src/a.tsx', 'src/phantom.tsx'], + quietMs: 100, + maxWaitMs: 10_000, + }), + ) + await extract(port, 'src/a.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + expect(res.body).toBe('base-css') + // Quiet exit (~100ms), not the 10s backstop. + expect(elapsed).toBeLessThan(5000) + + coordinator.close() + }) + // T6: a phantom expectedBaseFile that never extracts fails open via the // dormant maxWaitMs backstop instead of hanging the build forever. it('fails open on a phantom expectedBaseFile via maxWaitMs', async () => { diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index d391fd5f..b8fc6c41 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -226,7 +226,7 @@ describe('DevupUINextPlugin', () => { }, }, ], - '*.{tsx,ts,js,mjs}': { + '*.{tsx,ts,jsx,js,mjs}': { loaders: [ { loader: '@devup-ui/next-plugin/loader', @@ -310,7 +310,7 @@ describe('DevupUINextPlugin', () => { }, }, ], - '*.{tsx,ts,js,mjs}': { + '*.{tsx,ts,jsx,js,mjs}': { condition: { not: { path: new RegExp( @@ -402,7 +402,7 @@ describe('DevupUINextPlugin', () => { }, }, ], - '*.{tsx,ts,js,mjs}': { + '*.{tsx,ts,jsx,js,mjs}': { condition: { not: { path: new RegExp( diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index ca664d4d..5bc998f0 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -44,6 +44,19 @@ export interface CoordinatorOptions { * signal available). Exposed for tests; the plugin omits it. */ 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. + */ + quietMs?: number /** * Hard timeout (ms) for both the idle and per-bucket waits before failing * open. Defaults to 60000. Exposed for tests; the plugin omits it. @@ -146,8 +159,25 @@ let totalExtractions = 0 let lastCompletedAt = 0 let pendingExtractStarts = 0 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. +function bundlerQuiet(now: number): boolean { + return ( + totalExtractions > 0 && + activeExtractions === 0 && + pendingExtractStarts === 0 && + now - lastCompletedAt >= quietMs + ) +} + 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 @@ -177,6 +207,13 @@ 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. + if (expectedBaseFiles.size > 0 && bundlerQuiet(now)) { + resolve() + return + } // Fallback ONLY when no deterministic signal exists (no routes detected / // pre-pass failed -> expectedBaseFiles empty): the legacy idle heuristic. if ( @@ -248,20 +285,29 @@ function waitForBucket(bucket: string): Promise { resolve() return } - if (Date.now() - start > maxWaitMs) { + 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. + 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`, + ) + resolve() + return + } + if (now - start > maxWaitMs) { // Last-resort backstop only — NOT the primary completion mechanism. - // - // A bucket's member set comes from the import graph (`canonicalMap`), - // which now excludes type-only edges (`import type` / `export type`): - // those are erased by the bundler and never POST /extract, so before - // the fix they were phantom members that hung this wait until the - // wall clock expired. With runtime-only members, every member of a - // REQUESTED bucket is reachable and therefore extracted, so the loop - // above resolves deterministically and this timer never fires on a - // healthy build — its duration no longer affects correctness. It stays - // purely to fail open (serve partial CSS) on a pathological graph - // mismatch instead of hanging the build forever. Turbopack exposes no - // compilation-complete hook, so a timer is the only available backstop. + // Fires only when extraction traffic never pauses for `quietMs` + // within the whole window (a continuously busy build with a genuinely + // missing member). Turbopack exposes no compilation-complete hook, so + // a timer is the only available backstop against hanging forever. const missing = [...members].filter((m) => !extractedFiles.has(m)) console.warn( `[devup-ui] coordinator: bucket "${bucket}" not complete after ${maxWaitMs}ms; serving partial CSS (missing: ${missing.join(', ')})`, @@ -290,6 +336,7 @@ export function startCoordinator(options: CoordinatorOptions): { } = options idleThresholdMs = options.idleThresholdMs ?? 2500 + quietMs = options.quietMs ?? 10_000 maxWaitMs = options.maxWaitMs ?? 60_000 canonicalMapRef = options.canonicalMap bucketToMembers = buildBucketToMembers(options.canonicalMap) @@ -504,6 +551,7 @@ export const resetCoordinator = () => { lastCompletedAt = 0 pendingExtractStarts = 0 idleThresholdMs = 2500 + quietMs = 10_000 maxWaitMs = 60_000 extractedFiles.clear() fileNumToBucket.clear() diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 00c9cf81..e21ab578 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -246,7 +246,12 @@ export function DevupUI( }, }, ], - '*.{tsx,ts,js,mjs}': { + // Must cover every extension the import-graph pre-pass lists AND the + // webpack rule matches (tsx|ts|jsx|js|mjs). Omitting one (e.g. jsx) + // means those files are never extracted: their Box/styled markup hits + // the runtime stubs ("Cannot run on the runtime") and the coordinator + // waits on graph members that never POST /extract. + '*.{tsx,ts,jsx,js,mjs}': { loaders: [ { loader: '@devup-ui/next-plugin/loader', diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index 05e0f7e0..4981170c 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -388,6 +388,64 @@ describe('buildCanonicalMap', () => { }) }) + it('should not treat an all-inline-type `import { type T }` target as a member', () => { + // With no value bindings left, TypeScript import elision erases the whole + // statement at build time — no runtime module, so no graph edge. Keeping + // it made the target a phantom bucket member the bundler never compiles. + writeFixture( + 'src/a.tsx', + "import { type T } from './b'\nexport const a: T = 1\n", + ) + writeFixture('src/b.tsx', 'export type T = number\nexport const val = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should not treat a multiline all-inline-type import target as a member', () => { + writeFixture( + 'src/a.tsx', + "import {\n type T,\n type U,\n} from './b'\nexport const a = 1\n", + ) + writeFixture( + 'src/b.tsx', + 'export type T = number\nexport type U = string\nexport const val = 1\n', + ) + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should not treat an all-inline-type `export { type T } from` target as a member', () => { + writeFixture( + 'src/a.tsx', + "export { type T } from './b'\nexport const a = 1\n", + ) + writeFixture('src/b.tsx', 'export type T = number\nexport const val = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('keeps a mixed `export { type T, x } from` target (module still imported)', () => { + writeFixture('src/a.tsx', "export { type T, x } from './b'\n") + writeFixture('src/b.tsx', 'export type T = number\nexport const x = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('keeps an import of a value binding literally named `type`', () => { + // `{ type }` imports a VALUE named "type" — not an inline type specifier. + writeFixture( + 'src/a.tsx', + "import { type } from './b'\nexport const a = type\n", + ) + writeFixture('src/b.tsx', 'export const type = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + it('should collapse a shared dep into its only VALUE importer when others import it type-only', () => { // `a` imports `shared` at runtime; `b` only `import type`s it. Erasing the // type edge leaves `shared` with a single real importer -> it collapses into @@ -416,6 +474,31 @@ describe('buildCanonicalMap', () => { }) }) + it('ignores import-like code snippets inside template literals', () => { + // A docs/codegen file embedding example code in a template literal must + // NOT create a graph edge: the bundler never loads './b', so counting it + // would make it a phantom bucket member (the coordinator-stall class). + writeFixture( + 'src/a.tsx', + [ + 'const snippet = `', + "import { Box } from './b'", + "import './b'", + '`', + "const escaped = `mid \\` import './b' `", + "import './c'", + 'export const a = 1', + ].join('\n'), + ) + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/c.tsx', 'export const c = 1\n') + + // Only the real import ('./c') collapses; './b' stays edge-free. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/c.tsx': 'src/a.tsx', + }) + }) + it('parses imports while stripping comments and string escapes', () => { writeFixture( 'src/a.tsx', @@ -866,6 +949,84 @@ describe('oxc AST parsing path', () => { }) }) + it('skips AST import/export nodes whose specifiers are all inline-type', () => { + const program = { + type: 'Program', + body: [ + // all-inline-type import -> erased by the bundler -> no edge + { + type: 'ImportDeclaration', + importKind: 'value', + specifiers: [{ type: 'ImportSpecifier', importKind: 'type' }], + source: { value: './phantom' }, + }, + // all-inline-type re-export -> erased -> no edge + { + type: 'ExportNamedDeclaration', + exportKind: 'value', + specifiers: [{ type: 'ExportSpecifier', exportKind: 'type' }], + source: { value: './phantom2' }, + }, + // mixed inline types -> module still imported -> edge kept + { + type: 'ImportDeclaration', + importKind: 'value', + specifiers: [ + { type: 'ImportSpecifier', importKind: 'type' }, + { type: 'ImportSpecifier', importKind: 'value' }, + ], + source: { value: './mixed' }, + }, + // default specifier (no importKind) alongside an inline type -> kept + { + type: 'ImportDeclaration', + importKind: 'value', + specifiers: [ + { type: 'ImportDefaultSpecifier' }, + { type: 'ImportSpecifier', importKind: 'type' }, + ], + source: { value: './withdefault' }, + }, + // non-record specifier entry -> not type-only -> kept + { + type: 'ImportDeclaration', + importKind: 'value', + specifiers: ['bogus'], + source: { value: './bogus' }, + }, + // empty specifier list (`import {} from`) -> kept (side-effect import) + { + type: 'ImportDeclaration', + importKind: 'value', + specifiers: [] as unknown[], + source: { value: './empty' }, + }, + ] as unknown[], + } + __setOxcParserForTest({ + parseSync: (filename: string) => + filename.endsWith('a.tsx') + ? { program } + : { program: { type: 'Program', body: [] as unknown[] } }, + }) + + writeFixture('src/a.tsx', 'fake parser input') + writeFixture('src/phantom.tsx', 'fake parser input') + writeFixture('src/phantom2.tsx', 'fake parser input') + writeFixture('src/mixed.tsx', 'fake parser input') + writeFixture('src/withdefault.tsx', 'fake parser input') + writeFixture('src/bogus.tsx', 'fake parser input') + writeFixture('src/empty.tsx', 'fake parser input') + + // phantom/phantom2 gain no importer (edges dropped) -> roots, not members. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/bogus.tsx': 'src/a.tsx', + 'src/empty.tsx': 'src/a.tsx', + 'src/mixed.tsx': 'src/a.tsx', + 'src/withdefault.tsx': 'src/a.tsx', + }) + }) + it('falls back to the regex scan when the oxc parser throws', () => { __setOxcParserForTest({ parseSync: () => { diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index da191a45..b2da4eb1 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -584,7 +584,13 @@ function collectAstImports( // `import type`/`export type ... from` carry importKind/exportKind 'type'. // They are erased at build time (no runtime module), so they must NOT // become static graph edges — see the regex fallback in `scanImports`. - if (node.importKind !== 'type' && node.exportKind !== 'type') { + // The same applies when every specifier is inline-type + // (`import { type A } from` / `export { type A } from`). + if ( + node.importKind !== 'type' && + node.exportKind !== 'type' && + !hasOnlyInlineTypeSpecifiers(node) + ) { addAstImport(imports, 'static', node.source) } } else if (type === 'ImportExpression') { @@ -607,6 +613,20 @@ function collectAstImports( } } +// AST counterpart of `isAllInlineTypeSpecifiers`: an import/re-export whose +// specifiers are ALL inline-type is erased by the bundler (no runtime module), +// so it must not become a static graph edge. Default/namespace specifiers +// carry no `type` kind, so their presence keeps the edge. +function hasOnlyInlineTypeSpecifiers(node: Record): boolean { + const specifiers = node.specifiers + if (!Array.isArray(specifiers) || specifiers.length === 0) return false + return specifiers.every( + (specifier) => + isRecord(specifier) && + (specifier.importKind === 'type' || specifier.exportKind === 'type'), + ) +} + function addAstImport( imports: ImportReference[], kind: ImportReference['kind'], @@ -628,6 +648,29 @@ function isImportCallee(node: unknown): boolean { return node.type === 'Import' || node.name === 'import' } +// A brace clause whose specifiers are ALL inline-type (`{ type A, type B }`) +// is erased by the bundler exactly like a statement-level `import type`: +// TypeScript import elision (the Next.js/SWC default) removes the whole +// statement, so no runtime module is ever produced. Counting such an edge as +// static merges a phantom member into a bucket the bundler never compiles — +// the next-plugin coordinator then waits for a file that can never arrive. +// A mixed clause (`{ type A, b }`) still imports the module for `b` and is +// kept. A default/namespace clause is always a value import and is kept. +function isAllInlineTypeSpecifiers(clause: string | undefined): boolean { + if (!clause) return false + const trimmed = clause.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false + const specifiers = trimmed + .slice(1, -1) + .split(',') + .map((specifier) => specifier.trim()) + .filter((specifier) => specifier.length > 0) + return ( + specifiers.length > 0 && + specifiers.every((specifier) => /^type\s/.test(specifier)) + ) +} + function scanImports(source: string): ImportReference[] { const imports: ImportReference[] = [] const code = stripComments(source) @@ -636,21 +679,24 @@ function scanImports(source: string): ImportReference[] { // the bundler and produce NO runtime module — counting them as static graph // edges merges phantom members into a bucket that the bundler never compiles, // which is exactly what forced the coordinator's wall-clock fail-open to fire. - // Inline specifier types (`import { type A, b } from`) keep importing the - // module for `b`, so the leading group stays undefined and they are kept. + // The clause between the keyword and `from` is captured too, so all-inline- + // type specifier lists (`import { type A } from` / `export { type A } from`) + // — which the bundler also erases — are dropped via + // `isAllInlineTypeSpecifiers`. Mixed lists (`import { type A, b } from`) + // keep importing the module for `b`, so they are kept. const staticImportRegex = - /\bimport\s+(type\s+)?(?:[^'"`]*?\s+from\s*)?(['"])([^'"]+)\2/gm + /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm const exportFromRegex = - /\bexport\s+(type\s+)?(?:\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\2/gm + /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm for (const match of code.matchAll(staticImportRegex)) { - if (match[1]) continue - imports.push({ kind: 'static', specifier: match[3] }) + if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue + imports.push({ kind: 'static', specifier: match[4] }) } for (const match of code.matchAll(exportFromRegex)) { - if (match[1]) continue - imports.push({ kind: 'static', specifier: match[3] }) + if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue + imports.push({ kind: 'static', specifier: match[4] }) } for (const match of code.matchAll(dynamicImportRegex)) { imports.push({ kind: 'dynamic', specifier: match[2] }) @@ -669,6 +715,23 @@ function stripComments(source: string): string { const next = source[index + 1] if (quote) { + // Template-literal CONTENTS are blanked (delimiters and newlines kept): + // embedded code snippets (docs sites, codegen templates) would otherwise + // look like real import statements to the scanners below and create + // phantom graph edges for files the bundler never loads. Contents of + // '/" strings are preserved — import specifiers themselves are read from + // those literals by the scan regexes. + if (quote === '`') { + if (char === '\\') { + result += ' ' + index += 2 + continue + } + result += char === '`' || char === '\n' ? char : ' ' + if (char === '`') quote = false + index += 1 + continue + } result += char if (char === '\\') { result += next ?? ''