11import { WASQLiteVFS } from '@powersync/web' ;
22import { 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
2026createMultipleTabsTest ( ) ; // IndexedDB (default)
2127createMultipleTabsTest ( 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 */
249297function 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