perf(media): defer image/video decryption until near-viewport (P5-5)
Creates useNearViewport hook (IntersectionObserver, 200px rootMargin, one-shot disconnect after first trigger). ImageContent and VideoContent now gate loadSrc() on nearViewport — when autoPlay is enabled, encrypted media is not decrypted until the element is within 200px of the visible area, reducing initial page load cost on long timelines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -278,7 +278,7 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-5 · Intersection-Based Lazy Loading
|
||||
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
|
||||
|
||||
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
|
||||
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(sentinelRef);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||
@@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) loadSrc().catch(() => undefined);
|
||||
}, [autoPlay, loadSrc]);
|
||||
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||
}, [autoPlay, nearViewport, loadSrc]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||
|
||||
type RenderVideoProps = {
|
||||
title: string;
|
||||
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
const [error, setError] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(sentinelRef);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) loadSrc().catch(() => undefined);
|
||||
}, [autoPlay, loadSrc]);
|
||||
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||
}, [autoPlay, nearViewport, loadSrc]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Returns true once the observed element has come within `margin` pixels of
|
||||
* the viewport. Disconnects the observer after the first intersection so there
|
||||
* is no ongoing overhead.
|
||||
*/
|
||||
export function useNearViewport(ref: RefObject<Element | null>, margin = 200): boolean {
|
||||
const [triggered, setTriggered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || triggered) return undefined;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setTriggered(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: `${margin}px` },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, margin, triggered]);
|
||||
|
||||
return triggered;
|
||||
}
|
||||
Reference in New Issue
Block a user