-
Notifications
You must be signed in to change notification settings - Fork 558
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
Introducing an RFC for isomorphic IDs #32
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,315 @@ | ||
- Start Date: 2018-03-08 | ||
- RFC PR: (leave this empty) | ||
- React Issue: (leave this empty) | ||
|
||
# Summary | ||
|
||
Many accessibility features of HTML require linking elements using IDs. There is | ||
currently no easy and consistent way to programmatically handle these IDs in a | ||
way that survives the client hydration process when server-side rendering is | ||
involved. | ||
|
||
The key issue to solve here is a way to express relationships between | ||
HTML elements that are identified by the ID attribute in a way that allows that | ||
relationship to be valid and deterministic on both the server-side and | ||
client-side render of the tree. | ||
|
||
# Basic example | ||
|
||
Consider a simple Checkbox component. | ||
|
||
```js | ||
// Checkbox.js | ||
class Checkbox extends React.Component { | ||
|
||
render() { | ||
const { label, value, name, disabled } = this.props; | ||
const id = generateUniqueId(); // Any UUID-generating function. | ||
|
||
return ( | ||
<div> | ||
<input | ||
id={id} | ||
type="checkbox" | ||
name={name} | ||
value={value ? value : label} | ||
disabled={disabled || false} | ||
/> | ||
<label htmlFor={id}>{label}</label> | ||
</div> | ||
); | ||
} | ||
|
||
} | ||
|
||
// App.js | ||
<Checkbox label="My Label" value="my-label" name="MyLabel" disabled={false} /> | ||
``` | ||
|
||
The current state of the isomorphic world would result in something to the | ||
effect of: | ||
|
||
``` | ||
// Browser console. | ||
Warning: prop `id` did not match. Server: "Checkbox:..." Client: "Checkbox:...." | ||
``` | ||
|
||
```html | ||
<!-- Server output --> | ||
<div> | ||
<input | ||
id="unique-id-1" | ||
type="checkbox" | ||
name="MyLabel" | ||
value="my-label" | ||
/> | ||
<label htmlFor="unique-id-1">My Label</label> | ||
</div> | ||
|
||
<!-- Client output --> | ||
<div> | ||
<input | ||
id="unique-id-3" | ||
type="checkbox" | ||
name="MyLabel" | ||
value="my-label" | ||
/> | ||
<label htmlFor="unique-id-3">My Label</label> | ||
</div> | ||
``` | ||
|
||
**The goal of this RFC is to ensure that there is a way to guarantee that the | ||
IDs used by the server and client are the same.** | ||
|
||
# Motivation | ||
|
||
In order to successfully link the `label` with the `input`, the `input` element | ||
must have an ID and that same ID must be supplied to the `label` in the `html-for` | ||
attribute. | ||
|
||
From this component's perspective, the relationship between these two elements | ||
is always encapsulated inside the component. If only considering client-side | ||
rendering, the simple solution would be to generate a random, unique ID, store | ||
it as an instance variable, and assign that value to both elements. | ||
|
||
This procedure breaks down when introducing server-side rendering because the | ||
value will be different between the server's execution and the client's execution | ||
resulting in an invariance violation (see the Basic Example above). | ||
|
||
To work around this, the developer must break encapsulation and supply a unique | ||
value for every instance. This introduces additional human input and puts an | ||
unnecessary strain on developers to consider and coordinate uniqueness of IDs | ||
for these types of components. | ||
|
||
There should be no need for users of this component to consider global uniqueness | ||
when the component itself should know everything it needs to know to render | ||
correctly. | ||
|
||
# Detailed design | ||
|
||
There are probably several ways that this could be addressed. The simplest from a | ||
conceptual standpoint would be to ensure that the render path is deterministic so | ||
that an auto-increment ID generation approach would result in the same number | ||
being assigned to each ID. | ||
|
||
This is not a great solution. First off, it will probably result in a significant | ||
performance penalty which would be unacceptable. Secondly, it does not take into | ||
account potential (intentional) differences in what is rendered server-side and | ||
client-side. | ||
|
||
A more robust solution would be to take advantage of the hydration process to | ||
absorb attributes included in the server-rendered output into the hydrated DOM | ||
before it is merged into the browser's DOM. | ||
|
||
Let us suppose that we had a way to reserve an identifier from React. Something | ||
akin to the following: | ||
|
||
```js | ||
// Checkbox.js | ||
class Checkbox extends React.Component { | ||
|
||
constructor() { | ||
this.reserveUniqueIdentifier(); // <------------------------------------NEW | ||
} | ||
|
||
render() { | ||
const { label, value, name, disabled } = this.props; | ||
const id = this.reservedIdentifier(); // <----------------------------- NEW | ||
|
||
return ( | ||
<div> | ||
<input | ||
id={id} | ||
type="checkbox" | ||
name={name} | ||
value={value ? value : label} | ||
disabled={disabled || false} | ||
/> | ||
<label htmlFor={id}>{label}</label> | ||
</div> | ||
); | ||
} | ||
|
||
} | ||
|
||
// App.js | ||
<Checkbox label="My Label" value="my-label" name="MyLabel" disabled={false} /> | ||
``` | ||
|
||
On the server, this component would now be rendered as: | ||
|
||
```html | ||
<div data-react-reserved-id="6CB96365-C679-415A-8FDE-8CD52C2AA678"> | ||
<input | ||
id="6CB96365-C679-415A-8FDE-8CD52C2AA678" | ||
type="checkbox" | ||
name="MyLabel" | ||
value="my-label" | ||
/> | ||
<label htmlFor="6CB96365-C679-415A-8FDE-8CD52C2AA678">My Label</label> | ||
</div> | ||
``` | ||
|
||
> NOTE: Calling `this.reservedIdentifier` without (or before) calling | ||
> `this.reserveUniqueIdentifier` should result in an exception being thrown. | ||
|
||
By calling `this.reserveUniqueIdentifier` in the constructor, an additional | ||
property can be added to the object to tag it as having a reserved ID. This will | ||
be useful later for quickly determining if additional steps are needed during | ||
hydration. | ||
|
||
Example: | ||
```js | ||
const checkbox = <Checkbox label="My Label" value="my-label" name="MyLabel" disabled={false} />; | ||
console.log(checkbox.hasReservedUniqueIdentifier); | ||
// => true | ||
``` | ||
|
||
During hydration, whenever a component is encountered that has a reserved ID, | ||
it is scheduled for an update before the shadow DOM is merged with the browser's | ||
DOM. That element's position in the tree is determined and the corresponding | ||
element in the browser's DOM is retrieved, the `data-react-reserved-id` attribute | ||
is read, and its value is set as the reserved ID for the hydrated component. | ||
Then that component is enqueued for an update. Finally, after all components with | ||
reserved IDs have been updated, the hydrated DOM can be merged with the browser's | ||
DOM in the usual fashion. | ||
|
||
In the case where a corresponding element is not found in the browser's DOM | ||
(meaning that is was omitted during server-side render), then a new unique ID | ||
is generated and that new ID is used by the hydrated component. | ||
|
||
Furthermore, since this process is part of the hydration process then these | ||
additional checks will not be performed if not intentionally hydrating SSR output | ||
ensuring that there is no impact on the `render` path. | ||
|
||
### Step-by-step procedure | ||
|
||
#### Server procedure | ||
|
||
1. `ReactDOM.renderToStaticMarkup` is called. | ||
1. A `Checkbox` component is encountered. | ||
1. The `Checkbox` constructor calls `this.reserveUniqueIdentifier()`. | ||
1. `reserveUniqueIdentifier` determines that it is running on the server using | ||
`fbjs/lib/ExecutionEnvironment`. | ||
1. A unique ID is generated (for example using `UUIDv4`) and set to a private | ||
property on the instance. (for example `this.__reservedIdentifier`) | ||
1. Property `hasReservedUniqueIdentifier` is set to `true` on the instance. | ||
1. `Checkbox` component's `render` method is called. | ||
1. The reserved ID is retrieved by the `this.reservedIdentifier()` method | ||
that retrieves the value of the private instance variable. | ||
1. The `data-react-reserved-id` HTML attribute is added to the root element | ||
of the component with the value of `this.reservedIdentifier()`. | ||
1. HTML is sent to the client. | ||
|
||
#### Client procedure | ||
|
||
1. `ReactDOM.hydrate` is called. | ||
1. A data structure is created to store references to elements that have reserved | ||
identifiers. | ||
1. A component where `hasReservedUniqueIdentifier` is `true`. | ||
1. A reference to the Component instance is stored in the above data structure. | ||
1. After the whole client DOM has been processed: | ||
1. Iterate over instances stored in the above data structure. | ||
1. Determine the DOM path of the element in the shadow DOM. | ||
1. Look up the equivalent DOM path in the browser's DOM. | ||
1. If an element is found: | ||
1. Retrieve the value of `data-react-reserved-id`. | ||
1. If an element is not found: | ||
1. Generate a new ID. | ||
1. Set the value of the instance's `__reservedIdentifier` private property. | ||
1. Enqueue that instance for an update. | ||
1. Perform queued in the shadow DOM. | ||
1. Merge shadow DOM with browser DOM in normal fashion. | ||
|
||
#### Alternative client procedure | ||
|
||
1. `ReactDOM.hydrate` is called. | ||
1. A component where `hasReservedUniqueIdentifier` is `true`. | ||
1. Look up the equivalent DOM path in the browser's DOM. | ||
1. If an element is found: | ||
1. Retrieve the value of `data-react-reserved-id`. | ||
1. If an element is not found: | ||
1. Generate a new ID. | ||
1. Set the value of the instance's `__reservedIdentifier` private property. | ||
1. Continue processing the tree. | ||
1. Merge shadow DOM with browser DOM in normal fashion. | ||
|
||
|
||
# Drawbacks | ||
|
||
The major drawback with this approach is that it introduces some potentially | ||
expensive DOM queries to match up the server-generated DOM with the hydrated | ||
client DOM as well as an extra step during hydration that causes updates to be | ||
made before the first client DOM update is made. To be successful, this approach | ||
should not make a noticeable impact on performance. | ||
|
||
The design outlined above is intended to have a very light impact on the existing | ||
architecture but due to my lack of familiarness with the internals of React, there | ||
will undoubtedly be issues that I have not anticipated. | ||
|
||
# Alternatives | ||
|
||
Currently available techniques are lacking since they all require the client code | ||
to be merged with the DOM before queries can be made to extract any attributes | ||
that have been left by the server-rendered output. This results in either lost | ||
information (overwritten by the client render) or a double-render which can cause | ||
some interesting (and unwanted) side-effects. | ||
|
||
The best alternative that I've discovered so far is to set `id` as a required | ||
prop for these kinds of components, requiring the user to supply a valid value. | ||
This is also less-than-desirable for reasons outlined in the Motivation section | ||
above. | ||
|
||
# Adoption strategy | ||
|
||
This would be an opt-in feature and should only affect individual components | ||
that ask for a reserved ID. It should have no side-effects for components that | ||
don't opt in. It should also not have any effect outside of the `hydrate` path. | ||
|
||
# How we teach this | ||
|
||
Since there is special behavior associated with these reserved IDs, care must be | ||
given when writing the documentation to distinguish this feature from other IDs | ||
already present in HTML and React. Hence the suggestion of calling them | ||
`reservedIdentifier` to avoid confusion with the more ubiquitous `id`. | ||
|
||
No re-organization needed for existing documentation. There would be a few | ||
additional entries to the documentation for `Component` as well as maybe a | ||
tutorial page. | ||
|
||
# Unresolved questions | ||
|
||
This RFC represents a high-level proposal for how the goal might be achieved. | ||
Due to my unfamiliarity with the internals of React, there are undoubtedly still | ||
many questions that would need to be answered. Some examples off the top of my | ||
head: | ||
|
||
- Can the methodology described above for absorbing HTML attributes from the | ||
server-rendered markup be accomplished during the hydration phase before the | ||
first DOM merge? | ||
|
||
- Would this approach cover all use cases? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't think about using this RFC for threading before I openened facebook/react#18594. TL;DR: What about attributes that allow multiple ids (i.e. attributes allowing IDREFS) e.g. |
||
|
||
- Are there unintended and unwanted side-effects? | ||
|
||
- Does this approach work with asynchronous rendering? |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This RFC does not mention how (dynamic) children in composite widgets would communicate their IDs. An example for this would be the tabs widget where a
[role="tabpanel"]
is labelled by its corresponding[role="tab"]
.Given the current implementation of
useOpaqueIdentifier
and the rules of hooks, we would need to create ids for each part of the widget:-- Full example
If we could use
useOpaqueIdentifier
as prefix we could avoid boilerplate by creating a single opaque identifier per widget that is suffixed by the string representation of the tab value.The following hack only works with ReactDOM.render:
-- https://codesandbox.io/s/useopaqueidentifier-as-a-prefix-for-tabs-kke2c?file=/src/App.js:1029-1596
Update: dedicated issue: facebook/react#20822