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
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_next_webpack_css_delivery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"packages/next-plugin/package.json": "Patch",
"packages/webpack-plugin/package.json": "Patch",
"packages/plugin-utils/package.json": "Patch"
},
"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.",
"date": "2026-10-16T00:00:00.000Z"
}
9 changes: 6 additions & 3 deletions packages/next-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ describe('DevupUINextPlugin', () => {
ret.webpack!(createWebpackConfig(), createWebpackContext())

expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
cssDir: resolve('.next/cache', 'devup-ui'),
productionPrewarmFiles: [],
})
})

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

expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
package: 'new-package',
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
cssDir: resolve('.next/cache', 'devup-ui'),
productionPrewarmFiles: [],
})
})

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

expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
package: 'new-package',
cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
cssDir: resolve('.next/cache', 'devup-ui'),
productionPrewarmFiles: [],
})
expect(webpack).toHaveBeenCalled()
})
Expand Down
46 changes: 46 additions & 0 deletions packages/next-plugin/src/__tests__/prewarm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,50 @@ describe('collectProductionPrewarmFiles', () => {
}),
).toEqual(['src/app/page.tsx'])
})

it('follows accepted barrels, relative cycles, lazy modules and importer-local accepted dependencies', () => {
// Given a package barrel with a nested dependency and a rejected sibling.
writePackage('design-system', './index.cjs', {
'index.cjs': '',
'index.mjs': 'export * from "./child.js"; import("./lazy.js")',
'child.js':
'export * from "./index.mjs"; import "@devup-ui/nested"; import "rejected"',
'lazy.js': 'export const lazy = 1',
'node_modules/@devup-ui/nested/package.json': '{"main":"index.js"}',
'node_modules/@devup-ui/nested/index.js': 'export const nested = 1',
'node_modules/rejected/package.json': '{"main":"index.js"}',
'node_modules/rejected/index.js': 'export const rejected = 1',
})
// When collecting the complete extraction set.
const files = collectProductionPrewarmFiles({
cwd,
graph: makeGraph([], ['design-system']),
expectedBaseFiles: [],
libPackage: '@devup-ui/react',
include: ['design-system'],
})
// Then the closure is complete, deduplicated and policy-filtered.
expect(files).toEqual([
'node_modules/design-system/child.js',
'node_modules/design-system/index.mjs',
'node_modules/design-system/lazy.js',
'node_modules/design-system/node_modules/@devup-ui/nested/index.js',
])
})

it('propagates a genuine accepted-package collection error', () => {
// Given a malformed accepted package manifest.
writePackage('design-system', './index.js', { 'index.js': '' })
writeFileSync(join(cwd, 'node_modules/design-system/package.json'), '{')
// When collecting it, then the build cannot silently omit its styles.
expect(() =>
collectProductionPrewarmFiles({
cwd,
graph: makeGraph([], ['design-system']),
expectedBaseFiles: [],
libPackage: '@devup-ui/react',
include: ['design-system'],
}),
).toThrow()
})
})
80 changes: 80 additions & 0 deletions packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
module.exports = function deliveryFixture(put) {
const singleCss = process.env.DEVUP_NEXT_SINGLE_CSS === '1'
put(
'devup.json',
JSON.stringify({ theme: { colors: { default: { proof: '#123456' } } } }),
)
put(
'next.config.js',
`
const { DevupUI } = require('@devup-ui/next-plugin');
module.exports = DevupUI({
experimental: { cpus: 1, webpackBuildWorker: true },
transpilePackages: ['delivery-library'],
webpack(config, context) {
console.info('W65-TOPOLOGY ' + JSON.stringify({ pid: process.pid, threadId: require('node:worker_threads').threadId, isServer: context.isServer, nextRuntime: context.nextRuntime || 'client' }));
return config;
}
}, { singleCss: ${singleCss}, include: ['delivery-library'] });
`,
)
put(
'src/app/layout.js',
`
import { Box, globalCss } from '@devup-ui/react';
globalCss({ body: { background: '#040', color: '#248' } });
export default function Layout({ children }) {
return <html><body><Box id="layout-proof" color="white">{children}</Box></body></html>;
}
`,
)
put(
'src/app/page.js',
`
import { Box } from '@devup-ui/react';
import { Child } from 'delivery-library';
import Client from './client';
export default function Page() {
return <><Box id="server-proof" bg="maroon" p="13px">server-proof</Box><Client /><Child id="external-proof">external-proof</Child></>;
}
`,
)
put(
'src/app/client.js',
`
'use client';
import { Box } from '@devup-ui/react';
export default function Client() { return <Box id="client-proof" color="cyan">client-proof</Box>; }
`,
)
put('src/app/client/page.js', `export { default } from '../client';`)
put(
'src/pages/edge-page.js',
`
import { Box, css } from '@devup-ui/react';
export const config = { runtime: 'experimental-edge' };
const edgePage = css({ color: 'goldenrod', padding: '19px' });
export default function EdgePage() { return <Box id="layout-proof" color="white"><div id="edge-page-proof" className={edgePage}>edge-page-proof</div></Box>; }
`,
)
put(
'node_modules/delivery-library/package.json',
JSON.stringify({
name: 'delivery-library',
version: '1.0.0',
type: 'module',
main: './index.mjs',
}),
)
put(
'node_modules/delivery-library/index.mjs',
`export { Child } from './child.mjs';`,
)
put(
'node_modules/delivery-library/child.mjs',
`
import { styled } from '@devup-ui/react';
export const Child = styled('strong', { color: 'rebeccapurple', marginLeft: '17px' });
`,
)
}
156 changes: 156 additions & 0 deletions packages/next-plugin/src/__tests__/webpack-delivery-http.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
const assert = require('node:assert/strict')

const colors = {
maroon: '#800000',
cyan: '#00ffff',
aqua: '#00ffff',
white: '#ffffff',
goldenrod: '#daa520',
rebeccapurple: '#663399',
}
function normalize(value) {
const lower = value.trim().toLowerCase()
if (colors[lower]) return colors[lower]
if (/^#[\da-f]{3}$/.test(lower))
return '#' + [...lower.slice(1)].map((digit) => digit + digit).join('')
const rgb = lower.match(
/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:\s*[,/]\s*1)?\s*\)$/,
)
return rgb
? '#' +
rgb
.slice(1)
.map((part) => Number(part).toString(16).padStart(2, '0'))
.join('')
: lower
}
function rules(css, parse) {
const result = new Map()
parse(css).walkRules((rule) => {
for (const selector of rule.selectors) {
const declarations = result.get(selector) || new Map()
rule.walkDecls((decl) =>
declarations.set(decl.prop, normalize(decl.value)),
)
result.set(selector, declarations)
}
})
return result
}
function declarationProof(parsed, selectors, expected) {
const declarations = new Map(
selectors.flatMap((selector) => [...(parsed.get(selector) || [])]),
)
for (const [property, value] of Object.entries(expected))
assert.equal(
declarations.get(property),
normalize(value),
`${selectors.join(' ')} missing/wrong ${property}; actual ${JSON.stringify([...declarations])}`,
)
return [...declarations].sort(([a], [b]) => a.localeCompare(b))
}
function elementProof(html, parsed, proof) {
const tag = [...html.matchAll(/<[a-z][^>]*>/gi)]
.map(([value]) => value)
.find((value) => value.includes(`id="${proof.id}"`))
assert.ok(tag, `missing SSR element ${proof.id}`)
const classes = tag.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/)
assert.ok(classes?.length, `missing actual classes for ${proof.id}`)
for (const name of classes) {
assert.match(name, /^[\w-]+$/)
assert.ok(
parsed.get(`.${name}`)?.size,
`${proof.id}: actual class .${name} has no linked declarations`,
)
}
return {
id: proof.id,
classes: [...classes].sort(),
declarations: declarationProof(
parsed,
classes.map((name) => `.${name}`),
proof.expected,
),
}
}
async function documentProof(base, parse, page) {
const response = await fetch(new URL(page.path, base), {
signal: AbortSignal.timeout(10000),
})
assert.equal(response.status, 200, `${page.path} HTML status`)
assert.match(response.headers.get('content-type') || '', /text\/html/)
const html = await response.text()
const links = [...html.matchAll(/<link\b[^>]*>/g)]
.map(([tag]) => tag)
.filter((tag) => /\brel="stylesheet"/.test(tag))
.map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1].replaceAll('&amp;', '&'))
assert.ok(links.length > 0, `${page.path} must link CSS`)
const sheets = []
for (const link of [...new Set(links)]) {
assert.ok(link, 'stylesheet link must have href')
const response = await fetch(new URL(link, base), {
signal: AbortSignal.timeout(10000),
})
assert.equal(response.status, 200, `${page.path}: stylesheet ${link}`)
assert.match(response.headers.get('content-type') || '', /text\/css/)
const css = await response.text()
assert.ok(css.trim().length > 0, `${link} empty CSS`)
sheets.push({ url: link, css })
}
const parsed = rules(sheets.map(({ css }) => css).join('\n'), parse)
const elements = [
...page.elements,
{ id: 'layout-proof', expected: { color: 'white' } },
].map((proof) => elementProof(html, parsed, proof))
const body = declarationProof(parsed, ['body'], {
background: '#040',
color: '#248',
})
const theme = [...parsed.values()].some(
(declarations) => declarations.get('--proof') === '#123456',
)
assert.ok(theme, `${page.path} linked theme --proof missing`)
return { path: page.path, elements, body, theme, sheets }
}
module.exports = async function deliveryHttp(base, parse) {
const pages = [
{
path: '/',
elements: [
{
id: 'server-proof',
expected: { background: 'maroon', padding: '13px' },
},
{ id: 'client-proof', expected: { color: 'cyan' } },
{
id: 'external-proof',
expected: { color: 'rebeccapurple', 'margin-left': '17px' },
},
],
},
{
path: '/client',
elements: [{ id: 'client-proof', expected: { color: 'cyan' } }],
},
{
path: '/edge-page',
elements: [
{
id: 'edge-page-proof',
expected: { color: 'goldenrod', padding: '19px' },
},
],
},
]
const documents = []
for (const page of pages)
documents.push(await documentProof(base, parse, page))
console.info('W65-HTTP ' + JSON.stringify(documents))
// Compare semantic stylesheet contents/class bindings, never Next build-id URLs/manifests.
return documents.map(({ sheets, ...document }) => ({
...document,
css: sheets.map(({ css }) => css).sort(),
}))
}
module.exports.rules = rules
module.exports.declarationProof = declarationProof
18 changes: 18 additions & 0 deletions packages/next-plugin/src/__tests__/webpack-delivery-http.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'

import { expect, it } from 'bun:test'

it('rejects broken HTTP delivery boundaries in the native Node surface', () => {
// Given native Node fetch, isolated from the suite DOM preload.
// When real HTTP documents or their linked CSS violate delivery contracts.
const result = spawnSync(
'node',
[join(import.meta.dir, 'webpack-delivery-negative.cjs')],
{ encoding: 'utf8' },
)
// Then each rejection must identify the broken boundary, not a later error.
console.info(result.stdout + result.stderr)
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
})
Loading
Loading