Skip to content

Commit

Permalink
fix: SSR compatibility for textfield (#195)
Browse files Browse the repository at this point in the history
* fix SSR compatibility for textfield

* Update the useId hook

* Cleanup useId hook

* Still need generateId for combobox
  • Loading branch information
imprashast authored Jan 24, 2024
1 parent 0f008e4 commit 7779237
Show file tree
Hide file tree
Showing 5 changed files with 218 additions and 305 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@types/node": "^20.11.5",
"@types/react": "^17.0.75",
"@types/react-dom": "^17.0.25",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
Expand Down
3 changes: 1 addition & 2 deletions packages/textfield/src/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
} = props;

activateI18n(enMessages, nbMessages, fiMessages);

const id = useId(providedId);

const helpId = helpText ? `${id}__hint` : undefined;
const isInvalid = invalid || error;

Expand Down
18 changes: 13 additions & 5 deletions packages/textfield/stories/Textfield.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { TextField as TroikaTextField } from '../src';
import { TextField as WarpTextField } from '../src';
import { Affix } from '../../_helpers';

const metadata = { title: 'Forms/TextField' };
export default metadata;
export default { title: 'Forms/TextField', component: WarpTextField };

const Template = (args) => <WarpTextField label="Address"
onChange={action('change')}
onFocus={action('focus')}
onBlur={action('blur')} {...args} />;
export const Default = Template.bind({});
Default.args = {
value: 'test',
};

const TextField = (args) => (
<TroikaTextField
<WarpTextField
label="Address"
onChange={action('change')}
onFocus={action('focus')}
Expand Down Expand Up @@ -54,7 +62,7 @@ export const longLabelPrefix = () => (
<TextField className="[--w-prefix-width:90px]" value="With some value">
<Affix prefix label="Long prefix" />
</TextField>
);
);

export const clearSuffix = () => (
<TextField>
Expand Down
96 changes: 4 additions & 92 deletions packages/utils/src/useId.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,9 @@
/*
* Let's see if we can make sense of why this hook exists and its
* implementation.
*
* Some background:
* 1. Accessibiliy APIs rely heavily on element IDs
* 2. Requiring developers to put IDs on every element in Reach UI is both
* cumbersome and error-prone
* 3. With a component model, we can generate IDs for them!
*
* Solution 1: Generate random IDs.
*
* This works great as long as you don't server render your app. When React (in
* the client) tries to reuse the markup from the server, the IDs won't match
* and React will then recreate the entire DOM tree.
*
* Solution 2: Increment an integer
*
* This sounds great. Since we're rendering the exact same tree on the server
* and client, we can increment a counter and get a deterministic result between
* client and server. Also, JS integers can go up to nine-quadrillion. I'm
* pretty sure the tab will be closed before an app never needs
* 10 quadrillion IDs!
*
* Problem solved, right?
*
* Ah, but there's a catch! React's concurrent rendering makes this approach
* non-deterministic. While the client and server will end up with the same
* elements in the end, depending on suspense boundaries (and possibly some user
* input during the initial render) the incrementing integers won't always match
* up.
*
* Solution 3: Don't use IDs at all on the server; patch after first render.
*
* What we've done here is solution 2 with some tricks. With this approach, the
* ID returned is an empty string on the first render. This way the server and
* client have the same markup no matter how wild the concurrent rendering may
* have gotten.
*
* After the render, we patch up the components with an incremented ID. It
* doesn't have to be incremented, though; we could do something random, but
* incrementing a number is probably the cheapest thing we can do.
*
* @TODO Note that this should be axed if useOpaqueIdentifier becomes stable
* https://github.com/facebook/react/pull/17322
*/

import { useState, useEffect } from 'react';
import { useLayoutEffect } from './useIsomorphicLayoutEffect.js';

let serverHandoffComplete = false;
// Generate a pseudorandom seed to prefix to each generated id instead of solely relying on the counter.
// We don't want id collisions across React roots/podlets.
const prefix = generateId();

let id = 0;
const genId = () => {
id = id + 1;
return prefix + id;
};
import { useId as reactUseId } from 'react';

export const useId = (hasFallback?): string => {
/*
* If this instance isn't part of the initial render, we don't have to do the
* double render/patch-up dance. We can just generate the ID and return it.
*/
const initialId = hasFallback || (serverHandoffComplete ? genId() : null);

const [id, setId] = useState(initialId);

useLayoutEffect(() => {
if (id === null) {
/*
* Patch the ID after render. We do this in `useLayoutEffect` to avoid any
* rendering flicker, though it'll make the first render slower (unlikely
* to matter, but you're welcome to measure your app and let us know if
* it's a problem).
*/
setId(genId());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
if (serverHandoffComplete === false) {
/*
* Flag all future uses of `useId` to skip the update dance. This is in
* `useEffect` because it goes after `useLayoutEffect`, ensuring we don't
* accidentally bail out of the patch-up dance prematurely.
*/
serverHandoffComplete = true;
}
}, []);
return id;
// reactUseId returns a string that includes colons (:), e.g., :r0:, :r1:, etc.
// This string is NOT supported in CSS selectors. Hence the replace.
return hasFallback ?? reactUseId().replace(/:/g, '');
};

/**
Expand Down
Loading

0 comments on commit 7779237

Please sign in to comment.