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

Rewrite setInnerHTML tests to use Public API. #11385

Merged
merged 8 commits into from
Nov 4, 2017
Merged

Rewrite setInnerHTML tests to use Public API. #11385

merged 8 commits into from
Nov 4, 2017

Conversation

arielsilvestri
Copy link

Ref #11299

This rewrites the setInnerHTML tests to utilize dangerouslySetInnerHTML to ensure that nodes with and without the innerHTML attribute are properly set!

setInnerHTML(node, html);
expect(node.innerHTML).toBe(html);
const container = document.createElement('div');
const component = ReactDOM.render(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's keep this called node? It's not a component.

Copy link
Author

Choose a reason for hiding this comment

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

Sure-habit taking over from how I do this on the day-to-day. My bad!


setInnerHTML(nodeProxy, secondHtml);
let component = ReactDOM.render(
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not a component, it's a node. I know old tests are written this way but they're wrong. Let's use correct naming in new tests.

Copy link
Author

Choose a reason for hiding this comment

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

Sure, can do!

node,
);
ReactDOM.unmountComponentAtNode(
ReactDOM.findDOMNode(component).parentNode,
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's no need to findDOMNode because it is already a node.

Copy link
Author

Choose a reason for hiding this comment

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

Good catch!

@arielsilvestri
Copy link
Author

@gaearon Updated. Realized the last set of tests didn't need those variable assignments, since it's checking for container calls that we're spying on.


describe('setInnerHTML', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's also rename test file?

Copy link
Author

Choose a reason for hiding this comment

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

On it!

@arielsilvestri
Copy link
Author

arielsilvestri commented Oct 29, 2017

@gaearon yarn flow is producing a ton of Required module not found failures. This did not occur before my last merge, and rolling my local branch back before that merge reduces the amount of failures to one, in ReactNoop.js. Any chance something was merged recently that could be causing this? Not sure if anything I did could affect this.

@gaearon
Copy link
Collaborator

gaearon commented Oct 31, 2017

yarn flow is producing a ton of Required module not found failures.

Did you run yarn after merging master into your branch?

@arielsilvestri
Copy link
Author

@gaearon I believe I did, but when I get home I'll take a look and try again. Sorry for the delays.

@arielsilvestri
Copy link
Author

@gaearon Had to blow out the yarn.lock after the master merge-some things weren't updating properly.

Noticed that another failure was due to prettier not picking up my file using the yarn prettier command like the contribution guide mentions. yarn prettier-all, which is what the console in Circle CI suggested, did pick up my file and fix it up. Should some documentation be updated?

yarn.lock Outdated
@@ -10,12 +10,12 @@ JSONStream@^1.0.3:
through ">=2.2.7 <3"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please remove this change. It shouldn't be in this PR.

Conflicts:
	packages/react-dom/src/client/__tests__/setInnerHTML-test.js
	yarn.lock
@arielsilvestri
Copy link
Author

@gaearon Updated.

// Create a mock node that looks like an SVG in IE (without innerHTML)
node = document.createElementNS(Namespaces.svg, 'svg');

nodeProxy = new Proxy(node, {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Removing this causes the test to no longer verify the SVG IE code path. You can test it yourself by commenting out the related parts in setInnerHTML: the test passes despite them being broken.

<g dangerouslySetInnerHTML={{__html: firstHtml}} />,
container,
);
ReactDOM.unmountComponentAtNode(container);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is odd. Why do we need this?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems to defeat the purpose of the test (which is to verify setInnerHTML itself cleans up old nodes).

Copy link
Collaborator

@gaearon gaearon Nov 4, 2017

Choose a reason for hiding this comment

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

I think this is mistakingly testing that unmounting removes a child, and mounting into the same container later adds it.

Instead of testing that inside an already mounted container, operations setting dangerous HTML on SVG node, if it doesn't have innerHTML (as in IE11), will use appendChild / removeChild.

@gaearon
Copy link
Collaborator

gaearon commented Nov 4, 2017

I rewrote the SVG test because it didn't test the right thing. The other ones were good.
Thank you so much!

@gaearon gaearon merged commit 366600d into facebook:master Nov 4, 2017
@gaearon gaearon mentioned this pull request Nov 4, 2017
26 tasks
@arielsilvestri
Copy link
Author

@gaearon I appreciate that-thank you! I should have asked you about that test, as it was a little confusing to rewrite, before taking that approach. Can you just give me a little info on how I should have been approaching that one? Thanks!

@gaearon
Copy link
Collaborator

gaearon commented Nov 4, 2017

For future readers, here is the function we're testing:

var setInnerHTML = createMicrosoftUnsafeLocalFunction(function(node, html) {
// IE does not have innerHTML for SVG nodes, so instead we inject the
// new markup in a temp node and then move the child nodes across into
// the target node
if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {
reusableSVGContainer =
reusableSVGContainer || document.createElement('div');
reusableSVGContainer.innerHTML = '<svg>' + html + '</svg>';
var svgNode = reusableSVGContainer.firstChild;
while (node.firstChild) {
node.removeChild(node.firstChild);
}
while (svgNode.firstChild) {
node.appendChild(svgNode.firstChild);
}
} else {
node.innerHTML = html;
}
});

Here are the tests before:

beforeEach(() => {
// Create a mock node that looks like an SVG in IE (without innerHTML)
node = document.createElementNS(Namespaces.svg, 'svg');
nodeProxy = new Proxy(node, {
has: (target, prop) => {
return prop === 'innerHTML' ? false : prop in target;
},
});
spyOn(node, 'appendChild').and.callThrough();
spyOn(node, 'removeChild').and.callThrough();
});
it('sets innerHTML on it', () => {
var html = '<circle></circle><rect></rect>';
setInnerHTML(nodeProxy, html);
expect(node.appendChild.calls.argsFor(0)[0].outerHTML).toBe(
'<circle></circle>',
);
expect(node.appendChild.calls.argsFor(1)[0].outerHTML).toBe(
'<rect></rect>',
);
});
it('clears previous children', () => {
var firstHtml = '<rect></rect>';
var secondHtml = '<circle></circle>';
setInnerHTML(nodeProxy, firstHtml);
setInnerHTML(nodeProxy, secondHtml);
expect(node.removeChild.calls.argsFor(0)[0].outerHTML).toBe(
'<rect></rect>',
);
expect(node.innerHTML).toBe('<circle></circle>');
});

We need to rewrite the in terms of public API.

First of all, let's look at the branch it's testing:

if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {
reusableSVGContainer =
reusableSVGContainer || document.createElement('div');
reusableSVGContainer.innerHTML = '<svg>' + html + '</svg>';
var svgNode = reusableSVGContainer.firstChild;
while (node.firstChild) {
node.removeChild(node.firstChild);
}
while (svgNode.firstChild) {
node.appendChild(svgNode.firstChild);
}

To verify that your test didn't check the right thing, I just commented out the code in that branch. The test still passed. Therefore, it wasn't being tested. I'm not sure I understand how you came to that test implementation so I can't dive into that. But I can share how I approached it.

The existing test describes the problem here:

// IE does not have innerHTML for SVG nodes, so instead we inject the
// new markup in a temp node and then move the child nodes across into
// the target node

So our job is to:

  1. Simulate a situation where innerHTML doesn’t exist on SVG nodes, making this condition true:

if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {

  1. Verify that setInnerHTML can still replace its HTML content without innerHTML.

The original tests mocked appendChild on a DOM element passed to setInnerHTML for this:

expect(node.appendChild.calls.argsFor(0)[0].outerHTML).toBe(
'<circle></circle>',
);

But we can't pass an arbitrary element to it anymore. setInnerHTML is called by React for the node that has dangerouslySetInnerHTML on it, and that node itself is created by React. We can't somehow turn it into a proxy.

However, it's not even important that appendChild in particular is getting called. It's important that something happens and the result is equivalent to setting innerHTML. In other words, it's enough to verify that the content has been updated. We don't really care how, as we test the observable behavior, not implementation details.

So how do we force React to not "see" innerHTML on a node? It does 'innerHTML' in node check here:

if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {

so seems like the only way to work around it is to delete innerHTML. I looked it up, and DOM elements inherit innerHTML from Element.prototype. Therefore, we can force this code path to execute by deleting innerHTML from Element.prototype (and thus making the check false). That's what I did:

However, this breaks this line of code in our SVG innerHTML "polyfill":

reusableSVGContainer.innerHTML = '<svg>' + html + '</svg>';

That's because IE is only missing support for innerHTML on SVG nodes but it uses innerHTML on a div to work around it. So basically I need div.innerHTML to keep working.

It seemed natural that I could just put the innerHTML descriptor I deleted on Element.prototype, onto HTMLDivElement.prototype:

innerHTMLDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
delete Element.prototype.innerHTML;
Object.defineProperty(
HTMLDivElement.prototype,
'innerHTML',
innerHTMLDescriptor,
);

This way only divs would have innerHTML, and our polyfill wouldn't break. This is somewhat testing implementation details (now changing div to another HTML tag would break tests) but I don't think it's likely we'd ever want to change that, so this is acceptable.

The above is enough to crudely simulate what IE is doing. It's not exactly right but it's enough to test the behavior we need to test for, and it's unlikely we'd need to get more precise here. (Considering jsdom isn't exactly the same environment anyway, e.g. our version of jsdom doesn't even recognize SVG tags properly.)

Finally, how do we test that the content gets replaced? We just assert that we see the content we expect to see, before and after the change:

const rect = container.firstChild.firstChild;
expect(rect.tagName).toBe('rect');

const circle = container.firstChild.firstChild;
expect(circle.tagName).toBe('circle');

This is enough to verify we somehow set the content, which is all we needed.

Finally, it is important to undo any changes to globals (even if the test fails). Since otherwise it makes the test non-deterministic, and can cause other tests to fail:

afterEach(() => {
delete HTMLDivElement.prototype.innerHTML;
Object.defineProperty(
Element.prototype,
'innerHTML',
innerHTMLDescriptor,
);
});

I hope this helps!

@arielsilvestri
Copy link
Author

Very much so. Thanks!

@arielsilvestri
Copy link
Author

@gaearon In regards to why I took my approach, what I ended up doing was trying to make each test pass without changing too much from the original implementation. From what I looked up and what I was using, I didn't think I had to change too much, and as I was coming up with what I was refactoring, I had situations where I would get failing tests. I never thought to comment out what I already had in my setup, therefore I never noticed that I had a false positive in this instance.

@gaearon
Copy link
Collaborator

gaearon commented Nov 4, 2017

Yeah, that makes sense. These are not often trivial changes though :-) When rewriting unit tests into acceptance/integration tests, sometimes you have to rethink the whole approach and in that case it's easier to think about what situation it was written to test rather than what the original test was trying to do.

@arielsilvestri
Copy link
Author

@gaearon Totally fair. Thanks for the thoughtful feedback!

My main takeaway here though is that I definitely felt like, at the time, the test read weird to me and I was confused about whether or not I was properly testing a situation involving SVG nodes, and I could have asked a question to get a little help with it.

@gaearon
Copy link
Collaborator

gaearon commented Nov 5, 2017

I agree the existing one was pretty weird 😛 I think I remember some context on it from the past when I've merged some bugfixes to that code. IMO the most helpful thing to understand the test is to git blame it and find the PR that introduced it. That usually explains the motivation and sometimes provides a more realistic failing example (which unfortunately didn't become a unit test at the time).

@arielsilvestri
Copy link
Author

Good advice. I'll be sure to take a look at previous examples/PRs in the future when I make future contributions :)

Ethan-Arrowood pushed a commit to Ethan-Arrowood/react that referenced this pull request Dec 8, 2017
* Rewrite setInnerHTML tests to use Public API.

* Rename variables and drop unnecessary variable assignments.

* Rename testfile to dangerouslySetInnerHTML-test.js

* Properly prettify test file.

* Rewrite SVG tests to verify we recover from missing innerHTML
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.

3 participants