Don't wrap text in <span> elements #5753

Merged
merged 1 commit into from Feb 18, 2016

Projects

None yet

9 participants

@mwiencek
Contributor

Removes the <span> wrappers from text nodes, and handles cases where the browser may have split or merged adjacent text nodes.

Sorry if this is a totally insufficient way of going about this. It seems to work fine for my needs. If you can point me in a different direction I'm happy to keep working on it.

@syranide
Contributor

Worth mentioning; https://bugzilla.mozilla.org/show_bug.cgi?id=194231, Node.normalize() and possibly other scenarios. While relatively unlikely it's apparently fragile to rely on individually created adjacent text nodes, the only truly safe option seems to be to treat all adjacent text nodes as one.

@mwiencek
Contributor

Node.normalize() does seem to break things. I'll add a test and see if the other commit works with it.

@facebook-github-bot

@mwiencek updated the pull request.

@syranide
Contributor

This breaks if you're rendering onto pre-rendered markup right? Adjacent text nodes would then be merged and empty ones non-existent.

@zpao
Member
zpao commented Dec 30, 2015

cc @spicyj

@mwiencek
Contributor

@syranide It seems to complain about invalid checksums if you try to render text nodes over something generated with ReactDOMServer.renderToString, if that's what you mean.

@mwiencek
Contributor

Er, no, I accidentally changed the markup in the test, so I think it was right to complain. e.g. 9238567 works. If you have a small example of what you mean I can try to add another test.

@facebook-github-bot

@mwiencek updated the pull request.

@facebook-github-bot

@mwiencek updated the pull request.

@mwiencek
Contributor
mwiencek commented Jan 9, 2016

So I think my first approach of trying to merge the text elements before they're instantiated (and then expecting the browser to mount them sanely) was incorrect. Thanks @syranide for pointing me in a different direction.

The new approach is to try and detect when text nodes have been merged/split, and handle things after the fact.

@facebook-github-bot

@mwiencek updated the pull request.

@spicyj
Member
spicyj commented Jan 10, 2016

Sorry for not commenting earlier:

I was thinking that the best approach here might be to add delimiting comment tags around each text node so

<div>{'a'}{'b'}</div>

would become

<div>
  <!-- react-text: 2 -->a<!-- /react-text -->
  <!-- react-text: 3 -->b<!-- /react-text -->
</div>

when rendered. When updating we could update everything in between the matching comment tags. This would immediately solve the merging/splitting problem and would also leave us more resilient to browser extensions that change the page, like the popular "cloud to butt" extension or you might imagine an extension that bolds or highlights certain words on a page by adding tags. This seems more robust in my mind. (Technically we wouldn't even need the "closing" comments but I think it's simpler to have them.)

We also shouldn't add any DOM-specific logic to ReactChildReconciler – that's used on React Native too.

What do you think?

@syranide
Contributor

@spicyj It might not be a good idea for complexity/performance reasons, but I would imagine the best solution would be to simply merge adjacent strings (and remove empty strings) from the intermediate representation that gets rendered to the DOM. I.e. the DOM renderer only ever sees individual strings sandwiched between elements, never multiple or empty strings. Minimal markup and is completely safe, but intuitively it might demand some rather non-trivial logic.

As for <!-- react-text: 2 -->a<!-- /react-text --> I would say <div><!-- react-text: 2 -->a<!-- react-text: 3 -->b</div> should be sufficient no? The closing comment would not actually provide any benefit. The only downside to comments is that all elements do come with a "significant cost", so avoiding it if possible would be a good thing, but other than that it seems great to me.

@jimfb
Contributor
jimfb commented Jan 10, 2016

Yeah, I know things got better when we removed the react-data ids. Using comments may be no worse than using spans, but it does seem like it would be better to merge the strings if it doesn't make the code substantially more complex.

@mwiencek
Contributor

My main motivation here was to remove the chance of surprises from unexpected markup differences while we port a lot of files from TemplateToolkit into React. A lesser motivation was to remove clutter from the browser's elements inspector, so it's easier to see only the things that matter.

I think the comment delimiters solve the first motivation, and partially the second (they at least remove a level of nesting, and are easier to ignore). So definitely sounds fine to me if you guys deem that the safest way forward.

Trying to merge the strings sounds closer to what I attempted at first, but I think I was doing it in the wrong place: at the ReactElement level, before the components were instantiated, where it may not have made sense outside of a browser context.

@spicyj
Member
spicyj commented Jan 11, 2016

We can consider merging adjacent text nodes in a separate diff; I don't think that necessarily needs to be attached to this.

@syranide Yes, we don't need the "closing" tags as I mentioned in my comment but I think the behavior is simpler and easier to reason about if we keep them.

@mwiencek Yes, let's do that if you're up for it. I would like to not have the comment tags but I think it's better to be safe here and do something that we can be confident will work in all scenarios. Let me know if you run into any problems.

@syranide
Contributor

@syranide Yes, we don't need the "closing" tags as I mentioned in my comment but I think the behavior is simpler and easier to reason about if we keep them.

@spicyj It seems to me that it's basically if (node.isComment && node.text === '...') (for closing comment) vs if (node == null || !node.isTextNode) (for no closing comment). Unless I'm missing something they seem pretty much the same to me and IMHO I would favor the second for less markup.

@spicyj
Member
spicyj commented Jan 11, 2016

I was planning to support replacing tags in case an extension adds them, like if it wanted to bold a particular word.

@syranide
Contributor

@spicyj Hmm, an interesting point... previously we could test if a node was managed by React, is that not possible in master anymore? That would suffice if we could. Otherwise yeah that's not a bad idea, and realistically we could shorten the closing comment to <!-- / -->, it can't occur naturally and it's extremely unlikely that it would be inserted by whatever messed with the code.

However, I'm a little worried about <-- open -->foo<!-- close --><-- open -->bar<!-- close -->, if some external tool tried to bold that, wouldn't it break anyway? (Still a good idea though, this is rather less likely scenario)

@mwiencek
Contributor

So after implementing the comment-tag method, I'm getting a test failure in ReactDefaultPerf that I'm not sure how to fix, but I'll push what I have for now.

@mwiencek mwiencek and 1 other commented on an outdated diff Jan 12, 2016
src/renderers/dom/client/utils/DOMChildrenOperations.js
+ var node = startNode.nextSibling;
+ if (isClosingTextComment(node)) {
+ return node;
+ } else {
+ parentNode.removeChild(node);
+ }
+ }
+}
+
+function replaceDelimitedText(openingComment, stringText) {
+ var parentNode = openingComment.parentNode;
+ var nodeAfterComment = openingComment.nextSibling;
+ if (isClosingTextComment(nodeAfterComment)) {
+ // There are no text nodes between the opening and closing comments; insert
+ // a new one if stringText isn't empty.
+ if (stringText) {
@mwiencek
mwiencek Jan 12, 2016 Contributor

I made it so that if it's an empty string it doesn't insert an empty text node between the comments. Not really sure which is preferable.

@spicyj
spicyj Jan 12, 2016 Member

Having no text node seems reasonable.

@mwiencek mwiencek and 1 other commented on an outdated diff Jan 12, 2016
src/renderers/shared/reconciler/ReactMultiChild.js
@@ -342,7 +344,7 @@ var ReactMultiChild = {
);
}
nextIndex++;
- lastPlacedNode = ReactReconciler.getNativeNode(nextChild);
+ lastPlacedNode = ReactReconciler.getLastNativeNode(nextChild);
@mwiencek
mwiencek Jan 12, 2016 Contributor

Since text components consist of multiple nodes, I needed a way to get a reference to the last node (the closing comment) here...

@spicyj
spicyj Jan 12, 2016 Member

Perhaps getNativeNode can return some other object, perhaps with the pair of opening and closing nodes? You shouldn't need to change the API of ReactReconciler or ReactMultiChild, I think. It doesn't need to be the actual DOM node. The only things that use the output of getNativeNode are DOMChildrenOperations and replaceNodeWithMarkup I think.

(Though it would be nice if getNativeNode doesn't have to allocate because it's called on every reconciliation. I was thinking that perhaps we can just return the closing or opening comment here and traverse to find the other one when we need it but if you really do need both, we should at least cache whatever object to be returned on the text component instance so that we don't allocate each time getNativeNode is called.)

@mwiencek
mwiencek Jan 12, 2016 Contributor

Okay, it seems to work out nicely if I just make the closing comment node the "native node" and cache the opening comment node on the text component. The opening comment is only needed when calling removeDelimitedText or replaceDelimitedText. Or I suppose those functions could traverse backwards instead, if they're given the domID. I'll push the changes and let you be the judge. :)

@mwiencek mwiencek and 1 other commented on an outdated diff Jan 12, 2016
src/renderers/dom/shared/ReactDOMTextComponent.js
this._domID = domID;
this._nativeParent = nativeParent;
if (transaction.useCreateElement) {
var ownerDocument = nativeContainerInfo._ownerDocument;
- var el = ownerDocument.createElement('span');
- ReactDOMComponentTree.precacheNode(this, el);
- var lazyTree = DOMLazyTree(el);
- DOMLazyTree.queueText(lazyTree, this._stringText);
- return lazyTree;
+ var openingComment = ownerDocument.createComment(openingValue);
+ var closingComment = ownerDocument.createComment(closingValue);
+ this._closingComment = closingComment;
+ var fragment = ownerDocument.createDocumentFragment();
@mwiencek
mwiencek Jan 12, 2016 Contributor

Is there a better way than using a document fragment here?

@spicyj
spicyj Jan 12, 2016 Member

This seems fine I think.

@spicyj
spicyj Jan 12, 2016 Member

Though it might be nice to avoid appending in the case that DOMLazyTree is in its lazy mode (in IE) since then we'll be reparenting these nodes twice (see the comments in that file explaining if you haven't already). It's not a huge issue because they don't have children but fewer DOM operations is still usually better.

@facebook-github-bot

@mwiencek updated the pull request.

@spicyj
Member
spicyj commented Jan 12, 2016

This looks really good overall so far. Let me know if any of my comments are confusing or if I can help with anything.

@mwiencek mwiencek and 1 other commented on an outdated diff Jan 12, 2016
src/renderers/dom/shared/ReactDOMTextComponent.js
@@ -143,6 +156,14 @@ assign(ReactDOMTextComponent.prototype, {
},
unmountComponent: function() {
+ var openingComment = this._openingComment;
@mwiencek
mwiencek Jan 12, 2016 Contributor

So this and line 147 are the only places that need to reference this._openingComment now. I made unmountComponent remove the opening comment + text nodes here but the closing comment node is still removed via ReactMultiChild -> DOMChildrenOperations, since it's what's returned by getNativeNode. I didn't need to change anything about ReactMultiChild or DOMChildrenOperations.processUpdates that way.

@spicyj
spicyj Feb 17, 2016 Member

I think line 147 is fine here but (per my comment a couple lines down) we shouldn't need it here except to set to null.

@mwiencek
Contributor

@spicyj I really appreciate the feedback. The only thing I have trouble understanding is the ReactDefaultPerf failure https://travis-ci.org/facebook/react/jobs/101760702#L224

@facebook-github-bot

@mwiencek updated the pull request.

@spicyj
Member
spicyj commented Jan 18, 2016

I don't have time this moment to look at the code again, but the perf test is probably failing because replaceDelimitedText is not instrumented (see the bottom of DOMChildrenOperations).

@mwiencek
Contributor

Yup, that was it. Less sure of what I'm doing there but tests pass now.

@facebook-github-bot

@mwiencek updated the pull request.

@spicyj spicyj and 1 other commented on an outdated diff Feb 17, 2016
src/renderers/dom/shared/ReactDOMTextComponent.js
@@ -143,6 +156,14 @@ assign(ReactDOMTextComponent.prototype, {
},
unmountComponent: function() {
+ var openingComment = this._openingComment;
+ if (openingComment) {
+ DOMChildrenOperations.removeDelimitedText(
@spicyj
spicyj Feb 17, 2016 Member

You shouldn't need to explicitly unmount the nodes in unmountComponent here; when we unmount a whole tree we only remove the root node, not every descendant individually. (DOMChildrenOperations would do the removal in the multi-child case.) See ReactDOMComponent.unmountComponent for comparison.

@spicyj
spicyj Feb 17, 2016 Member

That is, I think it's better if DOMChildrenOperations is aware of this comment/text-node structure instead of only removing the closing node.

@mwiencek
mwiencek Feb 18, 2016 Contributor

So now I remember the problem with doing that in DOMChildrenOperations was that if I set _openingComment to null here, I wouldn't be able to access it in DOMChildrenOperations. Not sure of a good way around that. So I just added a getOpeningCommentNode method that takes the closing comment as a param and walks back from there (couldn't look at this._nativeNode either since it's also null in DOMChildrenOperations).

@spicyj
spicyj Feb 18, 2016 Member

What do you think of returning from getNativeNode an two-element array with the [open, close] comments? Then you would have both nodes already and wouldn't need to walk backwards in DOMChildrenOperations. (Let me know if this wouldn't work for some reason – what you have currently also seems reasonable, albeit a little clunky.)

@mwiencek
mwiencek Feb 18, 2016 Contributor

Hmm, I think returning just the closingComment was convenient because the lastPlacedNode stuff in ReactMultiChild uses getNativeNode for that, and I didn't have to add any special cases for it that way. But I'll see if that turns out to be simpler.

@spicyj
spicyj Feb 18, 2016 Member

I think you might only need to change getNodeAfter.

@mwiencek
mwiencek Feb 18, 2016 Contributor

Seems to work fine. :) But not sure if Array.isArray is a very robust way to check for this case.

@spicyj
spicyj Feb 18, 2016 Member

I think that should be fine.

@spicyj
Member
spicyj commented Feb 17, 2016

Sorry for the long delay. This is really awesome and I want to get this in. Can we look at moving the unmounting logic to DOMChildrenOperations? I think we may also have a problem with reordering text nodes when keyed text nodes are involved. Specifically,

<div>{new Map([['a', 'alpha'], ['b', 'beta']])}</div>

renders a div with two text children: alpha (with key a) and beta (with key b). If we swap the order to b then a, the text nodes should get moved around. (Sorry this is a little subtle.) Can you make sure this works and add a quick test in ReactMultiChildText for it?

Other than that, I think this looks great.

@facebook-github-bot

@mwiencek updated the pull request.

@mwiencek
Contributor

@spicyj not a problem, thanks for looking again. I fixed the reordering issue and added a suitable test, I hope.

@spicyj spicyj commented on an outdated diff Feb 18, 2016
...ared/reconciler/__tests__/ReactMultiChildText-test.js
+ var beta2 = childNodes[4];
+ var beta3 = childNodes[5];
+
+ ReactDOM.render(
+ <div>{new Map([['b', 'beta'], ['a', 'alpha']])}</div>,
+ container
+ );
+
+ childNodes = container.firstChild.childNodes;
+ expect(childNodes[0]).toBe(beta1);
+ expect(childNodes[1]).toBe(beta2);
+ expect(childNodes[2]).toBe(beta3);
+ expect(childNodes[3]).toBe(alpha1);
+ expect(childNodes[4]).toBe(alpha2);
+ expect(childNodes[5]).toBe(alpha3);
+ });
@spicyj
spicyj Feb 18, 2016 Member

Can you add a

// Using Maps as children gives a single warning
expect(console.error.calls.length).toBe(1);

to make sure new warnings don't pop up unexpectedly?

@mwiencek mwiencek Don't wrap text in <span> elements
Instead, use opening and closing comment nodes to delimit text data.
2038500
@facebook-github-bot

@mwiencek updated the pull request.

@spicyj
Member
spicyj commented Feb 18, 2016

This looks great. Thank you so much!

@spicyj spicyj merged commit 684ef3e into facebook:master Feb 18, 2016

1 check passed

continuous-integration/travis-ci/pr The Travis CI build passed
Details
@mwiencek
Contributor

woot! thank you!

@mwiencek mwiencek deleted the mwiencek:no-text-span-2 branch Feb 18, 2016
@jimfb
Contributor
jimfb commented Feb 18, 2016

@mwiencek I also wanted to jump in and say thanks! This has been one of our long standing todo items, and no one has tackled it because it was a tough one, but you did a great job! I'm really excited to see this merged! Thanks!

@gaearon
Member
gaearon commented Feb 18, 2016

@mwiencek Amazing work. Thank you so much for contributing!

@spicyj spicyj added a commit to spicyj/react that referenced this pull request Feb 19, 2016
@spicyj spicyj Fix text component rendering with server markup
These weren't caught by CI in #5753 because we don't automatically test that yet... fixing that next.
85f2e87
@spicyj spicyj added a commit to spicyj/react that referenced this pull request Feb 19, 2016
@spicyj spicyj Fix text component rendering with server markup
These weren't caught by CI in #5753 because we don't automatically test that yet... fixing that next.
e8fb8c7
@alunyov alunyov added a commit to alunyov/react that referenced this pull request Mar 10, 2016
@spicyj @alunyov spicyj + alunyov Fix text component rendering with server markup
These weren't caught by CI in #5753 because we don't automatically test that yet... fixing that next.
76e8194
@alcedo
alcedo commented May 19, 2016

quick question: cloud flare is stripping away the <!-- --> tags and rightfully so. what should i do in this case?

@jimfb
Contributor
jimfb commented May 19, 2016

@alcedo You can try turning off "Auto Minify" via your CloudFlare Settings control panel. Haven't tried it myself, but that sounds like the relevant setting. If that doesn't work, I'd recommend contacting CloudFlare support - I'd be extraordinarily surprised if they didn't provide a way for resources to be served unchanged.

@alcedo
alcedo commented May 19, 2016

got it. that should work :)

@artursgirons
artursgirons commented Aug 2, 2016 edited

Could this <!-- foo-->...<!-- /foo --> solution work for implementing "virtual" root element?
One use case for such virtual root element would be for creating wrapper component which could add siblings for wrapped component without interference with main html tag structure.

<!-- foo --> <-- wrapper component ("virtual" component root)
   <style>._c1 {color:red;}</style> <-- sibling to wrapped component added by wrapper
   <div class="_c1">text</div> <-- wrapped component
<!-- /foo -->

Maybe there is already some implementation of such idea?

@gaearon
Member
gaearon commented Aug 2, 2016

Please see the discussion in #2127.

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