From 5faaa628150722e43e062208f1b672d3cba2fb28 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:53:29 +0900 Subject: [PATCH 1/2] fix(vite-plugin): keep server page css dependencies while forwarding css once Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_vite_rsc_shared_css.json | 7 + .github/workflows/publish.yml | 59 +++ apps/vinext-rsc-css-repro/.gitignore | 4 + .../.next/types/routes.d.ts | 43 ++ apps/vinext-rsc-css-repro/devup.json | 1 + .../e2e/shared-css.spec.ts | 157 ++++++++ apps/vinext-rsc-css-repro/next-env.d.ts | 5 + apps/vinext-rsc-css-repro/next.config.ts | 4 + apps/vinext-rsc-css-repro/package.json | 26 ++ .../src/app/admins/page.tsx | 11 + apps/vinext-rsc-css-repro/src/app/layout.tsx | 9 + .../src/app/notice/page.tsx | 11 + apps/vinext-rsc-css-repro/src/app/page.tsx | 10 + .../src/app/users/edit/page.tsx | 19 + .../src/app/users/page.tsx | 13 + .../src/components/PageTitle.tsx | 25 ++ apps/vinext-rsc-css-repro/tsconfig.json | 16 + apps/vinext-rsc-css-repro/vite-env.d.ts | 2 + apps/vinext-rsc-css-repro/vite.config.ts | 10 + bindings/devup-ui-wasm/src/lib.rs | 8 +- bun.lock | 66 +++- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 8 +- libs/extractor/src/tailwind.rs | 4 +- package.json | 1 + .../vite-plugin/src/__tests__/plugin.test.ts | 366 +++++++++++++----- packages/vite-plugin/src/plugin.ts | 114 +++++- playwright.rsc-css.config.ts | 31 ++ 28 files changed, 894 insertions(+), 138 deletions(-) create mode 100644 .changepacks/changepack_log_vite_rsc_shared_css.json create mode 100644 apps/vinext-rsc-css-repro/.gitignore create mode 100644 apps/vinext-rsc-css-repro/.next/types/routes.d.ts create mode 100644 apps/vinext-rsc-css-repro/devup.json create mode 100644 apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts create mode 100644 apps/vinext-rsc-css-repro/next-env.d.ts create mode 100644 apps/vinext-rsc-css-repro/next.config.ts create mode 100644 apps/vinext-rsc-css-repro/package.json create mode 100644 apps/vinext-rsc-css-repro/src/app/admins/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/layout.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/notice/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/users/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/components/PageTitle.tsx create mode 100644 apps/vinext-rsc-css-repro/tsconfig.json create mode 100644 apps/vinext-rsc-css-repro/vite-env.d.ts create mode 100644 apps/vinext-rsc-css-repro/vite.config.ts create mode 100644 playwright.rsc-css.config.ts diff --git a/.changepacks/changepack_log_vite_rsc_shared_css.json b/.changepacks/changepack_log_vite_rsc_shared_css.json new file mode 100644 index 000000000..2373e44d8 --- /dev/null +++ b/.changepacks/changepack_log_vite_rsc_shared_css.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/vite-plugin/package.json": "Patch" + }, + "note": "Vinext/RSC production builds keep the CSS a server page needs. The plugin no longer removes the CSS files the client already emits from the server bundle's importedCss, which @vitejs/plugin-rsc also reads as the server pages' CSS dependencies (so a page that rendered a shared Devup UI component lost its stylesheet unless a client page sharing it was prefetched first). The duplicate emit (FILE_NAME_CONFLICT) is now avoided without touching the dependency metadata. DevupUI() now returns two plugins. No new build errors", + "date": "2026-10-03T00:00:00.000Z" +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd3b62a9f..0e6edbd21 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -138,6 +138,65 @@ jobs: path: playwright-report/ retention-days: 1 + # @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 + # actually applies when each route is entered directly, so a dropped + # stylesheet dependency fails here whatever the CSS files are called. + vinext-rsc-css-e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + 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-vinext-rsc-css-${{ 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-vinext-rsc-css-${{ 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: Cache Playwright Browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} + - name: Install Playwright Browsers + run: bunx playwright install chromium --with-deps + - name: Build fixture (per-file CSS) + run: bun run --filter vinext-rsc-css-repro build + - name: Run E2E Tests (per-file CSS) + run: bun run test:e2e:rsc-css + - name: Build fixture (singleCss) + run: | + rm -rf apps/vinext-rsc-css-repro/dist apps/vinext-rsc-css-repro/.vinext apps/vinext-rsc-css-repro/df + DEVUP_SINGLE_CSS=1 bun run --filter vinext-rsc-css-repro build + - name: Run E2E Tests (singleCss) + run: bun run test:e2e:rsc-css publish: runs-on: ubuntu-latest permissions: diff --git a/apps/vinext-rsc-css-repro/.gitignore b/apps/vinext-rsc-css-repro/.gitignore new file mode 100644 index 000000000..f8c1b8486 --- /dev/null +++ b/apps/vinext-rsc-css-repro/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/.vinext/ +/df/ +/node_modules/ diff --git a/apps/vinext-rsc-css-repro/.next/types/routes.d.ts b/apps/vinext-rsc-css-repro/.next/types/routes.d.ts new file mode 100644 index 000000000..dab45e2d5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/.next/types/routes.d.ts @@ -0,0 +1,43 @@ +// This file is generated by vinext. Do not edit. +import type * as React from "react"; + +declare global { + type PageProps = { + params: Promise; + searchParams: Promise>; + }; + + type LayoutProps = { + params: Promise; + children: React.ReactNode; + } & { + [K in VinextRouteTypes.LayoutSlotMap[Route]]: React.ReactNode; + }; + + type RouteContext = { + params: Promise; + }; +} + +declare namespace VinextRouteTypes { + type PageRoute = "/" | "/admins" | "/notice" | "/users" | "/users/edit"; + type LayoutRoute = "/"; + type RouteHandlerRoute = never; + type AppRoute = "/" | "/admins" | "/notice" | "/users" | "/users/edit"; + + interface ParamMap { + "/": {}; + "/admins": {}; + "/notice": {}; + "/users": {}; + "/users/edit": {}; + } + + interface LayoutSlotMap { + "/": never; + } +} + + + +export {}; diff --git a/apps/vinext-rsc-css-repro/devup.json b/apps/vinext-rsc-css-repro/devup.json new file mode 100644 index 000000000..ed9fe70ff --- /dev/null +++ b/apps/vinext-rsc-css-repro/devup.json @@ -0,0 +1 @@ +{ "theme": {} } diff --git a/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts b/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts new file mode 100644 index 000000000..6e9187c1f --- /dev/null +++ b/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts @@ -0,0 +1,157 @@ +import { expect, type Page, test } from '@playwright/test' + +const ROUTES = ['/notice/', '/admins/', '/users/', '/users/edit/'] as const +const EDIT_ROUTE = '/users/edit/' + +function permutations(items: readonly T[]): T[][] { + if (items.length <= 1) return [[...items]] + return items.flatMap((item, index) => + permutations([...items.slice(0, index), ...items.slice(index + 1)]).map( + (rest) => [item, ...rest], + ), + ) +} + +/** + * The users list links to the edit page and so prefetches it, which also loads + * the CSS its client bundle references. Refuse every request to the edit page + * except its own document, so a page can only get the header CSS from what the + * server rendered for it. + */ +async function blockEditPrefetch(page: Page) { + await page.route('**/users/edit/**', (route) => { + if (route.request().resourceType() === 'document') return route.continue() + return route.abort() + }) +} + +async function expectHeaderStyled(page: Page, route: string) { + const header = page.locator('[data-page-title]') + await expect(header, route).toHaveCount(1) + + const applied = await header.evaluate((element) => { + const read = (target: Element) => { + const style = getComputedStyle(target) + return { + display: style.display, + justifyContent: style.justifyContent, + alignItems: style.alignItems, + marginBottom: style.marginBottom, + columnGap: style.columnGap, + rowGap: style.rowGap, + } + } + const group = element.firstElementChild! + return { header: read(element), group: read(group) } + }) + expect(applied.header, `${route} header`).toMatchObject({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '20px', + }) + expect(applied.group, `${route} title group`).toMatchObject({ + display: 'flex', + alignItems: 'center', + columnGap: '12px', + rowGap: '12px', + }) + + const layout = await header.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const group = element.firstElementChild!.getBoundingClientRect() + const right = element.lastElementChild!.getBoundingClientRect() + return { bounds, group, right, hasRight: element.childElementCount > 1 } + }) + expect(layout.group.left, `${route} title group is at the start`).toBe( + layout.bounds.left, + ) + if (layout.hasRight) { + expect(layout.right.right, `${route} right slot is at the end`).toBe( + layout.bounds.right, + ) + } +} + +/** + * Every class the header renders must have a rule in a stylesheet the page + * really loaded (a `` the browser fetched), whatever the files are + * called. + */ +async function expectHeaderCssLoaded(page: Page, route: string) { + const result = await page.locator('[data-page-title]').evaluate((element) => { + const classes = [ + ...element.classList, + ...element.firstElementChild!.classList, + ] + const linked = [...document.styleSheets].filter( + (sheet) => sheet.href !== null, + ) + const collect = (rules: CSSRuleList): string[] => + [...rules].flatMap((rule) => + rule instanceof CSSStyleRule + ? [rule.selectorText] + : 'cssRules' in rule + ? collect(rule.cssRules as CSSRuleList) + : [], + ) + const selectors = linked.flatMap((sheet) => collect(sheet.cssRules)) + return { + linkedSheets: linked.length, + missing: classes.filter( + (name) => + !selectors.some((selector) => + selector.includes(`.${CSS.escape(name)}`), + ), + ), + classes: classes.length, + } + }) + expect(result.classes, `${route} header has atomic classes`).toBeGreaterThan( + 0, + ) + expect(result.linkedSheets, `${route} loaded stylesheets`).toBeGreaterThan(0) + expect(result.missing, `${route} classes without a loaded rule`).toEqual([]) +} + +async function expectRoute(page: Page, route: string) { + await expectHeaderCssLoaded(page, route) + await expectHeaderStyled(page, route) +} + +test.describe('shared Devup UI header in the static vinext build', () => { + for (const route of ROUTES) { + test(`direct entry and reload: ${route}`, async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + await blockEditPrefetch(page) + + await page.goto(route) + await expectRoute(page, route) + + await page.reload() + await expectRoute(page, route) + await context.close() + }) + } + + for (const order of permutations(ROUTES)) { + test(`entered in order ${order.join(' -> ')}`, async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + await blockEditPrefetch(page) + + for (const route of order) { + await page.goto(route) + await expectRoute(page, route) + } + await context.close() + }) + } + + test('the edit page itself renders the same header', async ({ page }) => { + await page.goto(EDIT_ROUTE) + await expect(page.getByRole('textbox', { name: 'name' })).toBeVisible() + await expectRoute(page, EDIT_ROUTE) + }) +}) diff --git a/apps/vinext-rsc-css-repro/next-env.d.ts b/apps/vinext-rsc-css-repro/next-env.d.ts new file mode 100644 index 000000000..c986d49fa --- /dev/null +++ b/apps/vinext-rsc-css-repro/next-env.d.ts @@ -0,0 +1,5 @@ +import "vinext/types"; +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/vinext-rsc-css-repro/next.config.ts b/apps/vinext-rsc-css-repro/next.config.ts new file mode 100644 index 000000000..d989d7d9d --- /dev/null +++ b/apps/vinext-rsc-css-repro/next.config.ts @@ -0,0 +1,4 @@ +export default { + output: 'export', + trailingSlash: true, +} diff --git a/apps/vinext-rsc-css-repro/package.json b/apps/vinext-rsc-css-repro/package.json new file mode 100644 index 000000000..08e528ec5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/package.json @@ -0,0 +1,26 @@ +{ + "name": "vinext-rsc-css-repro", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "build": "vinext build", + "lint": "eslint" + }, + "dependencies": { + "@devup-ui/react": "workspace:^", + "react": "^19.3", + "react-dom": "^19.3", + "react-server-dom-webpack": "^19.3", + "vinext": "1.0.0" + }, + "devDependencies": { + "@devup-ui/vite-plugin": "workspace:^", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.1", + "@vitejs/plugin-rsc": "0.5.35", + "typescript": "^7.0.2", + "vite": "8.3.1" + } +} diff --git a/apps/vinext-rsc-css-repro/src/app/admins/page.tsx b/apps/vinext-rsc-css-repro/src/app/admins/page.tsx new file mode 100644 index 000000000..8e7dd4ad3 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/admins/page.tsx @@ -0,0 +1,11 @@ +import { PageTitle } from '../../components/PageTitle' + +export default function AdminsPage() { + return ( +
+ Create}> + Admins + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/layout.tsx b/apps/vinext-rsc-css-repro/src/app/layout.tsx new file mode 100644 index 000000000..07e2db241 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/notice/page.tsx b/apps/vinext-rsc-css-repro/src/app/notice/page.tsx new file mode 100644 index 000000000..1ac64dcb8 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/notice/page.tsx @@ -0,0 +1,11 @@ +import { PageTitle } from '../../components/PageTitle' + +export default function NoticePage() { + return ( +
+ Create}> + Notice + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/page.tsx b/apps/vinext-rsc-css-repro/src/app/page.tsx new file mode 100644 index 000000000..89fb876f5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function HomePage() { + return ( +
+

Repro

+ notice +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx new file mode 100644 index 000000000..d23d7c9f5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useState } from 'react' + +import { PageTitle } from '../../../components/PageTitle' + +export default function UserEditPage() { + const [name, setName] = useState('') + return ( +
+ Edit user + setName(event.target.value)} + value={name} + /> +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/users/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/page.tsx new file mode 100644 index 000000000..74a0aaaf1 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/users/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link' + +import { PageTitle } from '../../components/PageTitle' + +export default function UsersPage() { + return ( +
+ Create}> + Users + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx b/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx new file mode 100644 index 000000000..94b8d3845 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx @@ -0,0 +1,25 @@ +import { Box, Flex, Text } from '@devup-ui/react' +import type { ReactNode } from 'react' + +export function PageTitle({ + children, + right, +}: { + children: ReactNode + right?: ReactNode +}) { + return ( + + + + {children} + + {right} + + ) +} diff --git a/apps/vinext-rsc-css-repro/tsconfig.json b/apps/vinext-rsc-css-repro/tsconfig.json new file mode 100644 index 000000000..c0b46b24f --- /dev/null +++ b/apps/vinext-rsc-css-repro/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "isolatedModules": true, + "jsx": "react-jsx", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/vinext-rsc-css-repro/vite-env.d.ts b/apps/vinext-rsc-css-repro/vite-env.d.ts new file mode 100644 index 000000000..6a5a1ad59 --- /dev/null +++ b/apps/vinext-rsc-css-repro/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/vinext-rsc-css-repro/vite.config.ts b/apps/vinext-rsc-css-repro/vite.config.ts new file mode 100644 index 000000000..9f163aa1a --- /dev/null +++ b/apps/vinext-rsc-css-repro/vite.config.ts @@ -0,0 +1,10 @@ +import { DevupUI } from '@devup-ui/vite-plugin' +import vinext from 'vinext' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + DevupUI({ singleCss: process.env.DEVUP_SINGLE_CSS === '1' }), + vinext(), + ], +}) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/bun.lock b/bun.lock index 9eea57e19..a6f7b10d1 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,26 @@ "@rsbuild/plugin-react": "^2.1", }, }, + "apps/vinext-rsc-css-repro": { + "name": "vinext-rsc-css-repro", + "version": "0.0.0", + "dependencies": { + "@devup-ui/react": "workspace:^", + "react": "^19.3", + "react-dom": "^19.3", + "react-server-dom-webpack": "^19.3", + "vinext": "1.0.0", + }, + "devDependencies": { + "@devup-ui/vite-plugin": "workspace:^", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.1", + "@vitejs/plugin-rsc": "0.5.35", + "typescript": "^7.0.2", + "vite": "8.3.1", + }, + }, "apps/vite": { "name": "vite-example", "version": "0.1.0", @@ -469,11 +489,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.80", + "version": "1.0.82", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.19", + "version": "1.0.21", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -488,7 +508,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.57", + "version": "0.1.59", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -515,7 +535,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.20", + "version": "1.0.21", "dependencies": { "@typescript-eslint/utils": "^8.70", "typescript-eslint": "^8.70", @@ -532,7 +552,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.87", + "version": "1.0.89", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -550,14 +570,14 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.15", + "version": "1.0.16", "devDependencies": { "typescript": "^7.0.2", }, }, "packages/react": { "name": "@devup-ui/react", - "version": "1.0.43", + "version": "1.0.44", "dependencies": { "csstype-extra": "latest", "react": "^19.3", @@ -578,7 +598,7 @@ }, "packages/reset-css": { "name": "@devup-ui/reset-css", - "version": "1.0.30", + "version": "1.0.31", "dependencies": { "@devup-ui/react": "workspace:^", }, @@ -591,7 +611,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.64", + "version": "1.0.66", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -607,7 +627,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.70", + "version": "1.0.72", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -622,7 +642,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.68", + "version": "1.0.70", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -2265,7 +2285,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], + "fflate": ["fflate@0.7.3", "", {}, "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw=="], "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], @@ -2313,6 +2333,8 @@ "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "harfbuzzjs": ["harfbuzzjs@0.10.0", "", {}, "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], @@ -3093,7 +3115,9 @@ "vinext-devup-ui-benchmark": ["vinext-devup-ui-benchmark@workspace:benchmark/vinext-devup-ui"], - "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + "vinext-rsc-css-repro": ["vinext-rsc-css-repro@workspace:apps/vinext-rsc-css-repro"], + + "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], "vite-example": ["vite-example@workspace:apps/vite"], @@ -3621,6 +3645,8 @@ "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], + "@stylexjs/babel-plugin/@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=="], "@stylexjs/babel-plugin/@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=="], @@ -3781,6 +3807,8 @@ "vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "vinext-rsc-css-repro/vinext": ["vinext@1.0.0", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "1.0.3", "@vinext/types": "^1.0.0", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-0PeWLqMoJO6mkPvprneY1mLY12nxxhaGTdr3+sTfVTD9xncwaIXQyhVWy+vrvgMlTOkg2e8MMgOiI3qnEdMp2w=="], + "vite-node/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "vite-node/obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -5631,6 +5659,12 @@ "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "vinext-rsc-css-repro/vinext/@vercel/og": ["@vercel/og@1.0.3", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.1", "satori": "0.33.5" }, "optionalDependencies": { "sharp": "^0.35.3" } }, "sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA=="], + + "vinext-rsc-css-repro/vinext/@vinext/types": ["@vinext/types@1.0.1", "", {}, "sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw=="], + + "vinext-rsc-css-repro/vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@babel/helper-create-class-features-plugin/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -6577,6 +6611,10 @@ "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "vinext-rsc-css-repro/vinext/@vercel/og/@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], + + "vinext-rsc-css-repro/vinext/@vercel/og/satori": ["satori@0.33.5", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw=="], + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], @@ -6593,6 +6631,8 @@ "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "vinext-rsc-css-repro/vinext/@vercel/og/satori/css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..96c6ca430 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/package.json b/package.json index 5d14f9fbe..795d7a498 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", + "test:e2e:rsc-css": "bunx playwright test -c playwright.rsc-css.config.ts", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c910f406..4e14b5fd8 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -59,6 +59,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string + plugins?: object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -113,6 +114,16 @@ interface ViteTestPlugin { resolveId: (source: string, importer?: string) => string | undefined } +interface ViteTestRestorePlugin { + name: string + sharedDuringBuild: true + apply: 'build' + generateBundle: { + order: 'post' + handler: (options: object, bundle: Record) => void + } +} + function createCodeExtractResult( overrides: Partial = {}, ): CodeExtractResult { @@ -128,8 +139,14 @@ function createCodeExtractResult( } as unknown as CodeExtractResult } +function createPlugins( + options?: Parameters[0], +): [ViteTestPlugin, ViteTestRestorePlugin] { + return DevupUI(options) as unknown as [ViteTestPlugin, ViteTestRestorePlugin] +} + function createPlugin(options?: Parameters[0]): ViteTestPlugin { - return DevupUI(options) as unknown as ViteTestPlugin + return createPlugins(options)[0] } const ROLLUP_META: ConfigHookMeta = { @@ -613,115 +630,270 @@ describe('devupUIVitePlugin', () => { expect(bundle['base.css'].source).toEqual('final complete sheet') }) - it('does not forward server css that the client already emits', async () => { - const plugin = createPlugin({}) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base sheet' : 'file sheet', - ) - const serverBundle = { - 'base.css': { source: 'stale', name: 'devup-ui.css' }, - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['base.css', 'file.css', 'server-only.css']), - }, - }, + describe('css forwarded from the server bundle by @vitejs/plugin-rsc', () => { + interface Asset { + type: 'asset' + fileName: string + source: string + name: string } - const clientBundle = { - 'base.css': { source: 'stale', name: 'devup-ui.css' }, - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + interface Chunk { + type: 'chunk' + fileName: string + name: string + viteMetadata?: { importedCss?: Set } + } + type Output = Asset | Chunk + type Bundle = Record + + const asset = (fileName: string, name: string): Asset => ({ + type: 'asset', + fileName, + source: 'stale', + name, + }) + const chunk = (fileName: string, css: string[]): Chunk => ({ + type: 'chunk', + fileName, + name: fileName, + viteMetadata: { importedCss: new Set(css) }, + }) + const serverEnv = { + environment: { name: 'rsc', config: { consumer: 'server' as const } }, + } + const clientEnv = { + environment: { + name: 'client', + config: { consumer: 'client' as const }, + }, } + const rscConfig = (bundles: Record) => ({ + plugins: [{ name: 'rsc:minimal', api: { manager: { bundles } } }], + }) - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) + /** + * What plugin-rsc does in the client `generateBundle`: emit every CSS file + * the server chunks import (under the asset's own fileName), then read the + * very same imports as the server pages' CSS dependencies. + */ + function forwardLikePluginRsc(rscBundle: Bundle) { + const imported = Object.values(rscBundle).flatMap((output) => + output.type === 'chunk' + ? [...(output.viteMetadata?.importedCss ?? [])] + : [], + ) + const emitted = [...new Set(imported)].map( + (file) => rscBundle[file].fileName, + ) + const dependencies = Object.fromEntries( + Object.values(rscBundle).flatMap((output) => + output.type === 'chunk' + ? [ + [ + output.fileName, + [...(output.viteMetadata?.importedCss ?? [])], + ], + ] + : [], + ), + ) + return { emitted, dependencies } + } - expect(serverBundle['base.css'].source).toEqual('base sheet') - expect(serverBundle['file.css'].source).toEqual('file sheet') - expect(clientBundle['base.css'].source).toEqual('base sheet') - expect(clientBundle['file.css'].source).toEqual('file sheet') - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['server-only.css']), - ) - }) + beforeEach(() => { + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base sheet' : 'file sheet', + ) + }) - it('ignores no-write analysis bundles when tracking server css', async () => { - const plugin = createPlugin({}) - const serverBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { importedCss: new Set(['file.css']) }, - }, - } - await plugin.generateBundle.call( + it.each([ { - environment: { - name: 'rsc', - config: { consumer: 'server', build: { write: false } }, - }, + label: 'per-file css', + singleCss: false, + shared: ['base.css', 'file.css'], + }, + { label: 'single css', singleCss: true, shared: ['base.css'] }, + ])( + 'keeps the server css dependencies and emits each file once ($label)', + async ({ singleCss, shared }) => { + const [plugin, restore] = createPlugins({ singleCss }) + const names: Record = { + 'base.css': 'devup-ui.css', + 'file.css': 'devup-ui-3.css', + } + const serverBundle: Bundle = { + 'base.css': asset('base.css', names['base.css']), + 'file.css': asset('file.css', names['file.css']), + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'page.js': chunk('page.js', [...shared, 'server-only.css']), + } + const clientBundle: Bundle = Object.fromEntries( + shared.map((file) => [file, asset(file, names[file])]), + ) + await plugin.configResolved( + rscConfig({ rsc: serverBundle, client: clientBundle }), + ) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const { emitted, dependencies } = forwardLikePluginRsc(serverBundle) + + expect(dependencies['page.js']).toEqual([ + ...shared, + 'server-only.css', + ]) + expect(emitted).toHaveLength(new Set(emitted).size) + expect(emitted.filter((name) => name in clientBundle)).toEqual([]) + expect(emitted).toContain('server-only.css') + + for (const name of emitted) clientBundle[name] = asset(name, name) + restore.generateBundle.handler({}, clientBundle) + + expect(Object.keys(clientBundle).sort()).toEqual( + [...shared, 'server-only.css'].sort(), + ) + for (const file of shared) { + expect(serverBundle[file].fileName).toBe(file) + } + expect(dependencies['page.js']).toEqual([ + ...(serverBundle['page.js'] as Chunk).viteMetadata!.importedCss!, + ]) + expect(clientBundle['base.css']).toHaveProperty( + 'source', + 'base sheet', + ) }, - {}, - serverBundle, ) - const clientBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - } - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) + it('undoes the stand-ins only once', async () => { + const [plugin, restore] = createPlugins() + const serverBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + expect(serverBundle['base.css'].fileName).not.toBe('base.css') - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['file.css']), - ) - }) + restore.generateBundle.handler({}, clientBundle) + expect(serverBundle['base.css'].fileName).toBe('base.css') - it('keeps server forwarding for a different output file name', async () => { - const plugin = createPlugin({}) - const serverBundle = { - 'devup-ui-3.server.css': { - source: 'stale', - name: 'devup-ui-3.css', + serverBundle['base.css'].fileName = 'changed.css' + restore.generateBundle.handler({}, clientBundle) + expect(serverBundle['base.css'].fileName).toBe('changed.css') + }) + + it('leaves css the client does not emit to plugin-rsc as it is', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'devup-ui-3.server.css': asset( + 'devup-ui-3.server.css', + 'devup-ui-3.css', + ), + 'page.js': chunk('page.js', ['devup-ui-3.server.css']), + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(forwardLikePluginRsc(serverBundle).emitted).toEqual([ + 'devup-ui-3.server.css', + ]) + }) + + it('does not park a server entry that is not an asset', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'shared.css': chunk('shared.css', []), + 'page.js': chunk('page.js', ['shared.css']), + 'plain.js': { type: 'chunk', fileName: 'plain.js', name: 'plain' }, + } + const clientBundle: Bundle = { + 'shared.css': asset('shared.css', 'shared.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(serverBundle['shared.css'].fileName).toBe('shared.css') + }) + + it('does not touch the client bundle the rsc plugin tracks', async () => { + const [plugin] = createPlugins() + const trackedClient: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ client: trackedClient })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(trackedClient['base.css'].fileName).toBe('base.css') + }) + + it.each([ + { label: 'no config', config: undefined }, + { label: 'no rsc plugin', config: { plugins: [{ name: 'other' }] } }, + { + label: 'no api', + config: { plugins: [{ name: 'rsc:minimal' }] }, }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['devup-ui-3.server.css']), - }, + { + label: 'no manager bundles', + config: { plugins: [{ name: 'rsc:minimal', api: { manager: {} } }] }, }, - } - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - const clientBundle = { - 'devup-ui-3.client.css': { - source: 'stale', - name: 'devup-ui-3.css', + ])( + 'does nothing when plugin-rsc exposes no bundles ($label)', + async ({ config }) => { + const [plugin, restore] = createPlugins() + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(config) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(Object.keys(clientBundle)).toEqual(['base.css']) }, - } - - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, ) - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['devup-ui-3.server.css']), - ) + it('ignores no-write analysis bundles', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call( + { + environment: { + name: 'client', + config: { consumer: 'client', build: { write: false } }, + }, + }, + {}, + clientBundle, + ) + + expect(serverBundle['base.css'].fileName).toBe('base.css') + }) }) it('resolves a stable id during build', async () => { @@ -1314,9 +1486,9 @@ describe('devupUIVitePlugin atom hoisting', () => { options: Parameters[0], config: unknown, ) => { - const plugin = DevupUI(options) as unknown as { - configResolved: ConfigResolved - } + const [plugin] = DevupUI(options) as unknown as [ + { configResolved: ConfigResolved }, + ] await plugin.configResolved(config) } diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 9d0b77613..91e585045 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -36,7 +36,9 @@ import { import type { EnvironmentModuleNode, ModuleNode, + Plugin, PluginOption, + ResolvedConfig, UserConfig, } from 'vite' @@ -122,11 +124,77 @@ interface ConfigHookMeta { } interface ViteOutputWithMetadata { + type?: string + fileName?: string viteMetadata?: { importedCss?: Set } } +type ForwardableBundle = Record + +/** + * Name a CSS file is parked under while @vitejs/plugin-rsc forwards it, chosen + * so that nothing treats it as a stylesheet and it cannot meet a real output. + */ +function getForwardStandInName(file: string): string { + return `${file}.devup-forwarded` +} + +/** + * @vitejs/plugin-rsc copies every CSS file its server bundle references into + * the client output with `emitFile`. For a file the client already emitted that + * is a FILE_NAME_CONFLICT, and the copy replaces the client's own, dropping the + * names its build manifest is keyed by. The same plugin reads the server + * bundle's `importedCss` to learn which CSS each server page depends on, so + * that metadata has to stay whole. + * + * It takes what it forwards from `bundle[file]`. For each file the client + * already owns, the server bundle gets a stand-in copy under another name: the + * forward lands on a throwaway file and the real one is emitted once. + * + * Returns the undo that puts the originals back and drops the stand-ins. + */ +function parkForwardedCss( + clientBundle: ForwardableBundle, + serverBundle: ForwardableBundle, +): (outputBundle: ForwardableBundle) => void { + const files = new Set() + for (const output of Object.values(serverBundle)) { + for (const file of output.viteMetadata?.importedCss ?? []) { + if (file in clientBundle && serverBundle[file]?.type === 'asset') { + files.add(file) + } + } + } + for (const file of files) { + serverBundle[file].fileName = getForwardStandInName(file) + } + return (outputBundle) => { + for (const file of files) { + serverBundle[file].fileName = file + delete outputBundle[getForwardStandInName(file)] + } + } +} + +interface RscPluginApi { + manager?: { bundles?: Record } +} + +/** + * The server bundles @vitejs/plugin-rsc itself tracks (and later reads to + * forward CSS). Bundle objects handed to a plugin are per-plugin views, and + * only the ones plugin-rsc holds reflect an edit it will see. + */ +function getRscServerBundles(config: ResolvedConfig | undefined) { + const rsc = config?.plugins.find((plugin) => plugin.name === 'rsc:minimal') + const bundles = (rsc?.api as RscPluginApi | undefined)?.manager?.bundles + return Object.entries(bundles ?? {}) + .filter(([name]) => name !== 'client') + .map(([, bundle]) => bundle) +} + /** * Vite merges a plugin's `config()` result over the user's, replacing function * values outright, so returning a bare `manualChunks` silently drops one the @@ -276,7 +344,11 @@ export function DevupUI({ } const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() - let serverBundleToForward: Record | undefined + let resolvedConfig: ResolvedConfig | undefined + // Set by the client `generateBundle`, run by the late hook of the sibling + // plugin once @vitejs/plugin-rsc has forwarded. + let restoreForwardedCss: + ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that @@ -287,13 +359,14 @@ export function DevupUI({ writtenCss.set(fileName, css) return writeFile(join(cssDir, fileName), css, 'utf-8') } - return { + const plugin: Plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, async configResolved(config) { + resolvedConfig = config isServe = config?.command === 'serve' const projectRoot = config?.root ?? process.cwd() // Vite ids are POSIX absolute paths @@ -596,23 +669,30 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - if (environment.config.consumer === 'client' && serverBundleToForward) { - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. - for (const output of Object.values(serverBundleToForward)) { - for (const file of cssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } + if (environment.config.consumer === 'client') { + const undo = getRscServerBundles(resolvedConfig).map((serverBundle) => + parkForwardedCss( + bundle as unknown as ForwardableBundle, + serverBundle, + ), + ) + restoreForwardedCss = (outputBundle) => { + for (const restore of undo) restore(outputBundle) } - } else if (environment.config.consumer === 'server') { - serverBundleToForward = bundle as unknown as Record< - string, - ViteOutputWithMetadata - > } }, } + const restorePlugin: Plugin = { + name: 'devup-ui:restore-forwarded-css', + sharedDuringBuild: true, + apply: 'build', + generateBundle: { + order: 'post', + handler(_options, bundle) { + restoreForwardedCss?.(bundle as unknown as ForwardableBundle) + restoreForwardedCss = undefined + }, + }, + } + return [plugin, restorePlugin] } diff --git a/playwright.rsc-css.config.ts b/playwright.rsc-css.config.ts new file mode 100644 index 000000000..57b9579d5 --- /dev/null +++ b/playwright.rsc-css.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Gates the static output of apps/vinext-rsc-css-repro (vinext + RSC). Build + * it first: `bun run --filter vinext-rsc-css-repro build`, or with + * DEVUP_SINGLE_CSS=1 for the singleCss variant. + */ +export default defineConfig({ + testDir: './apps/vinext-rsc-css-repro/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : 4, + reporter: 'list', + timeout: 60_000, + use: { + baseURL: 'http://localhost:3187', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'node e2e/serve-static.mjs 3187', + env: { + LANDING_OUTPUT_ROOT: 'apps/vinext-rsc-css-repro/dist/client', + }, + url: 'http://localhost:3187', + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + }, +}) From 2fdbfc5967f9aeb121eb201523ac131706309769 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:56:21 +0900 Subject: [PATCH 2/2] chore(vinext-rsc-css-repro): ignore generated type files Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- apps/vinext-rsc-css-repro/.gitignore | 3 ++ .../.next/types/routes.d.ts | 43 ------------------- apps/vinext-rsc-css-repro/next-env.d.ts | 5 --- 3 files changed, 3 insertions(+), 48 deletions(-) delete mode 100644 apps/vinext-rsc-css-repro/.next/types/routes.d.ts delete mode 100644 apps/vinext-rsc-css-repro/next-env.d.ts diff --git a/apps/vinext-rsc-css-repro/.gitignore b/apps/vinext-rsc-css-repro/.gitignore index f8c1b8486..8a6f6f50f 100644 --- a/apps/vinext-rsc-css-repro/.gitignore +++ b/apps/vinext-rsc-css-repro/.gitignore @@ -1,4 +1,7 @@ /dist/ /.vinext/ +/.next/ /df/ /node_modules/ +next-env.d.ts +*.tsbuildinfo diff --git a/apps/vinext-rsc-css-repro/.next/types/routes.d.ts b/apps/vinext-rsc-css-repro/.next/types/routes.d.ts deleted file mode 100644 index dab45e2d5..000000000 --- a/apps/vinext-rsc-css-repro/.next/types/routes.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -// This file is generated by vinext. Do not edit. -import type * as React from "react"; - -declare global { - type PageProps = { - params: Promise; - searchParams: Promise>; - }; - - type LayoutProps = { - params: Promise; - children: React.ReactNode; - } & { - [K in VinextRouteTypes.LayoutSlotMap[Route]]: React.ReactNode; - }; - - type RouteContext = { - params: Promise; - }; -} - -declare namespace VinextRouteTypes { - type PageRoute = "/" | "/admins" | "/notice" | "/users" | "/users/edit"; - type LayoutRoute = "/"; - type RouteHandlerRoute = never; - type AppRoute = "/" | "/admins" | "/notice" | "/users" | "/users/edit"; - - interface ParamMap { - "/": {}; - "/admins": {}; - "/notice": {}; - "/users": {}; - "/users/edit": {}; - } - - interface LayoutSlotMap { - "/": never; - } -} - - - -export {}; diff --git a/apps/vinext-rsc-css-repro/next-env.d.ts b/apps/vinext-rsc-css-repro/next-env.d.ts deleted file mode 100644 index c986d49fa..000000000 --- a/apps/vinext-rsc-css-repro/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "vinext/types"; -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.