LCOV - code coverage report
Current view: top level - dashql-app/src/app - app_ownership_gate.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 90.7 % 150 136
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1