From 0ed17e4cf470c49310c74d76d0596de739f325b8 Mon Sep 17 00:00:00 2001 From: Dominik Lubanski Date: Thu, 16 May 2019 09:39:43 +0200 Subject: [PATCH] fix(render): update sheduler refactor for performance boost Move from recursion to interation with deferred invalidate event --- docs/core-concepts/descriptors.md | 2 +- src/children.js | 4 +- src/define.js | 15 ++++-- src/parent.js | 20 ++------ src/render.js | 84 ++++++++++++++++++++----------- src/utils.js | 2 + test/spec/children.js | 4 +- test/spec/parent.js | 27 ++++++---- test/spec/render.js | 35 ++++++++----- 9 files changed, 120 insertions(+), 73 deletions(-) diff --git a/docs/core-concepts/descriptors.md b/docs/core-concepts/descriptors.md index bb2b1043..ad6a6bf3 100644 --- a/docs/core-concepts/descriptors.md +++ b/docs/core-concepts/descriptors.md @@ -158,7 +158,7 @@ You can use `connect` to attach event listeners, initialize property value (usin myElement.addEventListener('@invalidate', () => { ...}); ``` -When property cache value invalidates, change detection dispatches `@invalidate` custom event (composed and bubbling). You can listen to this event and observe changes in the element properties. It is dispatched implicit when you set new value by the assertion or explicit by calling `invalidate` in `connect` callback. The event type was chosen to avoid name collision with those created by the custom elements authors. +When property cache value invalidates, change detection dispatches `@invalidate` custom event (bubbling). You can listen to this event and observe changes in the element properties. It is dispatched implicit when you set new value by the assertion or explicit by calling `invalidate` in `connect` callback. The event type was chosen to avoid name collision with those created by the custom elements authors. If the third party code is responsible for the property value, you can use `invalidate` callback to update it and trigger event dispatch. For example, it can be used to connect to async web APIs or external libraries: diff --git a/src/children.js b/src/children.js index 19502c8f..de0a2979 100644 --- a/src/children.js +++ b/src/children.js @@ -1,3 +1,5 @@ +import { deferred } from './utils'; + function walk(node, fn, options, items = []) { Array.from(node.children).forEach((child) => { const hybrids = child.constructor.hybrids; @@ -24,7 +26,7 @@ export default function children(hybridsOrFn, options = { deep: false, nested: f const childEventListener = ({ target }) => { if (!set.size) { - Promise.resolve().then(() => { + deferred.then(() => { const list = host[key]; for (let i = 0; i < list.length; i += 1) { if (set.has(list[i])) { diff --git a/src/define.js b/src/define.js index 557fdd64..0a7f4e64 100644 --- a/src/define.js +++ b/src/define.js @@ -2,13 +2,22 @@ import property from './property'; import render from './render'; import * as cache from './cache'; -import { dispatch, pascalToDash } from './utils'; +import { dispatch, pascalToDash, deferred } from './utils'; /* istanbul ignore next */ try { process.env.NODE_ENV } catch(e) { var process = { env: { NODE_ENV: 'production' } }; } // eslint-disable-line +const dispatchSet = new Set(); + function dispatchInvalidate(host) { - dispatch(host, '@invalidate', { bubbles: true, composed: true }); + if (!dispatchSet.size) { + deferred.then(() => { + dispatchSet.forEach(target => dispatch(target, '@invalidate', { bubbles: true })); + dispatchSet.clear(); + }); + } + + dispatchSet.add(host); } const defaultMethod = (host, value) => value; @@ -73,7 +82,7 @@ if (process.env.NODE_ENV !== 'production') { const updateQueue = new Map(); update = (Hybrid, lastHybrids) => { if (!updateQueue.size) { - Promise.resolve().then(() => { + deferred.then(() => { walkInShadow(document.body, (node) => { if (updateQueue.has(node.constructor)) { const hybrids = updateQueue.get(node.constructor); diff --git a/src/parent.js b/src/parent.js index 9ac43ce8..0b280264 100644 --- a/src/parent.js +++ b/src/parent.js @@ -1,10 +1,3 @@ -const map = new WeakMap(); - -document.addEventListener('@invalidate', (event) => { - const set = map.get(event.composedPath()[0]); - if (set) set.forEach(fn => fn()); -}); - function walk(node, fn) { let parentElement = node.parentElement || node.parentNode.host; @@ -28,18 +21,15 @@ export default function parent(hybridsOrFn) { get: host => walk(host, fn), connect(host, key, invalidate) { const target = host[key]; + const cb = (event) => { + if (event.target === target) invalidate(false); + }; if (target) { - let set = map.get(target); - if (!set) { - set = new Set(); - map.set(target, set); - } - - set.add(invalidate); + target.addEventListener('@invalidate', cb); return () => { - set.delete(invalidate); + target.removeEventListener('@invalidate', cb); invalidate(); }; } diff --git a/src/render.js b/src/render.js index 8a92c2e1..365bad8f 100644 --- a/src/render.js +++ b/src/render.js @@ -1,50 +1,74 @@ -import { shadyCSS } from './utils'; +import { deferred, shadyCSS } from './utils'; const map = new WeakMap(); const cache = new WeakMap(); -const FPS_THRESHOLD = 1000 / 60; // 60 FPS ~ 16,67ms time window + let queue = []; +let index = 0; +let startTime = 0; + +const FPS_THRESHOLD = 1000 / 60; // 60 FPS ~ 16,67ms time window + +export function update() { + try { + if (!startTime) { + startTime = performance.now(); + } + + for (; index < queue.length; index += 1) { + const target = queue[index]; -export function update(index = 0, startTime = 0) { - if (startTime && (performance.now() - startTime > FPS_THRESHOLD)) { - requestAnimationFrame(() => update(index)); - } else { - const target = queue[index]; - const nextTime = performance.now(); - - if (!target) { - shadyCSS(shady => queue.forEach(t => shady.styleSubtree(t))); - queue = []; - } else { if (map.has(target)) { const key = map.get(target); const prevUpdate = cache.get(target); - try { - const nextUpdate = target[key]; - if (nextUpdate !== prevUpdate) { - cache.set(target, nextUpdate); - nextUpdate(); - if (!prevUpdate) shadyCSS(shady => shady.styleElement(target)); + const nextUpdate = target[key]; + + if (nextUpdate !== prevUpdate) { + cache.set(target, nextUpdate); + nextUpdate(); + if (!prevUpdate) { + shadyCSS(shady => shady.styleElement(target)); + } else { + shadyCSS(shady => shady.styleSubtree(target)); } - } catch (e) { - update(index + 1, nextTime); - throw e; + } + + if (index + 1 < queue.length && (performance.now() - startTime) > FPS_THRESHOLD) { + throw queue; } } - update(index + 1, nextTime); } + + queue = []; + index = 0; + deferred.then(() => { startTime = 0; }); + } catch (e) { + index += 1; + requestAnimationFrame(update); + deferred.then(() => { startTime = 0; }); + + if (e !== queue) throw e; } } function addToQueue(event) { - const target = event.composedPath()[0]; - if (target === event.currentTarget) { - if (!queue[0]) { - requestAnimationFrame((() => update())); - } - if (queue.indexOf(target) === -1) { - queue.push(target); + if (event.target === event.currentTarget && map.has(event.target)) { + if (!startTime) { + if (!queue.length) { + requestAnimationFrame(update); + } else { + queue.splice(index, 0, event.target); + return; + } + } else if (!queue.length) { + if ((performance.now() - startTime) > FPS_THRESHOLD) { + requestAnimationFrame(update); + } else { + deferred.then(update); + } } + + queue.push(event.target); } } diff --git a/src/utils.js b/src/utils.js index 1b1b23d7..eb24d380 100644 --- a/src/utils.js +++ b/src/utils.js @@ -27,3 +27,5 @@ export function stringifyElement(element) { } export const IS_IE = 'ActiveXObject' in window; + +export const deferred = Promise.resolve(); diff --git a/test/spec/children.js b/test/spec/children.js index 83e74fb0..32ee915b 100644 --- a/test/spec/children.js +++ b/test/spec/children.js @@ -74,7 +74,7 @@ describe('children:', () => { }); })); - it('updates parent computed property', done => tree((el) => { + it('updates parent computed property', done => tree(el => resolveTimeout(() => { expect(el.customName).toBe('one'); el.children[0].customName = 'four'; let called = false; @@ -88,7 +88,7 @@ describe('children:', () => { expect(el.customName).toBe('four'); done(); }); - })); + }))); }); describe('function condition', () => { diff --git a/test/spec/parent.js b/test/spec/parent.js index c4ade7f3..2cc929e7 100644 --- a/test/spec/parent.js +++ b/test/spec/parent.js @@ -86,17 +86,26 @@ describe('parent:', () => { expect(el.parent).toBe(null); })); - it('updates child computed property', () => directParentTree((el) => { - const spy = jasmine.createSpy('event callback'); - const child = el.children[0]; + it('updates child computed property', done => directParentTree((el) => { + Promise.resolve().then(() => { + const spy = jasmine.createSpy('event callback'); + const child = el.children[0]; + + expect(el.customProperty).toBe('value'); + expect(child.computed).toBe('value other value'); - expect(el.customProperty).toBe('value'); - expect(child.computed).toBe('value other value'); + child.addEventListener('@invalidate', spy); - child.addEventListener('@invalidate', spy); - el.customProperty = 'new value'; + el.customProperty = 'new value'; - expect(child.computed).toBe('new value other value'); - expect(spy).toHaveBeenCalledTimes(1); + Promise.resolve().then(() => { + Promise.resolve().then(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(child.computed).toBe('new value other value'); + + done(); + }); + }); + }); })); }); diff --git a/test/spec/render.js b/test/spec/render.js index 839c5d0e..f58a594e 100644 --- a/test/spec/render.js +++ b/test/spec/render.js @@ -111,11 +111,13 @@ describe('render:', () => { }); test('')(() => { - expect(() => { - update(); - }).toThrow(); - fn = () => {}; - done(); + Promise.resolve().then(() => { + expect(() => { + update(); + }).toThrow(); + fn = () => {}; + done(); + }); }); }); @@ -126,11 +128,13 @@ describe('render:', () => { }); test('')(() => { - expect(() => { - update(); - }).toThrow(); - fn = () => {}; - done(); + Promise.resolve().then(() => { + expect(() => { + update(); + }).toThrow(); + fn = () => {}; + done(); + }); }); }); @@ -239,11 +243,18 @@ describe('render:', () => { } }); - it('uses styleElement and styleSubtree', done => tree(() => resolveTimeout(() => { + it('uses styleElement on first paint', done => tree(() => resolveTimeout(() => { expect(window.ShadyCSS.styleElement).toHaveBeenCalled(); - expect(window.ShadyCSS.styleSubtree).toHaveBeenCalled(); done(); }))); + + it('uses styleSubtree on sequential paint', done => tree(el => resolveRaf(() => { + el.value = 1; + return resolveTimeout(() => { + expect(window.ShadyCSS.styleSubtree).toHaveBeenCalled(); + done(); + }); + }))); }); describe('options object with shadowRoot option', () => {