LCOV - code coverage report
Current view: top level - dashql-app/src/app - app_ownership_gate.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 90.6 % 149 135
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import * as baseStyles from '../ui/foundations/banner_page.module.css';
       4              : import * as styles from './app_ownership_gate.module.css';
       5              : 
       6              : import { AppOwnership, AppOwnershipTimeoutError, getAppOwnershipEnvironment, type AppOwnershipEnvironment } from '../platform/crosstab/app_ownership.js';
       7              : import { APP_HAND_OVER_CTX, APP_RESTART_CTX, reloadTab, type AppHandOverHook } from '../platform/crosstab/app_hand_over.js';
       8              : import { Button, ButtonVariant } from '../ui/foundations/button.js';
       9              : import { CompactNavBar } from './navbar.js';
      10              : import { useLogger } from '../platform/logger/logger_provider.js';
      11              : import { stringifyError } from '../platform/logger/logger.js';
      12              : 
      13            1 : const LOG_CTX = 'app_ownership';
      14              : 
      15              : /// How long a tab waits for the owner to hand over the app
      16            1 : const TAKE_OVER_TIMEOUT_MS = 10_000;
      17              : /// How long the owner waits for its writers to stop before it hands over anyway
      18            1 : const HAND_OVER_TIMEOUT_MS = 5_000;
      19              : 
      20            1 : enum GateStatus {
      21              :     /// Waiting for the lock
      22            1 :     ACQUIRING,
      23              :     /// The tab owns the app
      24            1 :     OWNER,
      25              :     /// The tab stops its writers for another tab
      26            1 :     HANDING_OVER,
      27              :     /// Another tab owns the app
      28            1 :     ELSEWHERE,
      29              :     /// The tab asked the owner for the app
      30            1 :     TAKING_OVER,
      31              :     /// The owner did not hand over the app
      32            1 :     UNRESPONSIVE,
      33            1 : }
      34              : 
      35              : interface Props {
      36              :     children: React.ReactElement;
      37              :     /// The tabs of the app, defaults to the Web Locks and broadcast channels of the browser
      38              :     environment?: AppOwnershipEnvironment | null;
      39              :     /// Leave the app after the writers stopped, defaults to a reload of the tab.
      40              :     /// A restart of the app leaves it the same way, the reloaded tab acquires the app again.
      41              :     leave?: () => Promise<void>;
      42              : }
      43              : 
      44              : /// Render the app only in the tab that owns it, see `AppOwnership`
      45            1 : export const AppOwnershipGate: React.FC<Props> = (props: Props) => {
      46            1 :     const logger = useLogger();
      47            1 :     const environment = React.useMemo(() => props.environment !== undefined ? props.environment : getAppOwnershipEnvironment(), [props.environment]);
      48            1 :     const [status, setStatus] = React.useState<GateStatus>(environment == null ? GateStatus.OWNER : GateStatus.ACQUIRING);
      49            1 :     const ownership = React.useRef<AppOwnership | null>(null);
      50              : 
      51              :     // The hooks of the writers that are stopped before a handoff
      52            1 :     const hooks = React.useRef(new Set<AppHandOverHook>());
      53            1 :     const register = React.useCallback((hook: AppHandOverHook) => {
      54            1 :         hooks.current.add(hook);
      55            1 :         return () => { hooks.current.delete(hook); };
      56            1 :     }, []);
      57              : 
      58            1 :     const leave = props.leave ?? reloadTab;
      59            1 :     const leaveRef = React.useRef(leave);
      60            1 :     leaveRef.current = leave;
      61              :     // Stop the writers, e.g. the storage and the embedded database, which cancels its running queries
      62            1 :     const stopWriters = React.useCallback(async () => {
      63            1 :         let timer: ReturnType<typeof setTimeout> | undefined;
      64            1 :         const timeout = new Promise<'timeout'>(resolve => { timer = setTimeout(() => resolve('timeout'), HAND_OVER_TIMEOUT_MS); });
      65            1 :         const stopped = Promise.all([...hooks.current].map(hook => hook().catch(error => {
      66            0 :             logger.error("Failed to stop a storage writer", { error: stringifyError(error) }, LOG_CTX);
      67            1 :         })));
      68            1 :         if (await Promise.race([stopped, timeout]) === 'timeout') {
      69            0 :             logger.warn("Storage writers did not stop in time", {}, LOG_CTX);
      70              :         }
      71            1 :         clearTimeout(timer);
      72            1 :     }, [logger]);
      73            1 :     const handOver = React.useCallback(async () => {
      74            1 :         logger.info("Handing over the app to another tab", {}, LOG_CTX);
      75            1 :         setStatus(GateStatus.HANDING_OVER);
      76            1 :         await stopWriters();
      77            1 :         ownership.current = null;
      78            1 :         await leaveRef.current();
      79            1 :         setStatus(GateStatus.ELSEWHERE);
      80            1 :     }, [logger, stopWriters]);
      81            1 :     const restart = React.useCallback(async () => {
      82            1 :         logger.info("Restarting the app", {}, LOG_CTX);
      83            1 :         await stopWriters();
      84            1 :         await leaveRef.current();
      85            1 :     }, [logger, stopWriters]);
      86              : 
      87            1 :     React.useEffect(() => {
      88            1 :         if (environment == null) {
      89            1 :             logger.warn("Web Locks are unavailable, tabs of the app are not coordinated", {}, LOG_CTX);
      90            1 :             return;
      91            1 :         }
      92            1 :         let cancelled = false;
      93            1 :         AppOwnership.tryAcquire(environment, handOver).then(acquired => {
      94            1 :             if (cancelled) {
      95            1 :                 acquired?.dispose();
      96            1 :                 return;
      97            1 :             }
      98            1 :             ownership.current = acquired;
      99            1 :             setStatus(acquired != null ? GateStatus.OWNER : GateStatus.ELSEWHERE);
     100            1 :         }, error => {
     101            0 :             logger.error("Failed to acquire the app", { error: stringifyError(error) }, LOG_CTX);
     102            1 :         });
     103            1 :         return () => {
     104            1 :             cancelled = true;
     105            1 :             ownership.current?.dispose();
     106            1 :             ownership.current = null;
     107            1 :         };
     108            1 :     }, [environment, handOver, logger]);
     109              : 
     110            1 :     const takeOver = React.useCallback(async () => {
     111            1 :         if (environment == null) {
     112            0 :             return;
     113              :         }
     114            1 :         setStatus(GateStatus.TAKING_OVER);
     115            1 :         try {
     116            1 :             ownership.current = await AppOwnership.takeOver(environment, handOver, TAKE_OVER_TIMEOUT_MS);
     117            1 :             logger.info("Took over the app from another tab", {}, LOG_CTX);
     118            1 :             setStatus(GateStatus.OWNER);
     119            1 :         } catch (error) {
     120            0 :             if (!(error instanceof AppOwnershipTimeoutError)) {
     121            0 :                 logger.error("Failed to take over the app", { error: stringifyError(error) }, LOG_CTX);
     122              :             }
     123            0 :             setStatus(GateStatus.UNRESPONSIVE);
     124            0 :         }
     125            1 :     }, [environment, handOver, logger]);
     126              : 
     127            1 :     switch (status) {
     128            1 :         case GateStatus.ACQUIRING:
     129            1 :             return null;
     130            1 :         case GateStatus.OWNER:
     131            1 :             return (
     132            1 :                 <APP_HAND_OVER_CTX.Provider value={register}>
     133            1 :                     <APP_RESTART_CTX.Provider value={restart}>{props.children}</APP_RESTART_CTX.Provider>
     134            1 :                 </APP_HAND_OVER_CTX.Provider>
     135              :             );
     136            1 :         case GateStatus.HANDING_OVER:
     137              :             // The app stays mounted until its writers stopped, the page blocks further edits
     138            1 :             return (
     139            1 :                 <APP_HAND_OVER_CTX.Provider value={register}>
     140            1 :                     {props.children}
     141            1 :                     <div className={styles.overlay}>
     142            1 :                         <AppElsewherePage status={status} />
     143            1 :                     </div>
     144            1 :                 </APP_HAND_OVER_CTX.Provider>
     145              :             );
     146            1 :         default:
     147            1 :             return <AppElsewherePage status={status} onTakeOver={takeOver} />;
     148            1 :     }
     149            1 : };
     150              : 
     151            1 : function AppElsewherePage(props: { status: GateStatus; onTakeOver?: () => void }) {
     152            1 :     let description: string;
     153            1 :     switch (props.status) {
     154            0 :         case GateStatus.HANDING_OVER:
     155            0 :             description = 'DashQL is moving to another tab.';
     156            0 :             break;
     157            0 :         case GateStatus.UNRESPONSIVE:
     158            0 :             description = 'The other tab did not respond. Close it and try again.';
     159            0 :             break;
     160            1 :         default:
     161            1 :             description = 'DashQL is open in another tab. Only one tab can use the app at a time, so that tabs don\'t overwrite each other\'s changes.';
     162              :             break;
     163            1 :     }
     164            1 :     return (
     165            1 :         <div className={baseStyles.page} data-electron-drag-region data-testid="app-elsewhere">
     166              :             {/* The internals need the storage, which another tab owns */}
     167            1 :             <CompactNavBar showInternals={false} />
     168            1 :             <div className={baseStyles.banner_and_content_container} data-electron-drag-region>
     169            1 :                 <div className={baseStyles.content_container} data-electron-drag-region>
     170            1 :                     <div className={`${baseStyles.card} ${styles.card}`}>
     171            1 :                         <div className={baseStyles.card_header} data-electron-drag-region>
     172            1 :                             <div className={baseStyles.card_header_left_container}>
     173            1 :                                 <div className={baseStyles.card_header_left_title}>
     174              :                                     Open in Another Tab
     175            1 :                                 </div>
     176            1 :                             </div>
     177            1 :                         </div>
     178            1 :                         <div className={baseStyles.card_section}>
     179            1 :                             <div className={`${baseStyles.section_entries} ${styles.description}`}>
     180            1 :                                 {description}
     181            1 :                             </div>
     182            1 :                             {props.onTakeOver && (
     183            1 :                                 <div className={baseStyles.card_actions}>
     184            1 :                                     <div className={baseStyles.card_actions_right}>
     185            1 :                                         <Button
     186            1 :                                             variant={ButtonVariant.Primary}
     187            1 :                                             data-testid="app-take-over"
     188            1 :                                             onClick={props.onTakeOver}
     189            1 :                                             disabled={props.status === GateStatus.TAKING_OVER}
     190            1 :                                         >
     191              :                                             Use Here
     192            1 :                                         </Button>
     193            1 :                                     </div>
     194            1 :                                 </div>
     195              :                             )}
     196            1 :                         </div>
     197            1 :                     </div>
     198            1 :                 </div>
     199            1 :             </div>
     200            1 :         </div>
     201              :     );
     202            1 : }
        

Generated by: LCOV version 2.0-1