Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/nine-knives-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@radix-ui/react-select': patch
---

fixed select component flicker when SSR by adding a prerender way

1 change: 1 addition & 0 deletions packages/react/select/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@radix-ui/react-slot": "workspace:*",
"@radix-ui/react-use-callback-ref": "workspace:*",
"@radix-ui/react-use-controllable-state": "workspace:*",
"@radix-ui/react-use-is-hydrated": "workspace:*",
"@radix-ui/react-use-layout-effect": "workspace:*",
"@radix-ui/react-use-previous": "workspace:*",
"@radix-ui/react-visually-hidden": "workspace:*",
Expand Down
173 changes: 172 additions & 1 deletion packages/react/select/src/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Primitive } from '@radix-ui/react-primitive';
import { createSlot } from '@radix-ui/react-slot';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { useIsHydrated } from '@radix-ui/react-use-is-hydrated';
import { useLayoutEffect } from '@radix-ui/react-use-layout-effect';
import { usePrevious } from '@radix-ui/react-use-previous';
import { VISUALLY_HIDDEN_STYLES } from '@radix-ui/react-visually-hidden';
Expand Down Expand Up @@ -52,6 +53,7 @@ const usePopperScope = createPopperScope();
type SelectContextValue = {
trigger: SelectTriggerElement | null;
onTriggerChange(node: SelectTriggerElement | null): void;
prerenderedValue?: string;
valueNode: SelectValueElement | null;
onValueNodeChange(node: SelectValueElement): void;
valueNodeHasChildren: boolean;
Expand Down Expand Up @@ -126,6 +128,7 @@ type SelectProps = SelectSharedProps & {
value?: string;
defaultValue?: string;
onValueChange?(value: string): void;
ssr?: boolean;
};

const Select: React.FC<SelectProps> = (props: ScopedProps<SelectProps>) => {
Expand All @@ -144,6 +147,7 @@ const Select: React.FC<SelectProps> = (props: ScopedProps<SelectProps>) => {
disabled,
required,
form,
ssr = true,
} = props;
const popperScope = usePopperScope(__scopeSelect);
const [trigger, setTrigger] = React.useState<SelectTriggerElement | null>(null);
Expand Down Expand Up @@ -177,9 +181,14 @@ const Select: React.FC<SelectProps> = (props: ScopedProps<SelectProps>) => {
.map((option) => option.props.value)
.join(';');

const isHydrated = useIsHydrated();

return (
<PopperPrimitive.Root {...popperScope}>
<SelectProvider
prerenderedValue={
ssr && !isHydrated ? getSelectItemTextForSSR(children, value ?? defaultValue) : undefined
}
required={required}
scope={__scopeSelect}
trigger={trigger}
Expand Down Expand Up @@ -371,11 +380,15 @@ const SelectValue = React.forwardRef<SelectValueElement, SelectValueProps>(
const { onValueNodeHasChildrenChange } = context;
const hasChildren = children !== undefined;
const composedRefs = useComposedRefs(forwardedRef, context.onValueNodeChange);
const isHydrated = useIsHydrated();

useLayoutEffect(() => {
onValueNodeHasChildrenChange(hasChildren);
}, [onValueNodeHasChildrenChange, hasChildren]);

const safeChildren =
!isHydrated && context.prerenderedValue && !children ? context.prerenderedValue : children;

return (
<Primitive.span
{...valueProps}
Expand All @@ -384,7 +397,7 @@ const SelectValue = React.forwardRef<SelectValueElement, SelectValueProps>(
// through the item they came from
style={{ pointerEvents: 'none' }}
>
{shouldShowPlaceholder(context.value) ? <>{placeholder}</> : children}
{shouldShowPlaceholder(context.value) ? <>{placeholder}</> : safeChildren}
</Primitive.span>
);
},
Expand Down Expand Up @@ -430,6 +443,19 @@ interface SelectPortalProps {
}

const SelectPortal: React.FC<SelectPortalProps> = (props: ScopedProps<SelectPortalProps>) => {
const hydrated = useIsHydrated();

if (!hydrated) {
return (
<Primitive.div
asChild
{...props}
style={{ display: 'none', visibility: 'hidden' }}
aria-hidden="true"
/>
);
}

return <PortalPrimitive asChild {...props} />;
};

Expand All @@ -454,6 +480,18 @@ const SelectContent = React.forwardRef<SelectContentElement, SelectContentProps>
setFragment(new DocumentFragment());
}, []);

const isHydrated = useIsHydrated();

if (!isHydrated) {
return (
<SelectContentProvider scope={props.__scopeSelect}>
<div style={{ display: 'none', visibility: 'hidden' }} aria-hidden="true">
{props.children}
</div>
</SelectContentProvider>
);
}

if (!context.open) {
const frag = fragment as Element | undefined;
return frag
Expand Down Expand Up @@ -1767,6 +1805,139 @@ function wrapArray<T>(array: T[], startIndex: number) {
return array.map<T>((_, index) => array[(startIndex + index) % array.length]!);
}

/* -------------------------------------------------------------------------------------------------
* Server Side Rendering Helpers
* -----------------------------------------------------------------------------------------------*/

const SELECT_ITEM_NAME = 'SelectItem';
const SELECT_ITEM_TEXT_NAME = 'SelectItemText';
const REACT_LAZY_TYPE = Symbol.for('react.lazy');

const lazyResolve: typeof React.use | undefined = (React as any)[' use '.trim().toString()];

interface LazyReactElement extends React.ReactElement {
$$typeof: typeof REACT_LAZY_TYPE;
_payload: PromiseLike<Exclude<React.ReactNode, PromiseLike<any>>>;
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return typeof value === 'object' && value !== null && 'then' in value;
}

function isLazyComponent(element: React.ReactNode): element is LazyReactElement {
return (
element != null &&
typeof element === 'object' &&
'$$typeof' in element &&
element.$$typeof === REACT_LAZY_TYPE &&
'_payload' in element &&
isPromiseLike(element._payload)
);
}

function getComponentName(type: any, lazyCache: WeakMap<any, any>): string | undefined {
if (!type) return undefined;

let resolved = type;

if (isLazyComponent(type) && typeof lazyResolve === 'function') {
const cached = lazyCache.get(type);
if (cached) {
resolved = cached;
} else {
resolved = lazyResolve(type._payload);
lazyCache.set(type, resolved);
}
}

return resolved?.displayName ?? resolved?.name;
}

function extractText(node: React.ReactNode): string | undefined {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}

if (Array.isArray(node)) {
return node.map(extractText).join('');
}

if (React.isValidElement(node)) {
const props = node.props as { children?: React.ReactNode };
return props?.children ? extractText(props.children) : undefined;
}

return undefined;
}

function findSelectItem(
node: React.ReactNode,
selectValue: any,
lazyCache: WeakMap<any, any>,
): React.ReactElement | null {
const children = React.Children.toArray(node);

for (const child of children) {
if (!React.isValidElement(child)) continue;

const name = getComponentName(child.type, lazyCache);
if (name === SELECT_ITEM_NAME) {
const props = child.props as { value?: string | number };
if (props?.value === selectValue) {
return child;
}
}

const props = child.props as { children?: React.ReactNode };
if (props?.children) {
const found = findSelectItem(props.children, selectValue, lazyCache);
if (found) return found;
}
}

return null;
}

function findItemTextInNode(
node: React.ReactNode,
lazyCache: WeakMap<any, any>,
): string | undefined {
const children = React.Children.toArray(node);

for (const child of children) {
if (!React.isValidElement(child)) continue;

const name = getComponentName(child.type, lazyCache);
if (name === SELECT_ITEM_TEXT_NAME) {
const props = child.props as { children?: React.ReactNode };
if (props?.children) {
return extractText(props.children);
}
}

const props = child.props as { children?: React.ReactNode };
if (props?.children) {
const result = findItemTextInNode(props.children, lazyCache);
if (result) return result;
}
}

return undefined;
}

function getSelectItemTextForSSR(children: React.ReactNode, selectValue: any) {
if (shouldShowPlaceholder(selectValue)) return undefined;

const lazyCache = new WeakMap<any, any>();
const targetItem = findSelectItem(children, selectValue, lazyCache);

if (!targetItem) return undefined;

const itemProps = (targetItem as React.ReactElement<{ children?: React.ReactNode }>).props;

return itemProps?.children ? findItemTextInNode(itemProps.children, lazyCache) : undefined;
}

const Root = Select;
const Trigger = SelectTrigger;
const Value = SelectValue;
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.