Skip to content

Commit 9141300

Browse files
authoredJul 13, 2020
fix(framework): make renderImmediately sync, fix lifecycle issues (#1929)
1 parent d55515f commit 9141300

File tree

6 files changed

+186
-82
lines changed

6 files changed

+186
-82
lines changed
 

‎packages/base/src/RenderQueue.js

+41-17
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,65 @@
1+
const MAX_PROCESS_COUNT = 10;
2+
13
class RenderQueue {
24
constructor() {
35
this.list = []; // Used to store the web components in order
4-
this.promises = new Map(); // Used to store promises for web component rendering
6+
this.lookup = new Set(); // Used for faster search
57
}
68

79
add(webComponent) {
8-
if (this.promises.has(webComponent)) {
9-
return this.promises.get(webComponent);
10+
if (this.lookup.has(webComponent)) {
11+
return;
1012
}
1113

12-
let deferredResolve;
13-
const promise = new Promise(resolve => {
14-
deferredResolve = resolve;
15-
});
16-
promise._deferredResolve = deferredResolve;
17-
1814
this.list.push(webComponent);
19-
this.promises.set(webComponent, promise);
15+
this.lookup.add(webComponent);
16+
}
17+
18+
remove(webComponent) {
19+
if (!this.lookup.has(webComponent)) {
20+
return;
21+
}
2022

21-
return promise;
23+
this.list = this.list.filter(item => item !== webComponent);
24+
this.lookup.delete(webComponent);
2225
}
2326

2427
shift() {
2528
const webComponent = this.list.shift();
2629
if (webComponent) {
27-
const promise = this.promises.get(webComponent);
28-
this.promises.delete(webComponent);
29-
return { webComponent, promise };
30+
this.lookup.delete(webComponent);
31+
return webComponent;
3032
}
3133
}
3234

33-
getList() {
34-
return this.list;
35+
isEmpty() {
36+
return this.list.length === 0;
3537
}
3638

3739
isAdded(webComponent) {
38-
return this.promises.has(webComponent);
40+
return this.lookup.has(webComponent);
41+
}
42+
43+
/**
44+
* Processes the whole queue by executing the callback on each component,
45+
* while also imposing restrictions on how many times a component may be processed.
46+
*
47+
* @param callback - function with one argument (the web component to be processed)
48+
*/
49+
process(callback) {
50+
let webComponent;
51+
const stats = new Map();
52+
53+
webComponent = this.shift();
54+
while (webComponent) {
55+
const timesProcessed = stats.get(webComponent) || 0;
56+
if (timesProcessed > MAX_PROCESS_COUNT) {
57+
throw new Error(`Web component processed too many times this task, max allowed is: ${MAX_PROCESS_COUNT}`);
58+
}
59+
callback(webComponent);
60+
stats.set(webComponent, timesProcessed + 1);
61+
webComponent = this.shift();
62+
}
3963
}
4064
}
4165

‎packages/base/src/RenderScheduler.js

+48-63
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,18 @@ import RenderQueue from "./RenderQueue.js";
22
import { getAllRegisteredTags } from "./CustomElementsRegistry.js";
33
import { isRtlAware } from "./locale/RTLAwareRegistry.js";
44

5-
const MAX_RERENDER_COUNT = 10;
65
const registeredElements = new Set();
76

8-
// Tells whether a render task is currently scheduled
9-
let renderTaskId;
10-
117
// Queue for invalidated web components
128
const invalidatedWebComponents = new RenderQueue();
139

1410
let renderTaskPromise,
15-
renderTaskPromiseResolve,
16-
taskResult;
11+
renderTaskPromiseResolve;
1712

1813
let mutationObserverTimer;
1914

15+
let queuePromise;
16+
2017
/**
2118
* Class that manages the rendering/re-rendering of web components
2219
* This is always asynchronous
@@ -27,77 +24,65 @@ class RenderScheduler {
2724
}
2825

2926
/**
30-
* Queues a web component for re-rendering
27+
* Schedules a render task (if not already scheduled) to render the component
28+
*
3129
* @param webComponent
30+
* @returns {Promise}
3231
*/
33-
static renderDeferred(webComponent) {
32+
static async renderDeferred(webComponent) {
3433
// Enqueue the web component
35-
const res = invalidatedWebComponents.add(webComponent);
34+
invalidatedWebComponents.add(webComponent);
3635

3736
// Schedule a rendering task
38-
RenderScheduler.scheduleRenderTask();
39-
return res;
37+
await RenderScheduler.scheduleRenderTask();
4038
}
4139

40+
/**
41+
* Renders a component synchronously
42+
*
43+
* @param webComponent
44+
*/
4245
static renderImmediately(webComponent) {
43-
// Enqueue the web component
44-
const res = invalidatedWebComponents.add(webComponent);
45-
46-
// Immediately start a render task
47-
RenderScheduler.runRenderTask();
48-
return res;
46+
webComponent._render();
4947
}
5048

5149
/**
52-
* Schedules a rendering task, if not scheduled already
50+
* Cancels the rendering of a component, added to the queue with renderDeferred
51+
*
52+
* @param webComponent
5353
*/
54-
static scheduleRenderTask() {
55-
if (!renderTaskId) {
56-
// renderTaskId = window.setTimeout(RenderScheduler.renderWebComponents, 3000); // Task
57-
// renderTaskId = Promise.resolve().then(RenderScheduler.renderWebComponents); // Micro task
58-
renderTaskId = window.requestAnimationFrame(RenderScheduler.renderWebComponents); // AF
59-
}
60-
}
61-
62-
static runRenderTask() {
63-
if (!renderTaskId) {
64-
renderTaskId = 1; // prevent another rendering task from being scheduled, all web components should use this task
65-
RenderScheduler.renderWebComponents();
66-
}
54+
static cancelRender(webComponent) {
55+
invalidatedWebComponents.remove(webComponent);
6756
}
6857

69-
static renderWebComponents() {
70-
// console.log("------------- NEW RENDER TASK ---------------");
71-
72-
let webComponentInfo,
73-
webComponent,
74-
promise;
75-
const renderStats = new Map();
76-
while (webComponentInfo = invalidatedWebComponents.shift()) { // eslint-disable-line
77-
webComponent = webComponentInfo.webComponent;
78-
promise = webComponentInfo.promise;
79-
80-
const timesRerendered = renderStats.get(webComponent) || 0;
81-
if (timesRerendered > MAX_RERENDER_COUNT) {
82-
// console.warn("WARNING RERENDER", webComponent);
83-
throw new Error(`Web component re-rendered too many times this task, max allowed is: ${MAX_RERENDER_COUNT}`);
84-
}
85-
webComponent._render();
86-
promise._deferredResolve();
87-
renderStats.set(webComponent, timesRerendered + 1);
88-
}
58+
/**
59+
* Schedules a rendering task, if not scheduled already
60+
*/
61+
static async scheduleRenderTask() {
62+
if (!queuePromise) {
63+
queuePromise = new Promise(resolve => {
64+
window.requestAnimationFrame(() => {
65+
// Render all components in the queue
66+
invalidatedWebComponents.process(component => component._render());
67+
68+
// Resolve the promise so that callers of renderDeferred can continue
69+
queuePromise = null;
70+
resolve();
8971

90-
// wait for Mutation observer just in case
91-
if (!mutationObserverTimer) {
92-
mutationObserverTimer = setTimeout(() => {
93-
mutationObserverTimer = undefined;
94-
if (invalidatedWebComponents.getList().length === 0) {
95-
RenderScheduler._resolveTaskPromise();
96-
}
97-
}, 200);
72+
// Wait for Mutation observer before the render task is considered finished
73+
if (!mutationObserverTimer) {
74+
mutationObserverTimer = setTimeout(() => {
75+
mutationObserverTimer = undefined;
76+
if (invalidatedWebComponents.isEmpty()) {
77+
RenderScheduler._resolveTaskPromise();
78+
}
79+
}, 200);
80+
}
81+
});
82+
});
9883
}
9984

100-
renderTaskId = undefined;
85+
await queuePromise;
10186
}
10287

10388
/**
@@ -111,7 +96,7 @@ class RenderScheduler {
11196
renderTaskPromise = new Promise(resolve => {
11297
renderTaskPromiseResolve = resolve;
11398
window.requestAnimationFrame(() => {
114-
if (invalidatedWebComponents.getList().length === 0) {
99+
if (invalidatedWebComponents.isEmpty()) {
115100
renderTaskPromise = undefined;
116101
resolve();
117102
}
@@ -132,13 +117,13 @@ class RenderScheduler {
132117
}
133118

134119
static _resolveTaskPromise() {
135-
if (invalidatedWebComponents.getList().length > 0) {
120+
if (!invalidatedWebComponents.isEmpty()) {
136121
// More updates are pending. Resolve will be called again
137122
return;
138123
}
139124

140125
if (renderTaskPromiseResolve) {
141-
renderTaskPromiseResolve.call(this, taskResult);
126+
renderTaskPromiseResolve.call(this);
142127
renderTaskPromiseResolve = undefined;
143128
renderTaskPromise = undefined;
144129
}

‎packages/base/src/UI5Element.js

+18-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class UI5Element extends HTMLElement {
4747
this._upgradeAllProperties();
4848
this._initializeContainers();
4949
this._upToDate = false;
50+
this._inDOM = false;
51+
this._fullyConnected = false;
5052

5153
let deferredResolve;
5254
this._domRefReadyPromise = new Promise(resolve => {
@@ -109,6 +111,8 @@ class UI5Element extends HTMLElement {
109111
const needsShadowDOM = this.constructor._needsShadowDOM();
110112
const slotsAreManaged = this.constructor.getMetadata().slotsAreManaged();
111113

114+
this._inDOM = true;
115+
112116
// Render the Shadow DOM
113117
if (needsShadowDOM) {
114118
if (slotsAreManaged) {
@@ -121,9 +125,14 @@ class UI5Element extends HTMLElement {
121125
await Promise.resolve();
122126
}
123127

128+
if (!this._inDOM) { // Component removed from DOM while _processChildren was running
129+
return;
130+
}
131+
124132
RenderScheduler.register(this);
125133
await RenderScheduler.renderImmediately(this);
126134
this._domRefReadyPromise._deferredResolve();
135+
this._fullyConnected = true;
127136
if (typeof this.onEnterDOM === "function") {
128137
this.onEnterDOM();
129138
}
@@ -139,20 +148,27 @@ class UI5Element extends HTMLElement {
139148
const needsStaticArea = this.constructor._needsStaticArea();
140149
const slotsAreManaged = this.constructor.getMetadata().slotsAreManaged();
141150

151+
this._inDOM = false;
152+
142153
if (needsShadowDOM) {
143154
if (slotsAreManaged) {
144155
this._stopObservingDOMChildren();
145156
}
146157

147158
RenderScheduler.deregister(this);
148-
if (typeof this.onExitDOM === "function") {
149-
this.onExitDOM();
159+
if (this._fullyConnected) {
160+
if (typeof this.onExitDOM === "function") {
161+
this.onExitDOM();
162+
}
163+
this._fullyConnected = false;
150164
}
151165
}
152166

153167
if (needsStaticArea) {
154168
this.staticAreaItem._removeFragmentFromStaticArea();
155169
}
170+
171+
RenderScheduler.cancelRender(this);
156172
}
157173

158174
/**

‎packages/base/test/specs/UI5ElementLifecycle.js

+3
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ describe("Lifecycle works", () => {
7979
});
8080

8181
document.body.appendChild(el);
82+
83+
await window.RenderScheduler.whenFinished(); // Must wait, otherwise onExitDOM won't be called
84+
8285
document.body.removeChild(el);
8386

8487
await window.RenderScheduler.whenFinished();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6+
<meta charset="utf-8">
7+
8+
<title>Button</title>
9+
10+
<script data-ui5-config type="application/json">
11+
{
12+
"language": "EN",
13+
"noConflict": {
14+
"events": ["click"]
15+
},
16+
"calendarType": "Islamic"
17+
}
18+
</script>
19+
20+
<script src="../../webcomponentsjs/webcomponents-loader.js"></script>
21+
<script src="../../resources/bundle.esm.js" type="module"></script>
22+
<script nomodule src="../../resources/bundle.es5.js"></script>
23+
24+
</head>
25+
26+
<body style="background-color: var(--sapBackgroundColor);">
27+
28+
<ui5-button id="openDialogButton">Open Dialog</ui5-button>
29+
<br />
30+
31+
<template id="dt">
32+
<ui5-dialog id="hello-dialog" header-text="Dialogs are easy!">
33+
<div stype="padding: 2rem; display:flex; justify-content: center;">
34+
Hello World!
35+
<ui5-button id="closeDialogButton">Dismiss</ui5-button>
36+
</div>
37+
</ui5-dialog>
38+
</template>
39+
40+
<script>
41+
const openBtn = document.getElementById("openDialogButton");
42+
openBtn.addEventListener("click", () => {
43+
const clone = document.getElementById("dt").content.cloneNode(true);
44+
document.body.appendChild(clone);
45+
const dialog = document.getElementById("hello-dialog");
46+
const closeBtn = dialog.querySelector("#closeDialogButton");
47+
closeBtn.addEventListener("click", () => {
48+
dialog.addEventListener("afterClose", () => {
49+
dialog.parentElement.removeChild(dialog);
50+
});
51+
dialog.close();
52+
});
53+
54+
document.body.appendChild(dialog);
55+
dialog.open();
56+
});
57+
</script>
58+
59+
</body>
60+
</html>

‎packages/main/test/specs/Dialog.spec.js

+16
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ describe("Dialog general interaction", () => {
3131

3232
assert.ok(popoverZIndex > dialogZIndex, "Popover is above dialog.");
3333
});
34+
35+
it("tests dialog lifecycle", () => {
36+
browser.url("http://localhost:8080/test-resources/pages/DialogLifecycle.html");
37+
38+
assert.ok(!browser.$("ui5-static-area").length, "No static area.");
39+
40+
const openDialogButton = browser.$("#openDialogButton");
41+
openDialogButton.click();
42+
43+
assert.ok(browser.$("ui5-static-area>ui5-static-area-item"), "Static area item exists.");
44+
45+
const closeDialogButton= browser.$("#closeDialogButton");
46+
closeDialogButton.click();
47+
48+
assert.ok(!browser.$("ui5-static-area").length, "No static area.");
49+
});
3450
});
3551

3652

0 commit comments

Comments
 (0)
Please sign in to comment.