From 2071508eafb321a2bdfb172f8ea26dbce07553ba Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Mon, 13 Jul 2026 23:57:23 +0800 Subject: [PATCH] fix(web-shell): restore packaged dialog styles on React 18 (#6827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web-shell): restore packaged dialog styles on React 18 * docs(web-shell): document UI component conventions * test(web-shell): verify workspace input focus * test(web-shell): verify button ref binding --------- Co-authored-by: 钉萁 --- AGENTS.md | 27 ++++++++++++ .../web-shell/client/build-artifact.test.ts | 23 ++++++++++ .../dialogs/AddWorkspaceDialog.test.tsx | 6 +++ .../client/components/ui/alert-dialog.tsx | 31 ++++++++------ .../web-shell/client/components/ui/button.tsx | 26 +++++++----- .../web-shell/client/components/ui/dialog.tsx | 36 +++++++++------- .../web-shell/client/components/ui/input.tsx | 31 +++++++------- .../components/ui/react18-ref-compat.test.tsx | 42 +++++++++++++++++++ .../web-shell/client/components/ui/select.tsx | 24 +++++++---- packages/web-shell/vite.lib.config.ts | 10 +++++ 10 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 packages/web-shell/client/components/ui/react18-ref-compat.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 39887f27af..36aaac8080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,33 @@ npm run preflight # Full check: clean → install → format → lint → build - **Commits**: Conventional Commits (e.g., `feat(cli): Add --json flag`) - **Node.js**: Development and production both require `>=22` (Ink 7 + React 19.2 requirement) +### Web Shell UI development + +- Prefer the shared primitives in + `packages/web-shell/client/components/ui` when developing Web Shell UI. Do + not duplicate an existing primitive or rewrite stable CSS Modules solely for + consistency. +- If a required primitive is missing, run + `npx shadcn@latest add ` from `packages/web-shell`, then review the + generated diff. Do not let the CLI overwrite the existing global CSS, + semantic tokens, CSS scoping, or portal-root integration. Keep generated + components internal unless a public package API is explicitly required. +- Web Shell supports React 18 and React 19. Generated shadcn components often + assume React 19 ref semantics, so wrappers that accept or receive refs — + including Radix `asChild`, `Slot`, `Presence`, and portal children — must use + `React.forwardRef` and pass the ref to the underlying DOM or Radix primitive. + Add a regression test for any ref-sensitive component path. +- Use unprefixed Tailwind classes and shadcn semantic color tokens such as + `background`, `primary`, and `muted`. The package build scopes generated CSS + to the Web Shell root and portal root and prefixes global animations and CSS + property registrations; changes must preserve that isolation from host-page + styles. +- Components with portals, such as dialogs, popovers, dropdown menus, and + tooltips, must use `useWebShellPortalRoot()` as the Radix portal container so + themes, scoped CSS, and z-index variables continue to apply. Preserve + existing `data-web-shell-*` attributes and public `--web-shell-*` CSS + variables. See `packages/web-shell/README.md` for the full conventions. + ## Development Guidelines ### General workflow diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts index 0cf4480974..9c65f99f20 100644 --- a/packages/web-shell/client/build-artifact.test.ts +++ b/packages/web-shell/client/build-artifact.test.ts @@ -75,6 +75,29 @@ describe('build artifact — package boundary', () => { expect(unscoped).toEqual([]); }); + it('applies Tailwind theme variables to WebShell roots', () => { + const themeRules: string[] = []; + postcss.parse(readInjectedCss()).walkRules((rule) => { + if ( + rule.nodes.some( + (node) => node.type === 'decl' && node.prop === '--spacing', + ) + ) { + themeRules.push(rule.selector); + } + }); + + expect(themeRules).toContain( + ':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn])', + ); + expect(themeRules).not.toEqual( + expect.arrayContaining([ + expect.stringContaining(':root'), + expect.stringContaining(':host'), + ]), + ); + }); + it('prefixes global CSS registrations and animations', () => { const unscoped: string[] = []; postcss.parse(readInjectedCss()).walkAtRules((atRule) => { diff --git a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx index 3fd42de9fc..1b8bd83212 100644 --- a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx @@ -54,6 +54,12 @@ function submit() { } describe('AddWorkspaceDialog', () => { + it('focuses the path input when opened', () => { + mount(); + + expect(document.activeElement).toBe(input()); + }); + it('describes the input with the hint and no error initially', () => { mount(); // Hint is always associated; error id is only added once an error exists so diff --git a/packages/web-shell/client/components/ui/alert-dialog.tsx b/packages/web-shell/client/components/ui/alert-dialog.tsx index 4c393d2375..cdf389ab8a 100644 --- a/packages/web-shell/client/components/ui/alert-dialog.tsx +++ b/packages/web-shell/client/components/ui/alert-dialog.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react'; +import * as React from 'react'; import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; import { cn } from '@/lib/utils'; @@ -33,12 +33,13 @@ function AlertDialogPortal({ ); } -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(function AlertDialogOverlay({ className, ...props }, ref) { return ( ); -} +}); -function AlertDialogContent({ - className, - size = 'default', - ...props -}: React.ComponentProps & { +type AlertDialogContentProps = React.ComponentProps< + typeof AlertDialogPrimitive.Content +> & { size?: 'default' | 'sm'; -}) { +}; + +const AlertDialogContent = React.forwardRef< + React.ComponentRef, + AlertDialogContentProps +>(function AlertDialogContent({ className, size = 'default', ...props }, ref) { return ( ); -} +}); function AlertDialogHeader({ className, diff --git a/packages/web-shell/client/components/ui/button.tsx b/packages/web-shell/client/components/ui/button.tsx index 83867022e5..c100788f40 100644 --- a/packages/web-shell/client/components/ui/button.tsx +++ b/packages/web-shell/client/components/ui/button.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react'; +import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { Slot } from 'radix-ui'; @@ -41,20 +41,26 @@ const buttonVariants = cva( }, ); -function Button({ - className, - variant = 'default', - size = 'default', - asChild = false, - ...props -}: React.ComponentProps<'button'> & +type ButtonProps = React.ComponentProps<'button'> & VariantProps & { asChild?: boolean; - }) { + }; + +const Button = React.forwardRef(function Button( + { + className, + variant = 'default', + size = 'default', + asChild = false, + ...props + }, + ref, +) { const Comp = asChild ? Slot.Root : 'button'; return ( ); -} +}); export { Button, buttonVariants }; diff --git a/packages/web-shell/client/components/ui/dialog.tsx b/packages/web-shell/client/components/ui/dialog.tsx index 004f215c3d..26360c9e2f 100644 --- a/packages/web-shell/client/components/ui/dialog.tsx +++ b/packages/web-shell/client/components/ui/dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import type * as React from 'react'; +import * as React from 'react'; import { Dialog as DialogPrimitive } from 'radix-ui'; import { cn } from '@/lib/utils'; @@ -40,12 +40,13 @@ function DialogClose({ return ; } -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(function DialogOverlay({ className, ...props }, ref) { return ( ); -} +}); -function DialogContent({ - className, - children, - showCloseButton = true, - overlayProps, - ...props -}: React.ComponentProps & { +type DialogContentProps = React.ComponentProps< + typeof DialogPrimitive.Content +> & { showCloseButton?: boolean; overlayProps?: React.ComponentProps; -}) { +}; + +const DialogContent = React.forwardRef< + React.ComponentRef, + DialogContentProps +>(function DialogContent( + { className, children, showCloseButton = true, overlayProps, ...props }, + ref, +) { return ( ); -} +}); function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( diff --git a/packages/web-shell/client/components/ui/input.tsx b/packages/web-shell/client/components/ui/input.tsx index dab96eb470..a67799724e 100644 --- a/packages/web-shell/client/components/ui/input.tsx +++ b/packages/web-shell/client/components/ui/input.tsx @@ -1,19 +1,22 @@ -import type * as React from 'react'; +import * as React from 'react'; import { cn } from '@/lib/utils'; -function Input({ className, type, ...props }: React.ComponentProps<'input'>) { - return ( - - ); -} +const Input = React.forwardRef>( + function Input({ className, type, ...props }, ref) { + return ( + + ); + }, +); export { Input }; diff --git a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx new file mode 100644 index 0000000000..c4039b8f03 --- /dev/null +++ b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import * as React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it } from 'vitest'; + +import { AlertDialogContent, AlertDialogOverlay } from './alert-dialog'; +import { Button } from './button'; +import { DialogContent, DialogOverlay } from './dialog'; +import { Input } from './input'; +import { SelectTrigger } from './select'; + +const FORWARD_REF_TYPE = Symbol.for('react.forward_ref'); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe('React 18 ref compatibility', () => { + it.each([ + ['AlertDialogContent', AlertDialogContent], + ['AlertDialogOverlay', AlertDialogOverlay], + ['Button', Button], + ['DialogContent', DialogContent], + ['DialogOverlay', DialogOverlay], + ['Input', Input], + ['SelectTrigger', SelectTrigger], + ])('%s forwards refs', (_name, Component) => { + expect(Component).toHaveProperty('$$typeof', FORWARD_REF_TYPE); + }); + + it('forwards a Button ref to its DOM element', () => { + const ref = React.createRef(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => root.render()); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/packages/web-shell/client/components/ui/select.tsx b/packages/web-shell/client/components/ui/select.tsx index 2f4d8ac0b7..a81d6c06d2 100644 --- a/packages/web-shell/client/components/ui/select.tsx +++ b/packages/web-shell/client/components/ui/select.tsx @@ -1,6 +1,6 @@ 'use client'; -import type * as React from 'react'; +import * as React from 'react'; import { Select as SelectPrimitive } from 'radix-ui'; import { cn } from '@/lib/utils'; @@ -32,16 +32,22 @@ function SelectValue({ return ; } -function SelectTrigger({ - className, - size = 'default', - children, - ...props -}: React.ComponentProps & { +type SelectTriggerProps = React.ComponentProps< + typeof SelectPrimitive.Trigger +> & { size?: 'sm' | 'default'; -}) { +}; + +const SelectTrigger = React.forwardRef< + React.ComponentRef, + SelectTriggerProps +>(function SelectTrigger( + { className, size = 'default', children, ...props }, + ref, +) { return ( ); -} +}); function SelectContent({ className, diff --git a/packages/web-shell/vite.lib.config.ts b/packages/web-shell/vite.lib.config.ts index 3391bfea5c..0512a11c22 100644 --- a/packages/web-shell/vite.lib.config.ts +++ b/packages/web-shell/vite.lib.config.ts @@ -8,6 +8,8 @@ import pkg from './package.json' with { type: 'json' }; const COMPONENT_SCOPE = ':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn], [data-web-shell-root][data-web-shell-shadcn] *, [data-web-shell-portal-root][data-web-shell-shadcn] *)'; +const COMPONENT_ROOT_SCOPE = + ':where([data-web-shell-root][data-web-shell-shadcn], [data-web-shell-portal-root][data-web-shell-shadcn])'; function scopeComponentCss(css: string): string { const root = postcss.parse(css); @@ -58,6 +60,14 @@ function scopeComponentCss(css: string): string { } parent = parent.parent; } + if ( + rule.selectors.every( + (selector) => selector === ':root' || selector === ':host', + ) + ) { + rule.selector = COMPONENT_ROOT_SCOPE; + return; + } rule.selector = selectorParser((selectors) => { selectors.each((selector) => { const first = selector.first;