Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_9JC2YOMwT_fB8O1o4sq4f.json
Original file line number Diff line number Diff line change
@@ -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"}
66 changes: 66 additions & 0 deletions packages/next-plugin/src/__tests__/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/next-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ describe('DevupUINextPlugin', () => {
},
},
],
'*.{tsx,ts,js,mjs}': {
'*.{tsx,ts,jsx,js,mjs}': {
loaders: [
{
loader: '@devup-ui/next-plugin/loader',
Expand Down Expand Up @@ -310,7 +310,7 @@ describe('DevupUINextPlugin', () => {
},
},
],
'*.{tsx,ts,js,mjs}': {
'*.{tsx,ts,jsx,js,mjs}': {
condition: {
not: {
path: new RegExp(
Expand Down Expand Up @@ -402,7 +402,7 @@ describe('DevupUINextPlugin', () => {
},
},
],
'*.{tsx,ts,js,mjs}': {
'*.{tsx,ts,jsx,js,mjs}': {
condition: {
not: {
path: new RegExp(
Expand Down
74 changes: 61 additions & 13 deletions packages/next-plugin/src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -177,6 +207,13 @@ function waitForBase(): Promise<void> {
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 (
Expand Down Expand Up @@ -248,20 +285,29 @@ function waitForBucket(bucket: string): Promise<void> {
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(', ')})`,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -504,6 +551,7 @@ export const resetCoordinator = () => {
lastCompletedAt = 0
pendingExtractStarts = 0
idleThresholdMs = 2500
quietMs = 10_000
maxWaitMs = 60_000
extractedFiles.clear()
fileNumToBucket.clear()
Expand Down
7 changes: 6 additions & 1 deletion packages/next-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading