Skip to content

fix(viewer): mount/unmount zoom viewer instead of Activity hide/show (2nd-open crash)#509

Merged
Zheaoli merged 1 commit into
mainfrom
fix/zoom-viewer-remount-on-reopen
May 31, 2026
Merged

fix(viewer): mount/unmount zoom viewer instead of Activity hide/show (2nd-open crash)#509
Zheaoli merged 1 commit into
mainfrom
fix/zoom-viewer-remount-on-reopen

Conversation

@Zheaoli
Copy link
Copy Markdown
Collaborator

@Zheaoli Zheaoli commented May 31, 2026

The bug

@Zheaoli : on the image detail page, the second time you click into the WebGL zoom, the whole page crashes and the image won't display.

Root cause (devtools-reproduced + code, confirmed by Design + Impl)

The full-screen viewer was wrapped in <Activity mode={showFullScreenViewer ? 'visible' : 'hidden'}>. React <Activity> preserves the child DOM (the same <canvas>) and state, but runs the child's effect cleanup on hide and re-runs effects on show. So:

  1. Open [ImgBot] Optimize images #1 → engine built on canvas C, context α ✓
  2. Close → Activity hidden → WebGLImageViewer effect cleanup → FU-13 destroy()loseContext() permanently loses α + nulls the engine ref; canvas C is preserved by Activity.
  3. Open v0.5.0 #2 → Activity re-runs the effect → setUpWebGLEngine(), but isInitialized state was preserved (true) → if (isInitialized) return skips rebuilding → null engine on a dead-context canvas → createProgram() returns null → attachShader(null, …) throws → blank image + full-page crash.

devtools evidence: open#1 context alive → close lost=1 → open#2 no new context created, ctxAlive=false.

This is a regression from FU-13 (#503) — its 10-cycle test only exercised album→detail→zoom→navigate-away (true unmount, fresh canvas next time), not detail-page open→close→reopen (Activity hide/show reusing the same canvas).

Fix

Conditionally mount/unmount the viewer ({showFullScreenViewer && ( … )}) instead of toggling <Activity>:

  • Every open = a fresh WebGLImageViewer → fresh <canvas> → fresh context → fresh isInitialized → engine builds normally.
  • Every close = a real unmount → FU-13 destroy()/loseContext() runs on a canvas that's being discarded — correct, and the leak fix is fully preserved (each open/close still creates + releases exactly one context).
  • Trade-off: zoom/pan resets on each open — expected for a lightbox.

No engine change; FU-13's loseContext() is untouched (correct under the mount/unmount model).

Verification

  • pnpm exec tsc --noEmit — no new errors; pnpm lint — 0 errors.
  • Post-deploy (devtools): detail → zoom open→close→open→close several times → no crash, image renders each time, each open creates a NEW live context (ctxAlive=true). Will re-run the reproduction harness after deploy.

Release-blocker.

🤖 Generated with Claude Code

The full-screen WebGL zoom viewer was wrapped in `<Activity mode=...>` to show/
hide it. React `<Activity>` preserves the child's DOM (the same `<canvas>`) and
state, but runs the child's effect cleanup on hide and re-runs effects on show.
So closing the zoom fires WebGLImageViewer's effect cleanup → the FU-13
`destroy()` → `loseContext()`, which permanently loses the (reused) canvas's
WebGL context and nulls the engine ref. On the second open the effect re-runs,
but `isInitialized` state is preserved across Activity hide/show, so
`setUpWebGLEngine`'s `if (isInitialized) return` guard skips rebuilding the
engine — leaving a null engine on a canvas whose context is dead. The engine
(when it does run on the lost context) gets a null program from createProgram()
and throws in attachShader → blank image + full-page crash on the 2nd zoom.

Reproduced via devtools: open#1 context alive → close `lost=1` → open#2 creates
no new context and `ctxAlive=false`.

Fix: conditionally MOUNT/UNMOUNT the viewer (`{showFullScreenViewer && ...}`)
instead of toggling `<Activity>`. Every open is a fresh WebGLImageViewer → fresh
`<canvas>` → fresh context → fresh `isInitialized` → the engine builds normally.
Every close is a real unmount, so the FU-13 `destroy()`/`loseContext()` runs on a
canvas that is being discarded — correct, and the leak fix is fully preserved
(open/close still create+release exactly one context each). The only trade-off
is that zoom/pan position resets on each open, which is expected for a lightbox.

Regression introduced by FU-13 (#503), whose 10-cycle test only covered the
album→detail→zoom→navigate-away (true unmount) path, not detail-page
open→close→reopen (Activity hide/show, reused canvas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
picimpact Ready Ready Preview, Comment May 31, 2026 11:04am

@Zheaoli Zheaoli merged commit 19e6ae2 into main May 31, 2026
6 checks passed
@Zheaoli Zheaoli deleted the fix/zoom-viewer-remount-on-reopen branch May 31, 2026 11:36
Zheaoli added a commit that referenced this pull request May 31, 2026
fix(viewer): keep zoom viewer mounted + CSS-toggle (restore context reuse/state, follow-up to #509)
pull Bot pushed a commit to candies404/PicImpact that referenced this pull request May 31, 2026
…t/state

Follow-up to besscroft#509. besscroft#509 fixed the 2nd-open crash by conditionally mounting/
unmounting the full-screen viewer, but that regressed UX: every open rebuilt the
WebGL engine (re-creation cost) and lost the zoom/pan state. The original design
deliberately kept one engine + context alive across open/close within the modal,
preserving zoom/pan and avoiding rebuilds.

Root cause of the crash was that the viewer was toggled with `<Activity>`, which
runs the child's effect cleanup on hide — firing FU-13's destroy()/loseContext()
on every close, so the 2nd open landed on a permanently-lost context (crash).
The destroy was bound to the wrong lifecycle event (zoom close instead of detail
unmount).

Fix C: keep the viewer MOUNTED once opened and toggle it with CSS
`visibility` instead of `<Activity>` / conditional unmount:
- Staying mounted means the effect cleanup never runs on close → one engine +
  WebGL context stays alive across open/close → context reused, zoom/pan state
  preserved, zero rebuild cost (restores the original UX).
- destroy()/loseContext() now only runs when ProgressiveImage unmounts (leaving
  the detail page) — via the WebGLImageViewer's own unmount cleanup — so the
  FU-13 leak fix is fully preserved (one context created+released per detail).
- `visibility:hidden` (not display:none) keeps the canvas sized, so re-showing
  doesn't drive the engine through a 0×0 resize that would reset the zoom;
  pointer-events are disabled while hidden so the invisible overlay never blocks
  the page underneath.
- `hasOpenedFullScreen` latches on first open so the engine is still built lazily
  (not before the user zooms).

The WebGL engine and FU-13's destroy()/loseContext() are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant