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);
});