Skip to content
Merged
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
6 changes: 1 addition & 5 deletions scripts/fiber/tests-failing.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js
* should not blow away user-entered text on successful reconnect to an uncontrolled checkbox
* should not blow away user-entered text on successful reconnect to a controlled checkbox
* should not blow away user-selected value on successful reconnect to an uncontrolled select
* should not blow away user-selected value on successful reconnect to an controlled select

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🎉

4 changes: 4 additions & 0 deletions scripts/fiber/tests-passing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,10 @@ src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js
* renders a controlled select with client render on top of good server markup
* should not blow away user-entered text on successful reconnect to an uncontrolled input
* should not blow away user-entered text on successful reconnect to a controlled input
* should not blow away user-entered text on successful reconnect to an uncontrolled checkbox
* should not blow away user-entered text on successful reconnect to a controlled checkbox
* should not blow away user-selected value on successful reconnect to an uncontrolled select
* should not blow away user-selected value on successful reconnect to an controlled select
* renders class child with context with server string render
* renders class child with context with clean client render
* renders class child with context with client render on top of good server markup
Expand Down
9 changes: 6 additions & 3 deletions src/renderers/dom/fiber/ReactDOMFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ var {
setInitialProperties,
diffProperties,
updateProperties,
diffHydratedProperties,
} = ReactDOMFiberComponent;
var {precacheFiberNode, updateFiberProps} = ReactDOMComponentTree;

Expand Down Expand Up @@ -409,19 +410,21 @@ var DOMRenderer = ReactFiberReconciler({
props: Props,
rootContainerInstance: Container,
internalInstanceHandle: Object,
): void {
): null | Array<mixed> {
precacheFiberNode(internalInstanceHandle, instance);
// TODO: Possibly defer this until the commit phase where all the events
// get attached.
updateFiberProps(instance, props);
setInitialProperties(instance, type, props, rootContainerInstance);
return diffHydratedProperties(instance, type, props, rootContainerInstance);
},

hydrateTextInstance(
textInstance: TextInstance,
text: string,
internalInstanceHandle: Object,
): void {
): boolean {
precacheFiberNode(internalInstanceHandle, textInstance);
return textInstance.nodeValue !== text;
},

scheduleAnimationCallback: ReactDOMFrameScheduling.rAF,
Expand Down
138 changes: 133 additions & 5 deletions src/renderers/dom/fiber/ReactDOMFiberComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,10 @@ function setInitialDOMProperties(
isCustomComponentTag: boolean,
): void {
for (var propKey in nextProps) {
var nextProp = nextProps[propKey];
if (!nextProps.hasOwnProperty(propKey)) {
continue;
}
var nextProp = nextProps[propKey];
if (propKey === STYLE) {
if (__DEV__) {
if (nextProp) {
Expand Down Expand Up @@ -406,27 +406,27 @@ var ReactDOMFiberComponent = {
props = rawProps;
break;
case 'input':
ReactDOMFiberInput.mountWrapper(domElement, rawProps);
ReactDOMFiberInput.initWrapperState(domElement, rawProps);
props = ReactDOMFiberInput.getHostProps(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
break;
case 'option':
ReactDOMFiberOption.mountWrapper(domElement, rawProps);
ReactDOMFiberOption.validateProps(domElement, rawProps);
props = ReactDOMFiberOption.getHostProps(domElement, rawProps);
break;
case 'select':
ReactDOMFiberSelect.mountWrapper(domElement, rawProps);
ReactDOMFiberSelect.initWrapperState(domElement, rawProps);
props = ReactDOMFiberSelect.getHostProps(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
break;
case 'textarea':
ReactDOMFiberTextarea.mountWrapper(domElement, rawProps);
ReactDOMFiberTextarea.initWrapperState(domElement, rawProps);
props = ReactDOMFiberTextarea.getHostProps(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
Expand Down Expand Up @@ -462,6 +462,9 @@ var ReactDOMFiberComponent = {
case 'option':
ReactDOMFiberOption.postMountWrapper(domElement, rawProps);
break;
case 'select':
ReactDOMFiberSelect.postMountWrapper(domElement, rawProps);
break;
default:
if (typeof props.onClick === 'function') {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
Expand Down Expand Up @@ -704,6 +707,131 @@ var ReactDOMFiberComponent = {
}
},

diffHydratedProperties(
domElement: Element,
tag: string,
rawProps: Object,
rootContainerElement: Element | Document,
): null | Array<mixed> {
if (__DEV__) {
var isCustomComponentTag = isCustomComponent(tag, rawProps);
validatePropertiesInDevelopment(tag, rawProps);
if (isCustomComponentTag && !didWarnShadyDOM && domElement.shadyRoot) {
warning(
false,
'%s is using shady DOM. Using shady DOM with React can ' +
'cause things to break subtly.',
getCurrentFiberOwnerName() || 'A component',
);
didWarnShadyDOM = true;
}
}

switch (tag) {
case 'audio':
case 'form':
case 'iframe':
case 'img':
case 'image':
case 'link':
case 'object':
case 'source':
case 'video':
case 'details':
trapBubbledEventsLocal(domElement, tag);
break;
case 'input':
ReactDOMFiberInput.initWrapperState(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
break;
case 'option':
ReactDOMFiberOption.validateProps(domElement, rawProps);
break;
case 'select':
ReactDOMFiberSelect.initWrapperState(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
break;
case 'textarea':
ReactDOMFiberTextarea.initWrapperState(domElement, rawProps);
trapBubbledEventsLocal(domElement, tag);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
break;
}

assertValidProps(tag, rawProps, getCurrentFiberOwnerName);

var updatePayload = null;
for (var propKey in rawProps) {
if (!rawProps.hasOwnProperty(propKey)) {
continue;
}
var nextProp = rawProps[propKey];
if (propKey === CHILDREN) {
// For text content children we compare against textContent. This
// might match additional HTML that is hidden when we read it using
// textContent. E.g. "foo" will match "f<span>oo</span>" but that still
// satisfies our requirement. Our requirement is not to produce perfect
// HTML and attributes. Ideally we should preserve structure but it's
// ok not to if the visible content is still enough to indicate what
// even listeners these nodes might be wired up to.
// TODO: Warn if there is more than a single textNode as a child.
// TODO: Should we use domElement.firstChild.nodeValue to compare?
if (typeof nextProp === 'string') {
if (domElement.textContent !== nextProp) {
updatePayload = [CHILDREN, nextProp];
}
} else if (typeof nextProp === 'number') {
if (domElement.textContent !== '' + nextProp) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The use of textContent here is interesting. I'd argue that innerHTML may be more technically correct, as textContent will ignore child elements in the existing DOM tree.

For example, for the JSX <div>My Content</div>, textContent would be fine hydrating <div>My <span style="color: blue">Content</span><input type='text' value='some input value'></div>, whereas innerHTML would not.

You are deliberately not checking everything in the tree, though, and the documentation for textContent on MDN says that it's tends to perform better than innerHTML, so maybe this is intentional? If so, I think a comment is in order.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

innerHTML would be difficult to compare to the string since it will contain escape characters etc. instead of the raw original text encoding representation.

This is interesting though. Since the goal here isn't to preserve the correct document structure and attributes etc, but actually only to preserve text content in terms of how they might line up to critical event handlers this still satisfies that requirement. Even if the text content doesn't fully line up.

I'll add a comment.

Copy link
Copy Markdown
Contributor

@aickin aickin Jun 6, 2017

Choose a reason for hiding this comment

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

Cool.

One other thought I just had: what about when children is an array of text or an array of numbers (or an array of arrays of strings, etc.)? I think this code will fail to fix up the text in that case, right?

Copy link
Copy Markdown
Contributor Author

@sebmarkbage sebmarkbage Jun 6, 2017

Choose a reason for hiding this comment

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

That will go through another pass in Fiber that deals with HostText. This particular pass is only an "optimization" that avoids extra Fibers etc.

hydrateTextInstance will return true if it diffs in those cases. That will schedule a commitTextUpdate to patch it up. (I'm going to move this into a helper in ReactDOMFiberComponent in a follow up diff that deals with warnings.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And hence my comment about still not understanding the Fiber internals. Thanks for your patience; I'll be quiet now. 😁

updatePayload = [CHILDREN, '' + nextProp];
}
}
} else if (registrationNameModules.hasOwnProperty(propKey)) {
if (nextProp) {
ensureListeningTo(rootContainerElement, propKey);
}
}
}

switch (tag) {
case 'input':
// TODO: Make sure we check if this is still unmounted or do any clean
// up necessary since we never stop tracking anymore.
inputValueTracking.trackNode((domElement: any));
ReactDOMFiberInput.postMountWrapper(domElement, rawProps);
break;
case 'textarea':
// TODO: Make sure we check if this is still unmounted or do any clean
// up necessary since we never stop tracking anymore.
inputValueTracking.trackNode((domElement: any));
ReactDOMFiberTextarea.postMountWrapper(domElement, rawProps);
break;
case 'select':
case 'option':
// For input and textarea we current always set the value property at
// post mount to force it to diverge from attributes. However, for
// option and select we don't quite do the same thing and select
// is not resilient to the DOM state changing so we don't do that here.
// TODO: Consider not doing this for input and textarea.
break;
default:
if (typeof rawProps.onClick === 'function') {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
}
break;
}

return updatePayload;
},

restoreControlledState(
domElement: Element,
tag: string,
Expand Down
2 changes: 1 addition & 1 deletion src/renderers/dom/fiber/wrappers/ReactDOMFiberInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ var ReactDOMInput = {
return hostProps;
},

mountWrapper: function(element: Element, props: Object) {
initWrapperState: function(element: Element, props: Object) {
if (__DEV__) {
ReactControlledValuePropTypes.checkPropTypes(
'input',
Expand Down
2 changes: 1 addition & 1 deletion src/renderers/dom/fiber/wrappers/ReactDOMFiberOption.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function flattenChildren(children) {
* Implements an <option> host component that warns when `selected` is set.
*/
var ReactDOMOption = {
mountWrapper: function(element: Element, props: Object) {
validateProps: function(element: Element, props: Object) {
// TODO (yungsters): Remove support for `selected` in <option>.
if (__DEV__) {
warning(
Expand Down
6 changes: 5 additions & 1 deletion src/renderers/dom/fiber/wrappers/ReactDOMFiberSelect.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ var ReactDOMSelect = {
});
},

mountWrapper: function(element: Element, props: Object) {
initWrapperState: function(element: Element, props: Object) {
var node = ((element: any): SelectWithWrapperState);
if (__DEV__) {
checkSelectPropTypes(props);
Expand All @@ -163,8 +163,12 @@ var ReactDOMSelect = {
);
didWarnValueDefaultValue = true;
}
},

postMountWrapper: function(element: Element, props: Object) {
var node = ((element: any): SelectWithWrapperState);
node.multiple = !!props.multiple;
var value = props.value;
if (value != null) {
updateOptions(node, !!props.multiple, value);
} else if (props.defaultValue != null) {
Expand Down
2 changes: 1 addition & 1 deletion src/renderers/dom/fiber/wrappers/ReactDOMFiberTextarea.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ var ReactDOMTextarea = {
return hostProps;
},

mountWrapper: function(element: Element, props: Object) {
initWrapperState: function(element: Element, props: Object) {
var node = ((element: any): TextAreaWithWrapperState);
if (__DEV__) {
ReactControlledValuePropTypes.checkPropTypes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,31 @@ const clientRenderOnServerString = async (element, errorCount = 0) => {
return clientElement;
};

const clientRenderOnBadMarkup = (element, errorCount = 0) => {
function BadMarkupExpected() {}

const clientRenderOnBadMarkup = async (element, errorCount = 0) => {
// First we render the top of bad mark up.
var domElement = document.createElement('div');
domElement.innerHTML =
'<div id="badIdWhichWillCauseMismatch" data-reactroot="" data-reactid="1"></div>';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd argue that it's probably a good idea to put in some junk text here to make sure that the patch up occurs. It's not impossible to assume that some tests try rendering <div someprop='some value'/> (either already or in the future), and that wouldn't trigger the patch up code at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is already other tests in other places in the test suite that covers this so I think it's mostly fine. What we don't have is a test that ensures that the text node is preserved (which I missed in the original PR). However, I think that's more of an optimization than correctness.

Copy link
Copy Markdown
Contributor

@aickin aickin Jun 6, 2017

Choose a reason for hiding this comment

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

I thought at one point about having the test in the hydrating "good markup" case actually walk the entire DOM tree before and after rendering to confirm that the nodes were not replaced by hydration. Would that be helpful as a PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't want to be too prescriptive but if it is a completely good tree that's probably pretty inherent to the whole model. So, yes, that would be great.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It turns out I can't put junk text in there because <div dangerouslySetInnerHTML={...} /> won't actually hit the patch up path so it fails on bad markup. However, I think we're fine not patching up the tree in that case since comparing would be difficult and expensive.

return renderIntoDom(element, domElement, errorCount + 1);
await renderIntoDom(element, domElement, errorCount + 1);

// This gives us the resulting text content.
var hydratedTextContent = domElement.textContent;

// Next we render the element into a clean DOM node client side.
const cleanDomElement = document.createElement('div');
ExecutionEnvironment.canUseDOM = true;
await asyncReactDOMRender(element, cleanDomElement);
ExecutionEnvironment.canUseDOM = false;
// This gives us the expected text content.
const cleanTextContent = cleanDomElement.textContent;

// The only guarantee is that text content has been patched up if needed.
expect(hydratedTextContent).toBe(cleanTextContent);

// Abort any further expects. All bets are off at this point.
throw new BadMarkupExpected();
};

// runs a DOM rendering test as four different tests, with four different rendering
Expand Down Expand Up @@ -148,8 +168,17 @@ function itClientRenders(desc, testFn) {
testFn(clientCleanRender));
it(`renders ${desc} with client render on top of good server markup`, () =>
testFn(clientRenderOnServerString));
it(`renders ${desc} with client render on top of bad server markup`, () =>
testFn(clientRenderOnBadMarkup));
it(`renders ${desc} with client render on top of bad server markup`, async () => {
try {
await testFn(clientRenderOnBadMarkup);
} catch (x) {
// We expect this to trigger the BadMarkupExpected rejection.
if (!(x instanceof BadMarkupExpected)) {
// If not, rethrow.
throw x;
}
}
});
}

function itThrows(desc, testFn) {
Expand Down Expand Up @@ -425,7 +454,7 @@ describe('ReactDOMServerIntegration', () => {

itRenders('no dangerouslySetInnerHTML attribute', async render => {
const e = await render(
<div dangerouslySetInnerHTML={{__html: 'foo'}} />,
<div dangerouslySetInnerHTML={{__html: '<foo />'}} />,
);
expect(e.getAttribute('dangerouslySetInnerHTML')).toBe(null);
});
Expand Down
2 changes: 1 addition & 1 deletion src/renderers/shared/fiber/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ if (__DEV__) {
module.exports = function<T, P, I, TI, PI, C, CX, PL>(
config: HostConfig<T, P, I, TI, PI, C, CX, PL>,
hostContext: HostContext<C, CX>,
hydrationContext: HydrationContext<I, TI, C>,
hydrationContext: HydrationContext<C>,
scheduleUpdate: (fiber: Fiber, priorityLevel: PriorityLevel) => void,
getPriorityContext: (fiber: Fiber, forceAsync: boolean) => PriorityLevel,
) {
Expand Down
18 changes: 13 additions & 5 deletions src/renderers/shared/fiber/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,10 +392,13 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
}
case HostComponent: {
const instance: I = finishedWork.stateNode;
if (instance != null && current !== null) {
if (instance != null) {
// Commit the work prepared earlier.
const newProps = finishedWork.memoizedProps;
const oldProps = current.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
const oldProps = current !== null ? current.memoizedProps : newProps;
const type = finishedWork.type;
// TODO: Type the updateQueue to be specific to host components.
const updatePayload: null | PL = (finishedWork.updateQueue: any);
Expand All @@ -415,13 +418,18 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
}
case HostText: {
invariant(
finishedWork.stateNode !== null && current !== null,
'This should only be done during updates. This error is likely ' +
finishedWork.stateNode !== null,
'This should have a text node initialized. This error is likely ' +
'caused by a bug in React. Please file an issue.',
);
const textInstance: TI = finishedWork.stateNode;
const newText: string = finishedWork.memoizedProps;
const oldText: string = current.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
const oldText: string = current !== null
? current.memoizedProps
: newText;
commitTextUpdate(textInstance, oldText, newText);
return;
}
Expand Down
Loading