Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/performance-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 36 additions & 12 deletions src/components/CommunityMap.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);

Expand Down
47 changes: 45 additions & 2 deletions test/community-map-overlay.test.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
});

Expand Down Expand Up @@ -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(<CommunityMap overlays={overlays} />);

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(
<StrictMode>
<CommunityMap overlays={overlays} />
</StrictMode>,
);

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(<CommunityMap overlays={overlays} />);
unmount();
await act(async () => {
await Promise.resolve();
});
expect(renderer.destroy).toHaveBeenCalledTimes(1);
});

Expand Down