Skip to content

Commit c6df05c

Browse files
fix react composed ref stability
Co-authored-by: Segun Adebayo <joseshegs@gmail.com>
1 parent 8fb6c6f commit c6df05c

32 files changed

Lines changed: 234 additions & 63 deletions

packages/react/src/components/color-picker/color-picker-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
77
import { usePresenceContext } from '../presence/index.ts'
88
import { useColorPickerContext } from './use-color-picker-context.ts'
@@ -14,12 +14,13 @@ export const ColorPickerContent = forwardRef<HTMLDivElement, ColorPickerContentP
1414
const colorPicker = useColorPickerContext()
1515
const presence = usePresenceContext()
1616
const mergedProps = mergeProps(colorPicker.getContentProps(), presence.getPresenceProps(), props)
17+
const composedRefs = useComposedRefs(presence.ref, ref)
1718

1819
if (presence.unmounted) {
1920
return null
2021
}
2122

22-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
23+
return <ark.div {...mergedProps} ref={composedRefs} />
2324
})
2425

2526
ColorPickerContent.displayName = 'ColorPickerContent'

packages/react/src/components/combobox/combobox-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
77
import { usePresenceContext } from '../presence/index.ts'
88
import { useComboboxContext } from './use-combobox-context.ts'
@@ -14,12 +14,13 @@ export const ComboboxContent = forwardRef<HTMLDivElement, ComboboxContentProps>(
1414
const combobox = useComboboxContext()
1515
const presence = usePresenceContext()
1616
const mergedProps = mergeProps(combobox.getContentProps(), presence.getPresenceProps(), props)
17+
const composedRefs = useComposedRefs(presence.ref, ref)
1718

1819
if (presence.unmounted) {
1920
return null
2021
}
2122

22-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
23+
return <ark.div {...mergedProps} ref={composedRefs} />
2324
})
2425

2526
ComboboxContent.displayName = 'ComboboxContent'

packages/react/src/components/date-picker/date-picker-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
77
import { usePresenceContext } from '../presence/index.ts'
88
import { useDatePickerContext } from './use-date-picker-context.ts'
@@ -14,12 +14,13 @@ export const DatePickerContent = forwardRef<HTMLDivElement, DatePickerContentPro
1414
const datePicker = useDatePickerContext()
1515
const presence = usePresenceContext()
1616
const mergedProps = mergeProps(datePicker.getContentProps(), presence.getPresenceProps(), props)
17+
const composedRefs = useComposedRefs(presence.ref, ref)
1718

1819
if (presence.unmounted) {
1920
return null
2021
}
2122

22-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
23+
return <ark.div {...mergedProps} ref={composedRefs} />
2324
})
2425

2526
DatePickerContent.displayName = 'DatePickerContent'

packages/react/src/components/dialog/dialog-backdrop.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { useRenderStrategyPropsContext } from '../../utils/render-strategy.ts'
77
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
88
import { usePresence } from '../presence/index.ts'
@@ -16,12 +16,13 @@ export const DialogBackdrop = forwardRef<HTMLDivElement, DialogBackdropProps>((p
1616
const renderStrategyProps = useRenderStrategyPropsContext()
1717
const presence = usePresence({ ...renderStrategyProps, present: dialog.open })
1818
const mergedProps = mergeProps(dialog.getBackdropProps(), presence.getPresenceProps(), props)
19+
const composedRefs = useComposedRefs(presence.ref, ref)
1920

2021
if (presence.unmounted) {
2122
return null
2223
}
2324

24-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
25+
return <ark.div {...mergedProps} ref={composedRefs} />
2526
})
2627

2728
DialogBackdrop.displayName = 'DialogBackdrop'

packages/react/src/components/dialog/dialog-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
77
import { usePresenceContext } from '../presence/index.ts'
88
import { useDialogContext } from './use-dialog-context.ts'
@@ -14,12 +14,13 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>((pro
1414
const dialog = useDialogContext()
1515
const presence = usePresenceContext()
1616
const mergedProps = mergeProps(dialog.getContentProps(), presence.getPresenceProps(), props)
17+
const composedRefs = useComposedRefs(presence.ref, ref)
1718

1819
if (presence.unmounted) {
1920
return null
2021
}
2122

22-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
23+
return <ark.div {...mergedProps} ref={composedRefs} />
2324
})
2425

2526
DialogContent.displayName = 'DialogContent'

packages/react/src/components/drawer/drawer-backdrop.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { useRenderStrategyPropsContext } from '../../utils/render-strategy.ts'
77
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
88
import { usePresence } from '../presence/index.ts'
@@ -16,12 +16,13 @@ export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((p
1616
const renderStrategyProps = useRenderStrategyPropsContext()
1717
const presence = usePresence({ ...renderStrategyProps, present: drawer.open })
1818
const mergedProps = mergeProps(drawer.getBackdropProps(), presence.getPresenceProps(), props)
19+
const composedRefs = useComposedRefs(presence.ref, ref)
1920

2021
if (presence.unmounted) {
2122
return null
2223
}
2324

24-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
25+
return <ark.div {...mergedProps} ref={composedRefs} />
2526
})
2627

2728
DrawerBackdrop.displayName = 'DrawerBackdrop'

packages/react/src/components/drawer/drawer-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { mergeProps } from '@zag-js/react'
44
import type { ContentProps } from '@zag-js/drawer'
55
import { forwardRef } from 'react'
6-
import { composeRefs } from '../../utils/compose-refs.ts'
6+
import { useComposedRefs } from '../../utils/compose-refs.ts'
77
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
88
import { usePresenceContext } from '../presence/index.ts'
99
import { useDrawerContext } from './use-drawer-context.ts'
@@ -23,12 +23,13 @@ export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((pro
2323
presence.getPresenceProps(),
2424
localProps,
2525
)
26+
const composedRefs = useComposedRefs(presence.ref, ref)
2627

2728
if (presence.unmounted) {
2829
return null
2930
}
3031

31-
return <ark.div {...mergedProps} ref={composeRefs(presence.ref, ref)} />
32+
return <ark.div {...mergedProps} ref={composedRefs} />
3233
})
3334

3435
DrawerContent.displayName = 'DrawerContent'

packages/react/src/components/factory.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render, screen } from '@testing-library/react'
22
import user from '@testing-library/user-event'
3+
import { useCallback, useReducer } from 'react'
34
import { ark } from './factory.ts'
45

56
const ComponentUnderTest = () => (
@@ -69,4 +70,34 @@ describe('Ark Factory', () => {
6970
)
7071
expect(screen.getByText('Ark UI')).not.toHaveAttribute('data-testid', 'parent')
7172
})
73+
74+
it('should not detach stable asChild callback ref on parent re-render', async () => {
75+
const callbackRef = vi.fn()
76+
77+
const AsChildTest = () => {
78+
const [, rerender] = useReducer((count) => count + 1, 0)
79+
const setRef = useCallback((node: HTMLSpanElement | null) => {
80+
callbackRef(node)
81+
}, [])
82+
83+
return (
84+
<>
85+
<ark.span ref={setRef} asChild>
86+
<span data-testid="child">Ark UI</span>
87+
</ark.span>
88+
<button type="button" onClick={() => rerender()}>
89+
re-render
90+
</button>
91+
</>
92+
)
93+
}
94+
95+
render(<AsChildTest />)
96+
expect(callbackRef).toHaveBeenCalledWith(screen.getByTestId('child'))
97+
98+
const callsAfterMount = callbackRef.mock.calls.length
99+
await user.click(screen.getByRole('button', { name: /re-render/i }))
100+
101+
expect(callbackRef).toHaveBeenCalledTimes(callsAfterMount)
102+
})
72103
})

packages/react/src/components/factory.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
isValidElement,
1212
memo,
1313
} from 'react'
14-
import { composeRefs } from '../utils/compose-refs.ts'
14+
import { useComposedRefs } from '../utils/compose-refs.ts'
1515

1616
export interface PolymorphicProps {
1717
/**
@@ -47,22 +47,22 @@ const withAsChild = (Component: React.ElementType) => {
4747
const Comp = memo(
4848
forwardRef<unknown, ArkPropsWithRef<typeof Component>>((props, ref) => {
4949
const { asChild, children, ...restProps } = props
50+
const onlyChild =
51+
asChild && isValidElement<Record<string, unknown>>(children) ? Children.only(children) : undefined
52+
const childRef = onlyChild ? getRef(onlyChild) : undefined
53+
const composedRef = useComposedRefs(ref, childRef)
5054

5155
if (!asChild) {
5256
return createElement(Component, { ...restProps, ref }, children)
5357
}
5458

55-
if (!isValidElement<Record<string, unknown>>(children)) {
59+
if (!onlyChild) {
5660
return null
5761
}
5862

59-
const onlyChild: React.ReactElement<Record<string, unknown>> = Children.only(children)
60-
61-
const childRef = getRef(onlyChild)
62-
6363
return cloneElement(onlyChild, {
6464
...mergeProps(restProps, onlyChild.props),
65-
ref: ref ? composeRefs(ref, childRef) : childRef,
65+
ref: ref ? composedRef : childRef,
6666
})
6767
}),
6868
)

packages/react/src/components/field/field-root.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { mergeProps } from '@zag-js/react'
44
import { forwardRef } from 'react'
5-
import { composeRefs } from '../../utils/compose-refs.ts'
5+
import { useComposedRefs } from '../../utils/compose-refs.ts'
66
import { createSplitProps } from '../../utils/create-split-props.ts'
77
import { type HTMLProps, type PolymorphicProps, ark } from '../factory.ts'
88
import { type UseFieldProps, useField } from './use-field.ts'
@@ -26,10 +26,11 @@ export const FieldRoot = forwardRef<HTMLDivElement, FieldRootProps>((props, ref)
2626

2727
const field = useField(useFieldProps)
2828
const mergedProps = mergeProps<HTMLProps<'div'>>(field.getRootProps(), localProps)
29+
const composedRefs = useComposedRefs(ref, field.refs.rootRef)
2930

3031
return (
3132
<FieldProvider value={field}>
32-
<ark.div {...mergedProps} ref={composeRefs(ref, field.refs.rootRef)} />
33+
<ark.div {...mergedProps} ref={composedRefs} />
3334
</FieldProvider>
3435
)
3536
})

0 commit comments

Comments
 (0)