Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_vite_rsc_shared_css.json
Original file line number Diff line number Diff line change
@@ -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"
}
59 changes: 59 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions apps/vinext-rsc-css-repro/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/dist/
/.vinext/
/.next/
/df/
/node_modules/
next-env.d.ts
*.tsbuildinfo
1 change: 1 addition & 0 deletions apps/vinext-rsc-css-repro/devup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "theme": {} }
157 changes: 157 additions & 0 deletions apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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 `<link>` 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)
})
})
4 changes: 4 additions & 0 deletions apps/vinext-rsc-css-repro/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default {
output: 'export',
trailingSlash: true,
}
26 changes: 26 additions & 0 deletions apps/vinext-rsc-css-repro/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/admins/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PageTitle } from '../../components/PageTitle'

export default function AdminsPage() {
return (
<main>
<PageTitle right={<button type="button">Create</button>}>
Admins
</PageTitle>
</main>
)
}
9 changes: 9 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
11 changes: 11 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/notice/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PageTitle } from '../../components/PageTitle'

export default function NoticePage() {
return (
<main>
<PageTitle right={<button type="button">Create</button>}>
Notice
</PageTitle>
</main>
)
}
10 changes: 10 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function HomePage() {
return (
<main>
<h1>Repro</h1>
<Link href="/notice/">notice</Link>
</main>
)
}
19 changes: 19 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'

import { useState } from 'react'

import { PageTitle } from '../../../components/PageTitle'

export default function UserEditPage() {
const [name, setName] = useState('')
return (
<main>
<PageTitle>Edit user</PageTitle>
<input
aria-label="name"
onChange={(event) => setName(event.target.value)}
value={name}
/>
</main>
)
}
13 changes: 13 additions & 0 deletions apps/vinext-rsc-css-repro/src/app/users/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Link from 'next/link'

import { PageTitle } from '../../components/PageTitle'

export default function UsersPage() {
return (
<main>
<PageTitle right={<Link href="/users/edit/">Create</Link>}>
Users
</PageTitle>
</main>
)
}
25 changes: 25 additions & 0 deletions apps/vinext-rsc-css-repro/src/components/PageTitle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
alignItems="center"
data-page-title=""
justifyContent="space-between"
mb="20px"
>
<Box alignItems="center" display="flex" gap={3}>
<button type="button">Back</button>
<Text>{children}</Text>
</Box>
{right}
</Flex>
)
}
16 changes: 16 additions & 0 deletions apps/vinext-rsc-css-repro/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions apps/vinext-rsc-css-repro/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vinext/types" />
Loading
Loading