diff --git a/docs/README.md b/docs/README.md index 4fe21f837..88d1c780f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -130,4 +130,5 @@ * [Selectors](/docs/api/selector.md) * [Change Log](/CHANGELOG.md) * [Future](/docs/future.md) + * [Adapter & Compatibility Proposal](/docs/future/compatibility.md) * [Contributing Guide](/CONTRIBUTING.md) diff --git a/docs/future.md b/docs/future.md index fc1c1b436..9ce5c318d 100644 --- a/docs/future.md +++ b/docs/future.md @@ -7,6 +7,11 @@ There are several things we'd like to address with Enzyme that often get asked. of projects that we plan on addressing in the near future: +### Enzyme Adapter & Compatibility + +[See the full proposal](future/compatibility.md) + + #### Improved CSS Selector Support Currently, "hierarchical" CSS selectors are not supported in Enzyme. That means that the only CSS diff --git a/docs/future/compatibility.md b/docs/future/compatibility.md new file mode 100644 index 000000000..276a6aba0 --- /dev/null +++ b/docs/future/compatibility.md @@ -0,0 +1,184 @@ +# Enzyme Adapter & Compatibility Proposal + + +## Motivation + +This proposal is attempting to address a handful of pain points that Enzyme has been +subject to for quite a while. This proposal has resulted mostly [#715](https://github.com/airbnb/enzyme/issues/715), +and a resulting discussion among core maintainers of this project. + +The desired results of this proposal are the following: + +1. Cleaner code, easier maintenance, less bug prone. + +By standardizing on a single tree specification, the implementation of Enzyme would no longer have +to take into account the matrix of supported structures and nuanced differences between different +versions of React, as well as to some extent the differences between `mount` and `shallow`. + +2. Additional libraries can provide compatible adapters + +React API-compatible libraries such as `preact` and `inferno` would be able to provide adapters to Enzyme +for their corresponding libraries, and be able to take full advantage of Enzyme's APIs. + +3. Better user experience (ie, bundlers won't complain about missing deps) + +Enzyme has had a long-standing issue with static-analysis bundlers such as Webpack and Browserify because +of our usage of internal React APIs. With this change, this would be minimized if not removed entirely, +since these things can be localized into the adapter modules, and users will only install the ones they need. + +Additionally, we can even attempt to remove the use of internal react APIs by lobbying for react-maintained packages +such as `react-test-renderer` to utilize the React Standard Tree (RST) format (details below). + +4. Standardization and interopability with other tools + +If we can agree on the tree format (specified below as "React Standard Tree"), other tools can start to use and +understand this format as well. Standardization is a good thing, and could allow tools to be built that maybe +don't even exist yet. + + +## Proposal + + +### React Standard Tree (RST) + +This proposal hinges on a standard tree specification. Keep in mind that this tree needs to account for more +than what is currently satisfied by the output of something like `react-test-renderer`, which is currently +only outputting the "host" nodes (ie, HTML elements). We need a tree format that allows for expressing a full +react component tree, including composite components. + +```js +// Strings and Numbers are rendered as literals. +type LiteralValue = string | number + +// A "node" in an RST is either a LiteralValue, or an RSTNode +type Node = LiteralValue | RSTNode + +// if node.type +type RenderedNode = RSTNode | [Node] + +type SourceLocation = {| + fileName: string + lineNumber: number +|} + +type NodeType = 'class' | 'function' | 'host'; + +// An RSTNode has this specific shape +type RSTNode = {| + // Either a string or a function. A string is considered a "host" node, and + // a function would be a composite component. It would be the component constructor or + // an SFC in the case of a function. + type: string | function; + + // This node's type + nodeType: NodeType; + + // The props object passed to the node, which will include `children` in its raw form, + // exactly as it was passed to the component. + props: object; + + // The backing instance to the node. Can be null in the case of "host" nodes and SFCs. + // Enzyme will expect instances to have the _public interface_ of a React Component, as would + // be expected in the corresponding React release returned by `getTargetVersion` of the + // renderer. Alternative React libraries can choose to provide an object here that implements + // the same interface, and Enzyme functionality that uses this will continue to work (An example + // of this would be the `setState()` prototype method). + instance: ComponentInstance?; + + // For a given node, this corresponds roughly to the result of the `render` function with the + // provided props, but transformed into an RST. For "host" nodes, this will always be `null` or + // an Array. For "composite" nodes, this will always be `null` or an `RSTNode`. + rendered: RenderedNode?; + + // an optional property with source information (useful in debug messages) that would be provided + // by this babel transform: https://babeljs.io/docs/plugins/transform-react-jsx-source/ + __source?: SourceLocation; +|} +``` + +### Enzyme Adapter Protocol + +**Definitions:** + +An `Element` is considered to be whatever data structure is returned by the JSX pragma being used. In the +react case, this would be the data structure returned from `React.createElement` + + +```js +type RendererOptions = { + // An optional predicate function that takes in an `Element` and returns + // whether or not the underlying Renderer should treat it as a "Host" node + // or not. This function should only be called with elements that are + // not required to be considered "host" nodes (ie, with a string `type`), + // so the default implementation of `isHost` is just a function that returns + // false. + ?isHost(Element): boolean; +} + +type EnzymeAdapter = { + // This is a method that will return a semver version string for the _react_ version that + // it expects enzyme to target. This will allow enzyme to know what to expect in the `instance` + // that it finds on an RSTNode, as well as intelligently toggle behavior across react versions + // etc. For react adapters, this will likely just be `() => React.Version`, but for other + // adapters for libraries like inferno or preact, it will allow those libraries to specify + // a version of the API that they are committing to. + getTargetApiVersion(): string; + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + createRenderer(?options: RendererOptions): EnzymeRenderer; + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + nodeToElement(RSTNode): Element; +} + +type EnzymeRenderer = { + // both initial render and updates for the renderer. + render(Element): void; + + // retrieve a frozen-in-time copy of the RST. + getNode(): RSTNode?; +} +``` + + +### Using different adapters with Enzyme + +At the top level, Enzyme would expose a `configure` method, which would allow for an `adapter` +option to be specified and globally configure Enzyme's adapter preference: + +```js +import Enzyme from 'enzyme'; +import ThirdPartyEnzymeAdapter from 'third-party-enzyme-adapter'; + +Enzyme.configure({ adapter: ThirdPartyEnzymeAdapter }); + +``` + +Additionally, each wrapper Enzyme exposes will allow for an overriding `adapter` option that will use a +given adapter for just that wrapper: + +```jsx +import { shallow } from 'enzyme'; +import ThirdPartyEnzymeAdapter from 'third-party-enzyme-adapter'; + +shallow(, { adapter: ThirdPartyEnzymeAdapter }); +``` + +Enzyme will build adapters for all major versions of React since React 0.13, though will deprecate +adapters as usage of a particular major version fades. + +```js +import React13Adapter from 'enzyme-adapter-react-13'; +import React14Adapter from 'enzyme-adapter-react-14'; +import React15Adapter from 'enzyme-adapter-react-15'; +// ... +``` + +### Validation + +Enzyme will provide an `validate(node): Error?` method that will traverse down a provided `RSTNode` and +return an `Error` if any deviations from the spec are encountered, and `null` otherwise. This will +provide a way for implementors of the adapters to determine whether or not they are in compliance or not. diff --git a/docs/future/rst_examples.js b/docs/future/rst_examples.js new file mode 100644 index 000000000..af2ea2ed7 --- /dev/null +++ b/docs/future/rst_examples.js @@ -0,0 +1,253 @@ +import Adapter from 'enzyme-adapter-foo'; + +let renderer; +let tree; + +// Example Components +// ================================================== + +// Composite returning host. no children props. +const Qoo = () => ( + Hello World! +); + +// composite returning host. passes through children. +const Foo = ({ className, children }) => ( +
+ Literal + {children} +
+); + +// composite returning composite. passes through children. +const Bar = ({ special, children }) => ( + + {children} + +); + +// composite return composite. no children props. +const Bam = () => ( + + + +); + +// Examples +// ================================================== + +// IMPORTANT NOTE: +// in these examples i'm excluding `children` from `props` so that +// it's easier to read the tree output. In reality, `children` will +// be present and unaltered in the props, however enzyme will +// not use it for traversal. + +renderer = Adapter.createRenderer({ + // this would be the default as well. + isHost: el => typeof el.type === 'string', +}); + +// Simple Example +renderer.render(); +// => + +// Expected HTML output: +// +// Hello World! + +// Conceptual debug output: +// +// +// Hello World! +// + +// Expected tree output: +// tree = renderer.getNode(); +tree = { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, +}; + +// Complex Example +renderer.render(); + +// Expected HTML output: +// +//
+// Literal +// Hello World! +//
+ +// Conceptual debug output: +// +// +// +// +//
+// Literal +// +// Hello World! +// +//
+//
+//
+//
+ +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'function', + props: { special: true }, + rendered: { + type: Foo, + nodeType: 'function', + props: { className: 'special' }, + rendered: { + type: 'div', + nodeType: 'host', + props: { className: 'Foo special' }, + rendered: [ + { + type: 'span', + nodeType: 'host', + props: { className: 'Foo2' }, + rendered: ['Literal'], + }, + { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, + }, + ], + }, + }, + }, +}; + + +renderer = Adapter.createRenderer({ + // this is "shallow", but only if we specify + // not to call this on the root node... which + // is kind of strange. + isHost: () => true, +}); + +renderer.render(); + +// Conceptual debug output: +// +// +// +// +// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'host', + props: { special: true }, + rendered: [ + { + type: Qoo, + nodeType: 'host', + props: {}, + rendered: null, + }, + ], + }, +}; + +renderer.render( +
+ +
+); + +// Conceptual debug output: +// +//
+// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: 'div', + nodeType: 'host', + props: {}, + rendered: [ + { + type: Foo, + props: {}, + nodeType: 'host', + rendered: null, + }, + ], +}; + +renderer = Adapter.createRenderer({ + // In this case, we treat `Bar` as a host node + // but `Qoo` is not, so gets rendered + isHost: el => [Bar].includes(el.type), +}); + +renderer.render(); + +// Conceptual debug output: +// +// +// +// Hello World! +// +// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'host', + props: { special: true }, + rendered: [ + { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, + }, + ], + }, +};