diff --git a/.changepacks/changepack_log_webpack_theme_delivery.json b/.changepacks/changepack_log_webpack_theme_delivery.json
new file mode 100644
index 000000000..b3194b234
--- /dev/null
+++ b/.changepacks/changepack_log_webpack_theme_delivery.json
@@ -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"
+}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2cad8f11a..106e40476 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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.
diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json
index a8344a571..2661a4c77 100644
--- a/packages/webpack-plugin/package.json
+++ b/packages/webpack-plugin/package.json
@@ -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"
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/README.md b/packages/webpack-plugin/src/__tests__/extends-theme/README.md
new file mode 100644
index 000000000..4bb7a85a5
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/README.md
@@ -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.
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/extends-theme-regression.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/extends-theme-regression.mjs
new file mode 100644
index 000000000..cdaef52d6
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/extends-theme-regression.mjs
@@ -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.
+ })
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/Fixture.jsx b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/Fixture.jsx
new file mode 100644
index 000000000..6249b9a32
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/Fixture.jsx
@@ -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 (
+
+
+ Static extraction
+
+
+ Extended theme
+
+
+ Responsive extraction
+
+
+ Hover extraction
+
+
+ Dynamic CSS variable
+
+
+ Imported module
+
+ )
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/base-theme.json b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/base-theme.json
new file mode 100644
index 000000000..cc494bd2f
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/base-theme.json
@@ -0,0 +1,6 @@
+{
+ "theme": {
+ "colors": { "default": { "primary": "#17395b" } },
+ "length": { "default": { "gutter": "11px" } }
+ }
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.cjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.cjs
new file mode 100644
index 000000000..d4c6fe864
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.cjs
@@ -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 }) => ``)
+ .join('')
+ const scripts = assets
+ .filter(({ name }) => name.endsWith('.js'))
+ .map(({ name }) => ``)
+ .join('')
+ await writeFile(
+ join(__dirname, 'dist/index.html'),
+ `
Release compatibility${styles}${scripts}`,
+ )
+ 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
+})
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.mjs
new file mode 100644
index 000000000..b5f006503
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/build-webpack.mjs
@@ -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 }) => ``)
+ .join('')
+ const scripts = assets
+ .filter(({ name }) => name.endsWith('.js'))
+ .map(({ name }) => ``)
+ .join('')
+ await writeFile(
+ join(import.meta.dirname, 'dist/index.html'),
+ `Release compatibility${styles}${scripts}`,
+ )
+ console.info(
+ `Standalone direct ESM Webpack ${webpack.version}: production build passed`,
+ )
+} finally {
+ await new Promise((resolve, reject) =>
+ compiler.close((error) => (error ? reject(error) : resolve())),
+ )
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/devup.json b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/devup.json
new file mode 100644
index 000000000..1e807ba67
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/devup.json
@@ -0,0 +1 @@
+{ "extends": ["./base-theme.json"] }
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/jsx-loader.cjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/jsx-loader.cjs
new file mode 100644
index 000000000..a17fed9b2
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/jsx-loader.cjs
@@ -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
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/layout.jsx b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/layout.jsx
new file mode 100644
index 000000000..993cd4899
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/layout.jsx
@@ -0,0 +1,3 @@
+export default function Layout({ children }) {
+ return {children}
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/main.jsx b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/main.jsx
new file mode 100644
index 000000000..78ab0d34c
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/main.jsx
@@ -0,0 +1,5 @@
+import { createRoot } from 'react-dom/client'
+
+import { Fixture } from './Fixture'
+
+createRoot(document.getElementById('root')).render()
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/next.config.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/next.config.mjs
new file mode 100644
index 000000000..239af8c33
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/next.config.mjs
@@ -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',
+ },
+)
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/page.jsx b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/page.jsx
new file mode 100644
index 000000000..c31ef91b4
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/page.jsx
@@ -0,0 +1,5 @@
+import { Fixture } from '../src/Fixture'
+
+export default function Page() {
+ return
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/styles.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/styles.mjs
new file mode 100644
index 000000000..6543735ba
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/styles.mjs
@@ -0,0 +1,3 @@
+import { css } from '@devup-ui/react'
+
+export const badge = css({ color: '#13579b', padding: '9px' })
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.cjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.cjs
new file mode 100644
index 000000000..4e21d853c
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.cjs
@@ -0,0 +1,25 @@
+const { DevupUIWebpackPlugin } = require('@devup-ui/webpack-plugin')
+const { join } = require('node:path')
+
+module.exports = {
+ mode: 'production',
+ entry: './src/main.jsx',
+ resolve: { extensions: ['.jsx', '...'] },
+ output: {
+ path: join(__dirname, 'dist'),
+ filename: '[name].[contenthash].js',
+ },
+ experiments: { css: true },
+ optimization: { minimize: false },
+ module: {
+ rules: [
+ { test: /\.jsx$/, use: [join(__dirname, 'jsx-loader.cjs')] },
+ { test: /\.css$/, type: 'css' },
+ ],
+ },
+ plugins: [
+ new DevupUIWebpackPlugin({
+ singleCss: process.env.RELEASE_SINGLE_CSS === '1',
+ }),
+ ],
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.mjs
new file mode 100644
index 000000000..c53fc92c6
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/fixtures/webpack.config.mjs
@@ -0,0 +1,26 @@
+import { join } from 'node:path'
+
+import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin'
+
+export default {
+ mode: 'production',
+ entry: './src/main.jsx',
+ resolve: { extensions: ['.jsx', '...'] },
+ output: {
+ path: join(import.meta.dirname, 'dist'),
+ filename: '[name].[contenthash].js',
+ },
+ experiments: { css: true },
+ optimization: { minimize: false },
+ module: {
+ rules: [
+ { test: /\.jsx$/, use: [join(import.meta.dirname, 'jsx-loader.cjs')] },
+ { test: /\.css$/, type: 'css' },
+ ],
+ },
+ plugins: [
+ new DevupUIWebpackPlugin({
+ singleCss: process.env.RELEASE_SINGLE_CSS === '1',
+ }),
+ ],
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs
new file mode 100644
index 000000000..baea23368
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/loader-identity.mjs
@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict'
+import { readFile, realpath, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+const require = createRequire(import.meta.url)
+const format = process.argv[2]
+let plugin
+let entry
+switch (format) {
+ case 'require':
+ plugin = require('@devup-ui/webpack-plugin')
+ entry = require.resolve('@devup-ui/webpack-plugin')
+ break
+ case 'import':
+ plugin = await import('@devup-ui/webpack-plugin')
+ entry = fileURLToPath(import.meta.resolve('@devup-ui/webpack-plugin'))
+ break
+ default:
+ throw new Error(`Unknown public entry format ${format}`)
+}
+
+const packageRoot = await realpath(
+ join(import.meta.dirname, 'node_modules/@devup-ui/webpack-plugin'),
+)
+const manifest = JSON.parse(
+ await readFile(join(packageRoot, 'package.json'), 'utf8'),
+)
+const expectedEntry = await realpath(
+ join(packageRoot, manifest.exports['.'][format]),
+)
+const expectedLoaders = await Promise.all(
+ ['./loader', './css-loader'].map((name) =>
+ realpath(join(packageRoot, manifest.exports[name].require)),
+ ),
+)
+const { webpack } = require('next/dist/compiled/webpack/webpack')
+const compiler = webpack({
+ mode: 'production',
+ optimization: { minimize: false },
+ entry: './src/main.jsx',
+ plugins: [new plugin.DevupUIWebpackPlugin()],
+})
+try {
+ const loaders = compiler.options.module.rules
+ .flatMap((rule) => rule.use || [])
+ .map((use) => use.loader)
+ .filter(Boolean)
+ const selectedLoaders = await Promise.all(
+ loaders.map((path) => realpath(path)),
+ )
+ const entryWasmPath =
+ format === 'import'
+ ? fileURLToPath(
+ import.meta.resolve('@devup-ui/wasm', pathToFileURL(entry).href),
+ )
+ : createRequire(entry).resolve('@devup-ui/wasm')
+ const wasmPaths = await Promise.all(
+ [
+ entryWasmPath,
+ ...loaders.map((path) => createRequire(path).resolve('@devup-ui/wasm')),
+ ].map((path) => realpath(path)),
+ )
+ const evidence = {
+ format,
+ entry: await realpath(entry),
+ expectedEntry,
+ packageRoot,
+ selectedLoaders,
+ expectedLoaders,
+ wasmPaths,
+ }
+ await writeFile(
+ join(import.meta.dirname, `identity-${format}.json`),
+ JSON.stringify(evidence, null, 2),
+ )
+ console.info(JSON.stringify(evidence))
+ assert.equal(evidence.entry, expectedEntry)
+ assert.deepEqual(
+ selectedLoaders,
+ expectedLoaders,
+ 'both loaders must belong to the installed plugin',
+ )
+ const wasmRoot = await realpath(
+ join(import.meta.dirname, 'node_modules/@devup-ui/wasm'),
+ )
+ const wasmManifest = JSON.parse(
+ await readFile(join(wasmRoot, 'package.json'), 'utf8'),
+ )
+ const consumerWasm = await realpath(
+ join(wasmRoot, wasmManifest.exports['.'][format]),
+ )
+ assert.deepEqual(
+ wasmPaths,
+ [consumerWasm, consumerWasm, consumerWasm],
+ 'entry and both loaders must share the consumer WASM file',
+ )
+ const entryWasm = await import(pathToFileURL(entryWasmPath).href)
+ for (const path of loaders) {
+ assert.equal(createRequire(path)('@devup-ui/wasm').getCss, entryWasm.getCss)
+ }
+} finally {
+ await new Promise((resolve, reject) =>
+ compiler.close((error) => (error ? reject(error) : resolve())),
+ )
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs
new file mode 100644
index 000000000..417ac132b
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/packed-consumer.mjs
@@ -0,0 +1,242 @@
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import {
+ cp,
+ mkdir,
+ mkdtemp,
+ readdir,
+ readFile,
+ writeFile,
+} from 'node:fs/promises'
+import { get } from 'node:https'
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const repository = fileURLToPath(new URL('../../../../../', import.meta.url))
+const fixtures = fileURLToPath(new URL('./fixtures/', import.meta.url))
+const packages = [
+ 'wasm',
+ 'plugin-utils',
+ 'react',
+ 'webpack-plugin',
+ 'next-plugin',
+]
+const tools = [
+ 'react',
+ 'react-dom',
+ 'next',
+ '@typescript/typescript6',
+ 'typescript',
+ 'csstype-extra',
+ '@types/react',
+ '@types/react-dom',
+ '@playwright/test',
+]
+
+export function command(binary, args, options) {
+ console.info(JSON.stringify({ binary, args, cwd: options.cwd }))
+ execFileSync(binary, args, {
+ ...options,
+ stdio: 'inherit',
+ timeout: 600_000,
+ windowsHide: true,
+ })
+}
+
+async function latest(name) {
+ const url = `https://registry.npmjs.org/${name.replace('/', '%2f')}/latest`
+ const manifest = await new Promise((resolveManifest, reject) => {
+ const request = get(url, { timeout: 30_000 }, (response) => {
+ if (response.statusCode !== 200) {
+ response.resume()
+ reject(new Error(`${url}: HTTP ${response.statusCode}`))
+ return
+ }
+ let text = ''
+ response.setEncoding('utf8')
+ response.on('data', (chunk) => {
+ text += chunk
+ })
+ response.on('error', reject)
+ response.on('end', () => {
+ try {
+ resolveManifest(JSON.parse(text))
+ } catch (error) {
+ reject(error)
+ }
+ })
+ })
+ request.on('error', reject)
+ request.on('timeout', () => request.destroy(new Error(`${url}: timeout`)))
+ })
+ if (manifest.name !== name || typeof manifest.version !== 'string')
+ throw new Error(`Invalid latest manifest for ${name}`)
+ return {
+ version: manifest.version,
+ url,
+ resolvedAt: new Date().toISOString(),
+ }
+}
+
+export async function prepareRun(root) {
+ const versions = {}
+ for (const name of tools) versions[name] = await latest(name)
+ const tarballs = {}
+ const hashes = []
+ const bun = process.env.BUN_BINARY || 'bun'
+ for (const name of packages) {
+ const source = join(
+ repository,
+ name === 'wasm' ? 'bindings/devup-ui-wasm' : `packages/${name}`,
+ )
+ const destination = join(root, 'packs', name)
+ await mkdir(destination, { recursive: true })
+ command(
+ bun,
+ [
+ 'pm',
+ 'pack',
+ '--ignore-scripts',
+ '--quiet',
+ '--destination',
+ destination,
+ ],
+ { cwd: source },
+ )
+ const files = (await readdir(destination)).filter((file) =>
+ file.endsWith('.tgz'),
+ )
+ if (files.length !== 1) throw new Error(`Expected one tarball for ${name}`)
+ const path = join(destination, files[0])
+ tarballs[`@devup-ui/${name}`] = `file:${path.replaceAll('\\', '/')}`
+ hashes.push({
+ name,
+ source,
+ path,
+ sha256: createHash('sha256')
+ .update(await readFile(path))
+ .digest('hex'),
+ })
+ }
+ const evidence = {
+ node: process.version,
+ bunVersion: execFileSync(bun, ['--version'], { encoding: 'utf8' }).trim(),
+ versions,
+ tarballs,
+ hashes,
+ repository,
+ }
+ await writeFile(
+ join(root, 'resolution.json'),
+ JSON.stringify(evidence, null, 2),
+ )
+ return { root, bun, ...evidence }
+}
+
+export async function prepareConsumer(context, target, singleCss) {
+ const directory = await mkdtemp(
+ join(context.root, `${target}-${singleCss ? 'single' : 'per-file'}-`),
+ )
+ await mkdir(join(directory, 'src'))
+ for (const name of ['Fixture.jsx', 'styles.mjs', 'main.jsx'])
+ await cp(join(fixtures, name), join(directory, 'src', name))
+ for (const name of ['devup.json', 'base-theme.json'])
+ await cp(join(fixtures, name), join(directory, name))
+ const dependencies = Object.fromEntries(
+ Object.entries(context.versions)
+ .filter(([name]) => name !== '@playwright/test')
+ .map(([name, value]) => [name, value.version]),
+ )
+ Object.assign(dependencies, context.tarballs)
+ await writeFile(
+ join(directory, 'package.json'),
+ JSON.stringify(
+ {
+ name: 'extends-theme-regression',
+ private: true,
+ type: 'module',
+ dependencies,
+ overrides: dependencies,
+ },
+ null,
+ 2,
+ ),
+ )
+ switch (target) {
+ case 'webpack':
+ case 'webpack-esm':
+ for (const name of [
+ 'webpack.config.cjs',
+ 'build-webpack.cjs',
+ 'jsx-loader.cjs',
+ ])
+ await cp(join(fixtures, name), join(directory, name))
+ if (target === 'webpack-esm')
+ for (const name of ['webpack.config.mjs', 'build-webpack.mjs'])
+ await cp(join(fixtures, name), join(directory, name))
+ break
+ case 'next-webpack':
+ await mkdir(join(directory, 'app'))
+ for (const name of ['page.jsx', 'layout.jsx'])
+ await cp(join(fixtures, name), join(directory, 'app', name))
+ await cp(
+ join(fixtures, 'next.config.mjs'),
+ join(directory, 'next.config.mjs'),
+ )
+ break
+ default:
+ throw new Error(`Unknown target ${target}`)
+ }
+ command(context.bun, ['install'], { cwd: directory })
+ const require = createRequire(join(directory, 'package.json'))
+ for (const [name, expected] of Object.entries(dependencies)) {
+ if (name.startsWith('@devup-ui/')) continue
+ const actual = JSON.parse(
+ await readFile(
+ join(directory, 'node_modules', name, 'package.json'),
+ 'utf8',
+ ),
+ )
+ if (actual.version !== expected)
+ throw new Error(`Resolution mismatch for ${name}`)
+ }
+ const args =
+ target === 'next-webpack'
+ ? [require.resolve('next/dist/bin/next'), 'build', '--webpack']
+ : [
+ join(
+ directory,
+ target === 'webpack-esm'
+ ? 'build-webpack.mjs'
+ : 'build-webpack.cjs',
+ ),
+ ]
+ return {
+ directory,
+ args,
+ target,
+ singleCss,
+ output: join(directory, target === 'next-webpack' ? 'out' : 'dist'),
+ }
+}
+
+export function buildConsumer(consumer) {
+ command(process.execPath, consumer.args, {
+ cwd: consumer.directory,
+ env: {
+ ...process.env,
+ RELEASE_SINGLE_CSS: consumer.singleCss ? '1' : '0',
+ NEXT_TELEMETRY_DISABLED: '1',
+ },
+ })
+}
+
+export function playwrightRequire() {
+ return createRequire(
+ join(
+ resolve(process.env.DEVUP_PLAYWRIGHT_ROOT || repository),
+ 'package.json',
+ ),
+ )
+}
diff --git a/packages/webpack-plugin/src/__tests__/extends-theme/shipped-styles.mjs b/packages/webpack-plugin/src/__tests__/extends-theme/shipped-styles.mjs
new file mode 100644
index 000000000..1ca7d9bfd
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/extends-theme/shipped-styles.mjs
@@ -0,0 +1,124 @@
+import { once } from 'node:events'
+import { readFile, stat } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { extname, isAbsolute, join, relative, resolve } from 'node:path'
+
+async function serve(output) {
+ const root = resolve(output)
+ const server = createServer(async (request, response) => {
+ const path = resolve(
+ root,
+ `.${new URL(request.url, 'http://localhost').pathname}`,
+ )
+ if (
+ relative(root, path).startsWith('..') ||
+ isAbsolute(relative(root, path))
+ ) {
+ response.writeHead(403).end()
+ return
+ }
+ try {
+ const file = (await stat(path)).isDirectory()
+ ? join(path, 'index.html')
+ : path
+ const types = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ }
+ response.writeHead(200, {
+ 'Content-Type': types[extname(file)] || 'application/octet-stream',
+ })
+ response.end(await readFile(file))
+ } catch (error) {
+ console.error(error)
+ response.writeHead(404).end()
+ }
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ const address = server.address()
+ if (!address || typeof address === 'string')
+ throw new Error('Expected TCP address')
+ return {
+ url: `http://127.0.0.1:${address.port}`,
+ close: () =>
+ new Promise((resolveClose, reject) => {
+ server.closeAllConnections()
+ server.close((error) => (error ? reject(error) : resolveClose()))
+ }),
+ }
+}
+
+export async function observe(consumer, tooling) {
+ const server = await serve(consumer.output)
+ let browser
+ const pageErrors = []
+ try {
+ browser = await tooling.playwright.chromium.launch({ headless: true })
+ const page = await browser.newPage()
+ page.on('pageerror', (error) => pageErrors.push(error.message))
+ const response = await page.goto(server.url, {
+ waitUntil: 'networkidle',
+ timeout: 30_000,
+ })
+ if (response.status() !== 200)
+ throw new Error(`HTML status ${response.status()}`)
+ await page.getByTestId('theme').waitFor()
+ await page.getByTestId('imported').waitFor()
+ const actual = await page.evaluate(() => {
+ function properties(element) {
+ if (!element) throw new Error('Missing style target')
+ const style = getComputedStyle(element)
+ return {
+ color: style.color,
+ paddingLeft: style.paddingLeft,
+ paddingTop: style.paddingTop,
+ primary: style.getPropertyValue('--primary').trim(),
+ gutter: style.getPropertyValue('--gutter').trim(),
+ }
+ }
+ return {
+ theme: properties(document.querySelector('[data-testid="theme"]')),
+ imported: properties(
+ document.querySelector('[data-testid="imported"]'),
+ ),
+ root: properties(document.documentElement),
+ hrefs: [...document.querySelectorAll('link[rel="stylesheet"]')].map(
+ (link) => link.href,
+ ),
+ }
+ })
+ const parse = tooling.parse
+ const linked = []
+ const declarations = []
+ for (const href of actual.hrefs) {
+ const cssResponse = await page.request.get(href)
+ if (cssResponse.status() !== 200)
+ throw new Error(`CSS status ${cssResponse.status()}: ${href}`)
+ const text = await cssResponse.text()
+ linked.push({ href, text })
+ // Next bundles PostCSS; parse real declarations rather than matching var() references.
+ parse(text).walkDecls((declaration) =>
+ declarations.push({
+ property: declaration.prop,
+ value: declaration.value,
+ }),
+ )
+ }
+ return {
+ ...actual,
+ linked,
+ declarations,
+ pageErrors,
+ browser: browser.version(),
+ }
+ } finally {
+ try {
+ if (browser) await browser.close()
+ } finally {
+ await server.close()
+ console.info('extends-theme: owned browser and server closed')
+ }
+ }
+}