diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 0e6edbd21..ac0bba9df 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -138,6 +138,61 @@ jobs:
path: playwright-report/
retention-days: 1
+ # The packages take React ^18 || ^19 as a peer. This builds a React 18.3 app
+ # with the Vite plugin and the UI kit, then checks refs, context and events
+ # in Chromium, so React-19-only APIs cannot slip back in.
+ react-18:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - uses: actions-rust-lang/setup-rust-toolchain@v2
+
+ - name: Cache cargo registry + target
+ uses: actions/cache@v6
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: cargo-react-18-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: "1.4.0"
+ name: Install bun
+
+ - name: Cache bun dependencies
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: bun-react-18-${{ 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: 24
+ - run: bun install
+ - run: bun run build
+ - name: Type-check and build the React 18 app
+ working-directory: apps/react-18
+ run: |
+ bun ../../node_modules/@typescript/native/bin/tsc
+ bun run build
+ - name: Cache Playwright Browsers
+ uses: actions/cache@v6
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-react-18-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
+ - name: Install Playwright Browsers
+ run: bunx playwright install chromium --with-deps
+ - name: Run React 18 E2E Tests
+ working-directory: apps/react-18
+ run: bun run test:e2e
+
# @devup-ui/vite-plugin must keep the CSS every server page needs when the
# build is vinext/RSC. The fixture shares one Devup UI header between server
# list pages and a client page, and the tests read the styles the browser
diff --git a/apps/react-18/e2e/react-18.spec.ts b/apps/react-18/e2e/react-18.spec.ts
new file mode 100644
index 000000000..3ff8e655d
--- /dev/null
+++ b/apps/react-18/e2e/react-18.spec.ts
@@ -0,0 +1,24 @@
+import { expect, test } from '@playwright/test'
+
+test('Devup UI runs on React 18', async ({ page }) => {
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text())
+ })
+ await page.goto('/')
+
+ await expect(page.getByTestId('version')).toHaveText(/^18\./)
+ await expect(page.getByTestId('refs')).toHaveText(
+ 'button,input,input,input,textarea,input',
+ )
+
+ await page.getByText('select', { exact: true }).click()
+ await page.getByText('second', { exact: true }).click()
+ await expect(page.getByTestId('selected')).toHaveText('second')
+
+ await page.getByTestId('increase').click()
+ await expect(page.getByTestId('stepper')).toHaveValue('1')
+
+ expect(errors).toEqual([])
+})
diff --git a/apps/react-18/index.html b/apps/react-18/index.html
new file mode 100644
index 000000000..9dfc14d76
--- /dev/null
+++ b/apps/react-18/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Devup UI on React 18
+
+
+
+
+
+
diff --git a/apps/react-18/package.json b/apps/react-18/package.json
new file mode 100644
index 000000000..6044cca14
--- /dev/null
+++ b/apps/react-18/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "react-18-example",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview --port 3098 --strictPort",
+ "test:e2e": "bunx playwright test -c playwright.config.ts",
+ "lint": "bun ../../node_modules/@typescript/native/bin/tsc && eslint"
+ },
+ "dependencies": {
+ "@devup-ui/components": "workspace:^",
+ "@devup-ui/react": "workspace:^",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@devup-ui/vite-plugin": "workspace:^",
+ "@playwright/test": "^1.63.0",
+ "@types/react": "^18.3",
+ "@types/react-dom": "^18.3",
+ "@vitejs/plugin-react": "^6.1",
+ "typescript": "^7.0.2",
+ "vite": "^8.3"
+ }
+}
diff --git a/apps/react-18/playwright.config.ts b/apps/react-18/playwright.config.ts
new file mode 100644
index 000000000..36905cf4c
--- /dev/null
+++ b/apps/react-18/playwright.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://localhost:3098',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command: 'bun run preview',
+ url: 'http://localhost:3098',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+})
diff --git a/apps/react-18/src/App.tsx b/apps/react-18/src/App.tsx
new file mode 100644
index 000000000..49ccf0502
--- /dev/null
+++ b/apps/react-18/src/App.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ Checkbox,
+ Input,
+ Radio,
+ Select,
+ SelectContainer,
+ SelectOption,
+ SelectTrigger,
+ Stepper,
+ StepperDecreaseButton,
+ StepperIncreaseButton,
+ StepperInput,
+ Textarea,
+} from '@devup-ui/components'
+import { Box, styled } from '@devup-ui/react'
+import { useEffect, useRef, useState, version } from 'react'
+
+const Field = styled('input', { color: 'red', p: 1 })
+
+export function App() {
+ const buttonRef = useRef(null)
+ const checkboxRef = useRef(null)
+ const inputRef = useRef(null)
+ const radioRef = useRef(null)
+ const textareaRef = useRef(null)
+ const fieldRef = useRef(null)
+ const [refs, setRefs] = useState('')
+ const [selected, setSelected] = useState('')
+
+ useEffect(() => {
+ setRefs(
+ [buttonRef, checkboxRef, inputRef, radioRef, textareaRef, fieldRef]
+ .map((ref) => ref.current?.tagName.toLowerCase() ?? 'null')
+ .join(','),
+ )
+ }, [])
+
+ return (
+
+
+
+ checkbox
+
+ radio
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/react-18/src/main.tsx b/apps/react-18/src/main.tsx
new file mode 100644
index 000000000..5922f3e84
--- /dev/null
+++ b/apps/react-18/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import { App } from './App'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/apps/react-18/tsconfig.json b/apps/react-18/tsconfig.json
new file mode 100644
index 000000000..a79fe22b4
--- /dev/null
+++ b/apps/react-18/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "paths": {
+ "react": ["./node_modules/@types/react"],
+ "react/*": ["./node_modules/@types/react/*"],
+ "react-dom": ["./node_modules/@types/react-dom"],
+ "react-dom/*": ["./node_modules/@types/react-dom/*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", "df/*.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/react-18/vite.config.ts b/apps/react-18/vite.config.ts
new file mode 100644
index 000000000..8d2b5e487
--- /dev/null
+++ b/apps/react-18/vite.config.ts
@@ -0,0 +1,16 @@
+import { DevupUI } from '@devup-ui/vite-plugin'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ // The workspace packages keep React 19 for their own tests; a consumer
+ // install resolves their React peer to the app's single copy, as this does
+ resolve: { dedupe: ['react', 'react-dom'] },
+ plugins: [
+ react(),
+ DevupUI({
+ include: ['@devup-ui/components'],
+ singleCss: true,
+ }),
+ ],
+})
diff --git a/bun.lock b/bun.lock
index 412668d4f..1f7b6c2be 100644
--- a/bun.lock
+++ b/bun.lock
@@ -78,6 +78,25 @@
"typescript": "^7.0.2",
},
},
+ "apps/react-18": {
+ "name": "react-18-example",
+ "version": "0.1.0",
+ "dependencies": {
+ "@devup-ui/components": "workspace:^",
+ "@devup-ui/react": "workspace:^",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ },
+ "devDependencies": {
+ "@devup-ui/vite-plugin": "workspace:^",
+ "@playwright/test": "^1.63.0",
+ "@types/react": "^18.3",
+ "@types/react-dom": "^18.3",
+ "@vitejs/plugin-react": "^6.1",
+ "typescript": "^7.0.2",
+ "vite": "^8.3",
+ },
+ },
"apps/rsbuild": {
"name": "rsbuild-example",
"version": "1.0.0",
@@ -2813,6 +2832,8 @@
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
+ "react-18-example": ["react-18-example@workspace:apps/react-18"],
+
"react-docgen": ["react-docgen@8.0.4", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ=="],
"react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="],
@@ -3769,6 +3790,14 @@
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
+ "react-18-example/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
+
+ "react-18-example/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+
+ "react-18-example/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+
+ "react-18-example/react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+
"react-docgen/@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"react-docgen/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
@@ -5617,6 +5646,8 @@
"pkg-dir/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
+ "react-18-example/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
"react-docgen/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"react-docgen/@babel/core/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],