-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat: seer drawer pictureInPicture #116670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
TkDodo
wants to merge
10
commits into
master
Choose a base branch
from
tkdodo/feat/seer-drawer-pip
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
144d8ed
feat: first shot at pip
TkDodo 029b3b5
fix: width
TkDodo 1da05ae
feat: preferInitialWindowPlacement
TkDodo 5b31087
fix: height
TkDodo 17d1045
speedup style copy
TkDodo b0c1cd4
move pip to scraps
TkDodo 338c942
fix sizing
TkDodo ac84605
Merge branch 'master' into tkdodo/feat/seer-drawer-pip
TkDodo cc6f514
ref: remove typings that we don't need right now
TkDodo bfadda8
Merge branch 'master' into tkdodo/feat/seer-drawer-pip
TkDodo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export {PictureInPictureProvider, usePictureInPicture} from './pictureInPicture'; | ||
| export {PictureInPicturePortal} from './pictureInPicturePortal'; |
166 changes: 166 additions & 0 deletions
166
static/app/components/core/pictureInPicture/pictureInPicture.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
193
static/app/components/core/pictureInPicture/pictureInPicture.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| ); | ||
| 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; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?