Skip to content

Commit 90b17dd

Browse files
jfuchscolebemis
andauthored
Use @react-aria/ssr for isomorphic ID generation. (#1409)
* Use @react-aria/ssr for isomorphic ID generation. * Update docs/content/ssr.mdx Co-authored-by: Cole Bemis <[email protected]> * Update docs/content/ssr.mdx Co-authored-by: Cole Bemis <[email protected]> * Doc readability Co-authored-by: Cole Bemis <[email protected]>
1 parent b2611b6 commit 90b17dd

File tree

19 files changed

+146
-72
lines changed

19 files changed

+146
-72
lines changed

.changeset/fast-coins-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/components': minor
3+
---
4+
5+
Use @react-aria/ssr for isomorphic ID generation.

docs/content/ssr.mdx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
title: Server-side rendering with Primer React
3+
---
4+
5+
## SSR-safe ID generation
6+
7+
Some Primer components generate their own DOM IDs. Those IDs must be isomorphic (so that server-side rendering and client-side rendering yield the same ID, avoiding hydration issues) and unique across the DOM. We use [@react-aria/ssr](https://react-spectrum.adobe.com/react-aria/ssr.html) to generate those IDs. In client-only rendering, this doesn't require any additional work. In SSR contexts, you must wrap your application with at least one `SSRProvider`:
8+
9+
```
10+
import {SSRProvider} from '@primer/components';
11+
12+
function App() {
13+
return (
14+
<SSRProvider>
15+
<MyApplication />
16+
</SSRProvider>
17+
)
18+
}
19+
```
20+
21+
`SSRProvider` maintains the context necessary to ensure IDs are consistent. In cases where some parts of the react tree are rendered asynchronously, you should wrap an additional `SSRProvider` around the conditionally rendered elements:
22+
23+
```
24+
function MyApplication() {
25+
const [dataA] = useAsyncData('a');
26+
const [dataB] = useAsyncData('b');
27+
28+
return <>
29+
<SSRProvider>
30+
{dataA && <MyComponentA data={dataA} />}
31+
</SSRProvider>
32+
<SSRProvider>
33+
{dataB && <MyComponentB data={dataB} />}
34+
</SSRProvider>
35+
</>
36+
}
37+
```
38+
39+
This will ensure that the IDs are consistent for any sequencing of `dataA` and `dataB` being resolved.
40+
41+
See also [React Aria's server side rendering documentation](https://react-spectrum.adobe.com/react-aria/ssr.html).

package-lock.json

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"dependencies": {
4646
"@primer/octicons-react": "^13.0.0",
4747
"@primer/primitives": "4.6.4",
48+
"@react-aria/ssr": "3.1.0",
4849
"@styled-system/css": "5.1.5",
4950
"@styled-system/props": "5.1.5",
5051
"@styled-system/theme-get": "5.1.2",

src/ActionList/Item.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {CheckIcon, IconProps} from '@primer/octicons-react'
2-
import React, {useCallback, useMemo} from 'react'
2+
import React, {useCallback} from 'react'
33
import {get} from '../constants'
44
import sx, {SxProp} from '../sx'
55
import Truncate from '../Truncate'
@@ -13,7 +13,7 @@ import {
1313
activeDescendantActivatedIndirectly,
1414
isActiveDescendantAttribute
1515
} from '../behaviors/focusZone'
16-
import {uniqueId} from '../utils/uniqueId'
16+
import {useSSRSafeId} from '@react-aria/ssr'
1717

1818
/**
1919
* These colors are not yet in our default theme. Need to remove this once they are added.
@@ -336,8 +336,8 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
336336
...props
337337
} = itemProps
338338

339-
const labelId = useMemo(() => uniqueId(), [])
340-
const descriptionId = useMemo(() => uniqueId(), [])
339+
const labelId = useSSRSafeId()
340+
const descriptionId = useSSRSafeId()
341341

342342
const keyPressHandler = useCallback(
343343
event => {

src/AnchoredOverlay/AnchoredOverlay.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import React, {useCallback, useEffect, useMemo} from 'react'
1+
import React, {useCallback, useEffect} from 'react'
22
import Overlay, {OverlayProps} from '../Overlay'
33
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
44
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
55
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
6-
import {uniqueId} from '../utils/uniqueId'
6+
import {useSSRSafeId} from '@react-aria/ssr'
77

88
interface AnchoredOverlayPropsWithAnchor {
99
/**
@@ -90,7 +90,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
9090
}) => {
9191
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
9292
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
93-
const anchorId = useMemo(uniqueId, [])
93+
const anchorId = useSSRSafeId()
9494

9595
const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose])
9696
const onEscape = useCallback(() => onClose?.('escape'), [onClose])

src/Dialog/Dialog.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {XIcon} from '@primer/octicons-react'
1111
import {useFocusZone} from '../hooks/useFocusZone'
1212
import {FocusKeys} from '../behaviors/focusZone'
1313
import Portal from '../Portal'
14-
import {uniqueId} from '../utils/uniqueId'
1514
import {useCombinedRefs} from '../hooks/useCombinedRefs'
15+
import {useSSRSafeId} from '@react-aria/ssr'
1616

1717
const ANIMATION_DURATION = '200ms'
1818

@@ -252,8 +252,8 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
252252
width = 'xlarge',
253253
height = 'auto'
254254
} = props
255-
const dialogLabelId = uniqueId()
256-
const dialogDescriptionId = uniqueId()
255+
const dialogLabelId = useSSRSafeId()
256+
const dialogDescriptionId = useSSRSafeId()
257257
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}
258258

259259
const dialogRef = useRef<HTMLDivElement>(null)

src/FilteredActionList/FilteredActionList.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import React, {KeyboardEventHandler, useCallback, useEffect, useMemo, useRef} from 'react'
1+
import React, {KeyboardEventHandler, useCallback, useEffect, useRef} from 'react'
22
import {GroupedListProps, ListPropsBase} from '../ActionList/List'
33
import TextInput, {TextInputProps} from '../TextInput'
44
import Box from '../Box'
55
import {ActionList} from '../ActionList'
66
import Spinner from '../Spinner'
77
import {useFocusZone} from '../hooks/useFocusZone'
8-
import {uniqueId} from '../utils/uniqueId'
98
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
109
import styled from 'styled-components'
1110
import {get} from '../constants'
1211
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
1312
import useScrollFlash from '../hooks/useScrollFlash'
13+
import {useSSRSafeId} from '@react-aria/ssr'
1414

1515
export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
1616
loading?: boolean
@@ -73,7 +73,7 @@ export function FilteredActionList({
7373
const listContainerRef = useRef<HTMLDivElement>(null)
7474
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
7575
const activeDescendantRef = useRef<HTMLElement>()
76-
const listId = useMemo(uniqueId, [])
76+
const listId = useSSRSafeId()
7777
const onInputKeyPress: KeyboardEventHandler = useCallback(
7878
event => {
7979
if (event.key === 'Enter' && activeDescendantRef.current) {

src/__tests__/ActionMenu.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import theme from '../theme'
66
import {ActionMenu} from '../ActionMenu'
77
import {COMMON} from '../constants'
88
import {behavesAsComponent, checkExports} from '../utils/testing'
9-
import {BaseStyles, ThemeProvider} from '..'
9+
import {BaseStyles, SSRProvider, ThemeProvider} from '..'
1010
import {ItemProps} from '../ActionList/Item'
1111
expect.extend(toHaveNoViolations)
1212

@@ -22,11 +22,13 @@ const mockOnActivate = jest.fn()
2222
function SimpleActionMenu(): JSX.Element {
2323
return (
2424
<ThemeProvider theme={theme}>
25-
<BaseStyles>
26-
<div id="something-else">X</div>
27-
<ActionMenu onAction={mockOnActivate} anchorContent="Menu" items={items} />
28-
<div id="portal-root"></div>
29-
</BaseStyles>
25+
<SSRProvider>
26+
<BaseStyles>
27+
<div id="something-else">X</div>
28+
<ActionMenu onAction={mockOnActivate} anchorContent="Menu" items={items} />
29+
<div id="portal-root"></div>
30+
</BaseStyles>
31+
</SSRProvider>
3032
</ThemeProvider>
3133
)
3234
}
@@ -40,7 +42,11 @@ describe('ActionMenu', () => {
4042
Component: ActionMenu,
4143
systemPropArray: [COMMON],
4244
options: {skipAs: true, skipSx: true},
43-
toRender: () => <ActionMenu items={[]} />
45+
toRender: () => (
46+
<SSRProvider>
47+
<ActionMenu items={[]} />
48+
</SSRProvider>
49+
)
4450
})
4551

4652
checkExports('ActionMenu', {

src/__tests__/AnchoredOverlay.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {behavesAsComponent, checkExports} from '../utils/testing'
44
import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react'
55
import {axe, toHaveNoViolations} from 'jest-axe'
66
import 'babel-polyfill'
7-
import {Button} from '../index'
7+
import {Button, SSRProvider} from '../index'
88
import theme from '../theme'
99
import BaseStyles from '../BaseStyles'
1010
import {ThemeProvider} from '../ThemeProvider'
@@ -38,16 +38,18 @@ const AnchoredOverlayTestComponent = ({
3838
)
3939
return (
4040
<ThemeProvider theme={theme}>
41-
<BaseStyles>
42-
<AnchoredOverlay
43-
open={open}
44-
onOpen={onOpen}
45-
onClose={onClose}
46-
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
47-
>
48-
<button type="button">Focusable Child</button>
49-
</AnchoredOverlay>
50-
</BaseStyles>
41+
<SSRProvider>
42+
<BaseStyles>
43+
<AnchoredOverlay
44+
open={open}
45+
onOpen={onOpen}
46+
onClose={onClose}
47+
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
48+
>
49+
<button type="button">Focusable Child</button>
50+
</AnchoredOverlay>
51+
</BaseStyles>
52+
</SSRProvider>
5153
</ThemeProvider>
5254
)
5355
}

src/__tests__/DropdownMenu.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import theme from '../theme'
66
import {DropdownMenu, DropdownButton} from '../DropdownMenu'
77
import {COMMON} from '../constants'
88
import {behavesAsComponent, checkExports} from '../utils/testing'
9-
import {BaseStyles, ThemeProvider} from '..'
9+
import {BaseStyles, ThemeProvider, SSRProvider} from '..'
1010
import {ItemInput} from '../ActionList/List'
1111

1212
expect.extend(toHaveNoViolations)
@@ -18,16 +18,18 @@ function SimpleDropdownMenu(): JSX.Element {
1818

1919
return (
2020
<ThemeProvider theme={theme}>
21-
<BaseStyles>
22-
<div id="something-else">X</div>
23-
<DropdownMenu
24-
items={items}
25-
placeholder="Select an Option"
26-
selectedItem={selectedItem}
27-
onChange={setSelectedItem}
28-
/>
29-
<div id="portal-root"></div>
30-
</BaseStyles>
21+
<SSRProvider>
22+
<BaseStyles>
23+
<div id="something-else">X</div>
24+
<DropdownMenu
25+
items={items}
26+
placeholder="Select an Option"
27+
selectedItem={selectedItem}
28+
onChange={setSelectedItem}
29+
/>
30+
<div id="portal-root"></div>
31+
</BaseStyles>
32+
</SSRProvider>
3133
</ThemeProvider>
3234
)
3335
}
@@ -41,7 +43,11 @@ describe('DropdownMenu', () => {
4143
Component: DropdownMenu,
4244
systemPropArray: [COMMON],
4345
options: {skipAs: true, skipSx: true},
44-
toRender: () => <DropdownMenu items={[]} />
46+
toRender: () => (
47+
<SSRProvider>
48+
<DropdownMenu items={[]} />
49+
</SSRProvider>
50+
)
4551
})
4652

4753
checkExports('DropdownMenu', {

src/__tests__/SelectPanel.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import theme from '../theme'
66
import {SelectPanel} from '../SelectPanel'
77
import {COMMON} from '../constants'
88
import {behavesAsComponent, checkExports} from '../utils/testing'
9-
import {BaseStyles, ThemeProvider} from '..'
9+
import {BaseStyles, SSRProvider, ThemeProvider} from '..'
1010
import {ItemInput} from '../ActionList/List'
1111

1212
expect.extend(toHaveNoViolations)
@@ -20,19 +20,21 @@ function SimpleSelectPanel(): JSX.Element {
2020

2121
return (
2222
<ThemeProvider theme={theme}>
23-
<BaseStyles>
24-
<SelectPanel
25-
items={items}
26-
placeholder="Select Items"
27-
placeholderText="Filter Items"
28-
selected={selected}
29-
onSelectedChange={setSelected}
30-
onFilterChange={setFilter}
31-
open={open}
32-
onOpenChange={setOpen}
33-
/>
34-
<div id="portal-root"></div>
35-
</BaseStyles>
23+
<SSRProvider>
24+
<BaseStyles>
25+
<SelectPanel
26+
items={items}
27+
placeholder="Select Items"
28+
placeholderText="Filter Items"
29+
selected={selected}
30+
onSelectedChange={setSelected}
31+
onFilterChange={setFilter}
32+
open={open}
33+
onOpenChange={setOpen}
34+
/>
35+
<div id="portal-root"></div>
36+
</BaseStyles>
37+
</SSRProvider>
3638
</ThemeProvider>
3739
)
3840
}

src/__tests__/__snapshots__/ActionMenu.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ exports[`ActionMenu renders consistently 1`] = `
7070
<button
7171
aria-haspopup="true"
7272
aria-label="menu"
73-
aria-labelledby="__primer_id_10000"
73+
aria-labelledby="react-aria-1"
7474
className="c0"
75-
id="__primer_id_10000"
75+
id="react-aria-1"
7676
onClick={[Function]}
7777
onKeyDown={[Function]}
7878
tabIndex={0}

0 commit comments

Comments
 (0)