Skip to content
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

Fix false positive <noscript> rehydration text difference warning in React 16 #11157

Merged
merged 4 commits into from
Oct 10, 2017

Conversation

gaearon
Copy link
Collaborator

@gaearon gaearon commented Oct 9, 2017

Fixes #10993.

It doesn’t seem like jsdom reproduces the original issue (?!) so instead I added a <noscript> into our SSR fixture. I verified the warning appears without the fix, and disappears after the fix.

The <noscript> tag is special because the browser parses it as text content when scripting is enabled. However, the way we normalized HTML did not reproduce the same behavior faithfully because we didn’t include the <noscript> tag itself into that HTML. Therefore, it didn’t switch the parsing mode to treat its content as text.

The fix adds a special case for <noscript> to wrap it an extra time. I decided to special-case it because it seemed more solid than always wrapping (which is not always possible to do one level, e.g. tr > td case).

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 9, 2017

cc @syranide as the resident DOM quirk expert

@gaearon gaearon mentioned this pull request Oct 9, 2017
19 tasks
@gaearon
Copy link
Collaborator Author

gaearon commented Oct 9, 2017

@sebmarkbage This one's for you


if (parent.tagName === 'NOSCRIPT') {
// <noscript> content is parsed as text, but only if the browser parses it
// together with <noscript> tag itself. So we have to wrap it once more.
Copy link
Contributor

Choose a reason for hiding this comment

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

a = document.createElement('noscript');
a.innerHTML = '<b>foobar</b>';
console.log(a.childNodes);

Outputs a single TextNode for me in both Chrome and Edge (it does not parse the b-tag), which seems to contradict the comment and the whole reason for this existing... right? Is there more to it than that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Interesting. What you're saying is right for document.createElement(), but not for document.implementation.createHTMLDocument('').createElement() which is what we're using. I'm not sure why there is a difference (is it because it doesn't have a body or something like this?)

Copy link
Collaborator

Choose a reason for hiding this comment

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

There doesn't seem to be a difference between the wrapper and not in this test:
http://jsbin.com/mofexaxaya/2/edit?js,console

What is different?

@sebmarkbage
Copy link
Collaborator

I'd like to understand where this difference is coming from and whether it actually only affects <noscript /> or something else too.

@sebmarkbage
Copy link
Collaborator

The only reason we do this in a new document is to avoid custom elements being created for registered tags and subsequently having side-effects. I don't know if this easy work around is enough but if it's not, we could also say that such custom elements are broken and just use the same document.

// together with <noscript> tag itself. So we have to wrap it once more.
var wrapperElement = document.createElement('div');
wrapperElement.innerHTML = '<noscript>' + html + '</noscript>';
var noscriptElement = wrapperElement.firstElementChild;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not just firstChild (since it goes back longer in browser support and seems sufficient)?

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

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

It seems to me that the reason this happens is because the detached document doesn't have "scripting enabled" because it doesn't have a browsing context.

I don't see why wrapping it matters but that doesn't matter because it breaks in another case.

          <div
            dangerouslySetInnerHTML={{
              __html: `<noscript><b>Enable JavaScript to run this app.</b></noscript>`,
            }}
          />

If the <noscript /> tag is nested deeply in any innerHTML you have the same problem.

I don't see a way to special case this nor a way to enable scripting on custom documents to force this parsing. Maybe we should just switch to using the normal document and bite the bullet that custom elements might mess with us?

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 9, 2017

It seems to me that the reason this happens is because the detached document doesn't have "scripting enabled" because it doesn't have a browsing context.

This makes sense. I saw it in the spec but the meaning of "browsing context" eluded me.

Maybe we should just switch to using the normal document and bite the bullet that custom elements might mess with us?

This sounds reasonable to me. I'll do tomorrow unless you get there first.

@syranide
Copy link
Contributor

syranide commented Oct 10, 2017

I'm curious, would it be possible to instead just stop generating noscript client-side and when hydrating it removes any noscript it encounters, or ignores its content entirely, because it doesn't matter. It seems counter-intuitive to downgrade custom elements just to support noscript?

(EDIT: I don't really have a good understanding of how this all fits into the bigger picture atm, so perhaps it doesn't make sense)

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 10, 2017

just stop generating noscript client-side and when hydrating it removes any noscript it encounters, or ignores its content entirely, because it doesn't matter

This wouldn’t help with the case in #11157 (review), would it?

@syranide
Copy link
Contributor

This wouldn’t help with the case in #11157 (review), would it?

@gaearon It would still be in the DOM, but React wouldn't trip on it right? Which is really the goal, or is there an important run-time difference that we care about? (aside from the parsing cost)

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 10, 2017

In that case React trips not because it rehydrates noscript but because it rehydrates that div, then compares its dangerouslySetInnerHTML, but the way it “normalizes” innerHTML from the server response doesn’t work with noscript.

I’m not sure how React could skip over noscript specifically when it’s part of larger HTML.

@syranide
Copy link
Contributor

@gaearon Ah my bad, I wasn't aware that v16 tests dangerouslySetInnerHTML too during hydration, I assumed it was ignored as in v15. So I don't mean to derail, but testing dangerouslySetInnerHTML seems problematic as I would assume the user is allowed to modify it freely (e.g. progressive enhancement, 3rd-party widget, etc).

Anyway, I don't have any opinion on this and you have a better understanding of the big picture.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 10, 2017

So I don't mean to derail, but testing dangerouslySetInnerHTML seems problematic as I would assume the user is allowed to modify it freely (e.g. progressive enhancement, 3rd-party widget, etc).

It’s still expected to be the same on the first render, is it not? Since in 15 the markup validation would fail otherwise. The only reason we have to “re-parse” the HTML is because we don’t do markup generation step on the client anymore.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 10, 2017

OK, I reverted the old fix and pushed the one that just uses parent.ownerDocument.

@syranide
Copy link
Contributor

syranide commented Oct 10, 2017

It’s still expected to be the same on the first render, is it not?

Usually yes (components renders html, then modifies it via script), but one could imagine that someone doesn't want to wait for React to load+hydrate and makes e.g. some progressive enhancement scripts load before React so that certain parts of the page are immediately interactive. I don't dabble with SSR myself (and would never to that extent), so I'm indifferent towards it, but I would have assumed that to be valid given that I'm free to modify it. However, it's only a warning so I guess it's technically allowed and it's understandable if React draws the line there.

I’m not sure how React could skip over noscript specifically when it’s part of larger HTML.

It's not nice, but I'm assuming a possibility would be to just skip the warning if one detects a noscript-tag (e.g. in the DOM or in the html string), "good enough". Similar to how React now normalizes the strings before warning when hydrating.

An alternative I guess would be to switch it around, instead of comparing the strings you can walk the DOM and compare node by node. It's a lot more work, but it would give you the possibility to bail at noscript or even normalize nodeValue vs innerHTML.

Perhaps a middle-ground could be to just generate the "comparison DOM" as you do, but step through the hierarchy and normalize all noscript it encounters before serializing? (i.e. read nodeValue and generate new DOM elements to replace it with). EDIT: Or like with the DOM string problem, only perform this step before emitting a warning.

PS. I dug around the code a bit and understand the context a bit better now, so I see where I didn't make sense before, sorry 😄

@syranide
Copy link
Contributor

Sorry, I meant the other way around, if you encounter a noscript-tag, serialize its nodes into a TextNode.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 10, 2017

My impression is that the use case of custom elements with constructor side effects inside dangerouslySetInnerHTML is a bit far fetched. I’d merge this and wait and see if anyone reports issues before worrying about it.

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

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

I think the most convincing argument around this is that third-party scripts in the innerHTML can do whatever they want to the DOM before this hydrates and therefore we should ignore the warning all together.

If that is needed, there is now an easy bailout mechanism with suppressHydrationWarning. That also solves any problems with custom elements, if there are any, since it'll skip this code path.

Seems better to try the more restrictive solution with an opt-out and if it's a problem we can relax it.

@sebmarkbage
Copy link
Collaborator

The custom element problem is something that might happen but we don't know, where as noscript will definitely happen.

@gaearon gaearon merged commit e5db530 into facebook:master Oct 10, 2017
@gregberge
Copy link

gregberge commented Oct 11, 2017

I had the same issue with <script type="application/ld+json"></script> but reading the issue, it should fix the problem. Thanks!

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 11, 2017

Can you check whether it does by applying the same modification locally?

@gregberge
Copy link

@gaearon server-side rendering code is correct. It is just a false positive warning.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 11, 2017

Yes, but do you still have the false positive warning after applying this fix?

@gaearon gaearon deleted the noscript-fix branch October 11, 2017 11:45
@gregberge
Copy link

I didn't try with the fix, I don't know how to apply it. I have never built React 😅.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 11, 2017

I mean you can just apply it manually in the build output in node_modules/react-dom/cjs/react-dom-server.node.development.js. Just find the place in the output by neighbor variable or function names.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 11, 2017

Oops, in this case it would be react-dom/cjs/react-dom.development.js (hydration happens in the browser). But the idea is the same. Just patch it up there.

@gregberge
Copy link

gregberge commented Oct 11, 2017

I applied the patch and it doesn't work. I investigated deeply and it result it was a script (Google Analytics) inserted client side just before ReactDOM.hydrate, React found the script and confused it with another.

I fixed it by inserting the script after rendering. The warning could be improved, the only information I got was the prop "type" that didn't match. I think you should mention the domElement.outerHTML in the warning. It could be very helpful.

@gaearon
Copy link
Collaborator Author

gaearon commented Oct 11, 2017

There is a separate issue tracking making these warnings better. The plan is to batch them and show a diff view.

@gregberge
Copy link

OK nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

React 16 fails to rehydrate noscripts
5 participants