Fiber test renderer #8628
|
| + | ||
| +var instanceCounter = 0; | ||
| + | ||
| +var ReactTestFiberComponent = { |
Note: the separation between ReactDOMFiber and ReactDOMFiberComponent is just an artifact of how it was forked from ReactDOMComponent and that we still want to keep them in sync when possible. So feel free to abandon that convention (e.g. see ReactNoop.js)
It would be fine to branch on renderer in the test file like the ReactART tests do. |
|
Thanks for the feedback thus far. Regarding refs I think I can reuse the class Component extends React.Component {
render() {
return (
<div className="purple">
<div />
<Child />
</div>
);
}
}
class Child extends React.Component {
render() {
return (
<div className="green" />
);
}
}
ReactTestRenderer.create(<Component />);
const hostInstance = TestRenderer.findHostInstance(root);
/* hostInstance ==>
{ type: 'div',
children:
[ { '$$typeof': Symbol(react.element),
type: 'div',
key: null,
ref: null,
props: {},
_owner: [Object],
_store: {} },
{ '$$typeof': Symbol(react.element),
type: [Function: Child],
key: null,
ref: null,
props: {},
_owner: [Object],
_store: {} },
{ type: 'div', children: null, props: {} },
{ type: 'div', children: null, props: [Object] } ],
props: { className: 'purple' } }
*/Is that expected? Why? |
|
Second issue is if I try to call class Component extends React.Component {
render() {
return (
<div className="purple">
<div />
{/* this callback ref will throw */}
<Child ref={fiber => console.log(ReactTestRenderer.findHostInstance(fiber)} />
</div>
);
}
}
class Child extends React.Component {
render() {
return (
<div className="green" />
);
}
}
ReactTestRenderer.create(<Component />);
// ref calback throws:
// Invariant Violation: Unable to find node on an unmounted component.I would expect that to be mounted by the time the ref callback is called... |
|
Woohoo! Only 2 failing tests now! I have ReactTestRenderer-test branching on the ReactDOMFeatureFlags useFiber switch now. To support the With the two failing tests, I am curious if these are failing because the feature is incomplete or because behavior has changed subtly.
Is this warning still to be implemented? If yes I would suspect it to “just work” here without any additional effort.
The ref callback is not being called yet on unmount. This is another situation where I would expect this to just work. Is this not yet implemented or do I need to track down a bug in this renderer?
The order of ilfecycles is different here. Based on watching the error boundary work between @acdlite and @gaearon I suspect that this may be expected. Before
After
|
|
Just checked master. It looks like |
|
@iamdustan Want to help with implementing that warning in master as a separate PR? |
| @@ -86,7 +86,11 @@ module.exports = function<T, P, I, TI, C, CX>( | ||
| function attachRef(current : ?Fiber, finishedWork : Fiber, instance : any) { | ||
| const ref = finishedWork.ref; | ||
| if (ref && (!current || current.ref !== ref)) { | ||
| - ref(instance); | ||
| + if (typeof config.getPublicInstance === 'function') { |
Please destructure this from config earlier. We don't want to read that value every time. I think we could also make this required to keep contract strict.
| + parentHostContext : HostContext, | ||
| + type : string, | ||
| + ) : HostContext { | ||
| + return {}; |
Please return the same object (emptyObject like in ReactNoop) so it doesn't push stack unnecessarily.
|
The only failing test remaining is that it The test expects the ref to be called with My biggest concern is that being called twice wasn’t necessarily an intentional decision before, but the test was added to assert the behavior and not regress accidentally. I’m not certain how much effort should be put into maintaining this because I think it would require changing @spicyj what thoughts or advise do you have since you wrote the original implementation? |
| + createNodeMock: Function; | ||
| + | ||
| + constructor(rootID, createNodeMock) { | ||
| + this.rootID = rootID; |
| + } | ||
| + | ||
| + update(type, props) { | ||
| + // console.log('update', type, props, this.children); |
| + } | ||
| + | ||
| + toJSON() { | ||
| + // eslint-disable ignore the children |
What are we ignoring specifically? Generally I'd like to avoid adding eslint ignores.
| + if (typeof child.toJSON === 'function') { | ||
| + childrenJSON.push(child.toJSON()); | ||
| + } else if (typeof child.text !== 'undefined') { | ||
| + childrenJSON.push(); |
| + rootContainerInstance : Container, | ||
| + ) : boolean { | ||
| + // console.log('finalizeInitialChildren'); | ||
| + // setInitialProperties(testElement, type, props, rootContainerInstance); |
| + ); | ||
| + }, | ||
| + | ||
| + resetTextContent(testElement : Instance) : void {}, |
Nit: Could you please use a consistent style for empty methods? Either like this, or with // Noop comment as long as it's the same style everywhere.
| + text : text, | ||
| + id: instanceCounter++, | ||
| + rootContainerInstance, | ||
| + toJSON: () => isNaN(+inst.text) ? inst.text : +inst.text, |
I'm not sure this is right. For example even "2" would become 2. Maybe this is unobservable implementation detail and we should just always use strings? Since that's what they get coerced to anyway.
good point. this was to make one of the tests pass, but I think to be correct I need to capture the type when creating the TextInstance and only coerce back to a number if it came in as a number.
I don't think you'd actually get a number though in createTextInstance? As far as I can see it's already coerced by Fiber code by then. I think it actually makes more sense than exposing this to the renderer, and I'm fine with divergent behavior here (always treating text as strings).
| + parentInstance.removeChild(child); | ||
| + }, | ||
| + | ||
| + scheduleAnimationCallback: window.requestAnimationFrame, |
I think this will throw in non-jsdom environment. Can you make it setTimeout? It seems like it wouldn't be used anyway, but at least we shouldn't throw.
| + } | ||
| + var container = new TestContainer('<default>', createNodeMock); | ||
| + var root = TestRenderer.createContainer(container); | ||
| + if (root) { |
I don’t think ever, but flow was complaining that TestRenderer.createContainer may return null
You'd need to see why other renderers didn't need this. It probably inferred that OpaqueNode type can be null, maybe from some other method definition.
| + }, | ||
| + | ||
| + /* eslint-disable camelcase */ | ||
| + unstable_batchedUpdates() { |
Oh yeah, forgot about this one. The stack-based ReactTestRenderer exposes this currently..
Exposing but throwing isn't much better. :-)
Let's either make it work or remove it.
| @@ -47,6 +47,7 @@ export type HostConfig<T, P, I, TI, C, CX, CI> = { | ||
| getRootHostContext(rootContainerInstance : C) : CX, | ||
| getChildHostContext(parentHostContext : CX, type : T) : CX, | ||
| + getPublicInstance(instance : I | TI) : any, // maybe add a PI (public instance type)? |
| @@ -317,6 +343,7 @@ describe('ReactTestRenderer', () => { | ||
| ]); | ||
| }); | ||
| + // this is only passing in Fiber because refs aren't actually working |
Is this comment outdated? It’s not telling me much: are refs broken in Fiber? Are they broken specifically for test renderer? What is our plan to fix them? Is this within the scope of this PR?
| + } | ||
| + ReactDOM.render(<Foo useDiv={true} />, container); | ||
| + ReactDOM.render(<Foo useDiv={false} />, container); | ||
| + expect(log).toEqual(['div', null, 'span']); |
oh whoops, didn’t mean to actually commit this. This was testing the behavior difference for refs between the test-renderer and DOM renderer re #8628 (comment)
|
I think the existing test for updates might be checking accidental implementation details. It's not testing refs themselves, it's testing calls of In particular, I don't see why it calls |
|
Right now We can try caching the mock node on the instance and only calling |
| @@ -463,7 +463,7 @@ module.exports = function<T, P, I, TI, C, CX, CI>( | ||
| } | ||
| const ref = finishedWork.ref; | ||
| if (ref) { | ||
| - const instance = getPublicInstance(finishedWork.stateNode); | ||
| + const instance = (getPublicInstance(finishedWork.stateNode) : any); |
the flow type for instance on master is any. Whilst adding the PI parameter does end up saying instance is PI, that fails flow due to ref expecting:
ref: null | (((handle : ?Object) => void) & { _stringRef: ?string }),
I suspect this could be improved, but for time constraints at the moment I simply dropped it back to equal the current inference.
| constructor(element: ReactElement) { | ||
| this._currentElement = element; | ||
| this._renderedChildren = null; | ||
| this._topLevelWrapper = null; | ||
| this._hostContainerInfo = null; | ||
| + this._nodeMock = UNSET; |
Hmm, that's trickier. Probably not since nodes are supposed to be kept intact. I'm actually not sure the API is right now, maybe we should've just passed the type instead of the element. But what's done is done?
welp, I’ve updated it to on mountComponent because that is the earliest point we can do so (that is when hostContainerInfo is passed in)
|
Ugh, GH collapsed my comment. Here it is: #8628 (comment). |
|
First two thoughts on how to fix this are:
|
|
I just pushed a commit that explicitly uses the Stack version on npm (since that's what npm package should use). |
|
how do you feel about adding an entrypoint at |
|
Added that too in that commit. (But didn't expose it in |
| + } else { | ||
| + var childrenJSON = []; | ||
| + this.children.forEach((child) => { | ||
| + if (typeof child.toJSON === 'function') { |
When would this ever be false? If I understand correctly child is also either an Instance or TextInstance which we have control over.
disclaimer: this implementation was mostly an amalgamation of ReactNoop and the stack ReactTestRenderer so there is likely some unnecessary complexity from the copypasta.
Yea, just saying we should clean this up a little so the next person looking isn't more confused than we were.
| + childrenJSON.push(child.toJSON()); | ||
| + } | ||
| + }); | ||
| + json.children = childrenJSON.length ? childrenJSON : null; |
Wouldn't this whole block look simpler as
json.children = this.children.length ?
this.children.map(child => child.toJSON()) :
null;?
| + this.children = []; | ||
| + this.rootContainerInstance = rootContainerInstance; | ||
| + | ||
| + Object.defineProperty(this, 'id', { value: this.id, enumerable: false }); |
What is id field for? I tried removing it everywhere and tests still pass.
| + appendChild(child) {} | ||
| + insertBefore(beforeChild, child) {} | ||
| + removeChild(child) {} | ||
| + toJSON() {} |
I find these empty implementations odd.
Can you help me understand why they're unnecessary here?
What if I call top-level create with a fragment?
The tldr is I originally wrote the TestContainer to hold the createNodeMock function and keep a pointer to it. This likely can be simplified. Top-level create with a fragment is likely broken right now.
I’ll look at unifying TestComponent and TestContainer tonight. Should I add a test case for top-level create?
Maybe. I'm pretty sure them being noops is wrong (since they do get called), but I'm not sure how to write a failing test. If you succeed with making a failing test against this then you'll know how to fix it.
Hmm...this actually seems to maybe work okay already? Here is my current test case:
it('can update text nodes when rendered as root', () => {
var Component = (props) => props.children;
var renderer = ReactTestRenderer.create(<Component>Hi</Component>);
expect(renderer.toJSON()).toEqual('Hi');
renderer.update(<Component>{['Hi', 'Bye']}</Component>);
expect(renderer.toJSON()).toEqual('Hi Bye');
renderer.update(<Component>Bye</Component>);
expect(renderer.toJSON()).toEqual('Bye');
renderer.update(<Component>{42}</Component>);
expect(renderer.toJSON()).toEqual(42);
renderer.update(<Component><div /></Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: null,
props: {},
});
});Though currently <Component>{['Hi', 'Bye']}</Component> toJSON() only returns Hi (instead of Hi Bye). I’m not actually certain what is expected at this point.
I’m going to add another test case later, family-time!
I'm confused. The test you're showing looks like my test about text nodes. But we were discussing root in this thread.
Sorry, this is the root node test as I understand it: https://github.com/facebook/react/pull/8628/files#diff-417ca2789067ee4dcafab55a48aa03cbR532
it('can render and update root fragments', () => {
var Component = (props) => props.children;
var renderer = ReactTestRenderer.create([
<Component>Hi</Component>,
<Component>Bye</Component>,
]);
expect(renderer.toJSON()).toEqual('Hi');
renderer.update(<div />);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: null,
props: {},
});
renderer.update([<Component>{42}</Component>, <Component>The answer</Component>]);
expect(renderer.toJSON()).toEqual(42);
});
Yea, I don't really know how to trigger the failing case for containers. It's just odd to me that those methods are blank. I would expect that either they shouldn't be defined at all, or they should do something.
expect(renderer.toJSON()).toEqual('Hi');
I don't think this looks right. We are rendering an array. Why do we get a single item as an output? I would expect it to return an array in Fiber.
The underlying implementation uses TestRenderer.findHostInstance(root). From looking at what I believe are the corresponding DOM tests, only the first element in a fragment is returned.
| + }, | ||
| + | ||
| + resetTextContent(testElement : Instance) : void { | ||
| + // Noop |
Why is this a no-op? It seems like there could be bugs related to this.
(e.g. DOM renderer implements this one.)
I think we should actually have shouldSetTextContent always return false.
AFAIK it only exists as an optimization to avoid creating additional Fibers for single-text-child divs.
But in case of test renderer it doesn't matter.
what is the tdlr of this method? No tests currently require it so I just left it empty. Sounds like I’ll need to add a test case...
Try commenting its implementation out in DOM renderer and see what fails.
By grepping for method name, I found this:
if (nextEffect.effectTag & ContentReset) {
config.resetTextContent(nextEffect.stateNode);
}By grepping for ContentReset, I found this:
// If we're switching from a direct text child to a normal child, or to
// empty, we need to schedule the text content to be reset.
workInProgress.effectTag |= ContentReset;So it likely happens when we switch from a "direct text child" (with your implementation of shouldSetTextContent, it's the case with <div>Text</div> or <div>{42}</div>) to a "normal child" (e.g. <div><p /></div>). The purpose is to clear the text in this case.
I think you can avoid the whole problem by making shouldSetTextContent always return false. Then this code never runs and we always create a real text instance.
Here is a failing test:
it('can update text nodes', () => {
class Component extends React.Component {
render() {
return (
<div>
{this.props.children}
</div>
);
}
}
var renderer = ReactTestRenderer.create(<Component>Hi</Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: ['Hi'],
props: {},
});
renderer.update(<Component>{['Hi', 'Bye']}</Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: ['Hi', 'Bye'],
props: {},
});
renderer.update(<Component>Bye</Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: ['Bye'],
props: {},
});
renderer.update(<Component>{42}</Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: [42],
props: {},
});
renderer.update(<Component><div /></Component>);
expect(renderer.toJSON()).toEqual({
type: 'div',
children: [{
type: 'div',
children: null,
props: {},
}],
props: {},
});Solved by returning false from shouldSetTextContent.
| + } | ||
| + var container = new TestContainer(createNodeMock); | ||
| + var root = TestRenderer.createContainer(container); | ||
| + if (root) { |
hmm...I can remove this one, but not those in the returned methods...
oh duh. It’s because in unmount we clean up our closured reference to root, but the consumer could still do:
const renderer = TestRenderer.create(<Comp />);
renderer.unmount();
renderer.update(<OtherComp />); // shouldn’t be possible because we unmounted.
I’ll need to look into what the current implementation does...
| + return; | ||
| + } | ||
| + TestRenderer.updateContainer(null, root, null, () => { | ||
| + container = null; |
I think you shouldn't need this callback.
Since it uses synch scheduling, everything should happen synchronously anyway.
So you should be able to clean up immediately after calling updateContainer.
| + var renderer = ReactTestRenderer.create(<Component>Hi</Component>); | ||
| + expect(renderer.toJSON()).toEqual('Hi'); | ||
| + renderer.update(<Component>{['Hi', 'Bye']}</Component>); | ||
| + expect(renderer.toJSON()).toEqual('Hi'); |
I’m not actually certain if this should be Hi or Hi Bye. I think that when given a fragment-like thing the first is the only one returned, but only spent 30 seconds on it so far.
It should be an array in Fiber. We should put the whole test behind a feature flag because it is Fiber specific. I think this demonstrates that root problem but maybe I'm wrong.
|
Okay, I can get rid of all the
This is the toJSON function:
I can make that happen if you want. Flow is very unhappy with me at the moment, though so |
If it looks/works better, sure. If not, don’t bother. :-) |
|
WIP commit d228c24 just pushed. I have three more flow issues to resolve to finish it off or I can revert it if it’s okay as is. |
|
If you can figure out how to resolve them that would be great. Otherwise I can try to take a look. |
| - parentInstance.removeChild(child); | ||
| + const index = parentInstance.children.indexOf(child); | ||
| + if (index === -1) { | ||
| + throw new Error('This child does not exist.'); |
Note sure those checks are important. They would indicate bugs in Fiber.
They exist in ReactNoop because it tests Fiber itself but doesn't look necessary here.
| @@ -246,8 +193,18 @@ var TestRenderer = ReactFiberReconciler({ | ||
| useSyncScheduling: true, | ||
| getPublicInstance(ref) { | ||
| - const createNodeMock = ref.rootContainerInstance.createNodeMock; | ||
| - return createNodeMock(ref); | ||
| + if (typeof ref.text === 'string') { |
how would that look? I could add $$typeof symbols to the container and text instances..
| + } | ||
| + | ||
| + // this should not be possible | ||
| + throw new Error('Attempted to getPublicInstance on an invalid ref.'); |
I thought it was just Instance or TextInstance, though Container is also actually a possibility.
This was added for exhaustiveness because I couldn’t get flow to be okay without else if check just before this.
Okay. Switch on $$typeof with a throwing default would look nicer than duck-typing though.
Not really important to use symbols there (if you don't leak those objects externally), you can just use an enum. Example declaration and switch.
|
I would like to take a brief moment to say thank you for your infinite patience and attention to detail in this PR @gaearon. |
| @@ -28,7 +32,8 @@ type ReactTestRendererJSON = {| | ||
| type Container = {| | ||
| children: Array<Instance | TextInstance>, | ||
| - createNodeMock: Function | ||
| + createNodeMock: Function, | ||
| + $$typeof: typeof CONTAINER_TYPE, |
without the typeof I get the following:
src/renderers/testing/ReactTestRendererFiber.js:36
36: $$typeof: CONTAINER_TYPE,
^^^^^^^^^^^^^^ string. Ineligible value used in/as type annotation (did you forget 'typeof'?)
36: $$typeof: CONTAINER_TYPE,
^^^^^^^^^^^^^^ CONTAINER_TYPE
| + ) : void { | ||
| + const index = parentInstance.children.indexOf(child); | ||
| + if (index !== -1) { | ||
| + this.children.splice(index, 1); |
1 check passed
|
https://github.com/facebook/react/blob/master/src/renderers/shared/fiber/ReactFiberReconciler.js#L173 Should be updated to return |
| @@ -462,7 +463,7 @@ module.exports = function<T, P, I, TI, C, CX>( | ||
| } | ||
| const ref = finishedWork.ref; | ||
| if (ref) { | ||
| - const instance = finishedWork.stateNode; | ||
| + const instance = getPublicInstance(finishedWork.stateNode); |
This is not type safe for the case finishedWork.tag === ClassComponent.
finishedWork.stateNode on a a class component will be the instance of the class. That doesn't match the generic types I | TI. I think it only accidentally works on runtime now because you assume they won't have a .tag field with the string INSTANCE so they fallback.
You can make separate branches by switching on finishedWork.tag and only use getPublicInstance on the branch on HostComponent.
| + | ||
| + useSyncScheduling: true, | ||
| + | ||
| + getPublicInstance(inst) { |
Hm. It's strange that you're allowed to avoid the type annotation here. It probably leads to some overly optimistic analysis by Flow. We should add a type annotation here. It's important to do in the renderers since otherwise the generic arguments can be inferred to be very weak.
Related, how can I type the createNodeMock function so PI is properly inferred?

This is the start of reimplementing
ReactTestRendereron Fiber. Current tests status:unstable_batchedUpdatesA good chunk of those tests have to do with refs and the ability to provide a
createNodeMockfunction. I believe thatReactFiberReconcilerwill need to be updated with agetPublicInstancemethod to handle this.Ignore all changes in
ReactTestRenderer-test.js. The first is to import the fiber renderer instead of stack (actually, is there a better way to do this? should I copy the ReactTestRenderer-test for ReactTestFiberRenderer?), the rest are testing/skip/etc and will be backed out when done.cc @spicyj