Skip to content

Commit 67f4fb0

Browse files
authored
Allow forms to skip hydration of hidden inputs (#26735)
This allows us to emit extra ephemeral data that will only be used on server rendered forms. First I refactored the shouldSkip functions to now just do that work inside the canHydrate methods. This makes the Config bindings a little less surface area but it also helps us optimize a bit since we now can look at the code together and find shared paths. canHydrate returns the instance if it matches, that used to just be there to refine the type but it can also be used to just return a different instance later that we find. If we don't find one, we'll bail out and error regardless so no need to skip past anything.
1 parent 8ea96ef commit 67f4fb0

File tree

5 files changed

+202
-209
lines changed

5 files changed

+202
-209
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 153 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
enableHostSingletons,
9090
enableTrustedTypesIntegration,
9191
diffInCommitPhase,
92+
enableFormActions,
9293
} from 'shared/ReactFeatureFlags';
9394
import {
9495
HostComponent,
@@ -1038,160 +1039,182 @@ export function isHydratableText(text: string): boolean {
10381039
return text !== '';
10391040
}
10401041

1041-
export function shouldSkipHydratableForInstance(
1042+
export function canHydrateInstance(
10421043
instance: HydratableInstance,
10431044
type: string,
10441045
props: Props,
1045-
): boolean {
1046-
if (instance.nodeType !== ELEMENT_NODE) {
1047-
// This is a suspense boundary or Text node.
1048-
// Suspense Boundaries are never expected to be injected by 3rd parties. If we see one it should be matched
1049-
// and this is a hydration error.
1050-
// Text Nodes are also not expected to be injected by 3rd parties. This is less of a guarantee for <body>
1051-
// but it seems reasonable and conservative to reject this as a hydration error as well
1052-
return false;
1053-
} else if (
1054-
instance.nodeName.toLowerCase() !== type.toLowerCase() ||
1055-
isMarkedHoistable(instance)
1056-
) {
1057-
// We are either about to
1058-
return true;
1059-
} else {
1060-
// We have an Element with the right type.
1046+
inRootOrSingleton: boolean,
1047+
): null | Instance {
1048+
while (instance.nodeType === ELEMENT_NODE) {
10611049
const element: Element = (instance: any);
10621050
const anyProps = (props: any);
1063-
1064-
// We are going to try to exclude it if we can definitely identify it as a hoisted Node or if
1065-
// we can guess that the node is likely hoisted or was inserted by a 3rd party script or browser extension
1066-
// using high entropy attributes for certain types. This technique will fail for strange insertions like
1067-
// extension prepending <div> in the <body> but that already breaks before and that is an edge case.
1068-
switch (type) {
1069-
// case 'title':
1070-
//We assume all titles are matchable. You should only have one in the Document, at least in a hoistable scope
1071-
// and if you are a HostComponent with type title we must either be in an <svg> context or this title must have an `itemProp` prop.
1072-
case 'meta': {
1073-
// The only way to opt out of hoisting meta tags is to give it an itemprop attribute. We assume there will be
1074-
// not 3rd party meta tags that are prepended, accepting the cases where this isn't true because meta tags
1075-
// are usually only functional for SSR so even in a rare case where we did bind to an injected tag the runtime
1076-
// implications are minimal
1077-
if (!element.hasAttribute('itemprop')) {
1078-
// This is a Hoistable
1079-
return true;
1080-
}
1081-
break;
1082-
}
1083-
case 'link': {
1084-
// Links come in many forms and we do expect 3rd parties to inject them into <head> / <body>. We exclude known resources
1085-
// and then use high-entroy attributes like href which are almost always used and almost always unique to filter out unlikely
1086-
// matches.
1087-
const rel = element.getAttribute('rel');
1088-
if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) {
1089-
// This is a stylesheet resource
1090-
return true;
1091-
} else if (
1092-
rel !== anyProps.rel ||
1093-
element.getAttribute('href') !==
1094-
(anyProps.href == null ? null : anyProps.href) ||
1095-
element.getAttribute('crossorigin') !==
1096-
(anyProps.crossOrigin == null ? null : anyProps.crossOrigin) ||
1097-
element.getAttribute('title') !==
1098-
(anyProps.title == null ? null : anyProps.title)
1051+
if (element.nodeName.toLowerCase() !== type.toLowerCase()) {
1052+
if (!inRootOrSingleton || !enableHostSingletons) {
1053+
// Usually we error for mismatched tags.
1054+
if (
1055+
enableFormActions &&
1056+
element.nodeName === 'INPUT' &&
1057+
(element: any).type === 'hidden'
10991058
) {
1100-
// rel + href should usually be enough to uniquely identify a link however crossOrigin can vary for rel preconnect
1101-
// and title could vary for rel alternate
1102-
return true;
1059+
// If we have extra hidden inputs, we don't mismatch. This allows us to embed
1060+
// extra form data in the original form.
1061+
} else {
1062+
return null;
11031063
}
1104-
break;
11051064
}
1106-
case 'style': {
1107-
// Styles are hard to match correctly. We can exclude known resources but otherwise we accept the fact that a non-hoisted style tags
1108-
// in <head> or <body> are likely never going to be unmounted given their position in the document and the fact they likely hold global styles
1109-
if (element.hasAttribute('data-precedence')) {
1110-
// This is a style resource
1111-
return true;
1112-
}
1113-
break;
1065+
// In root or singleton parents we skip past mismatched instances.
1066+
} else if (!inRootOrSingleton || !enableHostSingletons) {
1067+
// Match
1068+
if (
1069+
enableFormActions &&
1070+
type === 'input' &&
1071+
(element: any).type === 'hidden' &&
1072+
anyProps.type !== 'hidden'
1073+
) {
1074+
// Skip past hidden inputs unless that's what we're looking for. This allows us
1075+
// embed extra form data in the original form.
1076+
} else {
1077+
return element;
11141078
}
1115-
case 'script': {
1116-
// Scripts are a little tricky, we exclude known resources and then similar to links try to use high-entropy attributes
1117-
// to reject poor matches. One challenge with scripts are inline scripts. We don't attempt to check text content which could
1118-
// in theory lead to a hydration error later if a 3rd party injected an inline script before the React rendered nodes.
1119-
// Falling back to client rendering if this happens should be seemless though so we will try this hueristic and revisit later
1120-
// if we learn it is problematic
1121-
const srcAttr = element.getAttribute('src');
1122-
if (
1123-
srcAttr &&
1124-
element.hasAttribute('async') &&
1125-
!element.hasAttribute('itemprop')
1126-
) {
1127-
// This is an async script resource
1128-
return true;
1129-
} else if (
1130-
srcAttr !== (anyProps.src == null ? null : anyProps.src) ||
1131-
element.getAttribute('type') !==
1132-
(anyProps.type == null ? null : anyProps.type) ||
1133-
element.getAttribute('crossorigin') !==
1134-
(anyProps.crossOrigin == null ? null : anyProps.crossOrigin)
1135-
) {
1136-
// This script is for a different src
1137-
return true;
1079+
} else if (isMarkedHoistable(element)) {
1080+
// We've already claimed this as a hoistable which isn't hydrated this way so we skip past it.
1081+
} else {
1082+
// We have an Element with the right type.
1083+
1084+
// We are going to try to exclude it if we can definitely identify it as a hoisted Node or if
1085+
// we can guess that the node is likely hoisted or was inserted by a 3rd party script or browser extension
1086+
// using high entropy attributes for certain types. This technique will fail for strange insertions like
1087+
// extension prepending <div> in the <body> but that already breaks before and that is an edge case.
1088+
switch (type) {
1089+
// case 'title':
1090+
//We assume all titles are matchable. You should only have one in the Document, at least in a hoistable scope
1091+
// and if you are a HostComponent with type title we must either be in an <svg> context or this title must have an `itemProp` prop.
1092+
case 'meta': {
1093+
// The only way to opt out of hoisting meta tags is to give it an itemprop attribute. We assume there will be
1094+
// not 3rd party meta tags that are prepended, accepting the cases where this isn't true because meta tags
1095+
// are usually only functional for SSR so even in a rare case where we did bind to an injected tag the runtime
1096+
// implications are minimal
1097+
if (!element.hasAttribute('itemprop')) {
1098+
// This is a Hoistable
1099+
break;
1100+
}
1101+
return element;
1102+
}
1103+
case 'link': {
1104+
// Links come in many forms and we do expect 3rd parties to inject them into <head> / <body>. We exclude known resources
1105+
// and then use high-entroy attributes like href which are almost always used and almost always unique to filter out unlikely
1106+
// matches.
1107+
const rel = element.getAttribute('rel');
1108+
if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) {
1109+
// This is a stylesheet resource
1110+
break;
1111+
} else if (
1112+
rel !== anyProps.rel ||
1113+
element.getAttribute('href') !==
1114+
(anyProps.href == null ? null : anyProps.href) ||
1115+
element.getAttribute('crossorigin') !==
1116+
(anyProps.crossOrigin == null ? null : anyProps.crossOrigin) ||
1117+
element.getAttribute('title') !==
1118+
(anyProps.title == null ? null : anyProps.title)
1119+
) {
1120+
// rel + href should usually be enough to uniquely identify a link however crossOrigin can vary for rel preconnect
1121+
// and title could vary for rel alternate
1122+
break;
1123+
}
1124+
return element;
1125+
}
1126+
case 'style': {
1127+
// Styles are hard to match correctly. We can exclude known resources but otherwise we accept the fact that a non-hoisted style tags
1128+
// in <head> or <body> are likely never going to be unmounted given their position in the document and the fact they likely hold global styles
1129+
if (element.hasAttribute('data-precedence')) {
1130+
// This is a style resource
1131+
break;
1132+
}
1133+
return element;
1134+
}
1135+
case 'script': {
1136+
// Scripts are a little tricky, we exclude known resources and then similar to links try to use high-entropy attributes
1137+
// to reject poor matches. One challenge with scripts are inline scripts. We don't attempt to check text content which could
1138+
// in theory lead to a hydration error later if a 3rd party injected an inline script before the React rendered nodes.
1139+
// Falling back to client rendering if this happens should be seemless though so we will try this hueristic and revisit later
1140+
// if we learn it is problematic
1141+
const srcAttr = element.getAttribute('src');
1142+
if (
1143+
srcAttr &&
1144+
element.hasAttribute('async') &&
1145+
!element.hasAttribute('itemprop')
1146+
) {
1147+
// This is an async script resource
1148+
break;
1149+
} else if (
1150+
srcAttr !== (anyProps.src == null ? null : anyProps.src) ||
1151+
element.getAttribute('type') !==
1152+
(anyProps.type == null ? null : anyProps.type) ||
1153+
element.getAttribute('crossorigin') !==
1154+
(anyProps.crossOrigin == null ? null : anyProps.crossOrigin)
1155+
) {
1156+
// This script is for a different src
1157+
break;
1158+
}
1159+
return element;
1160+
}
1161+
default: {
1162+
// We have excluded the most likely cases of mismatch between hoistable tags, 3rd party script inserted tags,
1163+
// and browser extension inserted tags. While it is possible this is not the right match it is a decent hueristic
1164+
// that should work in the vast majority of cases.
1165+
return element;
11381166
}
1139-
break;
11401167
}
11411168
}
1142-
// We have excluded the most likely cases of mismatch between hoistable tags, 3rd party script inserted tags,
1143-
// and browser extension inserted tags. While it is possible this is not the right match it is a decent hueristic
1144-
// that should work in the vast majority of cases.
1145-
return false;
1146-
}
1147-
}
1148-
1149-
export function shouldSkipHydratableForTextInstance(
1150-
instance: HydratableInstance,
1151-
): boolean {
1152-
return instance.nodeType === ELEMENT_NODE;
1153-
}
1154-
1155-
export function shouldSkipHydratableForSuspenseInstance(
1156-
instance: HydratableInstance,
1157-
): boolean {
1158-
return instance.nodeType === ELEMENT_NODE;
1159-
}
1160-
1161-
export function canHydrateInstance(
1162-
instance: HydratableInstance,
1163-
type: string,
1164-
props: Props,
1165-
): null | Instance {
1166-
if (
1167-
instance.nodeType !== ELEMENT_NODE ||
1168-
instance.nodeName.toLowerCase() !== type.toLowerCase()
1169-
) {
1170-
return null;
1171-
} else {
1172-
return ((instance: any): Instance);
1169+
const nextInstance = getNextHydratableSibling(element);
1170+
if (nextInstance === null) {
1171+
break;
1172+
}
1173+
instance = nextInstance;
11731174
}
1175+
// This is a suspense boundary or Text node or we got the end.
1176+
// Suspense Boundaries are never expected to be injected by 3rd parties. If we see one it should be matched
1177+
// and this is a hydration error.
1178+
// Text Nodes are also not expected to be injected by 3rd parties. This is less of a guarantee for <body>
1179+
// but it seems reasonable and conservative to reject this as a hydration error as well
1180+
return null;
11741181
}
11751182

11761183
export function canHydrateTextInstance(
11771184
instance: HydratableInstance,
11781185
text: string,
1186+
inRootOrSingleton: boolean,
11791187
): null | TextInstance {
1188+
// Empty strings are not parsed by HTML so there won't be a correct match here.
11801189
if (text === '') return null;
11811190

1182-
if (instance.nodeType !== TEXT_NODE) {
1183-
// Empty strings are not parsed by HTML so there won't be a correct match here.
1184-
return null;
1191+
while (instance.nodeType !== TEXT_NODE) {
1192+
if (!inRootOrSingleton || !enableHostSingletons) {
1193+
return null;
1194+
}
1195+
const nextInstance = getNextHydratableSibling(instance);
1196+
if (nextInstance === null) {
1197+
return null;
1198+
}
1199+
instance = nextInstance;
11851200
}
11861201
// This has now been refined to a text node.
11871202
return ((instance: any): TextInstance);
11881203
}
11891204

11901205
export function canHydrateSuspenseInstance(
11911206
instance: HydratableInstance,
1207+
inRootOrSingleton: boolean,
11921208
): null | SuspenseInstance {
1193-
if (instance.nodeType !== COMMENT_NODE) {
1194-
return null;
1209+
while (instance.nodeType !== COMMENT_NODE) {
1210+
if (!inRootOrSingleton || !enableHostSingletons) {
1211+
return null;
1212+
}
1213+
const nextInstance = getNextHydratableSibling(instance);
1214+
if (nextInstance === null) {
1215+
return null;
1216+
}
1217+
instance = nextInstance;
11951218
}
11961219
// This has now been refined to a suspense node.
11971220
return ((instance: any): SuspenseInstance);
@@ -1416,12 +1439,14 @@ export function commitHydratedSuspenseInstance(
14161439
retryIfBlockedOn(suspenseInstance);
14171440
}
14181441

1419-
// @TODO remove this function once float lands and hydrated tail nodes
1420-
// are controlled by HostSingleton fibers
14211442
export function shouldDeleteUnhydratedTailInstances(
14221443
parentType: string,
14231444
): boolean {
1424-
return parentType !== 'head' && parentType !== 'body';
1445+
return (
1446+
(enableHostSingletons ||
1447+
(parentType !== 'head' && parentType !== 'body')) &&
1448+
(!enableFormActions || parentType !== 'form')
1449+
);
14251450
}
14261451

14271452
export function didNotMatchHydratedContainerTextInstance(

packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,4 +695,41 @@ describe('ReactDOMServerHydration', () => {
695695
);
696696
}
697697
});
698+
699+
// @gate enableFormActions
700+
it('allows rendering extra hidden inputs in a form', async () => {
701+
const element = document.createElement('div');
702+
element.innerHTML =
703+
'<form>' +
704+
'<input type="hidden" /><input type="hidden" name="a" value="A" />' +
705+
'<input type="hidden" /><input type="submit" name="b" value="B" />' +
706+
'<input type="hidden" /><button name="c" value="C"></button>' +
707+
'<input type="hidden" />' +
708+
'</form>';
709+
const form = element.firstChild;
710+
const ref = React.createRef();
711+
const a = React.createRef();
712+
const b = React.createRef();
713+
const c = React.createRef();
714+
await act(async () => {
715+
ReactDOMClient.hydrateRoot(
716+
element,
717+
<form ref={ref}>
718+
<input type="hidden" name="a" value="A" ref={a} />
719+
<input type="submit" name="b" value="B" ref={b} />
720+
<button name="c" value="C" ref={c} />
721+
</form>,
722+
);
723+
});
724+
725+
// The content should not have been client rendered.
726+
expect(ref.current).toBe(form);
727+
728+
expect(a.current.name).toBe('a');
729+
expect(a.current.value).toBe('A');
730+
expect(b.current.name).toBe('b');
731+
expect(b.current.value).toBe('B');
732+
expect(c.current.name).toBe('c');
733+
expect(c.current.value).toBe('C');
734+
});
698735
});

packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ export const getNextHydratableSibling = shim;
3131
export const getFirstHydratableChild = shim;
3232
export const getFirstHydratableChildWithinContainer = shim;
3333
export const getFirstHydratableChildWithinSuspenseInstance = shim;
34-
export const shouldSkipHydratableForInstance = shim;
35-
export const shouldSkipHydratableForTextInstance = shim;
36-
export const shouldSkipHydratableForSuspenseInstance = shim;
3734
export const canHydrateInstance = shim;
3835
export const canHydrateTextInstance = shim;
3936
export const canHydrateSuspenseInstance = shim;

0 commit comments

Comments
 (0)