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

Support more component return types #221

Merged
merged 5 commits into from
Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Support more return types (e.g. booleans, numbers, BigInts) from components

- Add an option (`renderToString`) to allow passing in a custom string renderer
to use for Enzyme's 'string' renderer instead of rendering into the DOM and
reading the HTML output. It is expected that `renderToString` from
Expand Down
10 changes: 6 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComponentFactory, VNode } from 'preact';
import type { ComponentChild, VNode } from 'preact';
import 'enzyme';
import { ReactElement, ReactInstance } from 'react';
import type { ReactElement, ReactInstance } from 'react';

// Extensions to the Preact types for compatibility with the Enzyme types.
declare module 'preact' {
Expand All @@ -18,6 +18,8 @@ declare module 'preact' {
declare module 'enzyme' {
export type NodeType = 'function' | 'class' | 'host';

export type RSTNodeChild = Exclude<ComponentChild, VNode> | RSTNode;

/**
* A "React Standard Tree" node.
*
Expand All @@ -37,7 +39,7 @@ declare module 'enzyme' {
instance: any;

/** The result of the `render` function from this component. */
rendered: Array<RSTNode | string | null>;
rendered: Array<RSTNodeChild>;
}

/**
Expand Down Expand Up @@ -125,7 +127,7 @@ declare module 'enzyme' {
createRenderer(options: AdapterOptions): Renderer;
elementToNode(element: ReactElement): RSTNode;
isValidElement(el: ReactElement): boolean;
nodeToElement(node: RSTNode): ReactElement | string;
nodeToElement(node: RSTNodeChild): ReactElement | string;
nodeToHostNode(node: RSTNode): Node | null;

// Optional methods.
Expand Down
26 changes: 18 additions & 8 deletions src/Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AdapterOptions,
MountRendererProps,
RSTNode,
RSTNodeChild,
ShallowRendererProps,
} from 'enzyme';
import enzyme from 'enzyme';
Expand All @@ -15,7 +16,7 @@ import StringRenderer from './StringRenderer.js';
import { childElements } from './compat.js';
import { rstNodeFromElement } from './preact10-rst.js';
import RootFinder from './RootFinder.js';
import { nodeToHostNode } from './util.js';
import { isRSTNode, nodeToHostNode } from './util.js';

export const { EnzymeAdapter } = enzyme;

Expand Down Expand Up @@ -81,20 +82,29 @@ export default class Adapter extends EnzymeAdapter {
}
}

nodeToElement(node: RSTNode | string): ReactElement | string {
if (typeof node === 'string') {
return node;
nodeToElement(node: RSTNodeChild): ReactElement | string {
if (!isRSTNode(node)) {
return node as any;
}
const childElements = node.rendered.map(n => this.nodeToElement(n as any));
return h(node.type as any, node.props, ...childElements) as ReactElement;

const props: any = { ...node.props };
if (node.key) {
props.key = node.key;
}
if (node.ref) {
props.ref = node.ref;
}

const childElements = node.rendered.map(n => this.nodeToElement(n));
return h(node.type as any, props, ...childElements) as ReactElement;
}

nodeToHostNode(node: RSTNode | string): Node | null {
nodeToHostNode(node: RSTNodeChild): Node | null {
return nodeToHostNode(node);
}

isValidElement(el: any) {
if (el == null) {
if (el == null || typeof el !== 'object') {
return false;
}
if (
Expand Down
21 changes: 12 additions & 9 deletions src/preact10-rst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
* The rendered result is converted to RST by traversing these vnode references.
*/

import type { NodeType, RSTNode } from 'enzyme';
import type { Component, VNode } from 'preact';
import { Fragment } from 'preact';
import type { NodeType, RSTNodeChild, RSTNode } from 'enzyme';
import type { Component, ComponentChild, VNode } from 'preact';
import { isValidElement, Fragment } from 'preact';

import { childElements } from './compat.js';
import {
Expand All @@ -22,7 +22,6 @@ import {
import { getRealType } from './shallow-render-utils.js';

type Props = { [prop: string]: any };
type RSTNodeTypes = RSTNode | string | null;

function stripSpecialProps(props: Props) {
const { children, key, ref, ...otherProps } = props;
Expand All @@ -42,7 +41,7 @@ function convertDOMProps(props: Props) {
/**
* Convert the rendered output of a vnode to RST nodes.
*/
function rstNodesFromChildren(nodes: (VNode | null)[] | null): RSTNodeTypes[] {
function rstNodesFromChildren(nodes: (VNode | null)[] | null): RSTNodeChild[] {
if (!nodes) {
return [];
}
Expand All @@ -59,14 +58,18 @@ function rstNodesFromChildren(nodes: (VNode | null)[] | null): RSTNodeTypes[] {
});
}

function rstNodeFromVNode(node: VNode | null): RSTNodeTypes | RSTNodeTypes[] {
function rstNodeFromVNode(node: VNode | null): RSTNodeChild | RSTNodeChild[] {
if (node == null) {
return null;
}

// Preact 10 represents text nodes as VNodes with `node.type == null` and
// `node.props` equal to the string content.
if (typeof node.props === 'string' || typeof node.props === 'number') {
if (
typeof node.props === 'string' ||
typeof node.props === 'number' ||
typeof node.props === 'bigint'
) {
return String(node.props);
}

Expand Down Expand Up @@ -112,8 +115,8 @@ function nodeTypeFromType(type: any): NodeType {
* Convert a JSX element tree returned by Preact's `h` function into an RST
* node.
*/
export function rstNodeFromElement(node: VNode | null | string): RSTNodeTypes {
if (node == null || typeof node === 'string') {
export function rstNodeFromElement(node: ComponentChild): RSTNodeChild {
if (!isValidElement(node)) {
return node;
}
const children = childElements(node).map(rstNodeFromElement);
Expand Down
18 changes: 15 additions & 3 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RSTNode } from 'enzyme';
import type { RSTNode, RSTNodeChild } from 'enzyme';
import type { VNode } from 'preact';

export function getType(obj: Object) {
Expand Down Expand Up @@ -57,13 +57,25 @@ export function toArray(obj: any) {
return Array.isArray(obj) ? obj : [obj];
}

export function isRSTNode(node: RSTNodeChild): node is RSTNode {
return (
node != null &&
typeof node == 'object' &&
'nodeType' in node &&
(node.nodeType === 'host' ||
node.nodeType === 'class' ||
node.nodeType === 'function') &&
'rendered' in node
);
}

/**
* @param node The node to start searching for a host node
* @returns The first host node in the children of the passed in node. Will
* return the passed in node if it is a host node
*/
export function nodeToHostNode(node: RSTNode | string | null): Node | null {
if (node == null || typeof node == 'string') {
export function nodeToHostNode(node: RSTNodeChild): Node | null {
if (!isRSTNode(node)) {
// Returning `null` here causes `wrapper.text()` to return nothing for a
// wrapper around a `Text` node. That's not intuitive perhaps, but it
// matches the React adapters' behaviour.
Expand Down
126 changes: 77 additions & 49 deletions test/Adapter_test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { VNode } from 'preact';
import * as preact from 'preact';
import { assert } from 'chai';
import type { RSTNode } from 'enzyme';
Expand All @@ -7,28 +6,7 @@ import Adapter from '../src/Adapter.js';
import MountRenderer from '../src/MountRenderer.js';
import ShallowRenderer from '../src/ShallowRenderer.js';
import StringRenderer from '../src/StringRenderer.js';

/**
* Return a deep copy of a vnode, omitting internal fields that have a `__`
* prefix.
*
* Stripping private fields is useful when comparing vnodes because the private
* fields may differ even if the VNodes are logically the same value. For example
* in some Preact versions VNodes include an ID counter field.
*/
function stripInternalVNodeFields(obj: object) {
const result = {} as Record<string, any>;
for (const [key, value] of Object.entries(obj)) {
if (!key.startsWith('__')) {
if (typeof value === 'object' && value !== null) {
result[key] = stripInternalVNodeFields(value);
} else {
result[key] = value;
}
}
}
return result;
}
import { stripInternalVNodeFields } from './shared.js';

describe('Adapter', () => {
it('adds `type` and `props` attributes to VNodes', () => {
Expand Down Expand Up @@ -98,18 +76,14 @@ describe('Adapter', () => {
});

describe('#nodeToElement', () => {
function stripPrivateKeys<T>(obj: T) {
const result: any = { ...obj };
Object.keys(result).forEach(key => {
if (key.startsWith('_')) {
delete result[key];
}
});
return result;
}
// Conversion from Preact elements to RST nodes is a lossy process because
// we clear the children prop, so converting back (RST nodes to Preact
// elements) will also be lossy. We have commented out that tests that do
// not pass due to this lossy behavior. Perhaps in the future we should
// investigate and fix this.
andrewiggins marked this conversation as resolved.
Show resolved Hide resolved

function TextComponent() {
return 'test' as unknown as VNode<any>;
return 'test' as unknown as preact.VNode<any>;
}

function Child() {
Expand All @@ -126,40 +100,94 @@ describe('Adapter', () => {

[
{
// Simple DOM element.
el: <button type="button">Click me</button>,
description: 'Simple DOM element',
element: <button type="button">Click me</button>,
},
{
// DOM elements with keys.
el: (
description: 'DOM elements with keys',
element: (
<ul>
<li key={1}>Test</li>
<li key={2}>Test</li>
</ul>
),
},
{
// DOM element with ref.
el: <div ref={() => {}} />,
description: 'DOM element with ref',
element: <div ref={() => {}} />,
},
{
description: 'Component that renders text',
element: <TextComponent />,
expected: {
type: TextComponent,
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: 'test',
},
},
},
{
// Component that renders text.
el: <TextComponent />,
description: 'Component with children',
element: <Parent />,
expected: {
type: Parent,
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: {
type: 'div',
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: {
type: Child,
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: {
type: 'span',
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: 'child',
},
},
},
},
},
},
},
},
},
{
// Component with children.
el: <Parent />,
description: 'Element with mixed typed children',
element: <div>{[null, undefined, true, false, 0, 1n, 'a string']}</div>,
expected: {
type: 'div',
constructor: undefined,
key: undefined,
ref: undefined,
props: {
children: ['0', '1', 'a string'],
},
},
},
].forEach(({ el }) => {
it('returns JSX element that matches original input', () => {
].forEach(({ description, element, expected }) => {
it(`returns JSX element that matches original input (${description})`, () => {
const renderer = new MountRenderer();
const el = <button type="button">Click me</button>;
renderer.render(el);
renderer.render(element);
const adapter = new Adapter();
const rstNode = renderer.getNode() as RSTNode;
assert.deepEqual(
stripPrivateKeys(adapter.nodeToElement(rstNode)),
stripPrivateKeys(el)
stripInternalVNodeFields(adapter.nodeToElement(rstNode)),
expected ?? stripInternalVNodeFields(element)
);
});
});
Expand Down
Loading