From fd1783d85428edd55294e70547d9d9187e30879c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 14:56:31 +0900 Subject: [PATCH 01/12] fix(react,components): support React 18 peers and drop React 19-only APIs Refs #691 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_react_18_support.json | 8 ++ bun.lock | 30 +++--- packages/components/package.json | 6 +- .../Select/__tests__/index.browser.test.tsx | 3 +- .../src/components/Select/index.tsx | 93 ++++++++++--------- .../src/components/Stepper/index.tsx | 4 +- packages/components/src/contexts/useSelect.ts | 4 +- packages/react/package.json | 6 +- 8 files changed, 84 insertions(+), 70 deletions(-) create mode 100644 .changepacks/changepack_log_react_18_support.json diff --git a/.changepacks/changepack_log_react_18_support.json b/.changepacks/changepack_log_react_18_support.json new file mode 100644 index 000000000..8fbde2ca0 --- /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 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/bun.lock b/bun.lock index 9eea57e19..36af23d4d 100644 --- a/bun.lock +++ b/bun.lock @@ -469,11 +469,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,11 +488,10 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.57", + "version": "0.1.59", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", - "react": "^19.3.0", }, "devDependencies": { "@devup-ui/vite-plugin": "workspace:^", @@ -502,6 +501,7 @@ "@types/react": "^19.3", "@typescript/typescript6": "^6.0", "eslint-plugin-storybook": "^10.6", + "react": "^19.3.0", "rollup-plugin-preserve-directives": "^0.4", "storybook": "^10.6", "typescript": "^7.0.2", @@ -510,12 +510,12 @@ }, "peerDependencies": { "@devup-ui/react": "workspace:^", - "react": "*", + "react": "^18 || ^19", }, }, "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 +532,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,22 +550,22 @@ }, "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", }, "devDependencies": { "@types/node": "^26.6", "@types/react": "^19.3", "@typescript/typescript6": "^6.0", + "react": "^19.3", "rollup-plugin-preserve-directives": "^0.4", "typescript": "^7.0.2", "vite": "^8.3", @@ -573,12 +573,12 @@ }, "peerDependencies": { "csstype-extra": "*", - "react": "*", + "react": "^18 || ^19", }, }, "packages/reset-css": { "name": "@devup-ui/reset-css", - "version": "1.0.30", + "version": "1.0.31", "dependencies": { "@devup-ui/react": "workspace:^", }, @@ -591,7 +591,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 +607,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 +622,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:^", diff --git a/packages/components/package.json b/packages/components/package.json index 25e552f51..212a785d8 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -44,7 +44,6 @@ "types": "./dist/index.d.ts", "dependencies": { "@devup-ui/react": "workspace:^", - "react": "^19.3.0", "clsx": "^2.1" }, "devDependencies": { @@ -59,10 +58,11 @@ "typescript": "^7.0.2", "@typescript/typescript6": "^6.0", "vite": "^8.3", - "vite-plugin-dts": "^5.1" + "vite-plugin-dts": "^5.1", + "react": "^19.3.0" }, "peerDependencies": { "@devup-ui/react": "workspace:^", - "react": "*" + "react": "^18 || ^19" } } diff --git a/packages/components/src/components/Select/__tests__/index.browser.test.tsx b/packages/components/src/components/Select/__tests__/index.browser.test.tsx index 116572383..444e4db22 100644 --- a/packages/components/src/components/Select/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Select/__tests__/index.browser.test.tsx @@ -449,7 +449,8 @@ describe('Select', () => { // offsetWidth > 1024px Object.defineProperty(selectContainer, 'offsetWidth', { value: 1100 }) - // rerender + // the container repositions when the window resizes + window.dispatchEvent(new Event('resize')) rerender() expect(container).toMatchSnapshot() diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 4ce1b0220..7392925d5 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -230,54 +230,59 @@ export function SelectContainer({ ...props }: SelectContainerProps) { const { open, setOpen, type, ref } = useSelect() + const containerRef = useRef(null) - if (!open) return null - return ( - { - if (!ref.current || !el) return - const combobox = ref.current - - // 요소가 움직일 때마다(스크롤, 리사이즈 등) 위치를 갱신하도록 이벤트를 등록합니다. - const updatePosition = () => { - const { - height, - x: comboboxX, - y: comboboxY, - top, - left, - } = combobox.getBoundingClientRect() - - const isOverflowBottom = - el.offsetHeight + top + window.scrollY + height + y > - document.documentElement.scrollHeight - - const isOverflowRight = - el.offsetWidth + left + window.scrollX + x > - document.documentElement.scrollWidth - - if (isOverflowBottom) - el.style.bottom = `${window.innerHeight - comboboxY + 10}px` - else el.style.top = `${comboboxY + height + 10 + y}px` - - if (isOverflowRight) - el.style.left = `${Math.max(comboboxX - el.offsetWidth + combobox.offsetWidth, 0) + x}px` - else el.style.left = `${comboboxX + x}px` - } + // An effect rather than a callback ref returning cleanup, which React 18 ignores + useEffect(() => { + const el = containerRef.current + if (!open || !ref.current || !el) return + const combobox = ref.current - // 최초 위치 설정 - updatePosition() + // 요소가 움직일 때마다(스크롤, 리사이즈 등) 위치를 갱신하도록 이벤트를 등록합니다. + const updatePosition = () => { + const { + height, + x: comboboxX, + y: comboboxY, + top, + left, + } = combobox.getBoundingClientRect() - // 스크롤, 리사이즈, DOM 변경 등 요소 위치가 변할 수 있는 이벤트에 리스너 등록 - window.addEventListener('scroll', updatePosition, true) - window.addEventListener('resize', updatePosition) + const isOverflowBottom = + el.offsetHeight + top + window.scrollY + height + y > + document.documentElement.scrollHeight - // 컴포넌트 언마운트 시 이벤트 해제 - return () => { - window.removeEventListener('scroll', updatePosition, true) - window.removeEventListener('resize', updatePosition) - } - }} + const isOverflowRight = + el.offsetWidth + left + window.scrollX + x > + document.documentElement.scrollWidth + + if (isOverflowBottom) + el.style.bottom = `${window.innerHeight - comboboxY + 10}px` + else el.style.top = `${comboboxY + height + 10 + y}px` + + if (isOverflowRight) + el.style.left = `${Math.max(comboboxX - el.offsetWidth + combobox.offsetWidth, 0) + x}px` + else el.style.left = `${comboboxX + x}px` + } + + // 최초 위치 설정 + updatePosition() + + // 스크롤, 리사이즈, DOM 변경 등 요소 위치가 변할 수 있는 이벤트에 리스너 등록 + window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition) + + // 컴포넌트 언마운트 시 이벤트 해제 + return () => { + window.removeEventListener('scroll', updatePosition, true) + window.removeEventListener('resize', updatePosition) + } + }, [open, ref, x, y]) + + if (!open) return null + return ( + (null) export const useStepper = () => { - const context = use(StepperContext) + const context = useContext(StepperContext) if (!context) { throw new Error('useStepper must be used within a StepperProvider') } diff --git a/packages/components/src/contexts/useSelect.ts b/packages/components/src/contexts/useSelect.ts index cb716bae2..da05b8aa4 100644 --- a/packages/components/src/contexts/useSelect.ts +++ b/packages/components/src/contexts/useSelect.ts @@ -1,5 +1,5 @@ 'use client' -import { createContext, use } from 'react' +import { createContext, useContext } from 'react' type SelectType = 'default' | 'radio' | 'checkbox' type SelectValue = T extends 'radio' ? string : string[] @@ -14,7 +14,7 @@ export const SelectContext = createContext<{ } | null>(null) export const useSelect = () => { - const context = use(SelectContext) + const context = useContext(SelectContext) if (!context) { throw new Error('useSelect must be used within a Select') } diff --git a/packages/react/package.json b/packages/react/package.json index a05b1c916..9a37d2af0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -66,7 +66,6 @@ ], "types": "./dist/index.d.ts", "dependencies": { - "react": "^19.3", "csstype-extra": "latest" }, "devDependencies": { @@ -76,10 +75,11 @@ "typescript": "^7.0.2", "@typescript/typescript6": "^6.0", "@types/react": "^19.3", - "@types/node": "^26.6" + "@types/node": "^26.6", + "react": "^19.3" }, "peerDependencies": { - "react": "*", + "react": "^18 || ^19", "csstype-extra": "*" } } From a641f0b80823a5c509f8b764a241bb834b598508 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 15:13:18 +0900 Subject: [PATCH 02/12] chore: satisfy clippy assert_is_empty lint on Rust 1.99 Refs #691 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bindings/devup-ui-wasm/src/lib.rs | 8 ++++---- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 8 ++++---- libs/extractor/src/tailwind.rs | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) 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/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] From b6c0d89d0813b2a92c804ff4965c6ba8d709a068 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 15:36:14 +0900 Subject: [PATCH 03/12] fix(components): forward refs from the UI kit form components Refs #691 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_ui_kit_forward_ref.json | 7 + bindings/devup-ui-wasm/src/lib.rs | 8 +- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 8 +- libs/extractor/src/tailwind.rs | 4 +- .../__tests__/forward-ref.browser.test.tsx | 26 + .../src/__tests__/index.browser.test.ts | 10 +- .../src/components/Button/index.tsx | 467 +++++++++--------- .../Checkbox/__tests__/index.browser.test.tsx | 7 + .../src/components/Checkbox/index.tsx | 259 +++++----- .../components/src/components/Input/index.tsx | 42 +- .../components/src/components/Radio/index.tsx | 36 +- .../src/components/Textarea/index.tsx | 204 ++++---- 13 files changed, 575 insertions(+), 505 deletions(-) create mode 100644 .changepacks/changepack_log_ui_kit_forward_ref.json create mode 100644 packages/components/src/__tests__/forward-ref.browser.test.tsx 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/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/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/packages/components/src/__tests__/forward-ref.browser.test.tsx b/packages/components/src/__tests__/forward-ref.browser.test.tsx new file mode 100644 index 000000000..59d355290 --- /dev/null +++ b/packages/components/src/__tests__/forward-ref.browser.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test' +import { render } from 'bun-test-env-dom' +import { createRef } from 'react' + +import { Button, Input, Radio, Textarea } from '../index' + +describe('forwardRef', () => { + it('hands each component ref to its form element', () => { + const button = createRef() + const input = createRef() + const radio = createRef() + const textarea = createRef() + render( + <> + + + r + " @@ -12,7 +12,7 @@ exports[`Textarea should render with default props 1`] = ` exports[`Textarea should render with disabled prop 1`] = ` "
-
" @@ -21,7 +21,7 @@ exports[`Textarea should render with disabled prop 1`] = ` exports[`Textarea should render error style when error is true 1`] = ` "
-
" @@ -30,9 +30,9 @@ exports[`Textarea should render error style when error is true 1`] = ` exports[`Textarea should render with error message 1`] = ` "
- - + Error message
@@ -42,7 +42,7 @@ exports[`Textarea should render with error message 1`] = ` exports[`Textarea should have typography when typography is provided 1`] = ` "
-
" diff --git a/packages/components/src/components/Textarea/__tests__/index.browser.test.tsx b/packages/components/src/components/Textarea/__tests__/index.browser.test.tsx index 349769a79..c509ec00d 100644 --- a/packages/components/src/components/Textarea/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Textarea/__tests__/index.browser.test.tsx @@ -32,29 +32,25 @@ describe('Textarea', () => { it('should render error style when error is true', () => { const { container } = render(