From 992361f218dd390e3218d46cdf177ef87f0cd306 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 23:42:35 +0000 Subject: [PATCH 1/2] fix(signal-viewer): wait for both canvases before constructing hybrid renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid renderer was constructed as soon as the BASE chrome canvas mounted, tolerating a null waveform canvas. Because the base precedes the waveform in the DOM, its ref callback fires first, so the renderer was always built with waveform=null — pinning every session to the Canvas2D fallback for the view's lifetime (the rendererRef guard blocks reconstruction once the waveform canvas arrives). WebGL2 therefore never activated in the real Signal Viewer, even though the renderer itself is correct and the fidelity gate (which constructs the renderers directly) passed. Require both canvases before constructing. --- src/views/Sessions/SignalViewer.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/views/Sessions/SignalViewer.tsx b/src/views/Sessions/SignalViewer.tsx index aaa8207..93d500b 100644 --- a/src/views/Sessions/SignalViewer.tsx +++ b/src/views/Sessions/SignalViewer.tsx @@ -906,9 +906,15 @@ export default function SignalViewer() { const tryInitRenderer = useCallback(() => { const base = canvasRef.current; const waveform = waveformCanvasRef.current; - // Need the base canvas at minimum; the waveform canvas may be null (then the - // hybrid runs Canvas2D-only, which is the same automatic fallback path). - if (!base) return; + // Construct only once BOTH canvases are mounted. The base chrome canvas and + // the waveform canvas mount in the same commit but their ref callbacks fire + // in DOM order (base first), so requiring both here prevents constructing the + // hybrid with a null waveform canvas — which would pin it to the Canvas2D + // fallback for the lifetime of the view (the `rendererRef.current` guard + // below blocks reconstruction). The waveform canvas is rendered + // unconditionally, so this never deadlocks. If WebGL2 is genuinely + // unavailable at runtime, HybridSignalRenderer still falls back internally. + if (!base || !waveform) return; if (rendererRef.current) return; // already constructed const renderer = new HybridSignalRenderer(base, waveform, colorResolver); From d689c180bf1695bb015c776cc7e9b79267532508 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 23:48:15 +0000 Subject: [PATCH 2/2] test(signal-viewer): assert hybrid renderer is constructed with the WebGL waveform canvas Regression guard for the WebGL2-activation bug where tryInitRenderer constructed HybridSignalRenderer as soon as the base chrome canvas mounted (`if (!base) return;`), tolerating a null waveform canvas and pinning the renderer to the Canvas2D fallback forever. Mounts the real SignalViewer with minimal mocked session data and a spy HybridSignalRenderer that records constructor args, then asserts the second argument (the WebGL waveform layer) is a non-null HTMLCanvasElement distinct from the base canvas. Red against the pre-fix `if (!base) return;`, green against `if (!base || !waveform) return;`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../SignalViewer.webglWiring.test.tsx | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/views/Sessions/__tests__/SignalViewer.webglWiring.test.tsx diff --git a/src/views/Sessions/__tests__/SignalViewer.webglWiring.test.tsx b/src/views/Sessions/__tests__/SignalViewer.webglWiring.test.tsx new file mode 100644 index 0000000..2627dc3 --- /dev/null +++ b/src/views/Sessions/__tests__/SignalViewer.webglWiring.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +import type { SignalManifest } from '@/services/storage/OPFSService'; + +/** + * Regression guard for the WebGL2-activation bug in the Signal Viewer. + * + * The bug (fixed on this branch): `tryInitRenderer` constructed the + * {@link HybridSignalRenderer} as soon as the BASE chrome canvas mounted, + * tolerating a null waveform canvas (`if (!base) return;`). Because the base + * `` precedes the transparent WebGL waveform `` in the DOM, the + * base ref-callback fires first, so the renderer was ALWAYS constructed as + * `new HybridSignalRenderer(base, null, ...)` (waveform null) — pinning it to the + * Canvas2D fallback for the lifetime of the view (the `rendererRef.current` + * guard blocks reconstruction once the waveform canvas mounts later). Net + * effect: WebGL2 never activated in the real Signal Viewer for anyone. + * + * The fidelity gate constructs the renderers directly via a harness, bypassing + * SignalViewer, which is exactly why nothing caught this. This test mounts the + * REAL `SignalViewer` and asserts the hybrid renderer is constructed with a + * non-null waveform `HTMLCanvasElement` as its second argument — i.e. that the + * WebGL layer is actually wired up. + * + * It is RED against the pre-fix `if (!base) return;` (waveform would be null/ + * undefined) and GREEN against the fixed `if (!base || !waveform) return;`. + */ + +// ── Capture every HybridSignalRenderer constructor invocation ────── +// +// Replace the real (WebGL/Canvas2D-heavy) class with a spy that records its +// constructor arguments and exposes the methods SignalViewer calls as no-op +// `vi.fn()`s, so the component renders to completion in jsdom. +const constructorCalls: unknown[][] = []; + +vi.mock('@/components/charts/HybridSignalRenderer', () => { + class HybridSignalRendererSpy { + constructor(...args: unknown[]) { + constructorCalls.push(args); + } + setOverlayCanvas = vi.fn(); + resize = vi.fn(); + render = vi.fn(); + renderOverlay = vi.fn(); + renderDuringPan = vi.fn(); + beginPan = vi.fn(); + panBy = vi.fn(); + endPan = vi.fn(); + getValuesAtTime = vi.fn(() => []); + dispose = vi.fn(); + } + return { HybridSignalRenderer: HybridSignalRendererSpy }; +}); + +// ── Minimal session data so the viewer reaches the canvas JSX ────── +// +// OPFSService must report support (else the viewer renders the "Browser Not +// Supported" branch and never mounts the canvases) and yield a one-channel +// manifest plus channel data. +const TEST_SESSION_ID = 'sess-webgl'; +const SESSION_START_MS = Date.parse('2026-01-15T23:00:00Z'); + +const TEST_MANIFEST: SignalManifest = { + version: 1, + sessionId: TEST_SESSION_ID, + startTime: SESSION_START_MS, + endTime: SESSION_START_MS + 60_000, + durationSeconds: 60, + chunkDurationSeconds: 300, + channels: [ + { + index: 0, + name: 'flow', + sampleRate: 25, + unit: 'L/s', + dtype: 'float32', + physicalMin: -1, + physicalMax: 1, + }, + ], + chunks: [], +}; + +vi.mock('@/services/storage/OPFSService', () => { + class OPFSServiceMock { + static isSupported = vi.fn(() => true); + initialize = vi.fn().mockResolvedValue(undefined); + readManifest = vi.fn().mockResolvedValue(TEST_MANIFEST); + readChannel = vi.fn().mockResolvedValue( + // A few non-zero samples so the channel is not detected as "empty". + Float32Array.from([0.1, 0.4, -0.3, 0.2, -0.1]), + ); + } + return { OPFSService: OPFSServiceMock }; +}); + +// Session detail + events come from useSignalData; return a ready session with +// no events so the viewer is fully hydrated without touching IndexedDB. +vi.mock('@/hooks/useSignalData', () => ({ + useSessionDetail: () => ({ + session: { + id: TEST_SESSION_ID, + startTime: new Date(SESSION_START_MS).toISOString(), + date: '2026-01-15', + }, + aggregate: null, + loading: false, + error: null, + }), + useEventData: () => ({ events: [], loading: false, error: null }), +})); + +// No wearable lanes and no breathing detection — keeps the render minimal and +// independent of those subsystems. +// Partial mock: keep the module's real exports (signalLanes.ts imports +// SLEEP_STAGE_CODES from here) and override only the hook. +vi.mock('@/hooks/useWearableLanes', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWearableLanes: () => ({ series: {}, loading: false, error: null }), + }; +}); + +vi.mock('@/hooks/useBreathingEpisodes', () => ({ + useBreathingEpisodes: () => ({ episodes: [], loading: false, error: null }), +})); + +// Import AFTER the mocks are registered. +import SignalViewer from '../SignalViewer'; + +function renderViewer() { + return render( + + + } /> + + , + ); +} + +describe('SignalViewer WebGL wiring', () => { + beforeEach(() => { + constructorCalls.length = 0; + localStorage.clear(); + }); + + it('constructs HybridSignalRenderer with a non-null waveform canvas (WebGL layer wired)', async () => { + renderViewer(); + + // The renderer is constructed by a canvas ref-callback once both the base + // and waveform canvases have mounted (after the async OPFS manifest load). + await waitFor(() => { + expect(constructorCalls.length).toBeGreaterThan(0); + }); + + const [baseArg, waveformArg] = constructorCalls[0] as [unknown, unknown, unknown]; + + // Pre-fix, the renderer was built the moment the BASE canvas mounted, with a + // still-null waveform canvas. The whole point of the fix is that BOTH canvases + // exist at construction time — so the second argument must be a real canvas. + expect(waveformArg).not.toBeNull(); + expect(waveformArg).not.toBeUndefined(); + expect(waveformArg).toBeInstanceOf(HTMLCanvasElement); + + // The base canvas (first argument) must also be a real canvas, and the two + // must be distinct elements (base chrome layer vs. transparent WebGL layer). + expect(baseArg).toBeInstanceOf(HTMLCanvasElement); + expect(waveformArg).not.toBe(baseArg); + }); +});