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

Add custom Preact shallow diff algorithm #223

Merged
merged 23 commits into from
Dec 7, 2022
Merged

Conversation

andrewiggins
Copy link
Member

@andrewiggins andrewiggins commented Nov 18, 2022

This PR implements a custom Preact shallow diff algorithm to better support shallow rendering and better match enzyme's React16Adapter's behavior. Below is a brief summary of the major changes.

This PR does not integrate the diff into any Enzyme adapter yet. That'll come in a follow up PR. I figured this PR was already very big and I didn't want to increase it more 😅

Preact10ShallowDiff

Adds Preact10ShallowDiff class modeled after the ReactShallowRenderer from the react-shallow-renderer package now maintained by enzyme. Preact10ShallowDiff Includes modified copies of Preact source to perform shallow rendering. The functions copied from preact and any modifications are commented as such.

New Babel transform step

Because the core diff algorithm is taken from Preact, the source of the function uses friendly internal property names (.e.g. _component). However, the built code that runs must use the mangled property names (e.g. __c) to interoperate with createElement and hooks. So I added an post build step that runs a babel transform on the Preact10ShallowDiff file to transform the property names to their mangled names.

New Tests

I've copied and TypeScript-ify the test suite from react-shallow-renderer. I'm running these tests in separate mocha run cuz this test suite requires using preact/compat and I didn't want to affect other tests. Also, the shallow diff algorithm should run without JSDOM so this separate mocha run doesn't setup JSDOM.

If you are curious, there is a separate commit for converting each test file from the original source to TypeScript and Preact if you want to see how I changed the tests.

@andrewiggins andrewiggins force-pushed the preact-shallow-diff branch 4 times, most recently from 370d499 to 51953bc Compare November 21, 2022 19:56
@andrewiggins andrewiggins force-pushed the preact-shallow-diff branch 3 times, most recently from 67e2485 to d048d6e Compare November 22, 2022 09:33
Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

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

I have had a look through this, and a skim through the copied tests. The main feedback I have is that, since this code is different from the rest of the codebase in a number of ways, and is copied directly from other codebases whose licenses we should reference, I suggest we put it in a separate subdirectory of src/.

I'm also thinking that in documentation for this package we should describe this alternate shallow renderer as existing primarily for compatibility with the React shallow renderer and to assist with migrating existing test suites, and not something that would be recommended for pure-Preact projects.

scripts/transform-internal-properties.mjs Outdated Show resolved Hide resolved
src/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
test/Preact10ShallowDiff/Preact10ShallowDiffMemo_test.tsx Outdated Show resolved Hide resolved
@andrewiggins
Copy link
Member Author

@robertknight Updated with your suggestions. Let me know if I understood and followed through correctly.

In a follow up PR I'm going to add a new Enzyme renderer (like the existing ShallowRenderer.tsx) that uses this class to shallowly renderer components. In other words, a future PR will add the code that integrates the Preact10ShallowDiff class with enzyme behind a flag. In that PR, we'll work on the public facing name for this feature and documentation :)

Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

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

The changes look good, although I noted a mismatch in the names of the src/ vs test/ sub-directories. I also had a few more comments/questions.

src/compat-shallow-renderer/Readme.md Outdated Show resolved Hide resolved
src/compat-shallow-renderer/Readme.md Outdated Show resolved Hide resolved
test/custom-shallow-renderer/Readme.md Outdated Show resolved Hide resolved
test/custom-shallow-renderer/LICENSE Outdated Show resolved Hide resolved
src/compat-shallow-renderer/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/compat-shallow-renderer/preact10-src.ts Outdated Show resolved Hide resolved

// These tests are from react-shallow-renderer which uses Jest for testing, so
// here is a lightweight adapter around `chai` to give it the same API as Jest's
// `expect` so we don't have to rewrite all the `expect` statements to `assert`
Copy link
Member

Choose a reason for hiding this comment

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

Chai does have an expect API. Can that be used directly or does it differ from Jest's expect API significantly?

Copy link
Member Author

Choose a reason for hiding this comment

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

It does differ. Chai's expect API would be something like expect(actual).to.equal(expected) whereas jest would be expect(actual).toBe(expected).

I thought about wrapping using Chai's expect in this helper but I figured it would be simpler to just stick with the assertion style we are already using

// The Preact diff uses `__v` to shortcut diffing VNodes that haven't
// changed since creation (it treats VNodes as immutable). So when Preact
// checks if `oldVNode.__v == newVNode.__v`, setting `__v` to NaN will
// always return false, per the rules of JavaScript (NaN !== NaN in JS).
Copy link
Member

Choose a reason for hiding this comment

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

I presume React doesn't have this short-circuiting behavior?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure. It might have something similar but I think it does it it in a different way that doesn't rely on private VNode properties (it has been a while since I looked).


// Override the vnodeId (`__v`) to NaN so that we can compare VNodes in
// tests and verify expected VNode output. We choose NaN here because it
// successfully threads the desired behavior we want for these tests.
Copy link
Member

Choose a reason for hiding this comment

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

To check my understanding here, what you're doing is disabling a Preact optimization in order to make Preact vnodes behave more like React vnodes and thereby enable a test suite originally written for React to pass?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yea, close. More precisely, I'm' trying to enable tests that do expect(<div />).toBe(<div />) which some our tests do. Our tests instead use the stripInternalVNodeFields to get around this __v issue. I went with this approach instead of using stripInternalVNodeFields just cuz it would be an easier change given the number of tests that compare VNodes.

It does disable a Preact optimization which is unfortunate though. The explicit goal isn't to make Preact VNodes behave like React VNodes but this change does make that more true.

Copy link
Member Author

@andrewiggins andrewiggins Nov 25, 2022

Choose a reason for hiding this comment

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

Actually, an idea a coworker had (we also have quite a few tests that do expect(<div />).toBe(<div />) at work) is to make all of the internal VNodes field non-enumerable. Doing this would remove the need for the stripInternalVNodeFields helper altogether.

I was thinking about doing that in a separate PR but could bring it here if you wanted since we are talking about it. Here is the relevant commit.

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, turns out my "make all internal fields non-enumerable" doesn't work as expected. Preact does iterate over internal VNode props to clone VNodes when rerendering so they need to be enumerable.

However, the __v field is specially handled so we can make just that property non-enumerable. I didn't see a place where that would break Preact but I think would enable doing things like expect(<div />).toEqual(<div />). Again, I could do this in a follow up PR or bring it here. My default would be to do it in a separate PR.

Copy link
Member Author

@andrewiggins andrewiggins left a comment

Choose a reason for hiding this comment

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

Thanks for the thorough review and additional comments! Keep 'em coming :)


// These tests are from react-shallow-renderer which uses Jest for testing, so
// here is a lightweight adapter around `chai` to give it the same API as Jest's
// `expect` so we don't have to rewrite all the `expect` statements to `assert`
Copy link
Member Author

Choose a reason for hiding this comment

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

It does differ. Chai's expect API would be something like expect(actual).to.equal(expected) whereas jest would be expect(actual).toBe(expected).

I thought about wrapping using Chai's expect in this helper but I figured it would be simpler to just stick with the assertion style we are already using

test/custom-shallow-renderer/LICENSE Outdated Show resolved Hide resolved
src/compat-shallow-renderer/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/compat-shallow-renderer/Preact10ShallowDiff.ts Outdated Show resolved Hide resolved
src/compat-shallow-renderer/preact10-src.ts Outdated Show resolved Hide resolved
// The Preact diff uses `__v` to shortcut diffing VNodes that haven't
// changed since creation (it treats VNodes as immutable). So when Preact
// checks if `oldVNode.__v == newVNode.__v`, setting `__v` to NaN will
// always return false, per the rules of JavaScript (NaN !== NaN in JS).
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure. It might have something similar but I think it does it it in a different way that doesn't rely on private VNode properties (it has been a while since I looked).


// Override the vnodeId (`__v`) to NaN so that we can compare VNodes in
// tests and verify expected VNode output. We choose NaN here because it
// successfully threads the desired behavior we want for these tests.
Copy link
Member Author

Choose a reason for hiding this comment

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

Yea, close. More precisely, I'm' trying to enable tests that do expect(<div />).toBe(<div />) which some our tests do. Our tests instead use the stripInternalVNodeFields to get around this __v issue. I went with this approach instead of using stripInternalVNodeFields just cuz it would be an easier change given the number of tests that compare VNodes.

It does disable a Preact optimization which is unfortunate though. The explicit goal isn't to make Preact VNodes behave like React VNodes but this change does make that more true.


// Override the vnodeId (`__v`) to NaN so that we can compare VNodes in
// tests and verify expected VNode output. We choose NaN here because it
// successfully threads the desired behavior we want for these tests.
Copy link
Member Author

@andrewiggins andrewiggins Nov 25, 2022

Choose a reason for hiding this comment

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

Actually, an idea a coworker had (we also have quite a few tests that do expect(<div />).toBe(<div />) at work) is to make all of the internal VNodes field non-enumerable. Doing this would remove the need for the stripInternalVNodeFields helper altogether.

I was thinking about doing that in a separate PR but could bring it here if you wanted since we are talking about it. Here is the relevant commit.

@andrewiggins
Copy link
Member Author

(side note: I've been resolving comments involving code changes that I do, as a way for me to track that I've done them. If that makes it more difficult to follow up and track your comments and my replies, let me know. I can stop doing that 😅)

@robertknight
Copy link
Member

Sorry to keep you waiting on this Andre. I'll be taking another look today.

Copy link
Member

@robertknight robertknight left a comment

Choose a reason for hiding this comment

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

Hello Andre,

Thanks for your patience with this. The organizational changes for src/compat-shallow-renderer and READMEs look good. I have largely skimmed the source files which have been copied and adapted from the corresponding react-shallow-renderer sources. When we come to integrate this into the adapter, I would suggest setting things up in such a way that the compat-shallow-renderer code is only included in test bundles for projects that actually use it.

"test": "nyc mocha -r build/test/init.js build/test/*.js && nyc mocha build/test/compat-shallow-renderer",
"posttest": "rm build/package.json",
"pretest-cjs": "yarn build-cjs",
"test-cjs": "nyc mocha -r build-cjs/test/init.js build-cjs/test/*.js && nyc mocha build-cjs/test/compat-shallow-renderer",
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we could think about dropping support for the CommonJS build at some point and going ESM-only.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup. Perhaps in the next major?

import { Fragment } from 'preact';

// This makes enzyme debug output easier to work with by giving Fragments a name that isn't minified
Fragment.displayName = 'Fragment';
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, it might be better to put this in one of the entry points for this library to avoid a potentially surprising side-effect of importing this package.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, yea good call. Since this is a side-effect, I'm going to remove this from the src and put in the test/compat-shallow-renderer/utils.ts for now to avoid the side-effect altogether at the moment. I think I'll add it only if the compat shallow renderer is used so it only gets installed if the compat behavior is opted into. That way the side-effect is considered part of compat side-effects only.

I'll do that in the next PR where I integrate the shallow renderer into enzyme and determine how to only include the files if they are opted into (so side-effects like this one are opted into)

return matchers;
}

export function installVNodeTestHook() {
Copy link
Member

Choose a reason for hiding this comment

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

Am I correct in understanding that once this test hook is installed in a process, it will affect all tests run afterwards? So this means that we need to ensure that the shallow renderer tests are run in a separate process from the other tests.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, that's correct. Agreed! The compat-shallow-renderer tests are running in their own nyc call in the package.json: nyc mocha -r build/test/init.js build/test/*.js && nyc mocha build/test/compat-shallow-renderer. I had originally done that cuz I wanted these tests to run without jsdom and so that the preact/compat usage wouldn't affect other tests. But like you suggest, this vnode option hook is another good reason to have these tests run separately.

].forEach(expectLifecycleNotCalled(SomeComponent));
});

it('changes between two memoed components', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Can you clarify the significance of these tests? Is this to make sure memo doesn't share any state between different memoed components?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sort of. The shallow renderer has special code to handle how Preact supports memo. These tests capture some edge cases my original handling of memo missed. More specially, this test ensures line 194 in PreactShallowRenderer.ts (this._memoElement?.type === element.type) detects two different memo components. Two different memo components should fail this check (i.e. not have equal types) so the renderer will unmount the previous component and mount the new one.

@@ -0,0 +1,1606 @@
/**
Copy link
Member

Choose a reason for hiding this comment

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

I see this is adapted from https://github.com/enzymejs/react-shallow-renderer/blob/master/src/__tests__/ReactShallowRendererMemo-test.js upstream. This does seem like a lot of tests to cover the memo API though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Haha yea, agreed. I was surprised as well. It looks like many of the tests from PreactShallowRenderer_test.tsx are duplicated here but using memo. I suspect this is because the react shallow renderer has to implement the memo behavior itself (react-shallow-renderer runs comparer function).

Copy link
Member Author

@andrewiggins andrewiggins left a comment

Choose a reason for hiding this comment

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

Thanks for taking the time to look again and all the great feedback. I really appreciate it <3

"test": "nyc mocha -r build/test/init.js build/test/*.js && nyc mocha build/test/compat-shallow-renderer",
"posttest": "rm build/package.json",
"pretest-cjs": "yarn build-cjs",
"test-cjs": "nyc mocha -r build-cjs/test/init.js build-cjs/test/*.js && nyc mocha build-cjs/test/compat-shallow-renderer",
Copy link
Member Author

Choose a reason for hiding this comment

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

Yup. Perhaps in the next major?

].forEach(expectLifecycleNotCalled(SomeComponent));
});

it('changes between two memoed components', () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

Sort of. The shallow renderer has special code to handle how Preact supports memo. These tests capture some edge cases my original handling of memo missed. More specially, this test ensures line 194 in PreactShallowRenderer.ts (this._memoElement?.type === element.type) detects two different memo components. Two different memo components should fail this check (i.e. not have equal types) so the renderer will unmount the previous component and mount the new one.

return matchers;
}

export function installVNodeTestHook() {
Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, that's correct. Agreed! The compat-shallow-renderer tests are running in their own nyc call in the package.json: nyc mocha -r build/test/init.js build/test/*.js && nyc mocha build/test/compat-shallow-renderer. I had originally done that cuz I wanted these tests to run without jsdom and so that the preact/compat usage wouldn't affect other tests. But like you suggest, this vnode option hook is another good reason to have these tests run separately.

@@ -0,0 +1,1606 @@
/**
Copy link
Member Author

Choose a reason for hiding this comment

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

Haha yea, agreed. I was surprised as well. It looks like many of the tests from PreactShallowRenderer_test.tsx are duplicated here but using memo. I suspect this is because the react shallow renderer has to implement the memo behavior itself (react-shallow-renderer runs comparer function).

import { Fragment } from 'preact';

// This makes enzyme debug output easier to work with by giving Fragments a name that isn't minified
Fragment.displayName = 'Fragment';
Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, yea good call. Since this is a side-effect, I'm going to remove this from the src and put in the test/compat-shallow-renderer/utils.ts for now to avoid the side-effect altogether at the moment. I think I'll add it only if the compat shallow renderer is used so it only gets installed if the compat behavior is opted into. That way the side-effect is considered part of compat side-effects only.

I'll do that in the next PR where I integrate the shallow renderer into enzyme and determine how to only include the files if they are opted into (so side-effects like this one are opted into)

@andrewiggins andrewiggins merged commit 68b489f into master Dec 7, 2022
@andrewiggins andrewiggins deleted the preact-shallow-diff branch December 7, 2022 20:22
@andrewiggins
Copy link
Member Author

Per usual, if you think of something else you want to comment on, feel free to leave the comment here. Just @-mention me and I'll address it in a follow up PR :)

andrewiggins added a commit that referenced this pull request Dec 20, 2022
This PR adds a new `CompatShallowRenderer` that implements Enzyme's shallow renderer interface using the Preact shallow rendering algorithm from #223. Usage of the new renderer is behind the `shallowRenderer` flag.

* Add new ShallowRenderer that uses Preact10ShallowDiff

* WIP: Copy shallow rendering tests into a new a new ShallowRendererNew test file

* Add CHANGELOG entry

* Add tests for getElement, children, and findWhere involving Fragments

* Fix lifecycle tests

* Improve simulateError in new shallow renderer

* Modify props test to accommodate new shallow renderer behavior

* Preserve children prop in new shallow renderer

* Add tests for methods on mount

* Add test for shallow update, setState, and setProps

* Flatten nested arrays in rstNodeFromElement

* Only install shallow component hooks if PreactShallowRenderer is instantiated

* Implement displayNameOfNode if compat renderer is enabled

* Remove old TODO

* Ensure debounce hook is installed

* Move isErrorBoundary to module scope

* Add initial compat sub-package scaffolding

* Move compat renderer code to compat sub package

* Apply suggestions from code review

Co-authored-by: Robert Knight <robertknight@gmail.com>

* Fix Enzyme capitalization

* Add comment explaining PreactShallowRendererMemo tests

* Updates from PR

Co-authored-by: Robert Knight <robertknight@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants