feat: hooks (#252)

This commit is contained in:
Miralem Drek
2020-01-10 17:34:16 +01:00
committed by GitHub
parent cf689997da
commit 3a11cbe4c1
9 changed files with 860 additions and 6 deletions

View File

@@ -13,7 +13,7 @@ describe('type', () => {
satisfies = sb.stub();
[{ default: create }] = aw.mock(
[
[require.resolve('@nebula.js/supernova'), () => SNFactory],
[require.resolve('@nebula.js/supernova'), () => ({ generator: SNFactory })],
['**/semver/functions/satisfies.js', () => satisfies],
['**/load.js', () => ({ load })],
],

View File

@@ -1,4 +1,4 @@
import SNFactory from '@nebula.js/supernova';
import { generator as SNFactory } from '@nebula.js/supernova';
import satisfies from 'semver/functions/satisfies';
import { load } from './load';

View File

@@ -0,0 +1,413 @@
/* eslint no-underscore-dangle: 0 */
import {
hook,
initiate,
teardown,
render,
runSnaps,
useState,
useEffect,
useMemo,
useModel,
useApp,
useGlobal,
useElement,
useSelections,
useTheme,
useLayout,
useTranslator,
onTakeSnapshot,
} from '../hooks';
const frame = () => new Promise(resolve => setTimeout(resolve));
describe('hooks', () => {
let c;
let sandbox;
let DEV;
before(() => {
sandbox = sinon.createSandbox();
DEV = global.__NEBULA_DEV__;
global.__NEBULA_DEV__ = true;
if (!global.requestAnimationFrame) {
global.requestAnimationFrame = setTimeout;
global.cancelAnimationFrame = clearTimeout;
}
});
after(() => {
global.__NEBULA_DEV__ = DEV;
});
afterEach(() => {
sandbox.restore();
});
it('hook should bind hooks to file scope', () => {
const fn = 'fn';
const h = hook(fn);
expect(h).to.eql({
__hooked: true,
fn: 'fn',
initiate,
render,
teardown,
runSnaps,
});
});
it('should throw when hook is used outside method context', () => {
const fn = () => useState(0);
expect(fn).to.throw('Invalid nebula hook call. Hooks can only be called inside a supernova component.');
});
it('should throw when hooks are used outside top level of method context', async () => {
c = {};
initiate(c);
const err = sandbox.stub(console, 'error');
c.fn = () => {
useEffect(() => {
useState(0);
});
};
render(c);
await frame();
expect(err.args[0][0].message).to.equal(
'Invalid nebula hook call. Hooks can only be called inside a supernova component.'
);
});
describe('teardown', () => {
it('should teardown hooks', () => {
const spy = sandbox.spy();
c = {
__hooks: {
list: [{ teardown: spy }],
pendingEffects: [],
},
};
teardown(c);
expect(spy.callCount).to.equal(1);
});
});
describe('runSnaps', () => {
it('should run snaps hooks', async () => {
const take1 = layout => {
return Promise.resolve({ take1: 'yes', ...layout });
};
c = {
__hooks: {
snaps: [{ fn: take1 }],
},
};
const s = await runSnaps(c, { a: '1' });
expect(s).to.eql({ a: '1', take1: 'yes' });
});
});
describe('useState', () => {
beforeEach(() => {
c = {};
initiate(c);
});
afterEach(() => {
teardown(c);
});
it('should initiate state with value', () => {
let countValue;
c.fn = () => {
[countValue] = useState(7);
};
render(c);
expect(countValue).to.equal(7);
});
it('should initiate state with function', () => {
let countValue;
c.fn = () => {
[countValue] = useState(() => 7);
};
render(c);
expect(countValue).to.equal(7);
});
it('should update state value', async () => {
let setter;
let countValue;
c.fn = () => {
[countValue, setter] = useState(7);
};
render(c);
expect(countValue).to.equal(7);
setter(12);
await frame();
expect(countValue).to.equal(12);
});
it('should throw error when setState is called on an unmounted component', async () => {
let setter;
let countValue;
c.fn = () => {
[countValue, setter] = useState(7);
};
await render(c);
c.__hooks.obsolete = true;
expect(setter).to.throw(
'Calling setState on an unmounted component is a no-op and indicates a memory leak in your component.'
);
expect(countValue).to.equal(7);
});
it('should update state value based on previous', async () => {
let setter;
let countValue;
c.fn = () => {
[countValue, setter] = useState(7);
};
render(c);
expect(countValue).to.equal(7);
setter(prev => prev + 2);
await frame();
expect(countValue).to.equal(9);
});
it('should not re-render when state has not changed', async () => {
let setter;
let num = 0;
c.fn = () => {
[, setter] = useState(7);
++num;
};
render(c);
expect(num).to.equal(1);
setter(7);
await frame();
expect(num).to.equal(1);
});
it('should re-render only once when multiple states have changed', async () => {
let setA;
let setB;
let setC;
let num = 0;
c.fn = () => {
[, setA] = useState(7);
[, setB] = useState(-4);
[, setC] = useState(0);
++num;
};
render(c);
expect(num).to.equal(1);
setA(8);
setB(-3);
setC(1);
await frame();
expect(num).to.equal(2);
});
});
describe('useMemo', () => {
beforeEach(() => {
c = {};
initiate(c);
});
afterEach(() => {
teardown(c);
});
it('should run only when deps change', () => {
const stub = sandbox.stub();
stub.onFirstCall().returns(5);
stub.onSecondCall().returns(6);
let dep = 'a';
let value;
c.fn = () => {
value = useMemo(stub, [dep]);
};
render(c);
render(c);
expect(value).to.equal(5);
dep = 'b';
render(c);
expect(value).to.equal(6);
});
});
describe('useEffect', () => {
beforeEach(() => {
c = {};
initiate(c);
});
afterEach(() => {
teardown(c);
});
it('without deps should run after every render', async () => {
const spy = sandbox.spy();
c.fn = () => {
useEffect(spy);
};
await render(c);
await render(c);
await render(c);
expect(spy.callCount).to.equal(3);
});
it('with empty deps should run after first render', async () => {
const spy = sandbox.spy();
c.fn = () => {
useEffect(spy, []);
};
await render(c);
await render(c);
await render(c);
expect(spy.callCount).to.equal(1);
});
it('with deps should run only when deps change', async () => {
const spy = sandbox.spy();
let dep1 = 'a';
let dep2 = 0;
c.fn = () => {
useEffect(spy, [dep1, dep2]);
};
await render(c);
dep1 = 'b';
await render(c);
expect(spy.callCount).to.equal(2);
dep2 = false;
await render(c);
expect(spy.callCount).to.equal(3);
});
it('should cleanup previous', async () => {
const spy = sandbox.spy();
const f = () => {
return spy;
};
c.fn = () => {
useEffect(f);
};
await render(c); // initial render
await render(c); // should cleanup previous effects on second render
expect(spy.callCount).to.equal(1);
});
});
describe('composed hooks', () => {
beforeEach(() => {
c = {};
c.context = {
model: 'model',
app: 'app',
global: 'global',
element: 'element',
selections: 'selections',
theme: 'theme',
layout: 'layout',
};
c.env = {
translator: 'translator',
};
initiate(c);
});
afterEach(() => {
teardown(c);
});
it('useModel', () => {
let value;
c.fn = () => {
value = useModel();
};
render(c);
expect(value).to.equal('model');
});
it('useApp', () => {
let value;
c.fn = () => {
value = useApp();
};
render(c);
expect(value).to.equal('app');
});
it('useGlobal', () => {
let value;
c.fn = () => {
value = useGlobal();
};
render(c);
expect(value).to.equal('global');
});
it('useElement', () => {
let value;
c.fn = () => {
value = useElement();
};
render(c);
expect(value).to.equal('element');
});
it('useSelections', () => {
let value;
c.fn = () => {
value = useSelections();
};
render(c);
expect(value).to.equal('selections');
});
it('useTheme', () => {
let value;
c.fn = () => {
value = useTheme();
};
render(c);
expect(value).to.equal('theme');
});
it('useLayout', () => {
let value;
c.fn = () => {
value = useLayout();
};
render(c);
expect(value).to.equal('layout');
});
it('useTranslator', () => {
let value;
c.fn = () => {
value = useTranslator();
};
render(c);
expect(value).to.equal('translator');
});
it('onTakeSnapshot', () => {
const spy = sandbox.spy();
c.fn = () => {
onTakeSnapshot(spy);
};
render(c);
c.__hooks.snaps[0].fn();
expect(spy.callCount).to.equal(1);
});
});
});

View File

@@ -5,6 +5,8 @@ import EventEmitter from 'node-event-emitter';
import JSONPatch from './json-patch';
import actionhero from './action-hero';
import { hook, render } from './hooks';
/**
* @interface SnComponent
* @alias SnComponent
@@ -45,7 +47,61 @@ const mixin = obj => {
return obj;
};
export default function create(generator, opts) {
function createWithHooks(generator, opts, env) {
if (__NEBULA_DEV__) {
if (generator.component.render !== render) {
// eslint-disable-next-line no-console
console.warn('Detected multiple supernova modules, this might cause problems.');
}
}
const c = {
context: {
element: undefined,
layout: {},
model: opts.model,
app: opts.app,
global: opts.global,
selections: opts.selections,
},
env,
fn: generator.component.fn,
created() {},
mounted(element) {
generator.component.initiate(c);
this.context = {
...this.context,
element,
};
},
render(r) {
this.context = {
...this.context,
...r.context,
layout: r.layout,
};
generator.component.render(this);
// TODO - deal with onRenderComplete
},
resize() {},
willUnmount() {
generator.component.teardown(this);
},
setSnapshotData(layout) {
return generator.component.runSnaps(this, layout);
},
destroy() {},
};
return [c, null];
}
function createClassical(generator, opts) {
if (__NEBULA_DEV__) {
// eslint-disable-next-line no-console
console.warn('Obsolete API - time to get hooked!');
}
const componentInstance = {
...defaultComponent,
};
@@ -95,6 +151,18 @@ export default function create(generator, opts) {
selections: opts.selections,
});
return [componentInstance, hero];
}
export default function create(generator, opts, env) {
if (typeof generator.component === 'function') {
generator.component = hook(generator.component);
}
const [componentInstance, hero] =
generator.component && generator.component.__hooked
? createWithHooks(generator, opts, env)
: createClassical(generator, opts);
const teardowns = [];
if (generator.qae.properties.onChange) {
@@ -149,7 +217,7 @@ export default function create(generator, opts) {
generator,
component: componentInstance,
selectionToolbar: {
items: hero.selectionToolbarItems,
items: hero ? hero.selectionToolbarItems : [],
},
destroy() {
teardowns.forEach(t => t());

267
apis/supernova/src/hooks.js Normal file
View File

@@ -0,0 +1,267 @@
/* eslint no-underscore-dangle: 0 */
/* eslint no-param-reassign: 0 */
/* eslint no-console: 0 */
// Hooks implementation heavily inspired by prect hooks
let currentComponent;
let currentIndex;
function depsChanged(prevDeps, deps) {
if (!prevDeps) {
return true;
}
if (deps.length !== prevDeps.length) {
return true;
}
for (let i = 0; i < deps.length; i++) {
if (prevDeps[i] !== deps[i]) {
return true;
}
}
return false;
}
export function initiate(component) {
component.__hooks = {
obsolete: false,
error: false,
list: [],
snaps: [],
pendingEffects: [],
};
}
export function teardown(component) {
component.__hooks.list.forEach(fx => {
try {
typeof fx.teardown === 'function' ? fx.teardown() : null;
} catch (e) {
console.error(e);
}
});
component.__hooks.obsolete = true;
component.__hooks.list.length = 0;
component.__hooks.pendingEffects.length = 0;
cancelAnimationFrame(component.__hooks.scheduled);
}
export async function render(component) {
if (component.__hooks.error || component.__hooks.obsolete) {
return;
}
currentIndex = -1;
currentComponent = component;
let num = -1;
if (currentComponent.__hooks.initiated) {
num = currentComponent.__hooks.list.length;
}
try {
currentComponent.fn.call(null);
} catch (e) {
console.error(e);
}
currentComponent.__hooks.initiated = true;
if (__NEBULA_DEV__) {
if (num > -1 && num !== currentComponent.__hooks.list.length) {
console.warn('Detected a change in the order of hooks called.');
}
}
const pending = currentComponent.__hooks;
pending.scheduled = null;
currentIndex = undefined;
currentComponent = undefined;
// eslint-disable-next-line consistent-return
return new Promise(resolve => {
setTimeout(() => {
afterRender(pending); // eslint-disable-line no-use-before-define
resolve();
}, 0);
});
}
export function runSnaps(component, layout) {
try {
return Promise.all(
component.__hooks.snaps.map(h => {
return Promise.resolve(h.fn(layout));
})
).then(snaps => {
return snaps[snaps.length - 1];
});
} catch (e) {
console.error(e);
}
return Promise.resolve();
}
function afterRender(hooks) {
try {
hooks.pendingEffects.forEach(fx => {
// teardown existing
typeof fx.teardown === 'function' ? fx.teardown() : null;
// update
fx.teardown = fx.value[0]();
});
} catch (e) {
console.error(e);
}
hooks.pendingEffects.length = 0;
}
function getHook(idx) {
if (typeof currentComponent === 'undefined') {
throw new Error('Invalid nebula hook call. Hooks can only be called inside a supernova component.');
}
const hooks = currentComponent.__hooks;
if (idx >= hooks.list.length) {
hooks.list.push({});
}
return hooks.list[idx];
}
function schedule(component) {
if (component.__hooks.scheduled) {
return;
}
component.__hooks.scheduled = requestAnimationFrame(() => {
render(component, true);
});
}
function useInternalContext(name) {
getHook(++currentIndex);
const ctx = currentComponent.context;
return ctx[name];
}
function useInternalEnv(name) {
getHook(++currentIndex);
const { env } = currentComponent;
return env[name];
}
// ======== EXTERNAL =========
export function hook(cb) {
return {
__hooked: true,
fn: cb,
initiate,
render,
teardown,
runSnaps,
};
}
export function useState(initial) {
const h = getHook(++currentIndex);
if (!h.value) {
// initiate
h.component = currentComponent;
const setState = s => {
if (h.component.__hooks.obsolete) {
if (__NEBULA_DEV__) {
throw new Error(
'Calling setState on an unmounted component is a no-op and indicates a memory leak in your component.'
);
}
return;
}
const v = typeof s === 'function' ? s(h.value[0]) : s;
if (v !== h.value[0]) {
h.value[0] = v;
schedule(h.component);
}
};
h.value = [typeof initial === 'function' ? initial() : initial, setState];
}
return h.value;
}
export function useEffect(cb, deps) {
if (__NEBULA_DEV__) {
if (typeof deps !== 'undefined' && !Array.isArray(deps)) {
throw new Error('Invalid dependencies. Second argument must be an array.');
}
}
const h = getHook(++currentIndex);
if (depsChanged(h.value ? h.value[1] : undefined, deps)) {
h.value = [cb, deps];
currentComponent.__hooks.pendingEffects.push(h);
}
}
export function useMemo(cb, deps) {
if (__NEBULA_DEV__) {
if (!deps || !deps.length) {
console.warn('useMemo called without dependencies.');
}
}
const h = getHook(++currentIndex);
if (depsChanged(h.value ? h.value[0] : undefined, deps)) {
h.value = [deps, cb()];
}
return h.value[1];
}
// ---- composed hooks ------
export function useModel() {
return useInternalContext('model');
}
export function useApp() {
return useInternalContext('app');
}
export function useGlobal() {
return useInternalContext('global');
}
export function useElement() {
return useInternalContext('element');
}
export function useSelections() {
return useInternalContext('selections');
}
export function useTheme() {
return useInternalContext('theme');
}
export function useLayout() {
return useInternalContext('layout');
}
export function useTranslator() {
return useInternalEnv('translator');
}
export function useBehaviour() {
return useInternalContext('permissions');
}
export function onTakeSnapshot(cb) {
const h = getHook(++currentIndex);
if (!h.value) {
h.value = 1;
currentComponent.__hooks.snaps.push(h);
}
h.fn = cb;
}

View File

@@ -1,3 +1,21 @@
import generator from './generator';
export default generator;
export { generator };
// core hooks
export { hook } from './hooks';
export { useEffect } from './hooks';
export { useState } from './hooks';
export { useMemo } from './hooks';
// composed hooks
export { useModel } from './hooks';
export { useApp } from './hooks';
export { useGlobal } from './hooks';
export { useElement } from './hooks';
export { useSelections } from './hooks';
export { useTheme } from './hooks';
export { useLayout } from './hooks';
export { useTranslator } from './hooks';
export { onTakeSnapshot } from './hooks';

View File

@@ -114,7 +114,7 @@ const config = (isEsm, dev = false) => {
output: {
file: path.resolve(targetDir, getFileName(isEsm ? 'esm' : '', dev)),
format: isEsm ? 'esm' : 'umd',
exports: 'default',
exports: targetName === 'supernova' ? 'named' : 'default',
name: umdName,
sourcemap: false,
banner,

View File

@@ -0,0 +1,46 @@
/* eslint import/no-extraneous-dependencies: 0 */
import { useState, useEffect, useLayout, useElement, useTheme, useTranslator } from '@nebula.js/supernova';
function sn() {
return {
component: () => {
const [count, setCount] = useState(0);
const element = useElement();
const translator = useTranslator();
const theme = useTheme();
const layout = useLayout();
useEffect(() => {
const listener = () => {
setCount(prev => prev + 1);
};
element.addEventListener('click', listener);
return () => {
element.removeEventListener('click', listener);
};
}, [element]);
element.innerHTML = `<div>
<div class="state">${count}</div>
<div class="layout">${layout.showTitles}</div>
<div class="translator">${translator.get('Common.Cancel')}</div>
<div class="theme">${theme.getColorPickerColor({ index: 2 })}</div>
</div>
`;
},
};
}
export default function fixture() {
return {
type: 'sn-mounted',
sn,
snConfig: {
context: {
permissions: ['passive', 'interact'],
},
},
};
}

View File

@@ -0,0 +1,42 @@
describe('hooks', () => {
const snSelector = '.nebulajs-sn';
before(async () => {
await page.goto(`${process.env.BASE_URL}/render/?fixture=./hooks/hooked.fix.js`);
await page.waitForSelector(snSelector, { visible: true });
});
it('should render with initial state', async () => {
const text = await page.$eval(`${snSelector} .state`, el => el.textContent);
expect(text).to.equal('0');
});
it('should update count state after click', async () => {
await page.click(snSelector);
await page.waitForFunction(
selector => document.querySelector(selector).textContent === '1',
{},
`${snSelector} .state`
);
const text = await page.$eval(`${snSelector} .state`, el => el.textContent);
expect(text).to.equal('1');
});
it('useLayout', async () => {
await page.click(snSelector);
const text = await page.$eval(`${snSelector} .layout`, el => el.textContent);
expect(text).to.equal('true');
});
it('useTranslator', async () => {
await page.click(snSelector);
const text = await page.$eval(`${snSelector} .translator`, el => el.textContent);
expect(text).to.equal('Cancel');
});
it('useTheme', async () => {
await page.click(snSelector);
const text = await page.$eval(`${snSelector} .theme`, el => el.textContent);
expect(text).to.equal('#a54343');
});
});