Skip to content

Commit 59d2484

Browse files
committed
Fix hydration mismatch error in OneTimePasswordField and add opt-in index prop
1 parent d05208f commit 59d2484

File tree

4 files changed

+50
-35
lines changed

4 files changed

+50
-35
lines changed

.changeset/stupid-bananas-talk.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@radix-ui/react-one-time-password-field': patch
3+
'@radix-ui/react-roving-focus': patch
4+
---
5+
6+
Fix hydration mismatch error in `OneTimePasswordField` and add opt-in `index` prop

apps/ssr-testing/app/one-time-password-field/page.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,26 @@ import { unstable_OneTimePasswordField as OneTimePasswordField } from 'radix-ui'
33

44
export default function Page() {
55
return (
6-
<OneTimePasswordField.Root>
7-
<OneTimePasswordField.Input />
8-
<OneTimePasswordField.Input />
9-
<OneTimePasswordField.Input />
10-
<OneTimePasswordField.Input />
11-
<OneTimePasswordField.Input />
12-
<OneTimePasswordField.Input />
13-
<OneTimePasswordField.HiddenInput />
14-
</OneTimePasswordField.Root>
6+
<div>
7+
<OneTimePasswordField.Root placeholder="123456">
8+
<OneTimePasswordField.Input />
9+
<OneTimePasswordField.Input />
10+
<OneTimePasswordField.Input />
11+
<OneTimePasswordField.Input />
12+
<OneTimePasswordField.Input />
13+
<OneTimePasswordField.Input />
14+
<OneTimePasswordField.HiddenInput />
15+
</OneTimePasswordField.Root>
16+
<h2>With indices</h2>
17+
<OneTimePasswordField.Root placeholder="123456">
18+
<OneTimePasswordField.Input index={0} />
19+
<OneTimePasswordField.Input index={1} />
20+
<OneTimePasswordField.Input index={2} />
21+
<OneTimePasswordField.Input index={3} />
22+
<OneTimePasswordField.Input index={4} />
23+
<OneTimePasswordField.Input index={5} />
24+
<OneTimePasswordField.HiddenInput />
25+
</OneTimePasswordField.Root>
26+
</div>
1527
);
1628
}

packages/react/one-time-password-field/src/one-time-password-field.tsx

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ interface OneTimePasswordFieldContextValue {
5656
name: string | undefined;
5757
orientation: Exclude<RovingFocusGroupProps['orientation'], undefined>;
5858
placeholder: string | undefined;
59-
preHydrationIndexTracker: React.RefObject<number>;
6059
readOnly: boolean;
6160
type: InputType;
6261
userActionRef: React.RefObject<KeyboardActionDetails | null>;
@@ -429,13 +428,6 @@ const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFie
429428
attemptSubmit();
430429
}
431430
}, [attemptSubmit, autoSubmit, currentValue, length, onAutoSubmit, value]);
432-
433-
// Before hydration (and in SSR) we can track the index of an input during
434-
// render, as indices calculated by the collection package should almost
435-
// always align with render order anyway. This ensures that index-dependent
436-
// attributes are immediately rendered, in case browser extensions rely on
437-
// those for auto-complete functionality and JS has not hydrated.
438-
const preHydrationIndexTracker = React.useRef<number>(0);
439431
const isHydrated = useIsHydrated();
440432

441433
return (
@@ -456,7 +448,6 @@ const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFie
456448
dispatch={dispatch}
457449
validationType={validationType}
458450
orientation={orientation}
459-
preHydrationIndexTracker={preHydrationIndexTracker}
460451
isHydrated={isHydrated}
461452
sanitizeValue={sanitizeValue}
462453
>
@@ -560,6 +551,12 @@ interface OneTimePasswordFieldInputProps
560551
* Callback fired when the user input fails native HTML input validation.
561552
*/
562553
onInvalidChange?: (character: string) => void;
554+
/**
555+
* User-provided index to determine the order of the inputs. This is useful if
556+
* you need certain index-based attributes to be set on the initial render,
557+
* often to prevent flickering after hydration.
558+
*/
559+
index?: number;
563560
}
564561

565562
const OneTimePasswordFieldInput = React.forwardRef<
@@ -569,6 +566,7 @@ const OneTimePasswordFieldInput = React.forwardRef<
569566
{
570567
__scopeOneTimePasswordField,
571568
onInvalidChange,
569+
index: indexProp,
572570
...props
573571
}: ScopedProps<OneTimePasswordFieldInputProps>,
574572
forwardedRef
@@ -592,25 +590,20 @@ const OneTimePasswordFieldInput = React.forwardRef<
592590
'OneTimePasswordFieldInput',
593591
__scopeOneTimePasswordField
594592
);
595-
const { dispatch, userActionRef, validationType, preHydrationIndexTracker, isHydrated } = context;
593+
const { dispatch, userActionRef, validationType, isHydrated } = context;
596594
const collection = useCollection(__scopeOneTimePasswordField);
597595
const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeOneTimePasswordField);
598596

599597
const inputRef = React.useRef<HTMLInputElement>(null);
600598
const [element, setElement] = React.useState<HTMLInputElement | null>(null);
601599

600+
const index = indexProp ?? (element ? collection.indexOf(element) : -1);
601+
const canSetPlaceholder = indexProp != null || isHydrated;
602602
let placeholder: string | undefined;
603-
let index: number;
604-
if (!isHydrated) {
605-
index = preHydrationIndexTracker.current;
606-
preHydrationIndexTracker.current++;
607-
} else {
608-
index = element ? collection.indexOf(element) : -1;
609-
if (context.placeholder && context.value.length === 0) {
610-
// only set placeholder after hydration to prevent flickering when indices
611-
// are re-calculated
612-
placeholder = context.placeholder[index];
613-
}
603+
if (canSetPlaceholder && context.placeholder && context.value.length === 0) {
604+
// only set placeholder after hydration to prevent flickering when indices
605+
// are re-calculated
606+
placeholder = context.placeholder[index];
614607
}
615608

616609
const composedInputRef = useComposedRefs(forwardedRef, inputRef, setElement);
@@ -640,8 +633,8 @@ const OneTimePasswordFieldInput = React.forwardRef<
640633
focusable={!context.disabled && isFocusable}
641634
active={index === lastSelectableIndex}
642635
>
643-
{({ isCurrentTabStop }) => {
644-
const supportsAutoComplete = isHydrated ? isCurrentTabStop : index === 0;
636+
{({ hasTabStop, isCurrentTabStop }) => {
637+
const supportsAutoComplete = hasTabStop ? isCurrentTabStop : index === 0;
645638
return (
646639
<Primitive.Root.input
647640
ref={composedInputRef}

packages/react/roving-focus/src/roving-focus-group.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,9 @@ interface RovingFocusItemProps extends Omit<PrimitiveSpanProps, 'children'> {
206206
tabStopId?: string;
207207
focusable?: boolean;
208208
active?: boolean;
209-
children?: React.ReactNode | ((props: { isCurrentTabStop: boolean }) => React.ReactNode);
209+
children?:
210+
| React.ReactNode
211+
| ((props: { hasTabStop: boolean; isCurrentTabStop: boolean }) => React.ReactNode);
210212
}
211213

212214
const RovingFocusGroupItem = React.forwardRef<RovingFocusItemElement, RovingFocusItemProps>(
@@ -225,7 +227,7 @@ const RovingFocusGroupItem = React.forwardRef<RovingFocusItemElement, RovingFocu
225227
const isCurrentTabStop = context.currentTabStopId === id;
226228
const getItems = useCollection(__scopeRovingFocusGroup);
227229

228-
const { onFocusableItemAdd, onFocusableItemRemove } = context;
230+
const { onFocusableItemAdd, onFocusableItemRemove, currentTabStopId } = context;
229231

230232
React.useEffect(() => {
231233
if (focusable) {
@@ -287,7 +289,9 @@ const RovingFocusGroupItem = React.forwardRef<RovingFocusItemElement, RovingFocu
287289
}
288290
})}
289291
>
290-
{typeof children === 'function' ? children({ isCurrentTabStop }) : children}
292+
{typeof children === 'function'
293+
? children({ isCurrentTabStop, hasTabStop: currentTabStopId != null })
294+
: children}
291295
</Primitive.span>
292296
</Collection.ItemSlot>
293297
);

0 commit comments

Comments
 (0)