Skip to content

Commit b0401cb

Browse files
authored
Merge pull request #783 from dev-five-git/fix/next-webpack-css-delivery
fix(next): deliver complete webpack CSS across compilers and cached builds
2 parents b4c143b + b0b57ef commit b0401cb

17 files changed

Lines changed: 952 additions & 70 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"changes": {
3+
"packages/next-plugin/package.json": "Patch",
4+
"packages/webpack-plugin/package.json": "Patch",
5+
"packages/plugin-utils/package.json": "Patch"
6+
},
7+
"note": "Reconstruct the complete deterministic Next webpack stylesheet after each server, edge and client compiler reset, including accepted external package dependency closures, so delivered CSS matches rendered classes in per-file and single-CSS modes. Keep production CSS request paths stable across build IDs so persistent webpack cache cannot bypass the CSS loader. Genuine production prewarm collection, read and extraction failures now fail the build instead of silently dropping styles; failed initialization releases its build session without leaking styles into unrelated builds.",
8+
"date": "2026-10-16T00:00:00.000Z"
9+
}

‎packages/next-plugin/src/__tests__/plugin.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ describe('DevupUINextPlugin', () => {
194194
ret.webpack!(createWebpackConfig(), createWebpackContext())
195195

196196
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
197-
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
197+
cssDir: resolve('.next/cache', 'devup-ui'),
198+
productionPrewarmFiles: [],
198199
})
199200
})
200201

@@ -221,7 +222,8 @@ describe('DevupUINextPlugin', () => {
221222

222223
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
223224
package: 'new-package',
224-
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
225+
cssDir: resolve('.next/cache', 'devup-ui'),
226+
productionPrewarmFiles: [],
225227
})
226228
})
227229

@@ -240,7 +242,8 @@ describe('DevupUINextPlugin', () => {
240242

241243
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
242244
package: 'new-package',
243-
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
245+
cssDir: resolve('.next/cache', 'devup-ui'),
246+
productionPrewarmFiles: [],
244247
})
245248
expect(webpack).toHaveBeenCalled()
246249
})

‎packages/next-plugin/src/__tests__/prewarm.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,50 @@ describe('collectProductionPrewarmFiles', () => {
119119
}),
120120
).toEqual(['src/app/page.tsx'])
121121
})
122+
123+
it('follows accepted barrels, relative cycles, lazy modules and importer-local accepted dependencies', () => {
124+
// Given a package barrel with a nested dependency and a rejected sibling.
125+
writePackage('design-system', './index.cjs', {
126+
'index.cjs': '',
127+
'index.mjs': 'export * from "./child.js"; import("./lazy.js")',
128+
'child.js':
129+
'export * from "./index.mjs"; import "@devup-ui/nested"; import "rejected"',
130+
'lazy.js': 'export const lazy = 1',
131+
'node_modules/@devup-ui/nested/package.json': '{"main":"index.js"}',
132+
'node_modules/@devup-ui/nested/index.js': 'export const nested = 1',
133+
'node_modules/rejected/package.json': '{"main":"index.js"}',
134+
'node_modules/rejected/index.js': 'export const rejected = 1',
135+
})
136+
// When collecting the complete extraction set.
137+
const files = collectProductionPrewarmFiles({
138+
cwd,
139+
graph: makeGraph([], ['design-system']),
140+
expectedBaseFiles: [],
141+
libPackage: '@devup-ui/react',
142+
include: ['design-system'],
143+
})
144+
// Then the closure is complete, deduplicated and policy-filtered.
145+
expect(files).toEqual([
146+
'node_modules/design-system/child.js',
147+
'node_modules/design-system/index.mjs',
148+
'node_modules/design-system/lazy.js',
149+
'node_modules/design-system/node_modules/@devup-ui/nested/index.js',
150+
])
151+
})
152+
153+
it('propagates a genuine accepted-package collection error', () => {
154+
// Given a malformed accepted package manifest.
155+
writePackage('design-system', './index.js', { 'index.js': '' })
156+
writeFileSync(join(cwd, 'node_modules/design-system/package.json'), '{')
157+
// When collecting it, then the build cannot silently omit its styles.
158+
expect(() =>
159+
collectProductionPrewarmFiles({
160+
cwd,
161+
graph: makeGraph([], ['design-system']),
162+
expectedBaseFiles: [],
163+
libPackage: '@devup-ui/react',
164+
include: ['design-system'],
165+
}),
166+
).toThrow()
167+
})
122168
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
module.exports = function deliveryFixture(put) {
2+
const singleCss = process.env.DEVUP_NEXT_SINGLE_CSS === '1'
3+
put(
4+
'devup.json',
5+
JSON.stringify({ theme: { colors: { default: { proof: '#123456' } } } }),
6+
)
7+
put(
8+
'next.config.js',
9+
`
10+
const { DevupUI } = require('@devup-ui/next-plugin');
11+
module.exports = DevupUI({
12+
experimental: { cpus: 1, webpackBuildWorker: true },
13+
transpilePackages: ['delivery-library'],
14+
webpack(config, context) {
15+
console.info('W65-TOPOLOGY ' + JSON.stringify({ pid: process.pid, threadId: require('node:worker_threads').threadId, isServer: context.isServer, nextRuntime: context.nextRuntime || 'client' }));
16+
return config;
17+
}
18+
}, { singleCss: ${singleCss}, include: ['delivery-library'] });
19+
`,
20+
)
21+
put(
22+
'src/app/layout.js',
23+
`
24+
import { Box, globalCss } from '@devup-ui/react';
25+
globalCss({ body: { background: '#040', color: '#248' } });
26+
export default function Layout({ children }) {
27+
return <html><body><Box id="layout-proof" color="white">{children}</Box></body></html>;
28+
}
29+
`,
30+
)
31+
put(
32+
'src/app/page.js',
33+
`
34+
import { Box } from '@devup-ui/react';
35+
import { Child } from 'delivery-library';
36+
import Client from './client';
37+
export default function Page() {
38+
return <><Box id="server-proof" bg="maroon" p="13px">server-proof</Box><Client /><Child id="external-proof">external-proof</Child></>;
39+
}
40+
`,
41+
)
42+
put(
43+
'src/app/client.js',
44+
`
45+
'use client';
46+
import { Box } from '@devup-ui/react';
47+
export default function Client() { return <Box id="client-proof" color="cyan">client-proof</Box>; }
48+
`,
49+
)
50+
put('src/app/client/page.js', `export { default } from '../client';`)
51+
put(
52+
'src/pages/edge-page.js',
53+
`
54+
import { Box, css } from '@devup-ui/react';
55+
export const config = { runtime: 'experimental-edge' };
56+
const edgePage = css({ color: 'goldenrod', padding: '19px' });
57+
export default function EdgePage() { return <Box id="layout-proof" color="white"><div id="edge-page-proof" className={edgePage}>edge-page-proof</div></Box>; }
58+
`,
59+
)
60+
put(
61+
'node_modules/delivery-library/package.json',
62+
JSON.stringify({
63+
name: 'delivery-library',
64+
version: '1.0.0',
65+
type: 'module',
66+
main: './index.mjs',
67+
}),
68+
)
69+
put(
70+
'node_modules/delivery-library/index.mjs',
71+
`export { Child } from './child.mjs';`,
72+
)
73+
put(
74+
'node_modules/delivery-library/child.mjs',
75+
`
76+
import { styled } from '@devup-ui/react';
77+
export const Child = styled('strong', { color: 'rebeccapurple', marginLeft: '17px' });
78+
`,
79+
)
80+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
const assert = require('node:assert/strict')
2+
3+
const colors = {
4+
maroon: '#800000',
5+
cyan: '#00ffff',
6+
aqua: '#00ffff',
7+
white: '#ffffff',
8+
goldenrod: '#daa520',
9+
rebeccapurple: '#663399',
10+
}
11+
function normalize(value) {
12+
const lower = value.trim().toLowerCase()
13+
if (colors[lower]) return colors[lower]
14+
if (/^#[\da-f]{3}$/.test(lower))
15+
return '#' + [...lower.slice(1)].map((digit) => digit + digit).join('')
16+
const rgb = lower.match(
17+
/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:\s*[,/]\s*1)?\s*\)$/,
18+
)
19+
return rgb
20+
? '#' +
21+
rgb
22+
.slice(1)
23+
.map((part) => Number(part).toString(16).padStart(2, '0'))
24+
.join('')
25+
: lower
26+
}
27+
function rules(css, parse) {
28+
const result = new Map()
29+
parse(css).walkRules((rule) => {
30+
for (const selector of rule.selectors) {
31+
const declarations = result.get(selector) || new Map()
32+
rule.walkDecls((decl) =>
33+
declarations.set(decl.prop, normalize(decl.value)),
34+
)
35+
result.set(selector, declarations)
36+
}
37+
})
38+
return result
39+
}
40+
function declarationProof(parsed, selectors, expected) {
41+
const declarations = new Map(
42+
selectors.flatMap((selector) => [...(parsed.get(selector) || [])]),
43+
)
44+
for (const [property, value] of Object.entries(expected))
45+
assert.equal(
46+
declarations.get(property),
47+
normalize(value),
48+
`${selectors.join(' ')} missing/wrong ${property}; actual ${JSON.stringify([...declarations])}`,
49+
)
50+
return [...declarations].sort(([a], [b]) => a.localeCompare(b))
51+
}
52+
function elementProof(html, parsed, proof) {
53+
const tag = [...html.matchAll(/<[a-z][^>]*>/gi)]
54+
.map(([value]) => value)
55+
.find((value) => value.includes(`id="${proof.id}"`))
56+
assert.ok(tag, `missing SSR element ${proof.id}`)
57+
const classes = tag.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/)
58+
assert.ok(classes?.length, `missing actual classes for ${proof.id}`)
59+
for (const name of classes) {
60+
assert.match(name, /^[\w-]+$/)
61+
assert.ok(
62+
parsed.get(`.${name}`)?.size,
63+
`${proof.id}: actual class .${name} has no linked declarations`,
64+
)
65+
}
66+
return {
67+
id: proof.id,
68+
classes: [...classes].sort(),
69+
declarations: declarationProof(
70+
parsed,
71+
classes.map((name) => `.${name}`),
72+
proof.expected,
73+
),
74+
}
75+
}
76+
async function documentProof(base, parse, page) {
77+
const response = await fetch(new URL(page.path, base), {
78+
signal: AbortSignal.timeout(10000),
79+
})
80+
assert.equal(response.status, 200, `${page.path} HTML status`)
81+
assert.match(response.headers.get('content-type') || '', /text\/html/)
82+
const html = await response.text()
83+
const links = [...html.matchAll(/<link\b[^>]*>/g)]
84+
.map(([tag]) => tag)
85+
.filter((tag) => /\brel="stylesheet"/.test(tag))
86+
.map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1].replaceAll('&amp;', '&'))
87+
assert.ok(links.length > 0, `${page.path} must link CSS`)
88+
const sheets = []
89+
for (const link of [...new Set(links)]) {
90+
assert.ok(link, 'stylesheet link must have href')
91+
const response = await fetch(new URL(link, base), {
92+
signal: AbortSignal.timeout(10000),
93+
})
94+
assert.equal(response.status, 200, `${page.path}: stylesheet ${link}`)
95+
assert.match(response.headers.get('content-type') || '', /text\/css/)
96+
const css = await response.text()
97+
assert.ok(css.trim().length > 0, `${link} empty CSS`)
98+
sheets.push({ url: link, css })
99+
}
100+
const parsed = rules(sheets.map(({ css }) => css).join('\n'), parse)
101+
const elements = [
102+
...page.elements,
103+
{ id: 'layout-proof', expected: { color: 'white' } },
104+
].map((proof) => elementProof(html, parsed, proof))
105+
const body = declarationProof(parsed, ['body'], {
106+
background: '#040',
107+
color: '#248',
108+
})
109+
const theme = [...parsed.values()].some(
110+
(declarations) => declarations.get('--proof') === '#123456',
111+
)
112+
assert.ok(theme, `${page.path} linked theme --proof missing`)
113+
return { path: page.path, elements, body, theme, sheets }
114+
}
115+
module.exports = async function deliveryHttp(base, parse) {
116+
const pages = [
117+
{
118+
path: '/',
119+
elements: [
120+
{
121+
id: 'server-proof',
122+
expected: { background: 'maroon', padding: '13px' },
123+
},
124+
{ id: 'client-proof', expected: { color: 'cyan' } },
125+
{
126+
id: 'external-proof',
127+
expected: { color: 'rebeccapurple', 'margin-left': '17px' },
128+
},
129+
],
130+
},
131+
{
132+
path: '/client',
133+
elements: [{ id: 'client-proof', expected: { color: 'cyan' } }],
134+
},
135+
{
136+
path: '/edge-page',
137+
elements: [
138+
{
139+
id: 'edge-page-proof',
140+
expected: { color: 'goldenrod', padding: '19px' },
141+
},
142+
],
143+
},
144+
]
145+
const documents = []
146+
for (const page of pages)
147+
documents.push(await documentProof(base, parse, page))
148+
console.info('W65-HTTP ' + JSON.stringify(documents))
149+
// Compare semantic stylesheet contents/class bindings, never Next build-id URLs/manifests.
150+
return documents.map(({ sheets, ...document }) => ({
151+
...document,
152+
css: sheets.map(({ css }) => css).sort(),
153+
}))
154+
}
155+
module.exports.rules = rules
156+
module.exports.declarationProof = declarationProof
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { join } from 'node:path'
3+
4+
import { expect, it } from 'bun:test'
5+
6+
it('rejects broken HTTP delivery boundaries in the native Node surface', () => {
7+
// Given native Node fetch, isolated from the suite DOM preload.
8+
// When real HTTP documents or their linked CSS violate delivery contracts.
9+
const result = spawnSync(
10+
'node',
11+
[join(import.meta.dir, 'webpack-delivery-negative.cjs')],
12+
{ encoding: 'utf8' },
13+
)
14+
// Then each rejection must identify the broken boundary, not a later error.
15+
console.info(result.stdout + result.stderr)
16+
expect(result.error).toBeUndefined()
17+
expect(result.status).toBe(0)
18+
})

0 commit comments

Comments
 (0)