Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions docs/guides/module-federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,22 @@ Just use themes as you would without module federation. Note that theme objects

> Overrides specified in global themes are not applied to local themes.

### Keeping generated ids unique across apps

InstUI generates element ids with React's `useId`, which only guarantees
uniqueness within a single React root. When a host and a guest app each mount
their own root on the same page, both start numbering from scratch and their ids
can collide. Give each root a distinct `identifierPrefix`:

```javascript
---
type: code
---
createRoot(hostEl, { identifierPrefix: 'host-' })
createRoot(guestEl, { identifierPrefix: 'guest-' })
```

Older InstUI versions handled this with a shared `instanceCounterMap`, which is
now ignored. See [Server side rendering](/#server-side-rendering) for details.

You can check out a sample application on [Github](https://github.com/matyasf/module-federation-instui)
143 changes: 143 additions & 0 deletions docs/guides/server-side-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
title: Server side rendering (SSR)
category: Guides
order: 8
relevantForAI: true
---

# Server side rendering (SSR)

**InstUI works under SSR with no SSR-specific setup.** Set up
[InstUISettingsProvider](/#InstUISettingsProvider) as you would in any app and
render — there is nothing extra to configure, and no per-component opt-in.

The rest of this page is the small number of cases where you do need to act.

## Why it works

Many components need an id for an element you never name: the target of an
`aria-describedby`, the `<title>` inside an SVG, the panel a tab controls.
InstUI generates those with React's built-in
[`useId`](https://react.dev/reference/react/useId), which produces the same
value for the same position in the React tree on the server and on the client,
so the server HTML and the hydrated tree agree.

Generated ids look like `ComponentName___token`, e.g. `FormFieldLayout___r7`.
The token is the `useId` value with React's delimiters (`:r0:` on React 18,
`«r0»` on React 19) stripped, so the id is always valid in a CSS selector. If
you pass your own `id` prop, yours is used and nothing is generated.

## Remove `instanceCounterMap` if you have one

Before ids came from `useId`, InstUI counted component instances in a shared map,
and earlier versions of this guide asked you to pass an `instanceCounterMap` to
`InstUISettingsProvider` to keep server and client ids aligned. **That map is no
longer read.** If you still pass one it is ignored — delete it.

| Deprecated | Replacement |
| ------------------------------------------------------------- | --------------------------------------------- |
| `instanceCounterMap` prop on `DeterministicIdContextProvider` | none needed — remove the prop |
| `DeterministicIdContext` | none needed — ids no longer come from context |
| `generateId` from `@instructure/ui-utils` | `useDeterministicId` / `withDeterministicId` |

These are still exported for backwards compatibility and will be removed in the
next major version.

## Multiple React roots on one page

`useId` guarantees uniqueness **within a single React root**. If a page mounts
two or more independent roots — a micro-frontend layout, a widget embedded in a
legacy page, or a [module federation](/#module-federation) host and guest — each
root numbers its ids from scratch, so the roots can collide.

This is the one case that needs your action. Give each root a distinct
`identifierPrefix`, and pass the matching prefix to the server renderer so both
sides agree:

```javascript
---
type: code
---
// server
renderToPipeableStream(<GuestApp />, { identifierPrefix: 'guest-' })

// client
hydrateRoot(document.getElementById('guest'), <GuestApp />, {
identifierPrefix: 'guest-'
})
```

The same option exists on `createRoot` for client-only roots.

## Next.js App Router

`InstUISettingsProvider` uses React context, so with the App Router it has to
live in a client component. Mark the layout that renders it with `'use client'`:

```javascript
---
type: code
---
// app/layout.tsx
'use client'
import { InstUISettingsProvider, canvas } from '@instructure/ui'

export default function RootLayout({ children }) {
return (
<html lang="en">
<InstUISettingsProvider theme={canvas}>
<body>{children}</body>
</InstUISettingsProvider>
</html>
)
}
```

With the Pages Router, render the provider in `pages/_app.js` instead; no
`'use client'` is involved.

## Building your own components

If you write components on top of InstUI, generate ids with the same utilities
rather than rolling your own, and they will be SSR-safe too.

In function components, use the `useDeterministicId` hook and call the returned
function **during render**:

```javascript
---
type: code
---
import { useDeterministicId } from '@instructure/ui-react-utils'

const MyComponent = () => {
const getId = useDeterministicId('MyComponent')
const id = getId()
const messagesId = getId('MyComponent-messages')

return (
<div id={id} aria-describedby={messagesId}>
<span id={messagesId}>Helpful text</span>
</div>
)
}
```

Call it more than once with different `instanceName` values to derive several
distinct, stable ids from one component instance. In class components, the
`withDeterministicId` decorator injects a `deterministicId` prop with the same
signature.

Three things break hydration, all of them avoidable:

- **Random or time-based ids during render.** `Math.random()`, `Date.now()` and
`uid()` from `@instructure/uid` return a different value on the server than on
the client, and a new one on every re-render — so any `aria-*` attribute
pointing at the id silently re-points. Use `uid()` only for client-side
identifiers that never reach the rendered markup.
- **Assigning ids in `useEffect`.** This dodges the hydration warning by
rendering the attribute as `undefined` first, but then the server HTML has no
id at all, and assistive technology reading the page before hydration finds a
dangling `aria-describedby`.
- **Counting renders.** An id from a counter that advances once per render pass
cannot match between a server render and a client render.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
},
"lint-staged": {
"*.{js,ts,tsx}": [
"oxlint -c .oxlintrc.json --fix",
"oxlint -c .oxlintrc.json --fix --no-error-on-unmatched-pattern",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note: This was throwing an error when a commit only changed the /regression-test/cypress/e2e/spec.cy.ts file - happy to pivot if necessary.

"prettier --write"
],
"*.{json,jsx,md,mdx,html}": [
Expand Down
10 changes: 9 additions & 1 deletion packages/__docs__/src/ComponentTheme/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,15 @@ class ComponentTheme extends Component<ComponentThemeProps> {
) {
for (const key in componentTheme) {
if (typeof componentTheme[key] === 'object') {
this.themeToArray(componentTheme[key], arr, key)
// Keep the accumulated prefix so nested keys stay fully qualified,
// e.g. `arrowsBackgroundHoverColor.modify.type`. Without this, every
// token with a `modify` block would collapse to the same `modify.type`
// name and collide as a React key.
this.themeToArray(
componentTheme[key],
arr,
prefix ? `${prefix}.${key}` : key
)
} else if (componentTheme[key] !== undefined) {
const name = prefix ? `${prefix}.${key}` : key
arr.push({ name: name, value: componentTheme[key] })
Expand Down
2 changes: 1 addition & 1 deletion packages/emotion/src/InstUISettingsProvider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Table of Contents:
- [Nesting theme providers](/#InstUISettingsProvider/#theme-management-nesting-theme-providers)
- [Theme overrides](/#InstUISettingsProvider/#theme-management-theme-overrides)
- [Text direction management](/#InstUISettingsProvider/#text-direction-management)
- [Server Side Rendering support](/#InstUISettingsProvider/#server-side-rendering-support)
- [Server side rendering (SSR)](/#server-side-rendering)
- [Properties](/#InstUISettingsProvider/#InstUISettingsProviderProperties)

### Theme management
Expand Down
19 changes: 19 additions & 0 deletions packages/ui-calendar/src/Calendar/__tests__/Calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -593,4 +593,23 @@ describe('<Calendar />', () => {
).toBeInTheDocument()
})
})

describe('weekday header ids', () => {
it('gives every weekday header a unique id', async () => {
const { container } = await render(
<Calendar renderWeekdayLabels={weekdayLabels} selectedLabel="Selected">
{generateDays()}
</Calendar>
)

const headers = Array.from(
container.querySelectorAll('[id^="weekday-header"]')
).map((el) => el.id)

expect(headers.length).toBeGreaterThan(1)
// `deterministicId` derives the id from the instance name, so a shared
// name would hand every header the same id.
expect(new Set(headers).size).toBe(headers.length)
})
})
})
5 changes: 4 additions & 1 deletion packages/ui-calendar/src/Calendar/v1/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ class Calendar extends Component<CalendarProps, CalendarState> {
this._weekdayHeaderIds = (
this.props.renderWeekdayLabels || this.defaultWeekdays
).reduce((ids: Record<number, string>, _label, i) => {
return { ...ids, [i]: this.props.deterministicId!('weekday-header') }
// The instance name must vary per weekday: `deterministicId` derives the
// id from the name, so calling it repeatedly with the same name returns
// the same id (it is no longer a counter) and every header would collide.
return { ...ids, [i]: this.props.deterministicId!(`weekday-header-${i}`) }
}, {})
this.state = this.calculateState(
this.locale(),
Expand Down
5 changes: 4 additions & 1 deletion packages/ui-calendar/src/Calendar/v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ class Calendar extends Component<CalendarProps, CalendarState> {
this._weekdayHeaderIds = (
this.props.renderWeekdayLabels || this.defaultWeekdays
).reduce((ids: Record<number, string>, _label, i) => {
return { ...ids, [i]: this.props.deterministicId!('weekday-header') }
// The instance name must vary per weekday: `deterministicId` derives the
// id from the name, so calling it repeatedly with the same name returns
// the same id (it is no longer a counter) and every header would collide.
return { ...ids, [i]: this.props.deterministicId!(`weekday-header-${i}`) }
}, {})
this.state = this.calculateState(
this.locale(),
Expand Down
13 changes: 4 additions & 9 deletions packages/ui-form-field/src/FormFieldLayout/v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* SOFTWARE.
*/

import { forwardRef, useEffect, useState, useCallback } from 'react'
import { forwardRef, useCallback } from 'react'
import { hasVisibleChildren } from '@instructure/ui-a11y-utils'
import { omitProps, useDeterministicId } from '@instructure/ui-react-utils'

Expand Down Expand Up @@ -62,18 +62,13 @@ const FormFieldLayout = forwardRef<Element, FormFieldLayoutProps>(
...rest
} = props

// Deterministic ID generation
const [deterministicId, setDeterministicId] = useState<string | undefined>()
const getId = useDeterministicId('FormFieldLayout')
useEffect(() => {
setDeterministicId(getId())
}, [])
// SSR-safe deterministic ID generation (stable across server/client render)
const deterministicId = useDeterministicId('FormFieldLayout')()

const messagesId = messagesIdProp || deterministicId
// Give the label an id so controls can reference only the label text via
// `aria-labelledby`, keeping messages out of the accessible name.
const labelId =
labelIdProp || (deterministicId ? `${deterministicId}-Label` : undefined)
const labelId = labelIdProp || `${deterministicId}-Label`

// Filter out error and success messages when disabled or readOnly
const filteredMessages =
Expand Down
9 changes: 2 additions & 7 deletions packages/ui-number-input/src/NumberInput/v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
useCallback,
useImperativeHandle,
forwardRef,
useEffect,
type RefObject
} from 'react'
import keycode from 'keycode'
Expand Down Expand Up @@ -103,12 +102,8 @@ const NumberInput = forwardRef<NumberInputHandle, NumberInputProps>(
const containerRef = useRef<Element | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)

// Deterministic ID generation
const [deterministicId, setDeterministicId] = useState<string | undefined>()
const getId = useDeterministicId('NumberInput')
useEffect(() => {
setDeterministicId(getId())
}, []) // Empty deps array - only run once on mount
// SSR-safe deterministic ID generation (stable across server/client render)
const deterministicId = useDeterministicId('NumberInput')()
const id = idProp || deterministicId

// Computed values
Expand Down
9 changes: 2 additions & 7 deletions packages/ui-radio-input/src/RadioInput/v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
useImperativeHandle,
forwardRef,
useCallback,
useEffect,
type RefObject
} from 'react'

Expand Down Expand Up @@ -77,12 +76,8 @@ const RadioInput = forwardRef<RadioInputHandle, RadioInputProps>(
const containerRef = useRef<HTMLDivElement | null>(null)
const inputElementRef = useRef<HTMLInputElement | null>(null)

// Deterministic ID generation
const [deterministicId, setDeterministicId] = useState<string | undefined>()
const getId = useDeterministicId('RadioInput')
useEffect(() => {
setDeterministicId(getId())
}, [])
// SSR-safe deterministic ID generation (stable across server/client render)
const deterministicId = useDeterministicId('RadioInput')()
const id = idProp || deterministicId

// Computed checked value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,23 @@
import React from 'react'
import type { DeterministicIdProviderValue } from './DeterministicIdContextProvider'

declare global {
var __INSTUI_GLOBAL_INSTANCE_COUNTER__: Map<string, number>
}
const instUIInstanceCounter = '__INSTUI_GLOBAL_INSTANCE_COUNTER__'

/**
* Returns a global (window-level) instance counter map.
* This needs to be global so that IDs are unique across application instances,
* e.g. in module federation applications are loaded as a .js blob, this method
* makes sure that there are no duplicate IDs across instances.
* @deprecated Id generation no longer uses an instance counter map. Ids are now
* generated with React's built-in `useId` (see `useDeterministicId` /
* `withDeterministicId`), which is SSR-safe and hydration-stable without any
* shared counter. This map is retained only for backwards compatibility and is
* no longer read; it will be removed in the next major version.
*/
function generateInstanceCounterMap(): DeterministicIdProviderValue {
if (globalThis[instUIInstanceCounter]) {
return globalThis[instUIInstanceCounter]
}
const map = new Map<string, number>()
globalThis[instUIInstanceCounter] = map
return map
}

const defaultDeterministicIDMap = generateInstanceCounterMap()
const defaultDeterministicIDMap: DeterministicIdProviderValue = new Map<
string,
number
>()

/**
* @deprecated This context is no longer consumed by the id generation utilities
* and has no effect. It is retained only for backwards compatibility and will be
* removed in the next major version.
*/
const DeterministicIdContext = React.createContext(defaultDeterministicIDMap)

export { DeterministicIdContext, defaultDeterministicIDMap }
Loading
Loading