Skip to content

Commit 078bd5c

Browse files
committed
Clean up WebGL context listeners on teardown
1 parent 0271acd commit 078bd5c

2 files changed

Lines changed: 163 additions & 11 deletions

File tree

packages/sdk/src/viewing/webGLRenderer/WebGLRenderer.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export class WebGLRenderer {
5555

5656
private _viewerSubs: (() => void)[];
5757
private _viewManagerSubs: (() => void)[];
58+
private _webglContextCanvas: HTMLCanvasElement | null = null;
59+
private _webglContextLostHandler: ((event: Event) => void) | null = null;
60+
private _webglContextRestoredHandler: ((event: Event) => void) | null = null;
5861
private _destroyed = false; // Indicates if the renderer has been destroyed
5962

6063
// Number of SceneModels currently building (loading). While > 0, per-view
@@ -793,19 +796,32 @@ export class WebGLRenderer {
793796

794797
this._shaderInspector = this._viewManager.shaderInspector;
795798

796-
this._viewManager.getWebGLCanvasElement().addEventListener("webglcontextlost", (event) => {
799+
this._installWebGLContextListeners(viewManager);
800+
801+
return {
802+
ok: true,
803+
value: undefined
804+
};
805+
}
806+
807+
private _installWebGLContextListeners(viewManager: ViewManager): void {
808+
this._removeWebGLContextListeners();
809+
810+
const canvas = viewManager.getWebGLCanvasElement();
811+
const contextLostHandler = (event: Event) => {
812+
if (this._viewManager !== viewManager) return;
797813
// preventDefault is required for the browser to fire webglcontextrestored.
798814
event.preventDefault();
799815
// Release GL-backed resources now, while the context is still flagged
800816
// lost, so their gl.delete* calls are no-ops instead of errors against
801817
// the restored context.
802-
this._viewManager.webglContextLost();
818+
viewManager.webglContextLost();
803819
this.events.webglContextLost.dispatch(this, event as WebGLContextEvent);
804-
});
820+
};
805821

806-
this._viewManager.getWebGLCanvasElement().addEventListener("webglcontextrestored", (event) => {
807-
if (!this._viewManager) return;
808-
const result = this._viewManager.webglContextRestored();
822+
const contextRestoredHandler = (_event: Event) => {
823+
if (this._viewManager !== viewManager) return;
824+
const result = viewManager.webglContextRestored();
809825
if (result.ok === false) {
810826
this.logError({
811827
ok: false,
@@ -824,12 +840,25 @@ export class WebGLRenderer {
824840
}
825841
}
826842
this.events.webglContextRestored.dispatch(this);
827-
});
828-
829-
return {
830-
ok: true,
831-
value: undefined
832843
};
844+
845+
this._webglContextCanvas = canvas;
846+
this._webglContextLostHandler = contextLostHandler;
847+
this._webglContextRestoredHandler = contextRestoredHandler;
848+
canvas.addEventListener("webglcontextlost", contextLostHandler);
849+
canvas.addEventListener("webglcontextrestored", contextRestoredHandler);
850+
}
851+
852+
private _removeWebGLContextListeners(): void {
853+
if (this._webglContextCanvas && this._webglContextLostHandler) {
854+
this._webglContextCanvas.removeEventListener("webglcontextlost", this._webglContextLostHandler);
855+
}
856+
if (this._webglContextCanvas && this._webglContextRestoredHandler) {
857+
this._webglContextCanvas.removeEventListener("webglcontextrestored", this._webglContextRestoredHandler);
858+
}
859+
this._webglContextCanvas = null;
860+
this._webglContextLostHandler = null;
861+
this._webglContextRestoredHandler = null;
833862
}
834863

835864
/**
@@ -937,6 +966,7 @@ export class WebGLRenderer {
937966
sub();
938967
}
939968
this._viewManagerSubs = [];
969+
this._removeWebGLContextListeners();
940970
this._viewManager.destroy();
941971
this._viewManager = undefined as unknown as ViewManager;
942972
this._renderSuspendCount = 0;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import {SDKErrorType} from "../../../base/core";
6+
7+
jest.mock("../internal/ViewManager", () => ({ViewManager: jest.fn()}));
8+
jest.mock("../internal/webGL", () => ({getWebGLExtension: jest.fn()}));
9+
jest.mock("../internal/inspectors", () => ({
10+
ShaderInspector: class {},
11+
RenderInspector: class {},
12+
}));
13+
14+
import {WebGLRenderer} from "../WebGLRenderer";
15+
import {ViewManager} from "../internal/ViewManager";
16+
17+
function createSubscribable() {
18+
const unsubs: jest.Mock[] = [];
19+
const event = {
20+
subscribe: jest.fn(() => {
21+
const unsub = jest.fn();
22+
unsubs.push(unsub);
23+
return unsub;
24+
}),
25+
};
26+
return {event, unsubs};
27+
}
28+
29+
function createViewer(hasScene: boolean) {
30+
const onSceneAttached = createSubscribable();
31+
const onSceneDetached = createSubscribable();
32+
const onViewerDestroyed = createSubscribable();
33+
34+
return {
35+
viewer: {
36+
scene: hasScene ? {} : null,
37+
events: {
38+
onSceneAttached: onSceneAttached.event,
39+
onSceneDetached: onSceneDetached.event,
40+
onViewerDestroyed: onViewerDestroyed.event,
41+
},
42+
},
43+
subscriptions: [onSceneAttached, onSceneDetached, onViewerDestroyed],
44+
};
45+
}
46+
47+
describe("WebGLRenderer.attachViewer", () => {
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
});
51+
52+
test("rolls back viewer state when ViewManager init fails during initial attach", () => {
53+
const destroy = jest.fn();
54+
(ViewManager as unknown as jest.Mock).mockImplementationOnce(() => ({
55+
init: jest.fn(() => ({
56+
ok: false,
57+
type: SDKErrorType.NotSupported,
58+
error: "WebGL2 unavailable",
59+
})),
60+
destroy,
61+
}));
62+
63+
const renderer = new WebGLRenderer() as any;
64+
renderer.logging = false;
65+
const failed = createViewer(true);
66+
67+
const result = renderer.attachViewer(failed.viewer as any);
68+
69+
expect(result.ok).toBe(false);
70+
expect(destroy).toHaveBeenCalledTimes(1);
71+
expect(renderer.viewer).toBeNull();
72+
expect(renderer._viewerSubs).toEqual([]);
73+
for (const sub of failed.subscriptions) {
74+
expect(sub.event.subscribe).toHaveBeenCalledTimes(1);
75+
}
76+
for (const unsub of failed.subscriptions.flatMap((sub) => sub.unsubs)) {
77+
expect(unsub).toHaveBeenCalledTimes(1);
78+
}
79+
80+
const retry = createViewer(false);
81+
expect(renderer.attachViewer(retry.viewer as any).ok).toBe(true);
82+
expect(renderer.viewer).toBe(retry.viewer);
83+
84+
renderer.detachViewer();
85+
});
86+
87+
test("removes WebGL context listeners when destroying the view manager", () => {
88+
const canvas = {
89+
addEventListener: jest.fn(),
90+
removeEventListener: jest.fn(),
91+
};
92+
const viewManager = {
93+
getWebGLCanvasElement: jest.fn(() => canvas),
94+
webglContextLost: jest.fn(),
95+
webglContextRestored: jest.fn(() => ({ok: true, value: undefined})),
96+
destroy: jest.fn(),
97+
};
98+
const renderer = new WebGLRenderer() as any;
99+
renderer._viewManager = viewManager;
100+
renderer._viewManagerSubs = [];
101+
102+
renderer._installWebGLContextListeners(viewManager);
103+
104+
const lostHandler = canvas.addEventListener.mock.calls[0][1];
105+
const restoredHandler = canvas.addEventListener.mock.calls[1][1];
106+
107+
expect(canvas.addEventListener).toHaveBeenCalledWith("webglcontextlost", lostHandler);
108+
expect(canvas.addEventListener).toHaveBeenCalledWith("webglcontextrestored", restoredHandler);
109+
110+
renderer._destroyViewManager();
111+
112+
expect(canvas.removeEventListener).toHaveBeenCalledWith("webglcontextlost", lostHandler);
113+
expect(canvas.removeEventListener).toHaveBeenCalledWith("webglcontextrestored", restoredHandler);
114+
expect(viewManager.destroy).toHaveBeenCalledTimes(1);
115+
116+
lostHandler({preventDefault: jest.fn()});
117+
restoredHandler({});
118+
119+
expect(viewManager.webglContextLost).not.toHaveBeenCalled();
120+
expect(viewManager.webglContextRestored).not.toHaveBeenCalled();
121+
});
122+
});

0 commit comments

Comments
 (0)