Co-Study4Grid / frontend /src /hooks /useDetachedTabs.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
11.4 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { TabId } from '../types';
import { colors } from '../styles/tokens';
/** Resolve the opener's current theme ('dark' | 'light'). */
function currentTheme(): 'dark' | 'light' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
/**
* Mirror the opener's theme onto a popup document (QW19). The popup clones the
* opener's stylesheets (incl. tokens.css with its `[data-theme="dark"]`
* overrides), but NOT the `data-theme` attribute the theme toggle writes on the
* opener's <html> — so without this a detached tab always rendered light.
*/
function mirrorTheme(popupDoc: Document): void {
const theme = currentTheme();
popupDoc.documentElement.setAttribute('data-theme', theme);
popupDoc.documentElement.style.colorScheme = theme;
}
/**
* A "detached" tab lives in a secondary browser window. The React component
* tree for the tab content is still rendered from App.tsx — only a stable
* portal-target DOM node is physically relocated (via appendChild) into the
* popup's body by `DetachableTabHost`. Because the createPortal target never
* changes (it's the same orphan div), React never unmounts the sub-tree —
* so refs, event listeners, MemoizedSvgContainer state and the SVG viewBox
* attribute all survive the detach/reattach round-trip unchanged.
*/
export interface DetachedTabEntry {
/** The opened popup window — closed popups are pruned automatically. */
window: Window;
/** The <div id="root"> inside the popup, used as the portal target. */
mountNode: HTMLElement;
}
export type DetachedTabsMap = Partial<Record<TabId, DetachedTabEntry>>;
const DEFAULT_POPUP_FEATURES = 'popup=yes,width=1100,height=800,resizable=yes,scrollbars=yes';
const TAB_TITLES: Record<TabId, string> = {
'n': 'Network (N)',
'contingency': 'Contingency (N-1)',
'action': 'Remedial Action',
'overflow': 'Overflow Analysis',
};
/**
* Clone all <style> and <link rel="stylesheet"> nodes from the opener
* document into the popup document so the portaled React tree picks up the
* same CSS rules (Vite HMR injects styles as <style> elements, and in
* production we get a stylesheet <link>).
*/
function cloneStylesIntoPopup(popupDoc: Document): void {
const openerDoc = window.document;
const head = popupDoc.head;
openerDoc.querySelectorAll('link[rel="stylesheet"], style').forEach(node => {
head.appendChild(node.cloneNode(true));
});
// Ensure the body fills the viewport so absolutely-positioned children
// (tab content uses position:absolute + inset:0) have something to
// size themselves against.
const bodyStyle = popupDoc.body.style;
bodyStyle.margin = '0';
bodyStyle.height = '100vh';
bodyStyle.width = '100vw';
bodyStyle.display = 'flex';
bodyStyle.flexDirection = 'column';
bodyStyle.background = colors.surface;
popupDoc.documentElement.style.height = '100%';
}
/**
* Build the popup's mount node — a flex-growing div inside body — so that
* the portaled tab content can use height:100% and position:absolute.
*/
function buildMountNode(popupDoc: Document): HTMLElement {
const mount = popupDoc.createElement('div');
mount.id = 'costudy4grid-detached-root';
mount.style.cssText = `flex: 1; position: relative; width: 100%; min-height: 0; background: ${colors.surface};`;
popupDoc.body.appendChild(mount);
return mount;
}
export interface UseDetachedTabsResult {
detachedTabs: DetachedTabsMap;
/** True if the tab is currently opened in a secondary window. */
isDetached: (tabId: TabId) => boolean;
/**
* Detach a tab into a new popup window. Returns the popup entry or null
* if window.open was blocked. Subsequent calls for the same tab focus
* the existing popup instead of creating a new one.
*/
detach: (tabId: TabId) => DetachedTabEntry | null;
/** Close the popup for the given tab (if any) and fold it back inline. */
reattach: (tabId: TabId) => void;
/** Focus the popup for the given tab if it exists. No-op otherwise. */
focus: (tabId: TabId) => void;
}
export function useDetachedTabs(
options: { onPopupBlocked?: () => void } = {}
): UseDetachedTabsResult {
const [detachedTabs, setDetachedTabs] = useState<DetachedTabsMap>({});
// Ref mirror so stable callbacks can read the latest map without
// re-binding their identity on every change. Updated in a
// useLayoutEffect to stay compatible with React strict-mode's
// "no ref writes during render" rule.
const detachedRef = useRef<DetachedTabsMap>({});
const onPopupBlockedRef = useRef(options.onPopupBlocked);
// Popups that should be closed AFTER React has had a chance to
// relocate the tab's DOM back to the main window. Closing the popup
// synchronously inside `reattach` can unmap its document while the
// tab's portal host is still inside it — which, with the previous
// React-portal implementation, tore down the React sub-tree in a
// dying document and left other tabs in a blank state. Deferring
// the close to a useEffect ensures the imperative DOM move has
// already run before the popup window goes away.
const pendingCloseRef = useRef<Window[]>([]);
useLayoutEffect(() => {
detachedRef.current = detachedTabs;
}, [detachedTabs]);
useLayoutEffect(() => {
onPopupBlockedRef.current = options.onPopupBlocked;
}, [options.onPopupBlocked]);
const pruneTab = useCallback((tabId: TabId) => {
setDetachedTabs(prev => {
if (!prev[tabId]) return prev;
const next = { ...prev };
delete next[tabId];
return next;
});
}, []);
const reattach = useCallback((tabId: TabId) => {
const entry = detachedRef.current[tabId];
if (entry && !entry.window.closed) {
// Queue the popup for closing; the effect below will close it
// only after VisualizationPanel's useLayoutEffect has moved
// the tab host back into the main window.
pendingCloseRef.current.push(entry.window);
}
pruneTab(tabId);
}, [pruneTab]);
// Runs after VisualizationPanel's own useLayoutEffect (children run
// before parents, and useEffect runs after useLayoutEffect in the
// same commit), so by the time we close the popup the tab's DOM has
// already been relocated back to the main tree.
useEffect(() => {
const popups = pendingCloseRef.current;
if (popups.length === 0) return;
pendingCloseRef.current = [];
for (const w of popups) {
try { if (!w.closed) w.close(); } catch { /* ignore */ }
}
}, [detachedTabs]);
const detach = useCallback((tabId: TabId): DetachedTabEntry | null => {
const existing = detachedRef.current[tabId];
if (existing && !existing.window.closed) {
try { existing.window.focus(); } catch { /* ignore */ }
return existing;
}
const popup = window.open('', `costudy4grid_tab_${tabId}`, DEFAULT_POPUP_FEATURES);
if (!popup) {
onPopupBlockedRef.current?.();
return null;
}
popup.document.open();
popup.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Co-Study4Grid — ${TAB_TITLES[tabId]}</title></head><body></body></html>`);
popup.document.close();
cloneStylesIntoPopup(popup.document);
mirrorTheme(popup.document);
const mountNode = buildMountNode(popup.document);
// When the popup is closed (by the user, by reload, or by our
// reattach() call) we must prune it from state so the content
// folds back into the main window. Using 'pagehide' is more
// reliable than 'beforeunload' in modern browsers.
const handleClose = () => pruneTab(tabId);
popup.addEventListener('pagehide', handleClose);
popup.addEventListener('beforeunload', handleClose);
const entry: DetachedTabEntry = { window: popup, mountNode };
setDetachedTabs(prev => ({ ...prev, [tabId]: entry }));
return entry;
}, [pruneTab]);
// Keep open popups in sync with live theme toggles (QW19): observe the
// opener's <html> data-theme and mirror every change onto each popup
// document. Reads detachedRef so it needs no dep churn.
useEffect(() => {
const observer = new MutationObserver(() => {
const theme = currentTheme();
for (const entry of Object.values(detachedRef.current)) {
if (entry && !entry.window.closed) {
try {
entry.window.document.documentElement.setAttribute('data-theme', theme);
entry.window.document.documentElement.style.colorScheme = theme;
} catch { /* popup navigating away */ }
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
const isDetached = useCallback((tabId: TabId) => {
const entry = detachedRef.current[tabId];
return !!entry && !entry.window.closed;
}, []);
const focus = useCallback((tabId: TabId) => {
const entry = detachedRef.current[tabId];
if (entry && !entry.window.closed) {
try { entry.window.focus(); } catch { /* ignore */ }
}
}, []);
// Poll periodically to catch popups that were closed without firing
// pagehide (rare, but observed on some browsers during tab-crashes).
useEffect(() => {
const tabIds = Object.keys(detachedTabs) as TabId[];
if (tabIds.length === 0) return;
const intervalId = window.setInterval(() => {
for (const tabId of tabIds) {
const entry = detachedTabs[tabId];
if (entry && entry.window.closed) pruneTab(tabId);
}
}, 1000);
return () => window.clearInterval(intervalId);
}, [detachedTabs, pruneTab]);
// On App unmount (full page reload / navigation) close any live popups
// so they don't linger as orphans pointing at a dead React tree.
useEffect(() => {
return () => {
const entries = detachedRef.current;
for (const key of Object.keys(entries) as TabId[]) {
const entry = entries[key];
if (entry && !entry.window.closed) {
try { entry.window.close(); } catch { /* ignore */ }
}
}
};
}, []);
return { detachedTabs, isDetached, detach, reattach, focus };
}