@theia/core 1.74.0 → 1.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/lib/browser/catalog.json +86 -5
- package/lib/browser/common-frontend-contribution.d.ts +3 -0
- package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
- package/lib/browser/common-frontend-contribution.js +15 -3
- package/lib/browser/common-frontend-contribution.js.map +1 -1
- package/lib/browser/common-frontend-contribution.spec.d.ts +2 -0
- package/lib/browser/common-frontend-contribution.spec.d.ts.map +1 -0
- package/lib/browser/common-frontend-contribution.spec.js +71 -0
- package/lib/browser/common-frontend-contribution.spec.js.map +1 -0
- package/lib/browser/frontend-application-contribution.d.ts +1 -1
- package/lib/browser/frontend-application-module.d.ts +2 -2
- package/lib/browser/frontend-application-module.d.ts.map +1 -1
- package/lib/browser/frontend-application-module.js +4 -2
- package/lib/browser/frontend-application-module.js.map +1 -1
- package/lib/browser/tree/tree-consistency.spec.js +103 -0
- package/lib/browser/tree/tree-consistency.spec.js.map +1 -1
- package/lib/browser/tree/tree.d.ts.map +1 -1
- package/lib/browser/tree/tree.js +6 -0
- package/lib/browser/tree/tree.js.map +1 -1
- package/lib/browser/widgets/select-component.d.ts +13 -0
- package/lib/browser/widgets/select-component.d.ts.map +1 -1
- package/lib/browser/widgets/select-component.js +44 -12
- package/lib/browser/widgets/select-component.js.map +1 -1
- package/lib/browser/window/default-secondary-window-service.d.ts +2 -0
- package/lib/browser/window/default-secondary-window-service.d.ts.map +1 -1
- package/lib/browser/window/default-secondary-window-service.js +8 -2
- package/lib/browser/window/default-secondary-window-service.js.map +1 -1
- package/lib/browser/window/default-window-service.d.ts +19 -0
- package/lib/browser/window/default-window-service.d.ts.map +1 -1
- package/lib/browser/window/default-window-service.js +34 -5
- package/lib/browser/window/default-window-service.js.map +1 -1
- package/lib/browser/window/default-window-service.spec.js +22 -0
- package/lib/browser/window/default-window-service.spec.js.map +1 -1
- package/lib/browser/window/secondary-window-service.d.ts +7 -0
- package/lib/browser/window/secondary-window-service.d.ts.map +1 -1
- package/lib/browser/window/window-service.d.ts +1 -1
- package/lib/electron-browser/menu/electron-menu-contribution.js +1 -1
- package/lib/electron-browser/menu/electron-menu-contribution.js.map +1 -1
- package/lib/electron-browser/window/electron-secondary-window-service.js +1 -1
- package/lib/electron-browser/window/electron-secondary-window-service.js.map +1 -1
- package/lib/electron-browser/window/electron-window-service.js +3 -3
- package/lib/electron-browser/window/electron-window-service.js.map +1 -1
- package/package.json +6 -6
- package/src/browser/common-frontend-contribution.spec.ts +84 -0
- package/src/browser/common-frontend-contribution.ts +15 -3
- package/src/browser/frontend-application-contribution.ts +1 -1
- package/src/browser/frontend-application-module.ts +4 -2
- package/src/browser/tree/tree-consistency.spec.ts +119 -0
- package/src/browser/tree/tree.ts +6 -0
- package/src/browser/widgets/select-component.tsx +49 -12
- package/src/browser/window/default-secondary-window-service.ts +8 -2
- package/src/browser/window/default-window-service.spec.ts +22 -0
- package/src/browser/window/default-window-service.ts +37 -5
- package/src/browser/window/secondary-window-service.ts +7 -0
- package/src/browser/window/window-service.ts +1 -1
- package/src/electron-browser/menu/electron-menu-contribution.ts +1 -1
- package/src/electron-browser/window/electron-secondary-window-service.ts +1 -1
- package/src/electron-browser/window/electron-window-service.ts +3 -3
|
@@ -21,6 +21,10 @@ import { TreeImpl, CompositeTreeNode, TreeNode } from './tree';
|
|
|
21
21
|
import { TreeModel } from './tree-model';
|
|
22
22
|
import { ExpandableTreeNode } from './tree-expansion';
|
|
23
23
|
import { TreeLabelProvider } from './tree-label-provider';
|
|
24
|
+
import { MockTreeModel } from './test/mock-tree-model';
|
|
25
|
+
import { MockLogger } from '../../common/test/mock-logger';
|
|
26
|
+
import { ILogger } from '../../common';
|
|
27
|
+
import { Deferred, timeout } from '../../common/promise-util';
|
|
24
28
|
|
|
25
29
|
@injectable()
|
|
26
30
|
class ConsistencyTestTree extends TreeImpl {
|
|
@@ -69,6 +73,48 @@ function createConsistencyTestRoot(rootName: string): CompositeTreeNode {
|
|
|
69
73
|
return root;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A `TreeImpl` whose `resolveChildren` can be stalled or overridden per node id, to
|
|
78
|
+
* simulate slow child resolution (e.g. a file system request) in tests.
|
|
79
|
+
*/
|
|
80
|
+
@injectable()
|
|
81
|
+
class StallingTestTree extends TreeImpl {
|
|
82
|
+
|
|
83
|
+
protected readonly stalled = new Map<string, Deferred<TreeNode[]>>();
|
|
84
|
+
protected readonly overrides = new Map<string, () => TreeNode[]>();
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stall the *next* `resolveChildren` for `id` on the returned deferred. Later
|
|
88
|
+
* resolutions for the same id proceed normally, so the stall cannot leak into
|
|
89
|
+
* unrelated refreshes (e.g. the cascade of a newly set root).
|
|
90
|
+
*/
|
|
91
|
+
stallNextResolve(id: string): Deferred<TreeNode[]> {
|
|
92
|
+
const gate = new Deferred<TreeNode[]>();
|
|
93
|
+
this.stalled.set(id, gate);
|
|
94
|
+
return gate;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Make the *next* `resolveChildren` for `id` return `children()` instead of the current child nodes. */
|
|
98
|
+
overrideNextResolve(id: string, children: () => TreeNode[]): void {
|
|
99
|
+
this.overrides.set(id, children);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected override resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
|
|
103
|
+
const gate = this.stalled.get(parent.id);
|
|
104
|
+
if (gate) {
|
|
105
|
+
this.stalled.delete(parent.id);
|
|
106
|
+
return gate.promise;
|
|
107
|
+
}
|
|
108
|
+
const override = this.overrides.get(parent.id);
|
|
109
|
+
if (override) {
|
|
110
|
+
this.overrides.delete(parent.id);
|
|
111
|
+
return Promise.resolve(override());
|
|
112
|
+
}
|
|
113
|
+
return super.resolveChildren(parent);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
}
|
|
117
|
+
|
|
72
118
|
describe('Tree Consistency', () => {
|
|
73
119
|
|
|
74
120
|
it('setting different tree roots should finish', async () => {
|
|
@@ -102,4 +148,77 @@ describe('Tree Consistency', () => {
|
|
|
102
148
|
assert.ok(false, 'Resolving does not stop, attempts: ' + tree.resolveCounter);
|
|
103
149
|
});
|
|
104
150
|
|
|
151
|
+
describe('stale refreshes', () => {
|
|
152
|
+
|
|
153
|
+
let tree: StallingTestTree;
|
|
154
|
+
let model: TreeModel;
|
|
155
|
+
let loggedErrors: unknown[][];
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
const container = createTreeTestContainer();
|
|
159
|
+
container.bind(StallingTestTree).toSelf();
|
|
160
|
+
container.rebind(TreeImpl).toService(StallingTestTree);
|
|
161
|
+
tree = container.get(StallingTestTree);
|
|
162
|
+
model = container.get<TreeModel>(TreeModel);
|
|
163
|
+
loggedErrors = [];
|
|
164
|
+
const logger = container.get<MockLogger>(ILogger);
|
|
165
|
+
logger.error = async (...args: unknown[]) => { loggedErrors.push(args); };
|
|
166
|
+
model.root = MockTreeModel.HIERARCHICAL_MOCK_ROOT();
|
|
167
|
+
// let the refresh cascade triggered by setting the root settle
|
|
168
|
+
await timeout(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('drops an in-flight refresh when the root is replaced meanwhile', async () => {
|
|
172
|
+
const stale = model.getNode('1.2') as CompositeTreeNode;
|
|
173
|
+
const gate = tree.stallNextResolve('1.2');
|
|
174
|
+
const pendingRefresh = tree.refresh(stale);
|
|
175
|
+
|
|
176
|
+
model.root = MockTreeModel.HIERARCHICAL_MOCK_ROOT();
|
|
177
|
+
gate.resolve(Array.from(stale.children));
|
|
178
|
+
|
|
179
|
+
assert.strictEqual(await pendingRefresh, undefined);
|
|
180
|
+
assert.deepStrictEqual(loggedErrors, []);
|
|
181
|
+
const fresh = model.getNode('1.2');
|
|
182
|
+
assert.ok(fresh);
|
|
183
|
+
assert.notStrictEqual(fresh, stale);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does not resurrect a removed subtree when a stale refresh completes', async () => {
|
|
187
|
+
const target = model.getNode('1.2') as CompositeTreeNode;
|
|
188
|
+
const gate = tree.stallNextResolve('1.2');
|
|
189
|
+
const pendingRefresh = tree.refresh(target);
|
|
190
|
+
|
|
191
|
+
CompositeTreeNode.removeChild(target.parent as CompositeTreeNode, target, tree);
|
|
192
|
+
assert.strictEqual(model.getNode('1.2'), undefined);
|
|
193
|
+
gate.resolve(Array.from(target.children));
|
|
194
|
+
|
|
195
|
+
assert.strictEqual(await pendingRefresh, undefined);
|
|
196
|
+
assert.strictEqual(model.getNode('1.2'), undefined);
|
|
197
|
+
assert.strictEqual(model.getNode('1.2.1'), undefined);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not re-index a node object that a concurrent ancestor refresh replaced', async () => {
|
|
201
|
+
const stale = model.getNode('1.2') as CompositeTreeNode;
|
|
202
|
+
const gate = tree.stallNextResolve('1.2');
|
|
203
|
+
const pendingRefresh = tree.refresh(stale);
|
|
204
|
+
|
|
205
|
+
// an ancestor refresh yields a *fresh* object for the same id, as trees whose
|
|
206
|
+
// `resolveChildren` builds new nodes do
|
|
207
|
+
const parent = model.getNode('1') as CompositeTreeNode;
|
|
208
|
+
const replacement: CompositeTreeNode = { id: '1.2', name: '1.2', parent, children: [] };
|
|
209
|
+
tree.overrideNextResolve('1', () => parent.children.map(child => child.id === '1.2' ? replacement : child));
|
|
210
|
+
await tree.refresh(parent);
|
|
211
|
+
assert.strictEqual(model.getNode('1.2'), replacement);
|
|
212
|
+
|
|
213
|
+
gate.resolve(Array.from(stale.children));
|
|
214
|
+
|
|
215
|
+
assert.strictEqual(await pendingRefresh, undefined);
|
|
216
|
+
assert.deepStrictEqual(loggedErrors, []);
|
|
217
|
+
// the stale object must not take the replacement's place in the index, nor bring its children back
|
|
218
|
+
assert.strictEqual(model.getNode('1.2'), replacement);
|
|
219
|
+
assert.strictEqual(model.getNode('1.2.1'), undefined);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
});
|
|
223
|
+
|
|
105
224
|
});
|
package/src/browser/tree/tree.ts
CHANGED
|
@@ -354,6 +354,12 @@ export class TreeImpl implements Tree {
|
|
|
354
354
|
result = parent;
|
|
355
355
|
const children = await this.resolveChildren(parent);
|
|
356
356
|
if (cancellationToken?.isCancellationRequested) { return; }
|
|
357
|
+
if (this.validateNode(parent) !== parent) {
|
|
358
|
+
// the tree changed while children were being resolved (e.g. a new root was
|
|
359
|
+
// set or the node was removed): drop the stale refresh
|
|
360
|
+
this.logger.debug(`Refresh of node '${parent.id}' dropped, it no longer belongs to the tree.`);
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
357
363
|
result = await this.setChildren(parent, children);
|
|
358
364
|
if (cancellationToken?.isCancellationRequested) { return; }
|
|
359
365
|
} finally {
|
|
@@ -61,6 +61,10 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
61
61
|
protected optimalWidth = 0;
|
|
62
62
|
protected optimalHeight = 0;
|
|
63
63
|
protected resizeObserver: ResizeObserver | undefined;
|
|
64
|
+
/** Window the scroll/wheel/resize listeners are attached to; may differ from the main window in secondary windows. */
|
|
65
|
+
protected listenersWindow: Window | undefined;
|
|
66
|
+
/** Perfect-scrollbar ancestors the `ps-scroll-y` listener was attached to. The field may have moved to another window since, so the elements are remembered. */
|
|
67
|
+
protected psScrollListenerTargets: HTMLElement[] = [];
|
|
64
68
|
|
|
65
69
|
constructor(props: SelectComponentProps) {
|
|
66
70
|
super(props);
|
|
@@ -71,14 +75,28 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
71
75
|
hover: selected
|
|
72
76
|
};
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
this.dropdownElement = this.resolveDropdownContainer(document);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The document that currently hosts this component. Falls back to the main document before
|
|
83
|
+
* the first render. Widgets can be moved to secondary windows, so this must be re-queried
|
|
84
|
+
* instead of captured once.
|
|
85
|
+
*/
|
|
86
|
+
protected getHostDocument(): Document {
|
|
87
|
+
return this.fieldRef.current?.ownerDocument ?? document;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Finds or creates the dropdown portal container in the given document. */
|
|
91
|
+
protected resolveDropdownContainer(hostDocument: Document): HTMLElement {
|
|
92
|
+
let list = hostDocument.getElementById(SELECT_COMPONENT_CONTAINER);
|
|
75
93
|
if (!list) {
|
|
76
|
-
list =
|
|
94
|
+
list = hostDocument.createElement('div');
|
|
77
95
|
list.id = SELECT_COMPONENT_CONTAINER;
|
|
78
96
|
list.className = 'theia-select-component-container';
|
|
79
|
-
|
|
97
|
+
hostDocument.body.appendChild(list);
|
|
80
98
|
}
|
|
81
|
-
|
|
99
|
+
return list;
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
protected getInitialSelectedIndex(props: SelectComponentProps): number {
|
|
@@ -179,12 +197,14 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
179
197
|
// neither triggers the `scroll`, `wheel` nor `blur` event
|
|
180
198
|
if (parent.classList.contains('ps')) {
|
|
181
199
|
parent.addEventListener('ps-scroll-y', hide);
|
|
200
|
+
this.psScrollListenerTargets.push(parent);
|
|
182
201
|
}
|
|
183
202
|
parent = parent.parentElement;
|
|
184
203
|
}
|
|
185
204
|
|
|
205
|
+
this.listenersWindow = this.getHostDocument().defaultView ?? window;
|
|
186
206
|
for (const [key, listener] of this.mountedListeners.entries()) {
|
|
187
|
-
|
|
207
|
+
this.listenersWindow.addEventListener(key, listener);
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
// Catch Lumino sash drags globally - observe the closest lm-Widget panel
|
|
@@ -222,18 +242,24 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
override componentWillUnmount(): void {
|
|
245
|
+
this.detachListeners();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
protected detachListeners(): void {
|
|
225
249
|
this.resizeObserver?.disconnect();
|
|
250
|
+
this.resizeObserver = undefined;
|
|
226
251
|
if (this.mountedListeners.size > 0) {
|
|
227
252
|
const eventListener = this.mountedListeners.get('scroll')!;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
parent.removeEventListener('ps-scroll-y', eventListener);
|
|
231
|
-
parent = parent.parentElement;
|
|
253
|
+
for (const target of this.psScrollListenerTargets) {
|
|
254
|
+
target.removeEventListener('ps-scroll-y', eventListener);
|
|
232
255
|
}
|
|
233
256
|
for (const [key, listener] of this.mountedListeners.entries()) {
|
|
234
|
-
window.removeEventListener(key, listener);
|
|
257
|
+
(this.listenersWindow ?? window).removeEventListener(key, listener);
|
|
235
258
|
}
|
|
259
|
+
this.mountedListeners.clear();
|
|
236
260
|
}
|
|
261
|
+
this.psScrollListenerTargets = [];
|
|
262
|
+
this.listenersWindow = undefined;
|
|
237
263
|
}
|
|
238
264
|
|
|
239
265
|
override render(): React.ReactNode {
|
|
@@ -345,6 +371,14 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
345
371
|
return;
|
|
346
372
|
}
|
|
347
373
|
if (!this.state.dimensions) {
|
|
374
|
+
// Re-resolve the portal container and listener window against the document that
|
|
375
|
+
// currently hosts the field: the widget may have been moved to a secondary window
|
|
376
|
+
// (or back to the main window) since the last time the dropdown was opened.
|
|
377
|
+
const hostDocument = this.getHostDocument();
|
|
378
|
+
this.dropdownElement = this.resolveDropdownContainer(hostDocument);
|
|
379
|
+
if (this.listenersWindow && this.listenersWindow !== hostDocument.defaultView) {
|
|
380
|
+
this.detachListeners();
|
|
381
|
+
}
|
|
348
382
|
const rect = this.fieldRef.current.getBoundingClientRect();
|
|
349
383
|
this.setState({ dimensions: rect });
|
|
350
384
|
} else {
|
|
@@ -361,7 +395,8 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
361
395
|
hover: selectedIndex
|
|
362
396
|
});
|
|
363
397
|
// Force releasing focus of the select element to allow closing via escape later on
|
|
364
|
-
|
|
398
|
+
const hostDocument = this.getHostDocument();
|
|
399
|
+
if (releaseFocus && hostDocument.activeElement && hostDocument.activeElement === this.fieldRef.current) {
|
|
365
400
|
this.fieldRef.current.blur();
|
|
366
401
|
}
|
|
367
402
|
}
|
|
@@ -371,7 +406,9 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
371
406
|
return;
|
|
372
407
|
}
|
|
373
408
|
|
|
374
|
-
|
|
409
|
+
// Secondary windows have no 'theia-app-shell' element; use the document element as the boundary there.
|
|
410
|
+
const hostDocument = this.getHostDocument();
|
|
411
|
+
const shellArea = (hostDocument.getElementById('theia-app-shell') ?? hostDocument.documentElement).getBoundingClientRect();
|
|
375
412
|
const maxWidth = this.alignLeft ? shellArea.width - this.state.dimensions.left : this.state.dimensions.right;
|
|
376
413
|
if (this.mountedListeners.size === 0) {
|
|
377
414
|
// Only attach our listeners once we render our dropdown menu
|
|
@@ -28,6 +28,8 @@ import { WindowFocusService } from './window-focus-service';
|
|
|
28
28
|
export class DefaultSecondaryWindowService implements SecondaryWindowService {
|
|
29
29
|
protected readonly onWindowOpenedEmitter = new Emitter<Window>;
|
|
30
30
|
readonly onWindowOpened: Event<Window> = this.onWindowOpenedEmitter.event;
|
|
31
|
+
protected readonly onWindowLoadedEmitter = new Emitter<Window>;
|
|
32
|
+
readonly onWindowLoaded: Event<Window> = this.onWindowLoadedEmitter.event;
|
|
31
33
|
protected readonly onWindowClosedEmitter = new Emitter<Window>;
|
|
32
34
|
readonly onWindowClosed: Event<Window> = this.onWindowClosedEmitter.event;
|
|
33
35
|
protected readonly beforeWidgetRestoreEmitter = new Emitter<[Widget, Window]>;
|
|
@@ -110,6 +112,10 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
|
|
|
110
112
|
this.secondaryWindows.push(newWindow);
|
|
111
113
|
this.onWindowOpenedEmitter.fire(newWindow);
|
|
112
114
|
newWindow.addEventListener('DOMContentLoaded', () => {
|
|
115
|
+
// This fires for the final secondary-window.html document, not for the initial
|
|
116
|
+
// about:blank document the window starts out with, so onWindowLoaded listeners
|
|
117
|
+
// can safely modify the document.
|
|
118
|
+
this.onWindowLoadedEmitter.fire(newWindow);
|
|
113
119
|
const focusRegistration = this.windowFocusService.registerWindow(newWindow);
|
|
114
120
|
newWindow.addEventListener('beforeunload', evt => {
|
|
115
121
|
const widgets = getAllWidgetsFromSecondaryWindow(newWindow) ?? [widget];
|
|
@@ -124,7 +130,7 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
|
|
|
124
130
|
}
|
|
125
131
|
}, { capture: true });
|
|
126
132
|
|
|
127
|
-
newWindow.addEventListener('
|
|
133
|
+
newWindow.addEventListener('pagehide', () => {
|
|
128
134
|
focusRegistration.dispose();
|
|
129
135
|
const extIndex = this.secondaryWindows.indexOf(newWindow);
|
|
130
136
|
if (extIndex > -1) {
|
|
@@ -140,7 +146,7 @@ export class DefaultSecondaryWindowService implements SecondaryWindowService {
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
protected windowCreated(newWindow: Window, widget: ExtractableWidget, shell: ApplicationShell): void {
|
|
143
|
-
newWindow.addEventListener('
|
|
149
|
+
newWindow.addEventListener('pagehide', () => {
|
|
144
150
|
this.restoreWidgets(newWindow, widget, shell);
|
|
145
151
|
});
|
|
146
152
|
}
|
|
@@ -77,4 +77,26 @@ describe('DefaultWindowService', () => {
|
|
|
77
77
|
assert(windowService['collectContributionUnloadVetoes']().length > 0, 'there should be vetoes');
|
|
78
78
|
assert(frontendContributions.every(contribution => contribution.onWillStopCalled), 'contributions should have been called');
|
|
79
79
|
});
|
|
80
|
+
it('should reload unconditionally when restored from the back/forward cache', () => {
|
|
81
|
+
const frontendContributions: TestFrontendApplicationContribution[] = [
|
|
82
|
+
new TestFrontendApplicationContribution(true),
|
|
83
|
+
];
|
|
84
|
+
const windowService = setupWindowService('always', frontendContributions);
|
|
85
|
+
let reloaded = 0;
|
|
86
|
+
windowService.reload = () => { reloaded++; };
|
|
87
|
+
windowService['handlePageShow']({ persisted: false } as PageTransitionEvent);
|
|
88
|
+
const reloadsBeforeRestore = reloaded;
|
|
89
|
+
windowService['handlePageShow']({ persisted: true } as PageTransitionEvent);
|
|
90
|
+
assert(reloadsBeforeRestore === 0, 'a regular page show should not reload');
|
|
91
|
+
assert(reloaded === 1, 'a restored page should reload');
|
|
92
|
+
assert(windowService['collectContributionUnloadVetoes']().length === 0, 'the recovery reload should not be vetoed');
|
|
93
|
+
});
|
|
94
|
+
it('onUnload should fire at most once, even if `pagehide` fires repeatedly', () => {
|
|
95
|
+
const windowService = setupWindowService('never', []);
|
|
96
|
+
let fired = 0;
|
|
97
|
+
windowService.onUnload(() => fired++);
|
|
98
|
+
windowService['handlePageHide']();
|
|
99
|
+
windowService['handlePageHide']();
|
|
100
|
+
assert(fired === 1, `onUnload should have fired once, but fired ${fired} time(s)`);
|
|
101
|
+
});
|
|
80
102
|
});
|
|
@@ -30,6 +30,7 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC
|
|
|
30
30
|
|
|
31
31
|
protected frontendApplication: FrontendApplication;
|
|
32
32
|
protected allowVetoes = true;
|
|
33
|
+
protected unloaded = false;
|
|
33
34
|
|
|
34
35
|
protected onUnloadEmitter = new Emitter<void>();
|
|
35
36
|
get onUnload(): Event<void> {
|
|
@@ -103,12 +104,43 @@ export class DefaultWindowService implements WindowService, FrontendApplicationC
|
|
|
103
104
|
* Implement the mechanism to detect unloading of the page.
|
|
104
105
|
*/
|
|
105
106
|
protected registerUnloadListeners(): void {
|
|
107
|
+
// If `beforeunload` is cancelled and the user stays, the page is not unloaded, so `pagehide` is not fired.
|
|
106
108
|
window.addEventListener('beforeunload', event => this.handleBeforeUnloadEvent(event));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
this.registerPageHideListener();
|
|
110
|
+
window.addEventListener('pageshow', event => this.handlePageShow(event));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `pagehide` is used instead of the deprecated `unload` event, which Chrome may block
|
|
115
|
+
* via permissions policy (https://developer.chrome.com/docs/web-platform/deprecating-unload),
|
|
116
|
+
* silently skipping the handler and thus e.g. losing the layout.
|
|
117
|
+
*/
|
|
118
|
+
protected registerPageHideListener(): void {
|
|
119
|
+
window.addEventListener('pagehide', () => this.handlePageHide());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `pagehide` also fires when the page enters the back/forward cache, in which case the frontend
|
|
124
|
+
* has already shut down (state saved, connections closed). Reload to get a working application
|
|
125
|
+
* again if the page is restored from the cache. That shutdown already happened, so the reload
|
|
126
|
+
* must not be vetoed by the contributions.
|
|
127
|
+
*/
|
|
128
|
+
protected handlePageShow(event: PageTransitionEvent): void {
|
|
129
|
+
if (event.persisted) {
|
|
130
|
+
this.setSafeToShutDown();
|
|
131
|
+
this.reload();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fires {@link onUnload} at most once per document: `pagehide` fires again for the reload that
|
|
137
|
+
* recovers a page restored from the back/forward cache, but the application is already stopped.
|
|
138
|
+
*/
|
|
139
|
+
protected handlePageHide(): void {
|
|
140
|
+
if (!this.unloaded) {
|
|
141
|
+
this.unloaded = true;
|
|
142
|
+
this.onUnloadEmitter.fire();
|
|
143
|
+
}
|
|
112
144
|
}
|
|
113
145
|
|
|
114
146
|
async isSafeToShutDown(stopReason: StopReason): Promise<boolean> {
|
|
@@ -55,6 +55,13 @@ export interface SecondaryWindowService {
|
|
|
55
55
|
*/
|
|
56
56
|
createSecondaryWindow(widget: ExtractableWidget, shell: ApplicationShell): SecondaryWindow | Window | undefined;
|
|
57
57
|
readonly onWindowOpened: Event<Window>;
|
|
58
|
+
/**
|
|
59
|
+
* Emitted once a secondary window's final document has finished loading. Listeners can safely
|
|
60
|
+
* modify the window's document, unlike with {@link onWindowOpened}: at that point the window
|
|
61
|
+
* still shows the initial `about:blank` document, and anything added to it is discarded when
|
|
62
|
+
* the final document replaces it.
|
|
63
|
+
*/
|
|
64
|
+
readonly onWindowLoaded: Event<Window>;
|
|
58
65
|
readonly onWindowClosed: Event<Window>;
|
|
59
66
|
readonly beforeWidgetRestore: Event<[Widget, Window]>;
|
|
60
67
|
|
|
@@ -56,7 +56,7 @@ export interface WindowService {
|
|
|
56
56
|
focus(): void;
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Fires when the
|
|
59
|
+
* Fires when the application is shutting down. On this event, the frontend application can save its state and release resource.
|
|
60
60
|
* Saving the state and releasing any resources must be a synchronous call. Any asynchronous calls invoked after emitting this event might be ignored.
|
|
61
61
|
*/
|
|
62
62
|
readonly onUnload: Event<void>;
|
|
@@ -139,7 +139,7 @@ export class ElectronMenuContribution extends BrowserMenuBarContribution impleme
|
|
|
139
139
|
const disposeHandler = window.electronTheiaCore.onWindowEvent('focus', () => {
|
|
140
140
|
this.setMenu(app);
|
|
141
141
|
});
|
|
142
|
-
window.addEventListener('
|
|
142
|
+
window.addEventListener('pagehide', () => disposeHandler.dispose());
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
protected attachMenuBarVisibilityListener(): void {
|
|
@@ -61,7 +61,7 @@ export class ElectronSecondaryWindowService extends DefaultSecondaryWindowServic
|
|
|
61
61
|
for (let i = this.secondaryWindows.length - 1; i >= 0; i--) {
|
|
62
62
|
const windowClosed = new Deferred<void>();
|
|
63
63
|
const win = this.secondaryWindows[i];
|
|
64
|
-
win.addEventListener('
|
|
64
|
+
win.addEventListener('pagehide', () => {
|
|
65
65
|
windowClosed.resolve();
|
|
66
66
|
});
|
|
67
67
|
promises.push(windowClosed.promise);
|
|
@@ -89,9 +89,9 @@ export class ElectronWindowService extends DefaultWindowService {
|
|
|
89
89
|
}
|
|
90
90
|
return willShutDown;
|
|
91
91
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
// Intentionally not calling `super`: in Electron the close request handler above takes the role of
|
|
93
|
+
// `beforeunload`, and there is no back/forward cache, so only the `pagehide` listener is shared.
|
|
94
|
+
this.registerPageHideListener();
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|