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
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_a2YdR0dEoHiYJ22qe39PI.json
Original file line number Diff line number Diff line change
@@ -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"
}
74 changes: 67 additions & 7 deletions packages/next-plugin/src/__tests__/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
141 changes: 141 additions & 0 deletions packages/next-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import * as coordinatorModule from '../coordinator'
import { DevupUI } from '../plugin'

type CodeExtractResult = ReturnType<typeof wasm.codeExtract>
type NextWebpackConfig = Parameters<
NonNullable<ReturnType<typeof DevupUI>['webpack']>
>[0]
Expand All @@ -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<typeof spyOn>
let mkdirSyncSpy: ReturnType<typeof spyOn>
let readFileSyncSpy: ReturnType<typeof spyOn>
Expand All @@ -54,6 +67,7 @@ let importFileMapSpy: ReturnType<typeof spyOn>
let exportSheetSpy: ReturnType<typeof spyOn>
let exportClassMapSpy: ReturnType<typeof spyOn>
let exportFileMapSpy: ReturnType<typeof spyOn>
let codeExtractSpy: ReturnType<typeof spyOn>
let devupUIWebpackPluginSpy: ReturnType<typeof spyOn>
let startCoordinatorSpy: ReturnType<typeof spyOn>

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -126,6 +143,7 @@ afterEach(() => {
exportSheetSpy.mockRestore()
exportClassMapSpy.mockRestore()
exportFileMapSpy.mockRestore()
codeExtractSpy.mockRestore()
devupUIWebpackPluginSpy.mockRestore()
startCoordinatorSpy.mockRestore()
})
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string>()],
[templateTarget, new Set<string>()],
]),
staticImporters: new Map([
[page, new Set<string>()],
[templateTarget, new Set<string>()],
]),
dynamicTargets: new Set(),
dynamicImports: new Map([
[page, new Set<string>()],
[templateTarget, new Set<string>()],
]),
externalImports: new Map([
[page, new Set<string>()],
[templateTarget, new Set<string>()],
]),
})
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(
Expand Down
Loading
Loading