Skip to content

Commit 8f95e10

Browse files
Update tabs test to be more indicative of the actual issues.
1 parent 3780223 commit 8f95e10

File tree

1 file changed

+119
-138
lines changed

1 file changed

+119
-138
lines changed

packages/web/tests/multiple_tabs_iframe.test.ts

Lines changed: 119 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { WASQLiteVFS } from '@powersync/web';
22
import { v4 as uuid } from 'uuid';
3-
import { describe, expect, it, onTestFinished, vi } from 'vitest';
3+
import { describe, expect, it, onTestFinished } from 'vitest';
44

55
/**
66
* Creates an iframe with a PowerSync client that connects using the same database.
@@ -16,16 +16,22 @@ interface IframeClient {
1616
getCredentialsFetchCount: () => Promise<number>;
1717
}
1818

19+
interface IframeClientResult {
20+
iframe: HTMLIFrameElement;
21+
cleanup: () => Promise<void>;
22+
ready: Promise<IframeClient>;
23+
}
24+
1925
// Run tests for both IndexedDB and OPFS
2026
createMultipleTabsTest(); // IndexedDB (default)
2127
createMultipleTabsTest(WASQLiteVFS.OPFSCoopSyncVFS);
2228

23-
async function createIframeWithPowerSyncClient(
29+
function createIframeWithPowerSyncClient(
2430
dbFilename: string,
2531
identifier: string,
2632
vfs?: WASQLiteVFS,
2733
waitForConnection?: boolean
28-
): Promise<IframeClient> {
34+
): IframeClientResult {
2935
const iframe = document.createElement('iframe');
3036
// Make iframe visible for debugging
3137
iframe.style.display = 'block';
@@ -91,33 +97,60 @@ async function createIframeWithPowerSyncClient(
9197
const url = URL.createObjectURL(blob);
9298
iframe.src = url;
9399

94-
return new Promise((resolve, reject) => {
95-
let requestIdCounter = 0;
96-
const pendingRequests = new Map<
97-
string,
98-
{
99-
resolve: (value: any) => void;
100-
reject: (error: Error) => void;
100+
let requestIdCounter = 0;
101+
const pendingRequests = new Map<
102+
string,
103+
{
104+
resolve: (value: any) => void;
105+
reject: (error: Error) => void;
106+
}
107+
>();
108+
109+
let messageHandler: ((event: MessageEvent) => void) | null = null;
110+
let isCleanedUp = false;
111+
112+
// Create cleanup function that can be called immediately
113+
const cleanup = async (): Promise<void> => {
114+
if (isCleanedUp) {
115+
return;
116+
}
117+
isCleanedUp = true;
118+
119+
// Remove message handler if it was added
120+
if (messageHandler) {
121+
window.removeEventListener('message', messageHandler);
122+
messageHandler = null;
123+
}
124+
125+
// Simulate abrupt tab closure - just remove the iframe without calling
126+
// disconnect/close on the PowerSync client. This tests dead tab detection.
127+
URL.revokeObjectURL(url);
128+
if (iframe.parentNode) {
129+
iframe.remove();
130+
}
131+
};
132+
133+
// Create promise that resolves when powersync-ready is received
134+
const ready = new Promise<IframeClient>((resolve, reject) => {
135+
messageHandler = async (event: MessageEvent) => {
136+
if (isCleanedUp) {
137+
return;
101138
}
102-
>();
103139

104-
const messageHandler = async (event: MessageEvent) => {
105140
const data = event.data;
106141

107142
if (data?.type === 'powersync-ready' && data.identifier === identifier) {
108143
// Don't remove the message handler - we need it to receive query results!
109144
resolve({
110145
iframe,
111-
cleanup: async () => {
112-
// Simulate abrupt tab closure - just remove the iframe without calling
113-
// disconnect/close on the PowerSync client. This tests dead tab detection.
114-
URL.revokeObjectURL(url);
115-
if (iframe.parentNode) {
116-
iframe.remove();
117-
}
118-
},
146+
cleanup,
119147
executeQuery: (query: string, parameters?: unknown[]): Promise<unknown[]> => {
120148
return new Promise((resolveQuery, rejectQuery) => {
149+
if (isCleanedUp) {
150+
rejectQuery(new Error('Iframe has been cleaned up'));
151+
return;
152+
}
153+
121154
const requestId = `query-${identifier}-${++requestIdCounter}`;
122155
pendingRequests.set(requestId, {
123156
resolve: resolveQuery,
@@ -126,6 +159,7 @@ async function createIframeWithPowerSyncClient(
126159

127160
const iframeWindow = iframe.contentWindow;
128161
if (!iframeWindow) {
162+
pendingRequests.delete(requestId);
129163
rejectQuery(new Error('Iframe window not available'));
130164
return;
131165
}
@@ -151,6 +185,11 @@ async function createIframeWithPowerSyncClient(
151185
},
152186
getCredentialsFetchCount: (): Promise<number> => {
153187
return new Promise((resolveCount, rejectCount) => {
188+
if (isCleanedUp) {
189+
rejectCount(new Error('Iframe has been cleaned up'));
190+
return;
191+
}
192+
154193
const requestId = `credentials-count-${identifier}-${++requestIdCounter}`;
155194
pendingRequests.set(requestId, {
156195
resolve: resolveCount,
@@ -159,6 +198,7 @@ async function createIframeWithPowerSyncClient(
159198

160199
const iframeWindow = iframe.contentWindow;
161200
if (!iframeWindow) {
201+
pendingRequests.delete(requestId);
162202
rejectCount(new Error('Iframe window not available'));
163203
return;
164204
}
@@ -182,7 +222,10 @@ async function createIframeWithPowerSyncClient(
182222
}
183223
});
184224
} else if (data?.type === 'powersync-error' && data.identifier === identifier) {
185-
window.removeEventListener('message', messageHandler);
225+
if (messageHandler) {
226+
window.removeEventListener('message', messageHandler);
227+
messageHandler = null;
228+
}
186229
URL.revokeObjectURL(url);
187230
if (iframe.parentNode) {
188231
iframe.remove();
@@ -212,6 +255,12 @@ async function createIframeWithPowerSyncClient(
212255
};
213256
window.addEventListener('message', messageHandler);
214257
});
258+
259+
return {
260+
iframe,
261+
cleanup,
262+
ready
263+
};
215264
}
216265

217266
/**
@@ -236,51 +285,33 @@ async function createIframeWithPowerSyncClient(
236285
* enableMultiTabs is true).
237286
*
238287
* Test Scenarios:
239-
* - Opening a long-lived reference tab that remains open throughout the test
240-
* - Opening multiple additional tabs simultaneously
241-
* - Simultaneously closing half of the tabs (simulating abrupt tab closures)
242-
* - Simultaneously reopening the closed tabs
243-
* - Verifying that all tabs remain functional and the shared database connection
244-
* is properly maintained across tab lifecycle events
288+
* - Opening 100 tabs simultaneously
289+
* - Waiting 1 second for all tabs to initialize
290+
* - Simultaneously closing all tabs except the middle (50th) tab
291+
* - Verifying that the remaining tab is still functional and the shared database
292+
* connection is properly maintained after closing 99 tabs
245293
*
246294
* This test suite runs for both IndexedDB and OPFS VFS backends to ensure dead tab
247295
* detection works correctly across different storage mechanisms.
248296
*/
249297
function createMultipleTabsTest(vfs?: WASQLiteVFS) {
250298
const vfsName = vfs || 'IndexedDB';
251-
describe(`Multiple Tabs with Iframes (${vfsName})`, { sequential: true, timeout: 20_000 }, () => {
299+
describe(`Multiple Tabs with Iframes (${vfsName})`, { sequential: true, timeout: 60_000 }, () => {
252300
const dbFilename = `test-multi-tab-${uuid()}.db`;
253301

254-
// Configurable number of tabs to create (excluding the long-lived tab)
255-
const NUM_TABS = 20;
256-
257-
it('should handle simultaneous close and reopen of tabs', async () => {
258-
// Step 1: Open a long-lived reference tab that stays open throughout the test
259-
const longLivedTab = await createIframeWithPowerSyncClient(dbFilename, 'long-lived-tab', vfs);
260-
onTestFinished(async () => {
261-
try {
262-
await longLivedTab.cleanup();
263-
} catch (e) {
264-
// Ignore cleanup errors
265-
}
266-
});
267-
268-
// Test query execution right after creating the long-lived tab
269-
const initialQueryResult = await longLivedTab.executeQuery('SELECT 1 as value');
270-
expect(initialQueryResult).toBeDefined();
271-
expect(Array.isArray(initialQueryResult)).toBe(true);
272-
expect(initialQueryResult.length).toBe(1);
273-
expect((initialQueryResult[0] as { value: number }).value).toBe(1);
302+
// Number of tabs to create
303+
const NUM_TABS = 100;
304+
// Index of the middle tab to keep (0-indexed, so 49 is the 50th tab)
305+
const MIDDLE_TAB_INDEX = 49;
274306

275-
// Step 2: Open a configurable number of other tabs
276-
const tabs: IframeClient[] = [];
277-
const tabIdentifiers: string[] = [];
307+
it('should handle opening and closing many tabs quickly', async () => {
308+
// Step 1: Open 100 tabs (don't wait for them to be ready)
309+
const tabResults: IframeClientResult[] = [];
278310

279311
for (let i = 0; i < NUM_TABS; i++) {
280312
const identifier = `tab-${i}`;
281-
tabIdentifiers.push(identifier);
282-
const result = await createIframeWithPowerSyncClient(dbFilename, identifier, vfs);
283-
tabs.push(result);
313+
const result = createIframeWithPowerSyncClient(dbFilename, identifier, vfs);
314+
tabResults.push(result);
284315

285316
// Register cleanup for each tab
286317
onTestFinished(async () => {
@@ -292,116 +323,66 @@ function createMultipleTabsTest(vfs?: WASQLiteVFS) {
292323
});
293324
}
294325

295-
expect(tabs.length).toBe(NUM_TABS);
326+
expect(tabResults.length).toBe(NUM_TABS);
296327

297-
// Verify all tabs are connected
298-
for (const tab of tabs) {
299-
expect(tab.iframe.isConnected).toBe(true);
328+
// Verify all iframes are created (they're created immediately)
329+
for (const result of tabResults) {
330+
expect(result.iframe.isConnected).toBe(true);
300331
}
301-
expect(longLivedTab.iframe.isConnected).toBe(true);
302-
303-
// Step 3: Simultaneously close the first and last quarters of the tabs (simulating abrupt closure)
304-
const quarterCount = Math.floor(NUM_TABS / 4);
305-
const firstQuarterEnd = quarterCount;
306-
const lastQuarterStart = NUM_TABS - quarterCount;
307332

308-
// Close the first quarter and last quarter of tabs
309-
const firstQuarter = tabs.slice(0, firstQuarterEnd);
310-
const lastQuarter = tabs.slice(lastQuarterStart);
311-
const tabsToClose = [...firstQuarter, ...lastQuarter];
333+
// Step 2: Wait 1 second
334+
await new Promise((resolve) => setTimeout(resolve, 1000));
312335

313-
// Keep the middle two quarters
314-
const tabsToKeep = tabs.slice(firstQuarterEnd, lastQuarterStart);
336+
// Step 3: Close all tabs except the middle (50th) tab
337+
const tabsToClose: IframeClientResult[] = [];
338+
for (let i = 0; i < NUM_TABS; i++) {
339+
if (i !== MIDDLE_TAB_INDEX) {
340+
tabsToClose.push(tabResults[i]);
341+
}
342+
}
315343

316-
// Close the first and last quarters of tabs simultaneously (without proper cleanup)
317-
// Do this in reverse order to ensure the last connected tab is closed first.
318-
const closePromises = tabsToClose.reverse().map((tab) => tab.cleanup());
344+
// Close all tabs except the middle one simultaneously (without waiting for ready)
345+
const closePromises = tabsToClose.map((result) => result.cleanup());
319346
await Promise.all(closePromises);
320347

321348
// Verify closed tabs are removed
322-
for (const tab of tabsToClose) {
323-
expect(tab.iframe.isConnected).toBe(false);
324-
expect(document.body.contains(tab.iframe)).toBe(false);
325-
}
326-
327-
// Verify remaining tabs and long-lived tab are still connected
328-
for (const tab of tabsToKeep) {
329-
expect(tab.iframe.isConnected).toBe(true);
330-
}
331-
expect(longLivedTab.iframe.isConnected).toBe(true);
332-
333-
// Step 4: Reopen the closed tabs
334-
const reopenedTabs: IframeClient[] = [];
335-
// Get the identifiers for the closed tabs by finding their indices in the original tabs array
336-
const closedTabIdentifiers = tabsToClose.map((closedTab) => {
337-
const index = tabs.indexOf(closedTab);
338-
return tabIdentifiers[index];
339-
});
340-
341-
const reopenPromises = closedTabIdentifiers.map(async (identifier) => {
342-
const result = await createIframeWithPowerSyncClient(dbFilename, identifier, vfs);
343-
reopenedTabs.push(result);
344-
345-
// Register cleanup for reopened tabs
346-
onTestFinished(async () => {
347-
try {
348-
await result.cleanup();
349-
} catch (e) {
350-
// Ignore cleanup errors
351-
}
352-
});
353-
return result;
354-
});
355-
356-
// Reopen all closed tabs simultaneously
357-
await Promise.all(reopenPromises);
358-
359-
// Verify all reopened tabs are connected
360-
for (const tab of reopenedTabs) {
361-
expect(tab.iframe.isConnected).toBe(true);
362-
}
363-
364-
// Verify tabs that were kept open are still connected
365-
for (const tab of tabsToKeep) {
366-
expect(tab.iframe.isConnected).toBe(true);
349+
for (let i = 0; i < NUM_TABS; i++) {
350+
if (i !== MIDDLE_TAB_INDEX) {
351+
expect(tabResults[i].iframe.isConnected).toBe(false);
352+
expect(document.body.contains(tabResults[i].iframe)).toBe(false);
353+
}
367354
}
368355

369-
// Final verification: all tabs should be mounted
370-
const allTabs = [...tabsToKeep, ...reopenedTabs];
371-
expect(allTabs.length).toBe(NUM_TABS);
372-
expect(longLivedTab.iframe.isConnected).toBe(true);
356+
// Verify the middle tab is still present
357+
expect(tabResults[MIDDLE_TAB_INDEX].iframe.isConnected).toBe(true);
358+
expect(document.body.contains(tabResults[MIDDLE_TAB_INDEX].iframe)).toBe(true);
373359

374-
// Step 5: Execute a test query in the long-lived tab to verify its DB is still functional
375-
const queryResult = await longLivedTab.executeQuery('SELECT 1 as value');
360+
// Step 4: Wait for the middle tab to be ready, then execute a test query to verify its DB is still functional
361+
const middleTabClient = await tabResults[MIDDLE_TAB_INDEX].ready;
362+
const queryResult = await middleTabClient.executeQuery('SELECT 1 as value');
376363

377364
// Verify the query result
378365
expect(queryResult).toBeDefined();
379366
expect(Array.isArray(queryResult)).toBe(true);
380367
expect(queryResult.length).toBe(1);
381368
expect((queryResult[0] as { value: number }).value).toBe(1);
382369

383-
// Step 6: Create a new tab which should trigger a connect. The shared sync worker should reconnect.
384-
// This ensures the shared sync worker is not stuck and is properly handling new connections
370+
// Step 5: Create another tab, wait for it to be ready, and verify its credentialsFetchCount is 1
385371
const newTabIdentifier = `new-tab-${Date.now()}`;
386-
const newTab = await createIframeWithPowerSyncClient(dbFilename, newTabIdentifier, vfs, true);
372+
const newTabResult = createIframeWithPowerSyncClient(dbFilename, newTabIdentifier, vfs, true);
387373
onTestFinished(async () => {
388374
try {
389-
await newTab.cleanup();
375+
await newTabResult.cleanup();
390376
} catch (e) {
391377
// Ignore cleanup errors
392378
}
393379
});
380+
const newTabClient = await newTabResult.ready;
394381

395-
// Wait for the new tab's credentials to be fetched (indicating the shared sync worker is active)
396-
// The mocked remote always returns 401, so the shared sync worker should try and fetch credentials again.
397-
await vi.waitFor(async () => {
398-
const credentialsFetchCount = await newTab.getCredentialsFetchCount();
399-
expect(
400-
credentialsFetchCount,
401-
'The new client should have been asked for credentials by the shared sync worker. ' +
402-
'This indicates the shared sync worker may be stuck or not processing new connections.'
403-
).toBeGreaterThanOrEqual(1);
404-
});
382+
// Verify the new tab's credentials fetch count is 1
383+
// This means the shared worker is using the db and attempting to connect to the PowerSync server.
384+
const credentialsFetchCount = await newTabClient.getCredentialsFetchCount();
385+
expect(credentialsFetchCount).toBe(1);
405386
});
406387
});
407388
}

0 commit comments

Comments
 (0)