Skip to content

Commit e35ab0d

Browse files
authored
Add support for SSR to RAC collections (#4913)
1 parent 145c903 commit e35ab0d

35 files changed

+1048
-315
lines changed

jest.ssr.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ module.exports = {
4242
// The test environment that will be used for testing
4343
testEnvironment: 'jsdom',
4444

45+
setupFilesAfterEnv: ['<rootDir>scripts/setupTests.js'],
46+
4547
// The glob patterns Jest uses to detect test files
4648
testMatch: [
4749
'**/packages/**/*.ssr.test.[tj]s?(x)'

packages/@react-aria/ssr/src/SSRProvider.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ import React, {ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState
2323
// consistent ids regardless of the loading order.
2424
interface SSRContextValue {
2525
prefix: string,
26-
current: number,
27-
isSSR: boolean
26+
current: number
2827
}
2928

3029
// Default context value to use in case there is no SSRProvider. This is fine for
@@ -34,11 +33,11 @@ interface SSRContextValue {
3433
// SSR case multiple copies of React Aria is not supported.
3534
const defaultContext: SSRContextValue = {
3635
prefix: String(Math.round(Math.random() * 10000000000)),
37-
current: 0,
38-
isSSR: false
36+
current: 0
3937
};
4038

4139
const SSRContext = React.createContext<SSRContextValue>(defaultContext);
40+
const IsSSRContext = React.createContext(false);
4241

4342
export interface SSRProviderProps {
4443
/** Your application here. */
@@ -54,9 +53,8 @@ function LegacySSRProvider(props: SSRProviderProps): JSX.Element {
5453
// If this is the first SSRProvider, start with an empty string prefix, otherwise
5554
// append and increment the counter.
5655
prefix: cur === defaultContext ? '' : `${cur.prefix}-${counter}`,
57-
current: 0,
58-
isSSR
59-
}), [cur, counter, isSSR]);
56+
current: 0
57+
}), [cur, counter]);
6058

6159
// If on the client, and the component was initially server rendered,
6260
// then schedule a layout effect to update the component after hydration.
@@ -71,7 +69,9 @@ function LegacySSRProvider(props: SSRProviderProps): JSX.Element {
7169

7270
return (
7371
<SSRContext.Provider value={value}>
74-
{props.children}
72+
<IsSSRContext.Provider value={isSSR}>
73+
{props.children}
74+
</IsSSRContext.Provider>
7575
</SSRContext.Provider>
7676
);
7777
}
@@ -194,6 +194,5 @@ export function useIsSSR(): boolean {
194194
}
195195

196196
// eslint-disable-next-line react-hooks/rules-of-hooks
197-
let cur = useContext(SSRContext);
198-
return cur.isSSR;
197+
return useContext(IsSSRContext);
199198
}

packages/@react-spectrum/combobox/test/ComboBox.test.js

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -879,14 +879,12 @@ describe('ComboBox', function () {
879879

880880
let combobox = getByRole('combobox');
881881
expect(combobox.value).toBe('Two');
882-
expect(onInputChange).toHaveBeenCalledTimes(1);
883-
expect(onInputChange).toHaveBeenLastCalledWith('Two');
884882

885883
act(() => combobox.focus());
886884
fireEvent.change(combobox, {target: {value: 'Tw'}});
887885
act(() => jest.runAllTimers());
888886

889-
expect(onInputChange).toHaveBeenCalledTimes(2);
887+
expect(onInputChange).toHaveBeenCalledTimes(1);
890888
expect(onInputChange).toHaveBeenLastCalledWith('Tw');
891889
expect(combobox.value).toBe('Tw');
892890
let listbox = getByRole('listbox');
@@ -907,7 +905,7 @@ describe('ComboBox', function () {
907905
expect(queryByRole('listbox')).toBeNull();
908906
expect(combobox.value).toBe('Two');
909907
expect(onSelectionChange).toHaveBeenCalledTimes(0);
910-
expect(onInputChange).toHaveBeenCalledTimes(3);
908+
expect(onInputChange).toHaveBeenCalledTimes(2);
911909
expect(onInputChange).toHaveBeenLastCalledWith('Two');
912910
});
913911

@@ -916,14 +914,12 @@ describe('ComboBox', function () {
916914

917915
let combobox = getByRole('combobox');
918916
expect(combobox.value).toBe('Two');
919-
expect(onInputChange).toHaveBeenCalledTimes(1);
920-
expect(onInputChange).toHaveBeenLastCalledWith('Two');
921917

922918
act(() => combobox.focus());
923919
fireEvent.change(combobox, {target: {value: 'Tw'}});
924920
act(() => jest.runAllTimers());
925921

926-
expect(onInputChange).toHaveBeenCalledTimes(2);
922+
expect(onInputChange).toHaveBeenCalledTimes(1);
927923
expect(onInputChange).toHaveBeenLastCalledWith('Tw');
928924
expect(combobox.value).toBe('Tw');
929925
let listbox = getByRole('listbox');
@@ -938,7 +934,7 @@ describe('ComboBox', function () {
938934
// selectionManager.select from useSingleSelectListState always calls onSelectionChange even if the key is the same
939935
expect(onSelectionChange).toHaveBeenCalledTimes(1);
940936
expect(onSelectionChange).toHaveBeenLastCalledWith('2');
941-
expect(onInputChange).toHaveBeenCalledTimes(3);
937+
expect(onInputChange).toHaveBeenCalledTimes(2);
942938
expect(onInputChange).toHaveBeenLastCalledWith('Two');
943939
});
944940

@@ -2344,7 +2340,6 @@ describe('ComboBox', function () {
23442340
});
23452341

23462342
expect(combobox.value).toBe('Two');
2347-
expect(onInputChange).toHaveBeenLastCalledWith('Two');
23482343

23492344
let listbox = getByRole('listbox');
23502345
expect(listbox).toBeVisible();
@@ -2382,7 +2377,6 @@ describe('ComboBox', function () {
23822377
let {getByRole, rerender} = render(<ExampleComboBox selectedKey="2" />);
23832378
let combobox = getByRole('combobox');
23842379
expect(combobox.value).toBe('Two');
2385-
expect(onInputChange).toHaveBeenLastCalledWith('Two');
23862380

23872381
rerender(<ExampleComboBox selectedKey="1" />);
23882382
expect(combobox.value).toBe('One');

packages/@react-stately/combobox/src/useComboBoxState.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,6 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
6161

6262
let [showAllItems, setShowAllItems] = useState(false);
6363
let [isFocused, setFocusedState] = useState(false);
64-
let [inputValue, setInputValue] = useControlledState(
65-
props.inputValue,
66-
props.defaultInputValue ?? '',
67-
props.onInputChange
68-
);
6964

7065
let onSelectionChange = (key) => {
7166
if (props.onSelectionChange) {
@@ -86,6 +81,12 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
8681
items: props.items ?? props.defaultItems
8782
});
8883

84+
let [inputValue, setInputValue] = useControlledState(
85+
props.inputValue,
86+
props.defaultInputValue ?? collection.getItem(selectedKey)?.textValue ?? '',
87+
props.onInputChange
88+
);
89+
8990
// Preserve original collection so we can show all items on demand
9091
let originalCollection = collection;
9192
let filteredCollection = useMemo(() => (
@@ -170,7 +171,6 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
170171
setInputValue(itemText);
171172
};
172173

173-
let isInitialRender = useRef(true);
174174
let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null);
175175
let lastSelectedKeyText = useRef(collection.getItem(selectedKey)?.textValue ?? '');
176176
// intentional omit dependency array, want this to happen on every render
@@ -219,11 +219,6 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
219219
}
220220
}
221221

222-
// If it is the intial render and inputValue isn't controlled nor has an intial value, set input to match current selected key if any
223-
if (isInitialRender.current && (props.inputValue === undefined && props.defaultInputValue === undefined)) {
224-
resetInputValue();
225-
}
226-
227222
// If the selectedKey changed, update the input value.
228223
// Do nothing if both inputValue and selectedKey are controlled.
229224
// In this case, it's the user's responsibility to update inputValue in onSelectionChange.
@@ -248,7 +243,6 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
248243
}
249244
}
250245

251-
isInitialRender.current = false;
252246
lastSelectedKey.current = selectedKey;
253247
lastSelectedKeyText.current = selectedItemText;
254248
});

packages/@react-stately/tabs/src/useTabListState.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CollectionStateBase} from '@react-types/shared';
13+
import {Collection, CollectionStateBase} from '@react-types/shared';
14+
import {Key, useEffect, useRef} from 'react';
1415
import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list';
1516
import {TabListProps} from '@react-types/tabs';
16-
import {useEffect, useRef} from 'react';
1717

1818
export interface TabListStateOptions<T> extends Omit<TabListProps<T>, 'children'>, CollectionStateBase<T> {}
1919

@@ -29,7 +29,8 @@ export interface TabListState<T> extends SingleSelectListState<T> {
2929
export function useTabListState<T extends object>(props: TabListStateOptions<T>): TabListState<T> {
3030
let state = useSingleSelectListState<T>({
3131
...props,
32-
suppressTextValueWarning: true
32+
suppressTextValueWarning: true,
33+
defaultSelectedKey: props.defaultSelectedKey ?? findDefaultSelectedKey(props.collection, props.disabledKeys ? new Set(props.disabledKeys) : new Set())
3334
});
3435

3536
let {
@@ -43,16 +44,7 @@ export function useTabListState<T extends object>(props: TabListStateOptions<T>)
4344
// Ensure a tab is always selected (in case no selected key was specified or if selected item was deleted from collection)
4445
let selectedKey = currentSelectedKey;
4546
if (selectionManager.isEmpty || !collection.getItem(selectedKey)) {
46-
selectedKey = collection.getFirstKey();
47-
// loop over tabs until we find one that isn't disabled and select that
48-
while (state.disabledKeys.has(selectedKey) && selectedKey !== collection.getLastKey()) {
49-
selectedKey = collection.getKeyAfter(selectedKey);
50-
}
51-
// if this check is true, then every item is disabled, it makes more sense to default to the first key than the last
52-
if (state.disabledKeys.has(selectedKey) && selectedKey === collection.getLastKey()) {
53-
selectedKey = collection.getFirstKey();
54-
}
55-
47+
selectedKey = findDefaultSelectedKey(collection, state.disabledKeys);
5648
if (selectedKey != null) {
5749
// directly set selection because replace/toggle selection won't consider disabled keys
5850
selectionManager.setSelectedKeys([selectedKey]);
@@ -71,3 +63,20 @@ export function useTabListState<T extends object>(props: TabListStateOptions<T>)
7163
isDisabled: props.isDisabled || false
7264
};
7365
}
66+
67+
function findDefaultSelectedKey<T>(collection: Collection<T> | null, disabledKeys: Set<Key>) {
68+
let selectedKey = null;
69+
if (collection) {
70+
selectedKey = collection.getFirstKey();
71+
// loop over tabs until we find one that isn't disabled and select that
72+
while (disabledKeys.has(selectedKey) && selectedKey !== collection.getLastKey()) {
73+
selectedKey = collection.getKeyAfter(selectedKey);
74+
}
75+
// if this check is true, then every item is disabled, it makes more sense to default to the first key than the last
76+
if (disabledKeys.has(selectedKey) && selectedKey === collection.getLastKey()) {
77+
selectedKey = collection.getFirstKey();
78+
}
79+
}
80+
81+
return selectedKey;
82+
}

packages/dev/parcel-transformer-docs/DocsTransformer.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ module.exports = new Transformer({
641641
}
642642

643643
function isReactForwardRef(path) {
644-
return isReactCall(path, 'forwardRef');
644+
return isReactCall(path, 'forwardRef') || (path.isCallExpression() && path.get('callee').isIdentifier({name: 'createHideableComponent'}));
645645
}
646646

647647
function isReactCall(path, name, module = 'react') {
@@ -669,13 +669,12 @@ module.exports = new Transformer({
669669

670670
function isReactComponent(path) {
671671
if (path.isFunction()) {
672-
if (
673-
path.node.returnType &&
674-
t.isTSTypeReference(path.node.returnType.typeAnnotation) &&
675-
t.isTSQualifiedName(path.node.returnType.typeAnnotation.typeName) &&
676-
t.isIdentifier(path.node.returnType.typeAnnotation.typeName.left, {name: 'JSX'}) &&
677-
t.isIdentifier(path.node.returnType.typeAnnotation.typeName.right, {name: 'Element'})
678-
) {
672+
let returnType = path.node.returnType?.typeAnnotation;
673+
if (isJSXElementType(returnType)) {
674+
return true;
675+
}
676+
677+
if (returnType && t.isTSUnionType(returnType) && returnType.types.some(isJSXElementType)) {
679678
return true;
680679
}
681680

@@ -697,6 +696,14 @@ module.exports = new Transformer({
697696
return false;
698697
}
699698

699+
function isJSXElementType(returnType) {
700+
return returnType &&
701+
t.isTSTypeReference(returnType) &&
702+
t.isTSQualifiedName(returnType.typeName) &&
703+
t.isIdentifier(returnType.typeName.left, {name: 'JSX'}) &&
704+
t.isIdentifier(returnType.typeName.right, {name: 'Element'});
705+
}
706+
700707
function getJSDocs(path) {
701708
let comments = getDocComments(path);
702709
if (comments) {

packages/dev/test-utils/src/testSSR.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ try {
2727
// ignore.
2828
}
2929

30-
export async function testSSR(filename, source) {
30+
export async function testSSR(filename, source, runAfterServer) {
3131
// Transform the code with babel so JSX becomes JS.
3232
source = babel.transformSync(source, {filename}).code;
3333

@@ -64,11 +64,12 @@ export async function testSSR(filename, source) {
6464
try {
6565
document.body.innerHTML = `<div id="root">${body}</div>`;
6666
let container = document.querySelector('#root');
67+
runAfterServer?.();
6768
let element = evaluate(source, filename);
6869
if (ReactDOMClient) {
6970
act(() => ReactDOMClient.hydrateRoot(container, <SSRProvider>{element}</SSRProvider>));
7071
} else {
71-
ReactDOM.hydrate(<SSRProvider>{element}</SSRProvider>, container);
72+
act(() => {ReactDOM.hydrate(<SSRProvider>{element}</SSRProvider>, container);});
7273
}
7374
} catch (err) {
7475
errors.push(err.stack);

packages/react-aria-components/docs/Select.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,10 +562,11 @@ interface MySelectProps<T extends object> extends Omit<SelectProps<T>, 'children
562562
label?: string,
563563
description?: string,
564564
errorMessage?: string,
565+
items?: Iterable<T>,
565566
children: React.ReactNode | ((item: T) => React.ReactNode)
566567
}
567568

568-
function MySelect<T extends object>({label, description, errorMessage, children, ...props}: MySelectProps<T>) {
569+
function MySelect<T extends object>({label, description, errorMessage, children, items, ...props}: MySelectProps<T>) {
569570
return (
570571
<Select {...props}>
571572
<Label>{label}</Label>
@@ -576,7 +577,7 @@ function MySelect<T extends object>({label, description, errorMessage, children,
576577
{description && <Text slot="description">{description}</Text>}
577578
{errorMessage && <Text slot="errorMessage">{errorMessage}</Text>}
578579
<Popover>
579-
<ListBox>
580+
<ListBox items={items}>
580581
{children}
581582
</ListBox>
582583
</Popover>

packages/react-aria-components/src/Breadcrumbs.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
import {AriaBreadcrumbsProps, useBreadcrumbs} from 'react-aria';
13+
import {Collection, Node} from 'react-stately';
1314
import {CollectionProps, useCollection} from './Collection';
1415
import {ContextValue, forwardRefType, Provider, SlotProps, StyleProps, useContextProps} from './utils';
1516
import {filterDOMProps} from '@react-aria/utils';
1617
import {HeadingContext} from './Heading';
1718
import {LinkContext} from './Link';
18-
import {Node} from 'react-stately';
19-
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes} from 'react';
19+
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, RefObject} from 'react';
2020

2121
export interface BreadcrumbsProps<T> extends Omit<CollectionProps<T>, 'disabledKeys'>, Omit<AriaBreadcrumbsProps, 'children'>, StyleProps, SlotProps {
2222
/** Whether the breadcrumbs are disabled. */
@@ -27,9 +27,26 @@ export const BreadcrumbsContext = createContext<ContextValue<BreadcrumbsProps<an
2727

2828
function Breadcrumbs<T extends object>(props: BreadcrumbsProps<T>, ref: ForwardedRef<HTMLElement>) {
2929
[props, ref] = useContextProps(props, ref, BreadcrumbsContext);
30-
let {navProps} = useBreadcrumbs(props);
3130
let {portal, collection} = useCollection(props);
3231

32+
// Render the portal first so that we have the collection by the time we render the DOM in SSR
33+
return (
34+
<>
35+
{portal}
36+
<BreadcrumbsInner props={props} collection={collection} breadcrumbsRef={ref} />
37+
</>
38+
);
39+
}
40+
41+
interface BreadcrumbsInnerProps<T> {
42+
props: BreadcrumbsProps<T>,
43+
collection: Collection<Node<T>>,
44+
breadcrumbsRef: RefObject<HTMLElement>
45+
}
46+
47+
function BreadcrumbsInner<T extends object>({props, collection, breadcrumbsRef: ref}: BreadcrumbsInnerProps<T>) {
48+
let {navProps} = useBreadcrumbs(props);
49+
3350
return (
3451
<nav
3552
ref={ref}
@@ -48,7 +65,6 @@ function Breadcrumbs<T extends object>(props: BreadcrumbsProps<T>, ref: Forwarde
4865
isDisabled={props.isDisabled} />
4966
))}
5067
</ol>
51-
{portal}
5268
</nav>
5369
);
5470
}

0 commit comments

Comments
 (0)