Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions static/app/components/core/pictureInPicture/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {PictureInPictureProvider, usePictureInPicture} from './pictureInPicture';
export {PictureInPicturePortal} from './pictureInPicturePortal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {act, renderHook, waitFor} from 'sentry-test/reactTestingLibrary';

import {
PictureInPictureProvider,
usePictureInPicture,
} from '@sentry/scraps/pictureInPicture';

type FakePipWindow = Window & {
__listeners: Record<string, Array<() => void>>;
};

function createFakePipWindow(): FakePipWindow {
const doc = document.implementation.createHTMLDocument('pip');
const listeners: Record<string, Array<() => void>> = {};

const win = {
document: doc,
closed: false,
close: jest.fn(() => {
win.closed = true;
(listeners.pagehide ?? []).forEach(fn => fn());
}),
focus: jest.fn(),
addEventListener: jest.fn((type: string, fn: () => void) => {
(listeners[type] ??= []).push(fn);
}),
removeEventListener: jest.fn((type: string, fn: () => void) => {
listeners[type] = (listeners[type] ?? []).filter(listener => listener !== fn);
}),
__listeners: listeners,
};

return win as unknown as FakePipWindow;
}

function stubDocumentPictureInPicture(pip: FakePipWindow) {
const requestWindow = jest.fn().mockResolvedValue(pip);
Object.defineProperty(window, 'documentPictureInPicture', {
configurable: true,
writable: true,
value: {requestWindow, window: null},
});
return requestWindow;
}

describe('usePictureInPicture', () => {
afterEach(() => {
// @ts-expect-error - cleaning up the stub
delete window.documentPictureInPicture;
});

it('throws when used outside of a provider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => usePictureInPicture())).toThrow(
'usePictureInPicture must be used within a PictureInPictureProvider'
);
// eslint-disable-next-line no-console
jest.mocked(console.error).mockRestore();
});

it('reports unsupported when the API is unavailable', () => {
const {result} = renderHook(() => usePictureInPicture(), {
wrapper: PictureInPictureProvider,
});
expect(result.current.isSupported).toBe(false);
expect(result.current.pipWindow).toBeNull();
});

it('opens a window and copies stylesheets into it', async () => {
const style = document.createElement('style');
style.textContent = '.pip-test{color:red;}';
document.head.appendChild(style);

const pip = createFakePipWindow();
const requestWindow = stubDocumentPictureInPicture(pip);

const {result} = renderHook(() => usePictureInPicture(), {
wrapper: PictureInPictureProvider,
});
expect(result.current.isSupported).toBe(true);

await act(async () => {
await result.current.requestPipWindow({width: 400, height: 600});
});

expect(requestWindow).toHaveBeenCalledWith(
expect.objectContaining({width: 400, height: 600})
);
await waitFor(() => expect(result.current.pipWindow).toBe(pip));
const copiedStyles = Array.from(pip.document.head.querySelectorAll('style'));
expect(copiedStyles.some(tag => tag.innerHTML.includes('.pip-test'))).toBe(true);

document.head.removeChild(style);
});

it('does not copy emotion style tags (they are re-injected by the portal)', async () => {
const emotionStyle = document.createElement('style');
emotionStyle.setAttribute('data-emotion', 'app');
emotionStyle.textContent = '.emotion-skip{color:blue;}';
document.head.appendChild(emotionStyle);

const pip = createFakePipWindow();
stubDocumentPictureInPicture(pip);

const {result} = renderHook(() => usePictureInPicture(), {
wrapper: PictureInPictureProvider,
});

await act(async () => {
await result.current.requestPipWindow();
});
await waitFor(() => expect(result.current.pipWindow).toBe(pip));

const copiedStyles = Array.from(pip.document.head.querySelectorAll('style'));
expect(copiedStyles.some(tag => tag.innerHTML.includes('.emotion-skip'))).toBe(false);

document.head.removeChild(emotionStyle);
});

it('resets state when the window is closed by the user', async () => {
const pip = createFakePipWindow();
stubDocumentPictureInPicture(pip);

const {result} = renderHook(() => usePictureInPicture(), {
wrapper: PictureInPictureProvider,
});

await act(async () => {
await result.current.requestPipWindow();
});
await waitFor(() => expect(result.current.pipWindow).toBe(pip));

// Simulate the user closing the window (fires `pagehide`).
act(() => {
pip.__listeners.pagehide!.forEach(fn => fn());
});

expect(result.current.pipWindow).toBeNull();
});

it('closePipWindow closes the window and is idempotent', async () => {
const pip = createFakePipWindow();
stubDocumentPictureInPicture(pip);

const {result} = renderHook(() => usePictureInPicture(), {
wrapper: PictureInPictureProvider,
});

await act(async () => {
await result.current.requestPipWindow();
});
await waitFor(() => expect(result.current.pipWindow).toBe(pip));

act(() => {
result.current.closePipWindow();
});
expect(pip.close).toHaveBeenCalledTimes(1);
expect(result.current.pipWindow).toBeNull();

// Calling again is a no-op.
act(() => {
result.current.closePipWindow();
});
expect(pip.close).toHaveBeenCalledTimes(1);
});
});
193 changes: 193 additions & 0 deletions static/app/components/core/pictureInPicture/pictureInPicture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';

interface RequestPipWindowOptions {
height?: number;
/**
* Opens at the browser's default placement instead of remembering where the
* window was last positioned. Useful to avoid the window reappearing far from
* where it was triggered.
*/
preferInitialWindowPlacement?: boolean;
width?: number;
}

interface PictureInPictureContextValue {
/**
* Closes the picture-in-picture window if one is open. Idempotent.
*/
closePipWindow: () => void;
/**
* Whether the Document Picture-in-Picture API is available in this browser.
*/
isSupported: boolean;
/**
* The currently open picture-in-picture window, or null. Watch this value to
* react to the window being closed (by the user or programmatically).
*/
pipWindow: Window | null;
/**
* Opens a picture-in-picture window. Must be called from a user gesture (e.g.
* a click handler) — the API requires transient activation.
*/
requestPipWindow: (options?: RequestPipWindowOptions) => Promise<void>;
}

const PictureInPictureContext = createContext<PictureInPictureContextValue | null>(null);

/**
* Copies the document's static stylesheets into the picture-in-picture window so
* its content renders with the same styles.
*
* Styles must be applied *synchronously* — content that measures itself on mount
* (e.g. autosizing textareas reading `getComputedStyle`) would otherwise compute
* the wrong size before async styles load. So linked stylesheets are inlined
* rule-by-rule rather than cloned (a cloned `<link>` fetches asynchronously);
* `<style>` tags are cloned since their text is already present.
*
* Emotion's own style tags are skipped because `PictureInPicturePortal`
* re-injects them via a PiP-scoped cache. Copying them here would duplicate a
* large amount of CSS and is the main cause of slow pop-out (especially in dev
* builds, where every styled component emits its own tag).
*/
function copyStyles(source: Document, target: Window) {
const nodes = source.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
'link[rel="stylesheet"], style'
);
Comment on lines +62 to +64
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason not to use CSSOM here? Not sure if we can tell what comes from emotion there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not seeing Rubik loading, maybe we need to copy that resource over too?

for (const node of nodes) {
// Emotion styles are re-injected via the PiP-scoped emotion cache.
if (node instanceof HTMLStyleElement && node.dataset.emotion) {
continue;
}

// Inline linked stylesheets so they apply immediately. Cross-origin sheets
// throw on `cssRules` access — fall back to cloning the <link> for those.
if (node instanceof HTMLLinkElement) {
try {
const cssText = Array.from(node.sheet?.cssRules ?? [])
.map(rule => rule.cssText)
.join('');
const style = target.document.createElement('style');
style.textContent = cssText;
target.document.head.appendChild(style);
continue;
} catch {
// Cross-origin stylesheet — clone the <link> (loads asynchronously).
}
}

target.document.head.appendChild(node.cloneNode(true));
}
}

/**
* Owns the single Document Picture-in-Picture window for the tab (the API allows
* only one PiP window per browser tab). Provides it through context so any
* component can open, close, or render into it via `usePictureInPicture`.
*
* Pair with `PictureInPicturePortal` to render React content into the window.
*/
export function PictureInPictureProvider({children}: {children: ReactNode}) {
const [pipWindow, setPipWindow] = useState<Window | null>(null);

const documentPictureInPicture =
typeof window !== 'undefined' && 'documentPictureInPicture' in window
? window.documentPictureInPicture
: null;

// Tracks the live window outside of React state so cleanup logic always sees
// the current value without re-running effects.
const pipWindowRef = useRef<Window | null>(null);

const handleClose = useCallback(() => {
pipWindowRef.current = null;
setPipWindow(null);
}, []);

const requestPipWindow = useCallback(
async ({
width,
height,
preferInitialWindowPlacement,
}: RequestPipWindowOptions = {}) => {
if (!documentPictureInPicture) {
return;
}
// Only one PiP window may exist per tab — reuse the existing one.
if (pipWindowRef.current && !pipWindowRef.current.closed) {
return;
}

const pip = await documentPictureInPicture.requestWindow({
width,
height,
preferInitialWindowPlacement,
});

copyStyles(document, pip);
// Mirror the theme class (e.g. `theme-dark`) onto the PiP body so global
// body selectors apply. Kept in sync afterwards by `PictureInPicturePortal`.
pip.document.body.className = document.body.className;

pip.addEventListener('pagehide', handleClose, {once: true});

pipWindowRef.current = pip;
setPipWindow(pip);
},
[documentPictureInPicture, handleClose]
);

const closePipWindow = useCallback(() => {
const pip = pipWindowRef.current;
if (pip && !pip.closed) {
// Fires `pagehide`, which drives `handleClose`.
pip.close();
}
}, []);

// On unmount, tear down the window.
useEffect(() => {
return () => {
const pip = pipWindowRef.current;
if (pip && !pip.closed) {
pip.removeEventListener('pagehide', handleClose);
pip.close();
}
pipWindowRef.current = null;
};
}, [handleClose]);

const value = useMemo<PictureInPictureContextValue>(
() => ({
pipWindow,
isSupported: !!documentPictureInPicture,
requestPipWindow,
closePipWindow,
}),
[pipWindow, documentPictureInPicture, requestPipWindow, closePipWindow]
);

return (
<PictureInPictureContext.Provider value={value}>
{children}
</PictureInPictureContext.Provider>
);
}

export function usePictureInPicture(): PictureInPictureContextValue {
const context = useContext(PictureInPictureContext);

if (!context) {
throw new Error('usePictureInPicture must be used within a PictureInPictureProvider');
}

return context;
}
Loading
Loading