Skip to content

Commit

Permalink
Use @react-aria/ssr for isomorphic ID generation. (#1409)
Browse files Browse the repository at this point in the history
* Use @react-aria/ssr for isomorphic ID generation.

* Update docs/content/ssr.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update docs/content/ssr.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Doc readability

Co-authored-by: Cole Bemis <colebemis@github.com>
  • Loading branch information
jfuchs and colebemis committed Sep 15, 2021
1 parent b2611b6 commit 90b17dd
Show file tree
Hide file tree
Showing 19 changed files with 146 additions and 72 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-coins-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/components': minor
---

Use @react-aria/ssr for isomorphic ID generation.
41 changes: 41 additions & 0 deletions docs/content/ssr.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: Server-side rendering with Primer React
---

## SSR-safe ID generation

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`:

```
import {SSRProvider} from '@primer/components';
function App() {
return (
<SSRProvider>
<MyApplication />
</SSRProvider>
)
}
```

`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:

```
function MyApplication() {
const [dataA] = useAsyncData('a');
const [dataB] = useAsyncData('b');
return <>
<SSRProvider>
{dataA && <MyComponentA data={dataA} />}
</SSRProvider>
<SSRProvider>
{dataB && <MyComponentB data={dataB} />}
</SSRProvider>
</>
}
```

This will ensure that the IDs are consistent for any sequencing of `dataA` and `dataB` being resolved.

See also [React Aria's server side rendering documentation](https://react-spectrum.adobe.com/react-aria/ssr.html).
12 changes: 9 additions & 3 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@primer/octicons-react": "^13.0.0",
"@primer/primitives": "4.6.4",
"@react-aria/ssr": "3.1.0",
"@styled-system/css": "5.1.5",
"@styled-system/props": "5.1.5",
"@styled-system/theme-get": "5.1.2",
Expand Down
8 changes: 4 additions & 4 deletions src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {CheckIcon, IconProps} from '@primer/octicons-react'
import React, {useCallback, useMemo} from 'react'
import React, {useCallback} from 'react'
import {get} from '../constants'
import sx, {SxProp} from '../sx'
import Truncate from '../Truncate'
Expand All @@ -13,7 +13,7 @@ import {
activeDescendantActivatedIndirectly,
isActiveDescendantAttribute
} from '../behaviors/focusZone'
import {uniqueId} from '../utils/uniqueId'
import {useSSRSafeId} from '@react-aria/ssr'

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

const labelId = useMemo(() => uniqueId(), [])
const descriptionId = useMemo(() => uniqueId(), [])
const labelId = useSSRSafeId()
const descriptionId = useSSRSafeId()

const keyPressHandler = useCallback(
event => {
Expand Down
6 changes: 3 additions & 3 deletions src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, {useCallback, useEffect, useMemo} from 'react'
import React, {useCallback, useEffect} from 'react'
import Overlay, {OverlayProps} from '../Overlay'
import {FocusTrapHookSettings, useFocusTrap} from '../hooks/useFocusTrap'
import {FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
import {useAnchoredPosition, useProvidedRefOrCreate, useRenderForcingRef} from '../hooks'
import {uniqueId} from '../utils/uniqueId'
import {useSSRSafeId} from '@react-aria/ssr'

interface AnchoredOverlayPropsWithAnchor {
/**
Expand Down Expand Up @@ -90,7 +90,7 @@ export const AnchoredOverlay: React.FC<AnchoredOverlayProps> = ({
}) => {
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const [overlayRef, updateOverlayRef] = useRenderForcingRef<HTMLDivElement>()
const anchorId = useMemo(uniqueId, [])
const anchorId = useSSRSafeId()

const onClickOutside = useCallback(() => onClose?.('click-outside'), [onClose])
const onEscape = useCallback(() => onClose?.('escape'), [onClose])
Expand Down
6 changes: 3 additions & 3 deletions src/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {XIcon} from '@primer/octicons-react'
import {useFocusZone} from '../hooks/useFocusZone'
import {FocusKeys} from '../behaviors/focusZone'
import Portal from '../Portal'
import {uniqueId} from '../utils/uniqueId'
import {useCombinedRefs} from '../hooks/useCombinedRefs'
import {useSSRSafeId} from '@react-aria/ssr'

const ANIMATION_DURATION = '200ms'

Expand Down Expand Up @@ -252,8 +252,8 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
width = 'xlarge',
height = 'auto'
} = props
const dialogLabelId = uniqueId()
const dialogDescriptionId = uniqueId()
const dialogLabelId = useSSRSafeId()
const dialogDescriptionId = useSSRSafeId()
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}

const dialogRef = useRef<HTMLDivElement>(null)
Expand Down
6 changes: 3 additions & 3 deletions src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import React, {KeyboardEventHandler, useCallback, useEffect, useMemo, useRef} from 'react'
import React, {KeyboardEventHandler, useCallback, useEffect, useRef} from 'react'
import {GroupedListProps, ListPropsBase} from '../ActionList/List'
import TextInput, {TextInputProps} from '../TextInput'
import Box from '../Box'
import {ActionList} from '../ActionList'
import Spinner from '../Spinner'
import {useFocusZone} from '../hooks/useFocusZone'
import {uniqueId} from '../utils/uniqueId'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import styled from 'styled-components'
import {get} from '../constants'
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
import useScrollFlash from '../hooks/useScrollFlash'
import {useSSRSafeId} from '@react-aria/ssr'

export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
loading?: boolean
Expand Down Expand Up @@ -73,7 +73,7 @@ export function FilteredActionList({
const listContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useMemo(uniqueId, [])
const listId = useSSRSafeId()
const onInputKeyPress: KeyboardEventHandler = useCallback(
event => {
if (event.key === 'Enter' && activeDescendantRef.current) {
Expand Down
20 changes: 13 additions & 7 deletions src/__tests__/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import theme from '../theme'
import {ActionMenu} from '../ActionMenu'
import {COMMON} from '../constants'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {BaseStyles, ThemeProvider} from '..'
import {BaseStyles, SSRProvider, ThemeProvider} from '..'
import {ItemProps} from '../ActionList/Item'
expect.extend(toHaveNoViolations)

Expand All @@ -22,11 +22,13 @@ const mockOnActivate = jest.fn()
function SimpleActionMenu(): JSX.Element {
return (
<ThemeProvider theme={theme}>
<BaseStyles>
<div id="something-else">X</div>
<ActionMenu onAction={mockOnActivate} anchorContent="Menu" items={items} />
<div id="portal-root"></div>
</BaseStyles>
<SSRProvider>
<BaseStyles>
<div id="something-else">X</div>
<ActionMenu onAction={mockOnActivate} anchorContent="Menu" items={items} />
<div id="portal-root"></div>
</BaseStyles>
</SSRProvider>
</ThemeProvider>
)
}
Expand All @@ -40,7 +42,11 @@ describe('ActionMenu', () => {
Component: ActionMenu,
systemPropArray: [COMMON],
options: {skipAs: true, skipSx: true},
toRender: () => <ActionMenu items={[]} />
toRender: () => (
<SSRProvider>
<ActionMenu items={[]} />
</SSRProvider>
)
})

checkExports('ActionMenu', {
Expand Down
24 changes: 13 additions & 11 deletions src/__tests__/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {behavesAsComponent, checkExports} from '../utils/testing'
import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react'
import {axe, toHaveNoViolations} from 'jest-axe'
import 'babel-polyfill'
import {Button} from '../index'
import {Button, SSRProvider} from '../index'
import theme from '../theme'
import BaseStyles from '../BaseStyles'
import {ThemeProvider} from '../ThemeProvider'
Expand Down Expand Up @@ -38,16 +38,18 @@ const AnchoredOverlayTestComponent = ({
)
return (
<ThemeProvider theme={theme}>
<BaseStyles>
<AnchoredOverlay
open={open}
onOpen={onOpen}
onClose={onClose}
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
<SSRProvider>
<BaseStyles>
<AnchoredOverlay
open={open}
onOpen={onOpen}
onClose={onClose}
renderAnchor={props => <Button {...props}>Anchor Button</Button>}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
</SSRProvider>
</ThemeProvider>
)
}
Expand Down
30 changes: 18 additions & 12 deletions src/__tests__/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import theme from '../theme'
import {DropdownMenu, DropdownButton} from '../DropdownMenu'
import {COMMON} from '../constants'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {BaseStyles, ThemeProvider} from '..'
import {BaseStyles, ThemeProvider, SSRProvider} from '..'
import {ItemInput} from '../ActionList/List'

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

return (
<ThemeProvider theme={theme}>
<BaseStyles>
<div id="something-else">X</div>
<DropdownMenu
items={items}
placeholder="Select an Option"
selectedItem={selectedItem}
onChange={setSelectedItem}
/>
<div id="portal-root"></div>
</BaseStyles>
<SSRProvider>
<BaseStyles>
<div id="something-else">X</div>
<DropdownMenu
items={items}
placeholder="Select an Option"
selectedItem={selectedItem}
onChange={setSelectedItem}
/>
<div id="portal-root"></div>
</BaseStyles>
</SSRProvider>
</ThemeProvider>
)
}
Expand All @@ -41,7 +43,11 @@ describe('DropdownMenu', () => {
Component: DropdownMenu,
systemPropArray: [COMMON],
options: {skipAs: true, skipSx: true},
toRender: () => <DropdownMenu items={[]} />
toRender: () => (
<SSRProvider>
<DropdownMenu items={[]} />
</SSRProvider>
)
})

checkExports('DropdownMenu', {
Expand Down
30 changes: 16 additions & 14 deletions src/__tests__/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import theme from '../theme'
import {SelectPanel} from '../SelectPanel'
import {COMMON} from '../constants'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {BaseStyles, ThemeProvider} from '..'
import {BaseStyles, SSRProvider, ThemeProvider} from '..'
import {ItemInput} from '../ActionList/List'

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

return (
<ThemeProvider theme={theme}>
<BaseStyles>
<SelectPanel
items={items}
placeholder="Select Items"
placeholderText="Filter Items"
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
open={open}
onOpenChange={setOpen}
/>
<div id="portal-root"></div>
</BaseStyles>
<SSRProvider>
<BaseStyles>
<SelectPanel
items={items}
placeholder="Select Items"
placeholderText="Filter Items"
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
open={open}
onOpenChange={setOpen}
/>
<div id="portal-root"></div>
</BaseStyles>
</SSRProvider>
</ThemeProvider>
)
}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/ActionMenu.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ exports[`ActionMenu renders consistently 1`] = `
<button
aria-haspopup="true"
aria-label="menu"
aria-labelledby="__primer_id_10000"
aria-labelledby="react-aria-1"
className="c0"
id="__primer_id_10000"
id="react-aria-1"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
Expand Down
Loading

0 comments on commit 90b17dd

Please sign in to comment.