Skip to content

Commit

Permalink
Rewrite of useLayout to avoid a delayed rendering. Adding render-debo…
Browse files Browse the repository at this point in the history
…uncer. (#15)

* Rewriting useLayout to avoid a delayed rendering. Adding render-debouncer to speed up the rendering

* updating react to use 16.9.0-alpha to be able to await an act (pure test func)

* Fixing review comments
  • Loading branch information
Helene Rignér committed May 3, 2019
1 parent 8f0123a commit 3d9e25f
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 86 deletions.
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,31 @@
hamus.js is a collection of React hooks for sharing reusable functionality used when
dealing with Qlik Associative Engine projects.

- [`useModel`](./docs/useModel.md) — creates a session object from a definition.
- [`useLayout`](./docs/useLayout.md) — fetches the layout from a model, and updates the layout on model changes.
- [`usePicasso`](./docs/usePicasso.md) — renders a [picasso.js](https://github.com/qlik-oss/picasso.js) visualization to an element.

# Usage

You need to have React 16.8.1 or later installed to use React Hooks API. Some of the hooks requires other packages as well,
see each hook individually below.
You need to have React 16.8.1 or later installed to use the React Hooks API. Some of the hooks requires other packages as well,
see each hook individually above.

This module uses ES6 import syntax, which means that you need Babel to transpile the code.

You can import each hook individually, e.g `import { useModel } from 'hamus.js'`.
You can import each hook individually, e.g `import useModel from 'hamus.js/src/use-model'`,

- [`useModel`](./docs/useModel.md) — creates a session object from a definition.
- [`useLayout`](./docs/useLayout.md) — fetches the layout from a model, and updates the layout on model changes.
- [`usePicasso`](./docs/usePicasso.md) — renders a [picasso.js](https://github.com/qlik-oss/picasso.js) visualization to an element.
or you can use ES6 named imports, e.g. `import { useModel } from 'hamus.js'`.

When using ES6 named imports you might run into missing dependency errors from hooks that you don't actually use in your project.
To resolve these errors, you either have to install the dependencies, or you can tranform the named import statements to individual
import statements with `[babel-plugin-import](https://github.com/ant-design/babel-plugin-import)`. The `.babelrc` file will look something
like this:

```
"plugins": [
["import", { "libraryName": "hamus.js", "libraryDirectory": "src"}]
]
```

# Example

Expand Down
62 changes: 46 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
"react-use-promise": "0.2.0"
},
"peerDependencies": {
"picasso.js": "0.25.1",
"picasso-plugin-q": "0.25.1",
"react": "16.8.6",
"react-dom": "16.8.6",
"enigma.js": "2.4.0"
"picasso.js": ">=0.14.0",
"picasso-plugin-q": ">=0.14.0",
"react": "^16.8.0",
"react-dom": "^16.8.0",
"enigma.js": "^2.0.0"
},
"devDependencies": {
"@babel/core": "7.4.4",
Expand All @@ -53,19 +53,20 @@
"parcel-bundler": "1.12.3",
"picasso.js": "0.25.1",
"picasso-plugin-q": "0.25.1",
"react": "16.8.6",
"react-dom": "16.8.6",
"react": "16.9.0-alpha.0",
"react-dom": "16.9.0-alpha.0",
"jest-puppeteer": "4.1.1",
"coveralls": "3.0.3",
"react-testing-library": "7.0.0",
"react-test-renderer": "16.8.6"
"react-test-renderer": "16.9.0-alpha.0"
},
"jest": {
"collectCoverage": true,
"collectCoverageOnlyFrom": {
"src/use-layout.js": true,
"src/use-model.js": true,
"src/use-picasso.js": true
"src/use-picasso.js": true,
"src/render-debouncer.js": true
}
}
}
31 changes: 31 additions & 0 deletions src/render-debouncer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ReactDOM from 'react-dom';

let timer = null;
let pendingStateMutators = [];
let startTime = null;
function fireAllPendingMutators() {
ReactDOM.unstable_batchedUpdates(() => {
pendingStateMutators.forEach(mutator => mutator());
pendingStateMutators = [];
});
}

function debounce(stateMutator) {
pendingStateMutators.push(stateMutator);
if (timer != null) {
// Timer already pending
clearTimeout(timer);
} else {
// No timer pending, set start time
startTime = new Date().getTime();
}
const now = new Date().getTime();
const averageInterMutateInterval = (now - startTime) / pendingStateMutators.length;
const timerInterval = (averageInterMutateInterval < 32) ? averageInterMutateInterval * 3 + 4 : 100; // Never wait more than 100 ms
timer = setTimeout(() => {
fireAllPendingMutators();
timer = null;
}, timerInterval);
}

export default debounce;
35 changes: 19 additions & 16 deletions src/use-layout.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { useState, useEffect } from 'react';
import usePromise from 'react-use-promise';
import debounce from './render-debouncer';

export default function useLayout(model) {
const [changed, setChanged] = useState(null);
let canceled = false;
let layout = null;
let error = null;

[layout, error] = usePromise(() => {
if (!model || canceled) return null;
return model.getAppLayout ? model.getAppLayout() : model.getLayout();
}, [model, changed]);
const [error, setError] = useState(null);
const [layout, setLayout] = useState(null);

useEffect(() => {
if (!model) {
return undefined;
}
const modelChanged = () => {
setChanged(new Date());
if (!model) return undefined;
const fetchModel = async () => {
try {
const newLayout = model.getAppLayout ? await model.getAppLayout() : await model.getLayout();
if (!canceled) {
debounce(() => {
setLayout(newLayout);
});
}
} catch (err) {
setError(err);
}
};
model.on('changed', modelChanged);
model.on('changed', fetchModel);
fetchModel();

return () => {
canceled = true;
model.removeListener('changed', modelChanged);
model.removeListener('changed', fetchModel);
};
}, [model && model.id]);

Expand Down
73 changes: 34 additions & 39 deletions test/unit/use-layout.test.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,98 @@
import { renderHook, act } from 'react-hooks-testing-library';
import { useLayout } from '../../src/index';
import TestPromise from './test-promise';

describe('useLayout', () => {
let promise;
let model;

const mockLayout = {
id: 'myLayout',
};

beforeEach(() => {
promise = new TestPromise();
model = {
id: 'myModel',
on: jest.fn(),
removeListener: jest.fn(),
getLayout: jest.fn(() => promise),
getLayout: () => jest.fn().mockReturnValue(mockLayout),
};
});

test('should return undefined when no model is present', () => {
test('should return null when no model is present', () => {
const { result } = renderHook(() => useLayout(null));
const [layout, error] = result.current;
expect(layout).toBeUndefined();
expect(error).toBeUndefined();
expect(layout).toBeNull();
expect(error).toBeNull();
});

test('should return undefined on pending promise', () => {
test('should catch and return error', async () => {
const rejectedError = new Error('Error occurred');
model.getLayout = () => { throw rejectedError; };
const { result } = renderHook(() => useLayout(model));
const [layout, error] = result.current;
expect(layout).toBeUndefined();
expect(error).toBeUndefined();
});

test('should throw error on rejected promise', () => {
const { result } = renderHook(() => useLayout(model));
const rejectedError = 'Error occurred';
act(() => promise.reject(rejectedError));
const [layout, error] = result.current;
expect(layout).toBeUndefined();
expect(layout).toBeNull();
expect(error).toEqual(rejectedError);
});

test('should return layout object', () => {
const { result } = renderHook(() => useLayout(model));
act(() => promise.resolve(mockLayout));
test('should return layout object', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLayout(model));
await act(async () => waitForNextUpdate());
const [layout, error] = result.current;
expect(layout).toEqual(mockLayout);
expect(error).toBeUndefined();
expect(error).toBeNull();
});

test('should add listener for model changes', () => {
test('should add listener for model changes', async () => {
model.getLayout = jest.fn();
renderHook(() => useLayout(model));
act(() => promise.resolve(mockLayout));
expect(model.getLayout).toHaveBeenCalledTimes(1);
expect(model.on).toHaveBeenCalledTimes(1);
expect(model.on).toHaveBeenCalledWith('changed', expect.any(Function));
// fake a changed event
const modelChangedHandler = model.on.mock.calls[0][1];
modelChangedHandler();
expect(model.getLayout).toHaveBeenCalledTimes(2);
expect(model.removeListener).toHaveBeenCalledTimes(0);
});

test('should remove event listener for model changes on unmount', () => {
const { unmount } = renderHook(() => useLayout(model));
act(() => promise.resolve(mockLayout));
test('should remove event listener for model changes on unmount', async () => {
const { waitForNextUpdate, unmount } = renderHook(() => useLayout(model));
await act(async () => waitForNextUpdate());
unmount();
expect(model.removeListener).toHaveBeenCalledTimes(1);
expect(model.removeListener).toHaveBeenCalledWith('changed', expect.any(Function));
});

test('side effect should run when model is updated', () => {
const { rerender } = renderHook(() => useLayout(model));
act(() => promise.resolve(mockLayout));
test('side effect should run when model is updated', async () => {
const { rerender, waitForNextUpdate } = renderHook(() => useLayout(model));
await act(async () => waitForNextUpdate());
rerender();
expect(model.on).toHaveBeenCalledTimes(1);
model.id = 'myNewId';
rerender();
expect(model.on).toHaveBeenCalledTimes(2);
});

test('if model object is a Doc, getAppLayout should be called', () => {
test('if model object is a Doc, getAppLayout should be called', async () => {
model = {
id: 'myApp',
on: jest.fn(),
removeListener: jest.fn(),
getAppLayout: jest.fn(() => promise),
getAppLayout: jest.fn().mockReturnValue(mockLayout),
};
const { result } = renderHook(() => useLayout(model));
act(() => promise.resolve(mockLayout));
const { result, waitForNextUpdate } = renderHook(() => useLayout(model));
await act(async () => waitForNextUpdate());
const [layout, error] = result.current;
expect(model.getAppLayout).toBeCalledTimes(1);
expect(layout).toEqual(mockLayout);
expect(error).toBeUndefined();
expect(error).toBeNull();
});

test('layout should not be updated if component has been unmounted when promise resolves', () => {
test('layout should not be updated if component has been unmounted when promise resolves', async () => {
model.getLayout = jest.fn();
const { result, unmount } = renderHook(() => useLayout(model));
unmount();
act(() => promise.resolve(mockLayout));
const [layout, error] = result.current;
expect(layout).toBeUndefined();
expect(error).toBeUndefined();
expect(layout).toBeNull();
expect(error).toBeNull();
});
});

0 comments on commit 3d9e25f

Please sign in to comment.