import * as React from 'react'; import { useMergedRefsN } from '../hooks/useMergedRefs'; import { getReactElementRef } from '../utils/getReactElementRef'; import { warn } from '../utils/warn'; import { EMPTY_ARRAY, EMPTY_OBJECT } from '../utils/empty'; import { resolveClassName } from '../utils/resolveClassName'; import { resolveStyle } from '../utils/resolveStyle'; import { mergeProps, mergePropsN, mergeClassNames, mergeStyles } from '../merge-props'; import type { NativeProps, RenderProp, ZestStyle } from '../types'; /** * Renders a Zest element. * * React Native adaptation of Base UI's `useRenderElement`. Differences from the web version: * - The default element is a React Native component (e.g. `View`, `Pressable`) instead of a tag name. * - There is no `data-*` attribute generation: state reaches consumers through `className`/`style` * functions, the `render` function's second argument, and each component's explicit * `accessibilityState` props. * - `style` values are merged by array composition (RN flattens arrays), never object spreading. * * @param DefaultComponent The default React Native component to render. Can be overridden by the `render` prop. * @param componentProps An object containing the `render`, `className` and `style` props. Other props are ignored. * @param params Additional parameters for rendering the element. */ export function useRenderElement< State extends Record, RenderedElementType, Enabled extends boolean | undefined = undefined, >( DefaultComponent: React.ElementType | undefined, componentProps: UseRenderElementComponentProps, params: UseRenderElementParameters = {}, ): Enabled extends false ? null : React.ReactElement { const renderProp = componentProps.render; const outProps = useRenderElementProps(componentProps, params); if (params.enabled === false) { return null as Enabled extends false ? null : React.ReactElement; } const state = params.state ?? (EMPTY_OBJECT as State); return evaluateRenderProp(DefaultComponent, renderProp, outProps, state) as Enabled extends false ? null : React.ReactElement; } /** * Computes render element final props. */ function useRenderElementProps< State extends Record, RenderedElementType, Enabled extends boolean | undefined, >( componentProps: UseRenderElementComponentProps, params: UseRenderElementParameters = {}, ): NativeProps { const { className: classNameProp, style: styleProp, render: renderProp } = componentProps; const { state = EMPTY_OBJECT as State, ref, props, enabled = true } = params; const className = enabled ? resolveClassName(classNameProp, state) : undefined; const style = enabled ? resolveStyle(styleProp, state) : undefined; // Ensure outProps is always a new mutable object when enabled, never EMPTY_OBJECT, // since EMPTY_OBJECT is frozen and mutations would fail in strict mode. const outProps: NativeProps = enabled && props ? resolveRenderFunctionProps(props) : {}; // Only the ref list branches; `useMergedRefsN` itself is called once, unconditionally, so the // Hook order is identical on every render whether the element is enabled or not and whether one // ref or an array of them was passed. When disabled the merge runs over an empty list and the // resulting `null` callback is discarded. const refsToMerge: Array | undefined> = !enabled ? (EMPTY_ARRAY as Array | undefined>) : Array.isArray(ref) ? [outProps.ref, getReactElementRef(renderProp), ...ref] : [outProps.ref, getReactElementRef(renderProp), ref]; const mergedRef = useMergedRefsN(refsToMerge); if (enabled) { outProps.ref = mergedRef; } if (!enabled) { return EMPTY_OBJECT; } if (className !== undefined) { outProps.className = mergeClassNames(outProps.className, className); } if (style !== undefined) { outProps.style = mergeStyles(outProps.style, style); } return outProps; } function resolveRenderFunctionProps( props: NonNullable['props']>, ): NativeProps { if (Array.isArray(props)) { return mergePropsN(props) as NativeProps; } return mergeProps(undefined, props) as NativeProps; } const COMPONENT_IDENTIFIER_PATTERN = /^[A-Z][A-Za-z0-9$]*$/; const LOWERCASE_CHARACTER_PATTERN = /[a-z]/; function evaluateRenderProp( DefaultComponent: React.ElementType | undefined, render: RenderProp | undefined, props: NativeProps, state: State, ): React.ReactElement { if (render) { if (typeof render === 'function') { if (process.env.NODE_ENV !== 'production') { warnIfRenderPropLooksLikeComponent(render); } return render(props, state); } const mergedProps = mergeProps(props, render.props as NativeProps); mergedProps.ref = props.ref; // There is a high number of indirections, the error message thrown by React.cloneElement() is // hard to use for developers, this logic provides a better context. if (process.env.NODE_ENV !== 'production') { if (!React.isValidElement(render)) { throw new Error( [ 'Zest: The `render` prop was provided an invalid React element as `React.isValidElement(render)` is `false`.', 'A valid React element must be provided to the `render` prop because it is cloned with props to replace the default element.', ].join('\n'), ); } } return React.cloneElement(render, mergedProps); } if (DefaultComponent) { return React.createElement(DefaultComponent, props); } throw new Error('Zest: Render element or function are not defined.'); } function warnIfRenderPropLooksLikeComponent(renderFn: { name: string }) { const functionName = renderFn.name; if (functionName.length === 0) { return; } if (!COMPONENT_IDENTIFIER_PATTERN.test(functionName)) { return; } if (!LOWERCASE_CHARACTER_PATTERN.test(functionName)) { return; } warn( `The \`render\` prop received a function named \`${functionName}\` that starts with an uppercase letter.`, 'This usually means a React component was passed directly as `render={Component}`.', 'Zest calls `render` as a plain function, which can break the Rules of Hooks during reconciliation.', 'If this is an intentional render callback, rename it to start with a lowercase letter.', 'Use `render={}` or `render={(props) => }` instead.', ); } export type UseRenderElementParameters< State, RenderedElementType, Enabled extends boolean | undefined, > = { /** * If `false`, the hook will skip most of its internal logic and return `null`. * This is useful for rendering a component conditionally. * @default true */ enabled?: Enabled | undefined; /** * The ref to apply to the rendered element. */ ref?: React.Ref | (React.Ref | undefined)[] | undefined; /** * The state of the component. */ state?: State | undefined; /** * Intrinsic props to be spread on the rendered element. */ props?: | NativeProps | Array NativeProps)> | undefined; }; export interface UseRenderElementComponentProps { /** * The class name to apply to the rendered element. * Can be a string or a function that accepts the state and returns a string. * Inert in plain React Native; consumed by className-based styling solutions. */ className?: string | ((state: State) => string | undefined) | undefined; /** * The render prop or React element to override the default element. */ render?: undefined | RenderProp; /** * The style to apply to the rendered element. * Can be a React Native style or a function that accepts the state and returns one. */ style?: ZestStyle; }