diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 000000000..6aa4c50d9 --- /dev/null +++ b/.changepacks/changepack_log_compose_known_styles.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 000000000..4d05b2838 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_react_18_support.json b/.changepacks/changepack_log_react_18_support.json new file mode 100644 index 000000000..ad74861ef --- /dev/null +++ b/.changepacks/changepack_log_react_18_support.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/react/package.json": "Patch", + "packages/components/package.json": "Patch" + }, + "note": "@devup-ui/react and @devup-ui/components take React as a peer of ^18 || ^19 instead of `*` and no longer depend on React 19 themselves, so a React 18 app no longer gets a second React. Select and Stepper read their context with useContext instead of React 19's use() and provide it with Context.Provider instead of React 19's (which crashes React 18), and Select repositions its container from an effect instead of a callback ref returning cleanup, which React 18 ignores", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_forward_ref.json b/.changepacks/changepack_log_styled_forward_ref.json new file mode 100644 index 000000000..7dd26ffbb --- /dev/null +++ b/.changepacks/changepack_log_styled_forward_ref.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components the build generates are wrapped in React's forwardRef, so a ref passed to them reaches the element they render on React 18 as well as React 19; the file imports forwardRef from react when it defines one", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_props.json b/.changepacks/changepack_log_styled_props.json new file mode 100644 index 000000000..8dfbb440e --- /dev/null +++ b/.changepacks/changepack_log_styled_props.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components no longer pass every prop to the tag they render: `$` props, `theme` and the props their style functions or attrs read are kept away from a tag unless it takes them as attributes, and `shouldForwardProp` (Emotion options or styled-components withConfig) is evaluated at build time. A `shouldForwardProp` the build cannot evaluate is a build error naming the file, line and code, with the forms it accepts. An element using a styled component the file defines drops props the component neither reads nor passes on; spread props are passed as written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_targets.json b/.changepacks/changepack_log_styled_targets.json new file mode 100644 index 000000000..a51d45fe7 --- /dev/null +++ b/.changepacks/changepack_log_styled_targets.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components render what their `as` prop names, defaulting to the tag or component they were defined with, and pass `forwardedAs` on as `as`, as styled-components and Emotion do; `as` used to reach the rendered tag as an attribute. `Component.withComponent(target)` on a styled component the file binds to a const builds a component rendering the same styles and attrs as `target` (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_ui_kit_forward_ref.json b/.changepacks/changepack_log_ui_kit_forward_ref.json new file mode 100644 index 000000000..883e0e61b --- /dev/null +++ b/.changepacks/changepack_log_ui_kit_forward_ref.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/components/package.json": "Patch" + }, + "note": "Button, Checkbox, Input, Radio and Textarea forward their ref to the button, input or textarea they render, so a ref reaches the form element on React 18 as well as React 19", + "date": "2026-10-01T00:00:00.000Z" +} 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 ( + + {version} + + checkbox + + radio +