diff --git a/docs/performance-optimization.md b/docs/performance-optimization.md index 3b66b71..5021926 100644 --- a/docs/performance-optimization.md +++ b/docs/performance-optimization.md @@ -41,6 +41,8 @@ CommunityMap.jsx ── createOverlayRenderer(canvas) ─┬─ worker mode: Both modes call the same `drawOverlayFrame()`, so the output is identical. Main-thread mode is used when `Worker`, `OffscreenCanvas` or `transferControlToOffscreen` is missing. If the worker fails after control was transferred, the canvas cannot be reused, so `CommunityMap` remounts a fresh canvas and forces main-thread mode. +React StrictMode replays effect setup and cleanup in development. Because a transferred canvas cannot be transferred again, `CommunityMap` retains the renderer across that synchronous replay and defers teardown by one microtask; a real unmount still destroys it, while a replacement canvas gets a new renderer. + Usage: pass `overlays` (an array of `{ id, x, y, kind }` in map viewBox units, `1140 x 540`) and optionally `onRenderStats`. With no `overlays` prop, no canvas is rendered and behaviour is unchanged. ### Frame rate diff --git a/src/components/CommunityMap.jsx b/src/components/CommunityMap.jsx index 48d4c8b..a39fac6 100644 --- a/src/components/CommunityMap.jsx +++ b/src/components/CommunityMap.jsx @@ -13,6 +13,8 @@ const MAP_HEIGHT = 540; export default function CommunityMap({ overlays, onRenderStats }) { const canvasRef = useRef(null); const rendererRef = useRef(null); + const rendererCanvasRef = useRef(null); + const rendererGenerationRef = useRef(0); const statsRef = useRef(onRenderStats); statsRef.current = onRenderStats; // A canvas whose control was transferred can't be reused; bump the key to @@ -57,21 +59,43 @@ export default function CommunityMap({ overlays, onRenderStats }) { useEffect(() => { const canvas = canvasRef.current; if (!hasOverlay || !canvas) return undefined; - const renderer = createOverlayRenderer(canvas, { - width: MAP_WIDTH, - height: MAP_HEIGHT, - dpr: Math.min(window.devicePixelRatio || 1, 2), - forceMainThread: forceMain, - onStats: (s) => statsRef.current?.(s), - onError: () => { + let renderer = rendererRef.current; + if (rendererCanvasRef.current !== canvas) { + if (renderer) renderer.destroy(); + rendererRef.current = null; + rendererCanvasRef.current = null; + try { + renderer = createOverlayRenderer(canvas, { + width: MAP_WIDTH, + height: MAP_HEIGHT, + dpr: Math.min(window.devicePixelRatio || 1, 2), + forceMainThread: forceMain, + onStats: (s) => statsRef.current?.(s), + onError: () => { + setForceMain(true); + setMountKey((k) => k + 1); + }, + }); + } catch (error) { + if (forceMain) throw error; + console.error("[CommunityMap] Offscreen worker setup failed; retrying on the main thread.", error); setForceMain(true); setMountKey((k) => k + 1); - }, - }); - rendererRef.current = renderer; + return undefined; + } + rendererRef.current = renderer; + rendererCanvasRef.current = canvas; + } + const generation = ++rendererGenerationRef.current; return () => { - renderer.destroy(); - rendererRef.current = null; + queueMicrotask(() => { + if (rendererGenerationRef.current !== generation) return; + if (rendererRef.current === renderer) { + renderer.destroy(); + rendererRef.current = null; + rendererCanvasRef.current = null; + } + }); }; }, [hasOverlay, mountKey, forceMain]); diff --git a/test/community-map-overlay.test.jsx b/test/community-map-overlay.test.jsx index 58a1ba6..4891432 100644 --- a/test/community-map-overlay.test.jsx +++ b/test/community-map-overlay.test.jsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { StrictMode } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act, cleanup } from '@testing-library/react'; @@ -12,8 +13,11 @@ import CommunityMap from '../src/components/CommunityMap.jsx'; const overlays = [{ id: 'a', x: 10, y: 20, kind: 'pending' }]; describe('CommunityMap canvas overlay', () => { - beforeEach(() => { + beforeEach(async () => { cleanup(); + await act(async () => { + await Promise.resolve(); + }); vi.clearAllMocks(); }); @@ -66,9 +70,48 @@ describe('CommunityMap canvas overlay', () => { expect(screen.getByTestId('map-overlay-canvas')).not.toBe(firstCanvas); }); - it('destroys the renderer on unmount', () => { + it('retries on a fresh main-thread canvas when worker initialization throws', () => { + const error = new DOMException('Canvas was already transferred', 'InvalidStateError'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + createOverlayRenderer.mockImplementationOnce(() => { + throw error; + }); + + render(); + + expect(createOverlayRenderer).toHaveBeenCalledTimes(2); + expect(createOverlayRenderer.mock.calls[1][1].forceMainThread).toBe(true); + expect(screen.getByTestId('map-overlay-canvas')).toBeTruthy(); + expect(consoleError).toHaveBeenCalledWith( + '[CommunityMap] Offscreen worker setup failed; retrying on the main thread.', + error, + ); + consoleError.mockRestore(); + }); + + it('does not transfer the same canvas twice during StrictMode effect replay', async () => { + const { unmount } = render( + + + , + ); + + expect(createOverlayRenderer).toHaveBeenCalledTimes(1); + expect(renderer.destroy).not.toHaveBeenCalled(); + + unmount(); + await act(async () => { + await Promise.resolve(); + }); + expect(renderer.destroy).toHaveBeenCalledTimes(1); + }); + + it('destroys the renderer on unmount', async () => { const { unmount } = render(); unmount(); + await act(async () => { + await Promise.resolve(); + }); expect(renderer.destroy).toHaveBeenCalledTimes(1); });