Skip to content

Commit

Permalink
[lens] Native renderer (#36165)
Browse files Browse the repository at this point in the history
* Add nativerenderer component

* Use native renderer in app and editor frame
  • Loading branch information
flash1293 authored May 12, 2019
1 parent 3329f33 commit ddb68e2
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 41 deletions.
11 changes: 3 additions & 8 deletions x-pack/plugins/lens/public/app_plugin/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,18 @@
*/

import { I18nProvider } from '@kbn/i18n/react';
import React, { useCallback } from 'react';
import React from 'react';

import { EditorFrameSetup } from '../types';
import { NativeRenderer } from '../native_renderer';

export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) {
const renderFrame = useCallback(node => {
if (node !== null) {
editorFrame.render(node);
}
}, []);

return (
<I18nProvider>
<div>
<h1>Lens</h1>

<div ref={renderFrame} />
<NativeRenderer render={editorFrame.render} nativeProps={undefined} />
</div>
</I18nProvider>
);
Expand Down
59 changes: 27 additions & 32 deletions x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import React, { useReducer, useEffect } from 'react';
import { Datasource, Visualization } from '../types';
import { NativeRenderer } from '../native_renderer';

interface EditorFrameProps {
datasources: { [key: string]: Datasource };
Expand Down Expand Up @@ -81,41 +82,35 @@ export function EditorFrame(props: EditorFrameProps) {
<div>
<h2>Editor Frame</h2>

<div
ref={domElement => {
if (domElement) {
props.datasources[state.datasourceName].renderDataPanel(domElement, {
state: state.datasourceState,
setState: newState =>
dispatch({
type: 'UPDATE_DATASOURCE',
payload: newState,
}),
});
}
<NativeRenderer
render={props.datasources[state.datasourceName].renderDataPanel}
nativeProps={{
state: state.datasourceState,
setState: (newState: any) =>
dispatch({
type: 'UPDATE_DATASOURCE',
payload: newState,
}),
}}
/>

<div
ref={domElement => {
if (domElement) {
props.visualizations[state.visualizationName].renderConfigPanel(domElement, {
datasource: props.datasources[state.datasourceName].getPublicAPI(
state.datasourceState,
newState =>
dispatch({
type: 'UPDATE_DATASOURCE',
payload: newState,
})
),
state: state.visualizationState,
setState: newState =>
dispatch({
type: 'UPDATE_VISUALIZATION',
payload: newState,
}),
});
}
<NativeRenderer
render={props.visualizations[state.visualizationName].renderConfigPanel}
nativeProps={{
datasource: props.datasources[state.datasourceName].getPublicAPI(
state.datasourceState,
newState =>
dispatch({
type: 'UPDATE_DATASOURCE',
payload: newState,
})
),
state: state.visualizationState,
setState: (newState: any) =>
dispatch({
type: 'UPDATE_VISUALIZATION',
payload: newState,
}),
}}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/lens/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

export * from './types';

import { IScope } from 'angular';
import { render, unmountComponentAtNode } from 'react-dom';
import { IScope } from 'angular';
import chrome from 'ui/chrome';
import { appSetup, appStop } from './app_plugin';

Expand Down
7 changes: 7 additions & 0 deletions x-pack/plugins/lens/public/native_renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export * from './native_renderer';
187 changes: 187 additions & 0 deletions x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { render } from 'react-dom';
import { NativeRenderer } from './native_renderer';
import { act } from 'react-dom/test-utils';

function renderAndTriggerHooks(element: JSX.Element, mountpoint: Element) {
// act takes care of triggering state hooks
act(() => {
render(element, mountpoint);
});
}

describe('native_renderer', () => {
let mountpoint: Element;

beforeEach(() => {
mountpoint = document.createElement('div');
});

afterEach(() => {
mountpoint.remove();
});

it('should render element in container', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
const containerElement = mountpoint.firstElementChild;
expect(renderSpy).toHaveBeenCalledWith(containerElement, testProps);
});

it('should not render again if props do not change', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});

it('should not render again if props do not change shallowly', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={{ ...testProps }} />,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});

it('should not render again for unchanged callback functions', () => {
const renderSpy = jest.fn();
const testCallback = () => {};
const testState = { a: 'abc' };

render(
<NativeRenderer
render={renderSpy}
nativeProps={{ state: testState, setState: testCallback }}
/>,
mountpoint
);
render(
<NativeRenderer
render={renderSpy}
nativeProps={{ state: testState, setState: testCallback }}
/>,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});

it('should render again once if props change', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={{ a: 'def' }} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={{ a: 'def' }} />,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(2);
const containerElement = mountpoint.firstElementChild;
expect(renderSpy).lastCalledWith(containerElement, { a: 'def' });
});

it('should render again once if props is just a string', () => {
const renderSpy = jest.fn();
const testProps = 'abc';

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(<NativeRenderer render={renderSpy} nativeProps="def" />, mountpoint);
renderAndTriggerHooks(<NativeRenderer render={renderSpy} nativeProps="def" />, mountpoint);
expect(renderSpy).toHaveBeenCalledTimes(2);
const containerElement = mountpoint.firstElementChild;
expect(renderSpy).lastCalledWith(containerElement, 'def');
});

it('should render again if props are extended', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={{ a: 'abc', b: 'def' }} />,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(2);
const containerElement = mountpoint.firstElementChild;
expect(renderSpy).lastCalledWith(containerElement, { a: 'abc', b: 'def' });
});

it('should render again if props are limited', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc', b: 'def' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={{ a: 'abc' }} />,
mountpoint
);
expect(renderSpy).toHaveBeenCalledTimes(2);
const containerElement = mountpoint.firstElementChild;
expect(renderSpy).lastCalledWith(containerElement, { a: 'abc' });
});

it('should render a div as container', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} nativeProps={testProps} />,
mountpoint
);
const containerElement: Element = mountpoint.firstElementChild!;
expect(containerElement.nodeName).toBe('DIV');
});

it('should render a specified element as container', () => {
const renderSpy = jest.fn();
const testProps = { a: 'abc' };

renderAndTriggerHooks(
<NativeRenderer render={renderSpy} tag="span" nativeProps={testProps} />,
mountpoint
);
const containerElement: Element = mountpoint.firstElementChild!;
expect(containerElement.nodeName).toBe('SPAN');
});
});
80 changes: 80 additions & 0 deletions x-pack/plugins/lens/public/native_renderer/native_renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useEffect, useRef } from 'react';

export interface NativeRendererProps<T> {
render: (domElement: Element, props: T) => void;
nativeProps: T;
tag?: string;
children?: never;
}

function is(x: unknown, y: unknown) {
return (x === y && (x !== 0 || 1 / (x as number) === 1 / (y as number))) || (x !== x && y !== y);
}

function isShallowDifferent<T>(objA: T, objB: T): boolean {
if (is(objA, objB)) {
return false;
}

if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return true;
}

const keysA = Object.keys(objA) as Array<keyof T>;
const keysB = Object.keys(objB) as Array<keyof T>;

if (keysA.length !== keysB.length) {
return true;
}

for (let i = 0; i < keysA.length; i++) {
if (!window.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) {
return true;
}
}

return false;
}

/**
* A component which takes care of providing a mountpoint for a generic render
* function which takes an html element and an optional props object.
* It also takes care of calling render again if the props object changes.
* By default the mountpoint element will be a div, this can be changed with the
* `tag` prop.
*
* @param props
*/
export function NativeRenderer<T>({ render, nativeProps, tag }: NativeRendererProps<T>) {
const elementRef = useRef<Element | null>(null);
const propsRef = useRef<T | null>(null);

function renderAndUpdate(element: Element) {
elementRef.current = element;
propsRef.current = nativeProps;
render(element, nativeProps);
}

useEffect(
() => {
if (elementRef.current && isShallowDifferent(propsRef.current, nativeProps)) {
renderAndUpdate(elementRef.current);
}
},
[nativeProps]
);

return React.createElement(tag || 'div', {
ref: element => {
if (element && element !== elementRef.current) {
renderAndUpdate(element);
}
},
});
}

0 comments on commit ddb68e2

Please sign in to comment.