Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use @react-aria/ssr for isomorphic ID generation. #1409

Merged
merged 12 commits into from
Sep 15, 2021
Merged
Show file tree
Hide file tree
Changes from 9 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
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.
37 changes: 37 additions & 0 deletions docs/content/ssr.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: Server-side rendering with Primer React
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for writing docs! 💖

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

---

## SSR-safe ID generation

Some primer components generate their own DOM IDs. Those IDs must be unique across the DOM and isomorphic (so that server-side rendering and client-side rendering yield the same ID, avoiding hydration issues). 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>`:
jfuchs marked this conversation as resolved.
Show resolved Hide resolved

```
import {SSRProvider} from '@primer/components';

<SSRProvider>
<MyApplication />
</SSRProvider>
jfuchs marked this conversation as resolved.
Show resolved Hide resolved
```

`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