Skip to content

Commit d31a864

Browse files
authored
fix(webview): retain instance when destroy fails (#2836)
* fix(webview): retain instance when destroy fails * test(webview): cover destroy failure lifecycle * fix(webview): serialize concurrent destruction * test(webview): cover concurrent destruction
1 parent 618a6fd commit d31a864

2 files changed

Lines changed: 87 additions & 5 deletions

File tree

src/lib/webview.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class WebView {
5959
this._messageCallbacks = [];
6060
this._eventCallbacks = [];
6161
this._destroyed = false;
62+
this._destroyPromise = null;
6263

6364
instances.set(id, this);
6465
}
@@ -126,11 +127,21 @@ class WebView {
126127

127128
async destroy() {
128129
this._checkDestroyed();
129-
this._destroyed = true;
130-
await nativeBridge.destroy(this.id);
131-
instances.delete(this.id);
132-
this._messageCallbacks = [];
133-
this._eventCallbacks = [];
130+
if (!this._destroyPromise) {
131+
this._destroyPromise = (async () => {
132+
await nativeBridge.destroy(this.id);
133+
this._destroyed = true;
134+
instances.delete(this.id);
135+
this._messageCallbacks = [];
136+
this._eventCallbacks = [];
137+
})();
138+
}
139+
140+
try {
141+
await this._destroyPromise;
142+
} finally {
143+
this._destroyPromise = null;
144+
}
134145
}
135146

136147
_checkDestroyed() {

tests/unit/webview.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { nativeBridge } = vi.hoisted(() => ({
4+
nativeBridge: {
5+
setMessageCallback: vi.fn(),
6+
create: vi.fn(),
7+
evaluate: vi.fn(),
8+
destroy: vi.fn(),
9+
},
10+
}));
11+
12+
vi.mock("../../src/plugins/webview/www/webview", () => ({
13+
default: nativeBridge,
14+
}));
15+
16+
import webviewAPI from "lib/webview";
17+
18+
describe("WebView lifecycle", () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
nativeBridge.create.mockResolvedValue("webview-1");
22+
nativeBridge.evaluate.mockResolvedValue("result");
23+
nativeBridge.destroy.mockResolvedValue(undefined);
24+
});
25+
26+
it("keeps the instance usable when native destruction fails", async () => {
27+
const webview = await webviewAPI.create();
28+
nativeBridge.destroy.mockRejectedValueOnce(new Error("native failure"));
29+
30+
await expect(webview.destroy()).rejects.toThrow("native failure");
31+
await expect(webview.evaluate("1 + 1")).resolves.toBe("result");
32+
expect(nativeBridge.evaluate).toHaveBeenCalledWith("webview-1", "1 + 1");
33+
34+
await expect(webview.destroy()).resolves.toBeUndefined();
35+
expect(nativeBridge.destroy).toHaveBeenCalledTimes(2);
36+
});
37+
38+
it("marks the instance destroyed only after native destruction succeeds", async () => {
39+
const webview = await webviewAPI.create();
40+
41+
await webview.destroy();
42+
43+
await expect(webview.evaluate("1 + 1")).rejects.toThrow(
44+
"WebView has been destroyed",
45+
);
46+
});
47+
48+
it("shares native destruction between concurrent callers", async () => {
49+
let resolveDestroy;
50+
nativeBridge.destroy.mockImplementationOnce(
51+
() =>
52+
new Promise((resolve) => {
53+
resolveDestroy = resolve;
54+
}),
55+
);
56+
const webview = await webviewAPI.create();
57+
58+
const firstDestroy = webview.destroy();
59+
const secondDestroy = webview.destroy();
60+
61+
expect(nativeBridge.destroy).toHaveBeenCalledTimes(1);
62+
resolveDestroy();
63+
await expect(Promise.all([firstDestroy, secondDestroy])).resolves.toEqual([
64+
undefined,
65+
undefined,
66+
]);
67+
await expect(webview.evaluate("1 + 1")).rejects.toThrow(
68+
"WebView has been destroyed",
69+
);
70+
});
71+
});

0 commit comments

Comments
 (0)