From 8ed3f405d2f725ba94fb6a4bbb3050cf7074ff2b Mon Sep 17 00:00:00 2001 From: Quentin VERDIER Date: Wed, 8 Oct 2025 16:07:08 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Implement=20iframe=20caching=20?= =?UTF-8?q?mechanism:=20add=20IframeCacheProvider=20and=20CachedIframePort?= =?UTF-8?q?al=20components,=20update=20ChatItem=20and=20StreamItem=20to=20?= =?UTF-8?q?utilize=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/IframeCache/IframeCache.tsx | 94 +++++++++++++++++++ .../gridItems/ChatItem/ChatItem.tsx | 3 +- .../gridItems/GridItem/GridItem.tsx | 35 ++++--- .../gridItems/StreamItem/StreamItem.tsx | 10 +- src/routes/watch.tsx | 79 ++++++++-------- 5 files changed, 167 insertions(+), 54 deletions(-) create mode 100644 src/components/IframeCache/IframeCache.tsx diff --git a/src/components/IframeCache/IframeCache.tsx b/src/components/IframeCache/IframeCache.tsx new file mode 100644 index 0000000..5071f9f --- /dev/null +++ b/src/components/IframeCache/IframeCache.tsx @@ -0,0 +1,94 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +interface IframeCacheContextValue { + getIframeContainer: (key: string) => HTMLDivElement; + releaseIframeContainer: (key: string) => void; +} + +const IframeCacheContext = createContext(null); + +export const useIframeCache = () => { + const context = useContext(IframeCacheContext); + if (!context) { + throw new Error('useIframeCache must be used within IframeCacheProvider'); + } + return context; +}; + +interface IframeCacheProviderProps { + readonly children: ReactNode; +} + +export function IframeCacheProvider({ children }: IframeCacheProviderProps) { + const containerMapRef = useRef>(new Map()); + const hiddenContainerRef = useRef(null); + + useEffect(() => { + // Create hidden container for cached iframes + const hiddenDiv = document.createElement('div'); + hiddenDiv.style.position = 'fixed'; + hiddenDiv.style.top = '-9999px'; + hiddenDiv.style.left = '-9999px'; + hiddenDiv.style.width = '0'; + hiddenDiv.style.height = '0'; + hiddenDiv.style.overflow = 'hidden'; + hiddenDiv.style.pointerEvents = 'none'; + document.body.appendChild(hiddenDiv); + hiddenContainerRef.current = hiddenDiv; + + return () => { + hiddenDiv.remove(); + }; + }, []); + + const getIframeContainer = useCallback((key: string): HTMLDivElement => { + if (!containerMapRef.current.has(key)) { + const container = document.createElement('div'); + container.style.width = '100%'; + container.style.height = '100%'; + containerMapRef.current.set(key, container); + } + return containerMapRef.current.get(key)!; + }, []); + + const releaseIframeContainer = useCallback((key: string) => { + const container = containerMapRef.current.get(key); + if (container) { + // Move to hidden container to keep iframe alive + hiddenContainerRef.current?.appendChild(container); + } + }, []); + + const value = useMemo( + () => ({ getIframeContainer, releaseIframeContainer }), + [getIframeContainer, releaseIframeContainer] + ); + + return {children}; +} + +interface CachedIframePortalProps { + cacheKey: string; + targetRef: React.RefObject; + children: ReactNode; +} + +export function CachedIframePortal({ cacheKey, targetRef, children }: CachedIframePortalProps) { + const { getIframeContainer, releaseIframeContainer } = useIframeCache(); + const container = getIframeContainer(cacheKey); + + useEffect(() => { + // Move container to target when mounted + if (targetRef.current) { + targetRef.current.appendChild(container); + } + + return () => { + // Release container when unmounted + releaseIframeContainer(cacheKey); + }; + }, [cacheKey, container, targetRef, releaseIframeContainer]); + + return createPortal(children, container); +} diff --git a/src/components/gridItems/ChatItem/ChatItem.tsx b/src/components/gridItems/ChatItem/ChatItem.tsx index 6417448..2cd456e 100644 --- a/src/components/gridItems/ChatItem/ChatItem.tsx +++ b/src/components/gridItems/ChatItem/ChatItem.tsx @@ -11,9 +11,10 @@ const ChatItem = memo( ({ streamName, isDarkThemePreferred = false }: ChatItemProps) => { const title = `Chat: ${streamName}`; const iframeSrc = `https://www.twitch.tv/embed/${streamName}/chat?parent=${window.location.hostname}${isDarkThemePreferred ? '&darkpopout' : ''}`; + const cacheKey = `chat-${streamName}`; return ( - + ); diff --git a/src/components/gridItems/GridItem/GridItem.tsx b/src/components/gridItems/GridItem/GridItem.tsx index c13ce8e..155aeb3 100644 --- a/src/components/gridItems/GridItem/GridItem.tsx +++ b/src/components/gridItems/GridItem/GridItem.tsx @@ -1,5 +1,6 @@ +import { CachedIframePortal } from '@/components/IframeCache/IframeCache'; import { useIframeRefresh } from '@/hooks/useIframeRefresh'; -import { forwardRef, memo, useImperativeHandle, type ReactNode } from 'react'; +import { forwardRef, memo, useImperativeHandle, useRef, type ReactNode } from 'react'; import GridItemHeader from './GridItemHeader/GridItemHeader'; interface GridItemProps { @@ -9,6 +10,7 @@ interface GridItemProps { streamName?: string; onRefresh?: () => void; children?: ReactNode; + cacheKey: string; } export interface GridItemRef { @@ -17,8 +19,9 @@ export interface GridItemRef { const GridItem = memo( forwardRef( - ({ title, iframeSrc, allowFullScreen, streamName, onRefresh, children }, ref) => { + ({ title, iframeSrc, allowFullScreen, streamName, onRefresh, children, cacheKey }, ref) => { const { iframeRef, refreshIframe } = useIframeRefresh(onRefresh); + const iframeContainerRef = useRef(null); useImperativeHandle(ref, () => ({ refreshIframe }), [refreshIframe]); @@ -30,18 +33,22 @@ const GridItem = memo( {children} -