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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_webpack_theme_delivery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/webpack-plugin/package.json": "Patch"
},
"note": "Keep packed CommonJS Webpack plugins relocatable so initialization and both loaders use the installed package and the same WASM instance. Inherited theme declarations now survive in shipped production CSS in per-file and single-CSS modes. No new build errors.",
"date": "2026-10-19T00:00:00.000Z"
}
72 changes: 72 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,78 @@ jobs:
- name: Run E2E Tests (singleCss)
run: bun run test:e2e:rsc-css

webpack-extends-theme:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
env:
CARGO_BUILD_JOBS: "2"
steps:
- name: Configure isolated paths
run: |
printf 'DEVUP_PLAYWRIGHT_ROOT=%s/webpack-extends-playwright\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'PLAYWRIGHT_BROWSERS_PATH=%s/webpack-extends-browsers\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'DEVUP_EXTENDS_RESULTS=%s/webpack-extends-results\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v7
- uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
components: clippy, rustfmt
- name: Cache cargo registry + target
uses: actions/cache@v6
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: cargo-webpack-extends-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache bun dependencies
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: bun-webpack-extends-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- uses: jetli/wasm-pack-action@v0.4.0
with:
version: latest
- name: Install Node.js
uses: actions/setup-node@v7
with:
node-version: latest
- run: bun install
- name: Build fresh WASM
working-directory: bindings/devup-ui-wasm
run: bun run build
- run: bun run build
- name: Install latest private Playwright and Chromium
run: |
mkdir -p "$DEVUP_PLAYWRIGHT_ROOT" "$DEVUP_EXTENDS_RESULTS"
printf '{"private":true,"type":"module"}\n' > "$DEVUP_PLAYWRIGHT_ROOT/package.json"
bun add --cwd "$DEVUP_PLAYWRIGHT_ROOT" @playwright/test@latest
node "$DEVUP_PLAYWRIGHT_ROOT/node_modules/@playwright/test/cli.js" install chromium --with-deps
- name: Installed formats and production theme delivery regressions
run: |
set -euo pipefail
env -u CI node --test packages/webpack-plugin/src/__tests__/extends-theme/extends-theme-regression.mjs 2>&1 | tee "$DEVUP_EXTENDS_RESULTS/matrix.log"
- name: Upload packed-consumer matrix evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: webpack-extends-theme-evidence
path: |
${{ runner.temp }}/webpack-extends-results/matrix.log
${{ runner.temp }}/webpack-extends-results/extends-theme-*/*.json
${{ runner.temp }}/webpack-extends-results/extends-theme-*/*/identity-*.json
${{ runner.temp }}/webpack-extends-results/extends-theme-*/*/bun.lock
${{ runner.temp }}/webpack-extends-results/extends-theme-*/packs/*/*.tgz
if-no-files-found: error
retention-days: 7

# Validation: runs on pull requests and on pushes to main, read-only. The job
# id stays `publish` because it is the status check the repository requires;
# the privileged release steps live in `release` and `deploy-pages` below.
Expand Down
2 changes: 1 addition & 1 deletion packages/webpack-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"type": "module",
"version": "1.0.71",
"scripts": {
"build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external"
"build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external --define import.meta.url=module.filename && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external"
},
"publishConfig": {
"access": "public"
Expand Down
17 changes: 17 additions & 0 deletions packages/webpack-plugin/src/__tests__/extends-theme/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Packed Webpack extends-theme regression

After building WASM and all packages, run with current Node and Bun:

```sh
node --test packages/webpack-plugin/src/__tests__/extends-theme/extends-theme-regression.mjs
```

This is a production integration matrix, separate from mocked Bun unit tests. It packs the five public packages with lifecycle scripts disabled, resolves current registry versions once, installs independent consumers, and runs standalone Next-included Webpack (original `minimize: false`) and actual `next build --webpack`, each with per-file and single CSS. Two companion standalone builds directly import the public Webpack ESM entry, since the Next ESM config delegates to the CJS Webpack entry. No product rebuild is performed here.

Two more independent consumers each launch a fresh Node child using public `require` or direct `import`. The child initializes a real Webpack compiler, compares both selected loaders against the installed plugin manifest, and asserts the entry and both loaders resolve the same physical consumer WASM file and share its `getCss` function. Expected paths come from installed manifests, not build-tree paths. Each compiler is closed, including on assertion failure. The original eleven D fixture files remain unchanged.

Install the latest Playwright and its Chromium browser before running. `DEVUP_PLAYWRIGHT_ROOT` optionally selects an external tooling package root, `PLAYWRIGHT_BROWSERS_PATH` selects its browser cache, and `DEVUP_EXTENDS_RESULTS` selects the artifact parent. The runner checks Playwright against that run's registry snapshot. Consumer installs, lockfiles, package SHA256s, production output and observations are retained under the printed unique artifact directory for debugging.

The independent read-only `webpack-extends-theme` Linux CI job builds fresh WASM/packages, installs latest Playwright plus Chromium into a private `RUNNER_TEMP` package, and explicitly invokes this Node runner. These `.mjs` integration tests are not Bun-autodiscovered. Evidence is uploaded even on failure when available; the normal publish validation and changepack guard are unchanged.

The D fixture deliberately keeps `styles.mjs` separate, and keeps `devup.json` extending `base-theme.json` with primary `#17395b` and gutter `11px`. Tests parse declarations from HTTP-fetched **HTML-linked** stylesheets and check computed theme color/padding plus imported-style control `rgb(19, 87, 155)`/`9px`. A generated base file alone, variable references, mocked `getCss`, or injected CSS cannot satisfy this contract. Each browser/server is closed before the next cell; run this matrix under the caller's foreground process-tree memory/deadline guard.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { cp, mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'

import {
buildConsumer,
command,
playwrightRequire,
prepareConsumer,
prepareRun,
} from './packed-consumer.mjs'
import { observe } from './shipped-styles.mjs'

const parent = process.env.DEVUP_EXTENDS_RESULTS || tmpdir()
await mkdir(parent, { recursive: true })
const root = await mkdtemp(join(parent, 'extends-theme-'))
console.info(`extends-theme evidence: ${root}`)
const context = await prepareRun(root)
const tooling = playwrightRequire()
const playwright = tooling('@playwright/test/package.json')
assert.equal(
playwright.version,
context.versions['@playwright/test'].version,
'browser tooling must be latest',
)

for (const target of ['webpack', 'next-webpack', 'webpack-esm']) {
for (const singleCss of [false, true]) {
await test(`${target} delivers inherited theme when singleCss=${singleCss}`, async () => {
// Given: packed public packages, unchanged D fixture, separate imported-style control.
const consumer = await prepareConsumer(context, target, singleCss)
const require = createRequire(join(consumer.directory, 'package.json'))
const parser = createRequire(require.resolve('next/package.json'))(
'postcss',
).parse
const browserTools = {
playwright: tooling('@playwright/test'),
parse: parser,
}
// When: the real production builder ships HTML and its linked CSS.
buildConsumer(consumer)
const actual = await observe(consumer, browserTools)
await writeFile(
join(root, `${target}-${singleCss ? 'single' : 'per-file'}.json`),
JSON.stringify({ consumer, actual }, null, 2),
)
// Then: control styling survives, and inherited declarations reach those same assets/elements.
assert.deepEqual(actual.pageErrors, [])
assert.deepEqual(
[actual.imported.color, actual.imported.paddingTop],
['rgb(19, 87, 155)', '9px'],
)
const primary = actual.declarations
.filter((value) => value.property === '--primary')
.map((value) => value.value.toLowerCase())
const gutter = actual.declarations
.filter((value) => value.property === '--gutter')
.map((value) => value.value)
assert.deepEqual(
{
declaresPrimary: primary.includes('#17395b'),
declaresGutter: gutter.includes('11px'),
color: actual.theme.color,
paddingLeft: actual.theme.paddingLeft,
primary: actual.theme.primary.toLowerCase(),
gutter: actual.theme.gutter,
rootPrimary: actual.root.primary.toLowerCase(),
rootGutter: actual.root.gutter,
},
{
declaresPrimary: true,
declaresGutter: true,
color: 'rgb(23, 57, 91)',
paddingLeft: '11px',
primary: '#17395b',
gutter: '11px',
rootPrimary: '#17395b',
rootGutter: '11px',
},
)
})
}
}

for (const format of ['require', 'import']) {
await test(`public ${format} shares installed loaders and WASM when relocated`, async () => {
// Given: an independent consumer installed from the public package tarballs.
const consumer = await prepareConsumer(context, 'webpack', false)
const probe = join(consumer.directory, 'loader-identity.mjs')
await cp(new URL('./loader-identity.mjs', import.meta.url), probe)
// When: a fresh Node process loads the public entry and initializes real Webpack.
command(
process.execPath,
['--experimental-import-meta-resolve', probe, format],
{
cwd: consumer.directory,
},
)
// Then: the child asserts manifest-selected physical loader/WASM identity.
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { Box, Flex, globalCss } from '@devup-ui/react'
import { useState } from 'react'

import { badge } from './styles.mjs'

globalCss({ body: { margin: 0 } })

export function Fixture() {
const [color, setColor] = useState('#123456')
return (
<Flex as="main" data-testid="layout" flexDir="column" gap="12px">
<Box data-testid="static" bg="#2468ac" p={4} borderRadius="7px">
Static extraction
</Box>
<Box data-testid="theme" color="$primary" px="$gutter">
Extended theme
</Box>
<Box data-testid="responsive" w={['100px', '200px']}>
Responsive extraction
</Box>
<Box as="button" data-testid="hover" bg="#112233" _hover={{ bg: '#abcdef' }}>
Hover extraction
</Box>
<Box data-testid="dynamic" bg={color}>
Dynamic CSS variable
</Box>
<button onClick={() => setColor('#654321')}>Change value</button>
<div data-testid="imported" className={badge}>Imported module</div>
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"theme": {
"colors": { "default": { "primary": "#17395b" } },
"length": { "default": { "gutter": "11px" } }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { writeFile } = require('node:fs/promises')
const { join } = require('node:path')
const { webpack } = require('next/dist/compiled/webpack/webpack')

async function build() {
const compiler = webpack(require('./webpack.config.cjs'))
try {
const stats = await new Promise((resolve, reject) =>
compiler.run((error, result) =>
error ? reject(error) : resolve(result),
),
)
if (stats.hasErrors())
throw new Error(stats.toString({ colors: false, errors: true }))
const assets = stats.toJson({ all: false, assets: true }).assets
const styles = assets
.filter(({ name }) => name.endsWith('.css'))
.map(({ name }) => `<link rel="stylesheet" href="/${name}">`)
.join('')
const scripts = assets
.filter(({ name }) => name.endsWith('.js'))
.map(({ name }) => `<script defer src="/${name}"></script>`)
.join('')
await writeFile(
join(__dirname, 'dist/index.html'),
`<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>Release compatibility</title>${styles}</head><body><div id="root"></div>${scripts}</body></html>`,
)
console.info(
`Standalone Webpack ${webpack.version}: production build passed`,
)
} finally {
await new Promise((resolve, reject) =>
compiler.close((error) => (error ? reject(error) : resolve())),
)
}
}

void build().catch((error) => {
console.error(error)
process.exitCode = 1
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'

import config from './webpack.config.mjs'

const require = createRequire(import.meta.url)
const { webpack } = require('next/dist/compiled/webpack/webpack')
const compiler = webpack(config)
try {
const stats = await new Promise((resolve, reject) =>
compiler.run((error, result) => (error ? reject(error) : resolve(result))),
)
if (stats.hasErrors())
throw new Error(stats.toString({ colors: false, errors: true }))
const assets = stats.toJson({ all: false, assets: true }).assets
const styles = assets
.filter(({ name }) => name.endsWith('.css'))
.map(({ name }) => `<link rel="stylesheet" href="/${name}">`)
.join('')
const scripts = assets
.filter(({ name }) => name.endsWith('.js'))
.map(({ name }) => `<script defer src="/${name}"></script>`)
.join('')
await writeFile(
join(import.meta.dirname, 'dist/index.html'),
`<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>Release compatibility</title>${styles}</head><body><div id="root"></div>${scripts}</body></html>`,
)
console.info(
`Standalone direct ESM Webpack ${webpack.version}: production build passed`,
)
} finally {
await new Promise((resolve, reject) =>
compiler.close((error) => (error ? reject(error) : resolve())),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "extends": ["./base-theme.json"] }
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const ts = require('@typescript/typescript6')

module.exports = function transformJsx(source) {
return ts.transpileModule(source, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
},
fileName: this.resourcePath,
}).outputText
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Layout({ children }) {
return <html lang="en"><body>{children}</body></html>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client'

import { Fixture } from './Fixture'

createRoot(document.getElementById('root')).render(<Fixture />)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { DevupUI } from '@devup-ui/next-plugin'

export default DevupUI(
{
output: 'export',
experimental: { useTypeScriptCli: true },
},
{
singleCss: process.env.RELEASE_SINGLE_CSS === '1',
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Fixture } from '../src/Fixture'

export default function Page() {
return <Fixture />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { css } from '@devup-ui/react'

export const badge = css({ color: '#13579b', padding: '9px' })
Loading
Loading