Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions apps/azure/src/components/editor/InvestigationWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type {
Question,
} from '@variscout/core';
import {
DEFAULT_PROCESS_HUB_ID,
normalizeProcessHubId,
hasTeamFeatures,
inferCharacteristicType,
computeMainEffects,
Expand Down Expand Up @@ -171,7 +171,7 @@ export const InvestigationWorkspace: React.FC<InvestigationWorkspaceProps> = ({
const setWallViewMode = useCanvasViewportStore(s => s.setViewMode);
// Phase 13 scale features — threaded into WallCanvas so zoom, pan, and
// tributary clustering route through the existing store + persistence.
const wallHubId = processContext?.processHubId ?? DEFAULT_PROCESS_HUB_ID;
const wallHubId = normalizeProcessHubId(processContext?.processHubId);
const wallZoom = useCanvasViewportStore(s => s.viewports[wallHubId]?.zoom ?? 1);
const wallPan = useCanvasViewportStore(s => s.viewports[wallHubId]?.pan ?? DEFAULT_WALL_PAN);
const setWallPan = useCanvasViewportStore(s => s.setPan);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
rehydrateCanvasViewport,
useCanvasViewportStore,
} from '@variscout/stores';
import type { ProcessHubId } from '@variscout/core/processHub';
import { loadBlobCanvasViewport, saveBlobCanvasViewport } from '../../../services/blobClient';
import type { LoadedViewport } from '../../../services/blobClient';
import { safeTrackEvent } from '../../../lib/appInsights';
Expand All @@ -39,7 +40,7 @@ const mockLoadBlob = vi.mocked(loadBlobCanvasViewport);
const mockSaveBlob = vi.mocked(saveBlobCanvasViewport);
const mockTrackEvent = vi.mocked(safeTrackEvent);

const HUB_ID = 'hub-blob-test';
const HUB_ID = 'hub-blob-test' as ProcessHubId;

const BLOB_SNAPSHOT: LoadedViewport['snapshot'] = {
zoom: 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import {
rehydrateCanvasViewport,
useCanvasViewportStore,
} from '@variscout/stores';
import type { ProcessHubId } from '@variscout/core/processHub';
import { useCanvasViewportLifecycle } from '../useCanvasViewportLifecycle';

const h = (id: string) => id as ProcessHubId;

const mockPersist = vi.mocked(persistCanvasViewport);
const mockRehydrate = vi.mocked(rehydrateCanvasViewport);

Expand Down Expand Up @@ -52,7 +55,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => {
viewports: {
...s.viewports,
[hubId]: {
...s.getViewport(hubId),
...s.getViewport(h(hubId)),
zoom: 2,
},
},
Expand Down Expand Up @@ -83,7 +86,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => {

expect(useCanvasViewportStore.getState().viewMode).toBe('map');
expect(useCanvasViewportStore.getState().railOpen).toBe(true);
expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1);
expect(useCanvasViewportStore.getState().getViewport(h('hub-A')).zoom).toBe(1);
expect(mockPersist).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -121,9 +124,9 @@ describe('useCanvasViewportLifecycle (Azure)', () => {
mockPersist.mockClear();

act(() => {
useCanvasViewportStore.getState().setZoom('hub-A', 2);
useCanvasViewportStore.getState().setPan('hub-A', { x: 10, y: 20 });
useCanvasViewportStore.getState().setGroupByTributary('hub-A', true);
useCanvasViewportStore.getState().setZoom(h('hub-A'), 2);
useCanvasViewportStore.getState().setPan(h('hub-A'), { x: 10, y: 20 });
useCanvasViewportStore.getState().setGroupByTributary(h('hub-A'), true);
});

act(() => {
Expand All @@ -139,8 +142,8 @@ describe('useCanvasViewportLifecycle (Azure)', () => {
mockPersist.mockClear();

act(() => {
useCanvasViewportStore.getState().setZoom('hub-B', 3);
useCanvasViewportStore.getState().setPan('hub-B', { x: -10, y: -20 });
useCanvasViewportStore.getState().setZoom(h('hub-B'), 3);
useCanvasViewportStore.getState().setPan(h('hub-B'), { x: -10, y: -20 });
vi.advanceTimersByTime(500);
});

Expand All @@ -152,7 +155,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => {
mockPersist.mockClear();

act(() => {
useCanvasViewportStore.getState().setZoom('hub-A', 5);
useCanvasViewportStore.getState().setZoom(h('hub-A'), 5);
});

unmount();
Expand Down
26 changes: 14 additions & 12 deletions apps/azure/src/features/investigation/useCanvasViewportLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
rehydrateCanvasViewport,
useCanvasViewportStore,
} from '@variscout/stores';
import { normalizeProcessHubId } from '@variscout/core';
import { safeTrackEvent } from '../../lib/appInsights';
import { loadBlobCanvasViewport, saveBlobCanvasViewport } from '../../services/blobClient';
import type { ViewportBlobShape } from '../../services/blobClient';
Expand All @@ -30,21 +31,22 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo

useEffect(() => {
if (!hubId) return;
const boundHubId = normalizeProcessHubId(hubId);

let timer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;

// ── Mount: Dexie cache (instant) then Blob reconcile ──────────────────
rehydrateCanvasViewport(hubId, () => !cancelled).catch(() => undefined);
rehydrateCanvasViewport(boundHubId, () => !cancelled).catch(() => undefined);

void (async () => {
// Read local updatedAt before the async Blob fetch so we compare
// against the version already loaded by rehydrateCanvasViewport.
const localUpdatedAt = await getLocalViewportUpdatedAt(hubId);
const localUpdatedAt = await getLocalViewportUpdatedAt(boundHubId);

if (cancelled) return;

const loaded = await loadBlobCanvasViewport(hubId);
const loaded = await loadBlobCanvasViewport(boundHubId);
if (cancelled) return;
if (!loaded) return;

Expand All @@ -56,11 +58,11 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo
useCanvasViewportStore.setState(s => ({
viewports: {
...s.viewports,
[hubId]: { zoom, pan, currentLevel, focalStepId, nodePositions, groupByTributary },
[boundHubId]: { zoom, pan, currentLevel, focalStepId, nodePositions, groupByTributary },
},
}));
// Write back to Dexie so the next offline session gets this state.
await persistCanvasViewport(hubId).catch(() => undefined);
await persistCanvasViewport(boundHubId).catch(() => undefined);
}
})();

Expand All @@ -69,19 +71,19 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo
const changed =
state.viewMode !== prev.viewMode ||
state.railOpen !== prev.railOpen ||
state.viewports[hubId] !== prev.viewports[hubId];
state.viewports[boundHubId] !== prev.viewports[boundHubId];
if (!changed) return;

if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
if (cancelled) return;

// Dexie persist (existing).
persistCanvasViewport(hubId).catch(() => undefined);
persistCanvasViewport(boundHubId).catch(() => undefined);

// Build Blob snapshot from current store state.
const current = useCanvasViewportStore.getState();
const vp = current.viewports[hubId];
const vp = current.viewports[boundHubId];
if (!vp) return; // hub not in store yet; skip blob write

const snapshot: ViewportBlobShape = {
Expand All @@ -94,7 +96,7 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo
updatedAt: Date.now(),
};

void saveBlobCanvasViewport(hubId, snapshot, etagRef.current).then(result => {
void saveBlobCanvasViewport(boundHubId, snapshot, etagRef.current).then(result => {
if (cancelled) return;

if (result.ok) {
Expand All @@ -109,11 +111,11 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo
});

// Re-fetch Blob; apply if newer than our last write.
void loadBlobCanvasViewport(hubId).then(loaded => {
void loadBlobCanvasViewport(boundHubId).then(loaded => {
if (cancelled || !loaded) return;
etagRef.current = loaded.etag;

const currentVp = useCanvasViewportStore.getState().viewports[hubId];
const currentVp = useCanvasViewportStore.getState().viewports[boundHubId];
if (!currentVp) return;

// last-write-wins: blob wins the conflict; apply to store.
Expand All @@ -122,7 +124,7 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo
useCanvasViewportStore.setState(s => ({
viewports: {
...s.viewports,
[hubId]: {
[boundHubId]: {
zoom,
pan,
currentLevel,
Expand Down
3 changes: 2 additions & 1 deletion apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { useEmbedMessaging } from './hooks/useEmbedMessaging';
import { SAMPLES } from '@variscout/data';
import {
DEFAULT_PROCESS_HUB_ID,
normalizeProcessHubId,
type ExclusionReason,
type Question,
toNumericValue,
Expand Down Expand Up @@ -1072,7 +1073,7 @@ function AppMain() {
/>
) : panels.activeView === 'investigation' ? (
<InvestigationView
canvasViewportHubId={canvasViewportHubId ?? DEFAULT_PROCESS_HUB_ID}
canvasViewportHubId={normalizeProcessHubId(canvasViewportHubId)}
filteredData={filteredData ?? []}
outcome={outcome}
factors={factors}
Expand Down
120 changes: 120 additions & 0 deletions apps/pwa/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// apps/pwa/src/__tests__/App.test.tsx
//
// Regression: bare usePanelsStore() whole-store subscription in useAppPanels
// triggered React 19 Strict-Mode tearing detection, producing:
// "Cannot update a component (AppMain) while rendering a different component"
//
// Reproduces the trigger path: mount App → call showFrame() on the panels store
// (simulates Frame-tab activation) → assert zero setState-in-render warnings.
//
// vi.mock() blocks MUST come before any component imports (testing.md invariant).
import 'fake-indexeddb/auto';
import { vi } from 'vitest';

vi.mock('../components/Dashboard', () => ({
default: () => <div data-testid="dashboard-stub">Dashboard</div>,
}));
vi.mock('../components/views/FrameView', () => ({
default: () => <div data-testid="frame-view-stub">FrameView</div>,
}));
vi.mock('../components/views/InvestigationView', () => ({
default: () => <div data-testid="investigation-view-stub">InvestigationView</div>,
}));
vi.mock('../components/views/ImprovementView', () => ({
default: () => <div data-testid="improvement-view-stub">ImprovementView</div>,
}));
vi.mock('../components/views/ReportView', () => ({
default: () => <div data-testid="report-view-stub">ReportView</div>,
}));
vi.mock('../components/ProcessIntelligencePanel', () => ({
default: () => <div data-testid="pi-panel-stub">PI Panel</div>,
}));
vi.mock('../components/YamazumiDashboard', () => ({
default: () => <div data-testid="yamazumi-stub">Yamazumi</div>,
}));
vi.mock('../components/WhatIfPage', () => ({
default: () => <div data-testid="whatif-stub">What-If</div>,
}));
vi.mock('../components/settings/SettingsPanel', () => ({
default: () => <div data-testid="settings-stub">Settings</div>,
}));
vi.mock('../components/data/DataTableModal', () => ({
default: () => <div data-testid="data-table-stub">Data Table</div>,
}));
vi.mock('../components/FindingsPanel', () => ({
default: () => <div data-testid="findings-panel-stub">Findings</div>,
}));
vi.mock('../workers/useStatsWorker', () => ({
useStatsWorker: () => null,
}));

import { render, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import App from '../App';
import { LocaleProvider } from '../context/LocaleContext';
import { registerLocaleLoaders, type MessageCatalog } from '@variscout/core';
import { usePanelsStore, initialPanelsState } from '../features/panels/panelsStore';

// Register locale loaders (mirrors main.tsx) so useTranslation works.
registerLocaleLoaders(
import.meta.glob<Record<string, MessageCatalog>>(
'../../../../packages/core/src/i18n/messages/*.ts',
{ eager: false }
)
);

describe('setState-in-render regression — useAppPanels individual selectors', () => {
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
// Reset panels store so test isolation is guaranteed.
usePanelsStore.setState(initialPanelsState);
// Spy BEFORE render so any synchronous warning during mount is captured.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleError.mockRestore();
});

it('mounting App does not produce a setState-in-render warning', async () => {
await act(async () => {
render(
<LocaleProvider>
<App />
</LocaleProvider>
);
});

const warningCalls = consoleError.mock.calls.filter(
(args: unknown[]) =>
typeof args[0] === 'string' && /Cannot update a component.*while rendering/i.test(args[0])
);

expect(warningCalls).toHaveLength(0);
});

it('calling showFrame() after mount does not produce a setState-in-render warning', async () => {
await act(async () => {
render(
<LocaleProvider>
<App />
</LocaleProvider>
);
});

// Reset spy counts after mount so we only capture the showFrame transition.
consoleError.mockClear();

await act(async () => {
usePanelsStore.getState().showFrame();
});

const warningCalls = consoleError.mock.calls.filter(
(args: unknown[]) =>
typeof args[0] === 'string' && /Cannot update a component.*while rendering/i.test(args[0])
);

expect(warningCalls).toHaveLength(0);
});
});
3 changes: 2 additions & 1 deletion apps/pwa/src/components/views/InvestigationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { ColumnTypeMap } from '@variscout/core/findings';
import type { DrillStep } from '@variscout/hooks';
import { GripVertical } from 'lucide-react';
import { useCanvasViewportStore, useProjectStore, useInvestigationStore } from '@variscout/stores';
import type { ProcessHubId } from '@variscout/core/processHub';
import { useFindingsStore } from '../../features/findings/findingsStore';
import {
useInvestigationFeatureStore,
Expand All @@ -47,7 +48,7 @@ import { usePanelsStore } from '../../features/panels/panelsStore';
const DEFAULT_WALL_PAN = { x: 0, y: 0 };

interface InvestigationViewProps {
canvasViewportHubId: string;
canvasViewportHubId: ProcessHubId;
// Data context
filteredData: Record<string, unknown>[];
outcome: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,20 @@ import {
useProjectStore,
} from '@variscout/stores';
import { DEFAULT_PROCESS_HUB_ID } from '@variscout/core';
import type { ProcessHubId } from '@variscout/core/processHub';
import { RETURN_NAVIGATION_STORAGE_KEY } from '@variscout/hooks';
import InvestigationView from '../InvestigationView';

const h = (id: string) => id as ProcessHubId;

// ── 3. Minimal props factory ───────────────────────────────────────────────

function makeMinimalProps(
overrides: Partial<React.ComponentProps<typeof InvestigationView>> = {}
): React.ComponentProps<typeof InvestigationView> {
const noOp = vi.fn();
return {
canvasViewportHubId: 'hub-test',
canvasViewportHubId: h('hub-test'),
filteredData: [],
outcome: null,
factors: [],
Expand Down Expand Up @@ -193,13 +196,15 @@ describe('PWA InvestigationView Map/Wall toggle', () => {
},
});

render(<InvestigationView {...makeMinimalProps({ canvasViewportHubId: 'session-hub-1' })} />);
render(
<InvestigationView {...makeMinimalProps({ canvasViewportHubId: h('session-hub-1') })} />
);

const groupByTributary = screen.getByRole('button', { name: /group by tributary/i });
fireEvent.click(groupByTributary);

const state = useCanvasViewportStore.getState();
expect(state.viewports['session-hub-1']?.groupByTributary).toBe(true);
expect(state.viewports[h('session-hub-1')]?.groupByTributary).toBe(true);
expect(state.viewports[DEFAULT_PROCESS_HUB_ID]).toBeUndefined();
});

Expand Down
Loading