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.
|
**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.
|
**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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{typeof blurHash === 'string' && !load && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
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