Skip to content

fix(3d/ui): Dispose Three.js geometries, materials, and canvas listeners on unmount to prevent WebGL crashes on mobile#394

Merged
2 commits merged into
devpathindcommunity-india:masterfrom
Niteshagarwal01:master
May 31, 2026
Merged

fix(3d/ui): Dispose Three.js geometries, materials, and canvas listeners on unmount to prevent WebGL crashes on mobile#394
2 commits merged into
devpathindcommunity-india:masterfrom
Niteshagarwal01:master

Conversation

@Niteshagarwal01
Copy link
Copy Markdown
Contributor

fix(3d/ui): Dispose Three.js geometries, materials, and canvas listeners on unmount to prevent WebGL crashes on mobile #364

Summary

Audited all canvas and Three.js components and resolved three distinct resource leak
patterns that collectively caused WebGL context crashes on mobile devices, dangling
event listeners accumulating across route changes, and GPU memory never being freed
between theme/color transitions.

Two files were patched. InteractiveBackground.tsx was already correct and required
no changes.


Root Cause Analysis

Bug 1 — HeaderScene.tsx / Model: Materials leaked on every color change

// BEFORE — new material created, old one silently abandoned on the GPU
scene.traverse((child) => {
    if ((child as THREE.Mesh).isMesh) {
        const mesh = child as THREE.Mesh;
        const material = new THREE.MeshStandardMaterial({ ... });
        mesh.material = material; // old material never disposed
    }
});
// no return cleanup — materials never freed on unmount either

Every time the color prop changed (e.g. theme toggle), the useEffect ran again,
creating N new MeshStandardMaterial instances and assigning them to meshes —
without ever calling .dispose() on the materials being replaced. On mobile
WebGL contexts, which have a strict GPU memory budget, this silently accumulated
allocated shader programs and texture slots until the browser killed the context.


Bug 2 — HeaderScene.tsx / FallbackGeometry: Geometry and material never freed on unmount

// BEFORE — no dispose, geometry and material live on the GPU indefinitely
return (
    <mesh ref={meshRef}>
        <torusKnotGeometry args={[0.7, 0.22, 120, 16]} />
        <meshStandardMaterial color={color} ... />
    </mesh>
);

When FallbackGeometry unmounted (e.g. navigating away from the homepage), the
BufferGeometry for the torus knot and its associated MeshStandardMaterial
remained allocated on the WebGL context. React Three Fiber does not automatically
call .dispose() on JSX-declared Three.js objects — this is an intentional design
decision by R3F to give authors control. Without an explicit cleanup, every unmount
leaked GPU resources.


Bug 3 — ParticleSystem.tsx: Anonymous resize listener impossible to deregister

// BEFORE — inline arrow function, cannot be matched by removeEventListener
window.addEventListener('resize', () => {
    resizeCanvas();
    createParticles();
});

return () => {
    window.removeEventListener('resize', resizeCanvas); // ← WRONG reference, no-op
    cancelAnimationFrame(animationFrameId);
};

removeEventListener requires the exact same function reference that was passed
to addEventListener. Because an inline arrow function creates a new anonymous
function object that is immediately discarded, the cleanup's call to
removeEventListener('resize', resizeCanvas) silently did nothing. Every time
the component mounted, a new resize listener accumulated. On SPAs with frequent
route changes, this eventually produced dozens of stale, duplicate listeners all
firing on every window resize — updating canvas dimensions on a dead, unmounted
component.


Fix

src/components/3d/HeaderScene.tsx

Model — dispose replaced and created materials

// AFTER — dispose old before replacing, collect new ones for cleanup
const createdMaterials: THREE.MeshStandardMaterial[] = [];

scene.traverse((child) => {
    if ((child as THREE.Mesh).isMesh) {
        const mesh = child as THREE.Mesh;

        // Dispose old material before replacing to free GPU memory
        if (mesh.material) {
            (mesh.material as THREE.Material).dispose();
        }

        const material = new THREE.MeshStandardMaterial({ ... });
        mesh.material = material;
        createdMaterials.push(material);
    }
});

// Cleanup: dispose all materials we created on color/scene change or unmount
return () => {
    createdMaterials.forEach((mat) => mat.dispose());
};

FallbackGeometry — dispose geometry and material on unmount

// AFTER — explicit dispose via useEffect cleanup
useEffect(() => {
    const mesh = meshRef.current;
    return () => {
        if (mesh) {
            mesh.geometry.dispose();
            if (Array.isArray(mesh.material)) {
                mesh.material.forEach((m) => m.dispose());
            } else {
                mesh.material.dispose();
            }
        }
    };
}, []);

The mesh reference is captured at mount time (before the ref can become null),
ensuring the cleanup always has access to the correct object. The Array.isArray
guard handles both single-material and multi-material mesh configurations.


src/components/ui/ParticleSystem.tsx

Fix dangling resize listener via named function reference

// AFTER — named reference, addEventListener and removeEventListener are symmetric
const handleResize = () => {
    resizeCanvas();
    createParticles();
};

window.addEventListener('resize', handleResize);

return () => {
    window.removeEventListener('resize', handleResize); // ← correct reference
    cancelAnimationFrame(animationFrameId);
};

Files Changed

File Changes
src/components/3d/HeaderScene.tsx Dispose replaced mesh materials in traverse loop; collect and dispose all created materials in useEffect cleanup; add unmount disposal useEffect to FallbackGeometry
src/components/ui/ParticleSystem.tsx Extract named handleResize function so removeEventListener can correctly deregister it
src/components/ui/InteractiveBackground.tsx No changes — already correctly implemented

Verification

  1. Open the homepage on a mobile device or Chrome DevTools in mobile emulation.
  2. Toggle between light and dark mode multiple times — previously this would accumulate
    unreleased shader programs; now each toggle disposes the prior materials before creating new ones.
  3. Navigate away from the homepage and back multiple times — open chrome://gpu or
    use the Memory tab in DevTools; WebGL memory should return to baseline after each navigation.
  4. Resize the browser window rapidly after multiple route changes — previously this fired
    N resize callbacks for N mount cycles; now exactly one fires.
  5. Check the browser console — no WebGL: CONTEXT_LOST_WEBGL errors or
    Warning: Can't perform a React state update on an unmounted component messages.

Impact

  • Mobile stability: Eliminates the primary cause of WebGL context loss on
    low-memory mobile devices caused by accumulated unreleased GPU buffers.
  • Memory: Three.js materials and geometries are now fully freed on every unmount
    and color-change cycle, keeping GPU allocation flat across the session.
  • Event hygiene: ParticleSystem no longer accumulates stale resize listeners
    across route changes — listener count stays at exactly one regardless of mount frequency.
  • No breaking changes: All visual behaviour is identical. Dispose calls are
    purely additive cleanup with no effect on render output.

@Niteshagarwal01
Copy link
Copy Markdown
Contributor Author

@Aditya948351 kindly merge it

@Aditya948351 Aditya948351 closed this pull request by merging all changes into devpathindcommunity-india:master in 05d6327 May 31, 2026
Copy link
Copy Markdown
Collaborator

@Aditya948351 Aditya948351 left a comment

Choose a reason for hiding this comment

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

Disposing of the Three.js materials properly on unmount is crucial for mobile performance. Fantastic fix for the WebGL memory leaks!

@Niteshagarwal01
Copy link
Copy Markdown
Contributor Author

@Aditya948351 kindly add labels according to issue

@Aditya948351 Aditya948351 added gssoc26 This is a official GirlScript Summer of Code label. level:critical type:bug gssoc:approved give 50+ base points labels May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved give 50+ base points gssoc26 This is a official GirlScript Summer of Code label. level:critical type:bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants