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
34 changes: 34 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,40 @@ describe("ChatView timeline estimator parity (full app)", () => {
document.body.innerHTML = "";
});

it("mounts the regular chat surface without a transport render loop", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-transport-mount" as MessageId,
targetText: "transport mount",
}),
});

try {
await vi.waitFor(() => {
expect(
wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.getSnapshot),
).toBe(true);
});

await expect.element(page.getByTestId("new-thread-button")).toBeInTheDocument();
expect(
consoleErrorSpy.mock.calls.some((call) =>
call.some(
(value) =>
typeof value === "string" &&
(value.includes("Too many re-renders") ||
value.includes("Minified React error #301")),
),
),
).toBe(false);
} finally {
consoleErrorSpy.mockRestore();
await mounted.cleanup();
}
});

it.each(TEXT_VIEWPORT_MATRIX)(
"keeps long user message estimate close at the $name viewport",
async (viewport) => {
Expand Down
22 changes: 15 additions & 7 deletions apps/web/src/hooks/useConnectionHealth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { ConnectionMetrics, TransportState } from "../wsTransport";
import { type ConnectionHealth, useConnectionHealth } from "./useConnectionHealth";

const { createWsNativeApiMock, getTransportMetricsMock, onTransportStateChangeMock } = vi.hoisted(
() => ({
createWsNativeApiMock: vi.fn(),
getTransportMetricsMock: vi.fn(),
onTransportStateChangeMock: vi.fn(),
}),
);
const {
createWsNativeApiMock,
getTransportMetricsMock,
getTransportStateSnapshotMock,
onTransportStateChangeMock,
} = vi.hoisted(() => ({
createWsNativeApiMock: vi.fn(),
getTransportMetricsMock: vi.fn(),
getTransportStateSnapshotMock: vi.fn(),
onTransportStateChangeMock: vi.fn(),
}));

vi.mock("../wsNativeApi", () => ({
createWsNativeApi: createWsNativeApiMock,
getTransportMetrics: getTransportMetricsMock,
getTransportStateSnapshot: getTransportStateSnapshotMock,
onTransportStateChange: onTransportStateChangeMock,
}));

Expand Down Expand Up @@ -88,6 +93,7 @@ beforeEach(() => {

createWsNativeApiMock.mockReset();
getTransportMetricsMock.mockReset().mockImplementation(() => currentMetrics);
getTransportStateSnapshotMock.mockReset().mockImplementation(() => currentState);
onTransportStateChangeMock.mockReset().mockImplementation((listener) => {
transportStateListeners.add(listener);
listener(currentState);
Expand Down Expand Up @@ -120,6 +126,8 @@ describe("useConnectionHealth", () => {
await mountHook();

expect(createWsNativeApiMock.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(getTransportStateSnapshotMock.mock.calls.length).toBeGreaterThan(0);
expect(onTransportStateChangeMock).toHaveBeenCalledTimes(1);
expect(latestHealth).toEqual({
state: "connecting",
isConnected: false,
Expand Down
26 changes: 13 additions & 13 deletions apps/web/src/hooks/useConnectionHealth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";

import { createWsNativeApi, getTransportMetrics, onTransportStateChange } from "../wsNativeApi";
import {
createWsNativeApi,
getTransportMetrics,
getTransportStateSnapshot,
onTransportStateChange,
} from "../wsNativeApi";
import type { ConnectionMetrics, TransportState } from "../wsTransport";

export interface ConnectionHealth {
Expand All @@ -24,25 +29,20 @@ const DEFAULT_METRICS: ConnectionMetrics = {
uptimeMs: 0,
};

function subscribe(callback: () => void): () => void {
createWsNativeApi();
return onTransportStateChange(() => callback());
}

/**
* Returns a reactive snapshot of the WebSocket connection health.
* The transport state updates synchronously; metrics are polled at
* a comfortable 5-second interval to avoid unnecessary re-renders.
*/
export function useConnectionHealth(): ConnectionHealth {
const state = useSyncExternalStore(
(callback) => {
createWsNativeApi();
return onTransportStateChange(() => callback());
},
() => {
let current: TransportState = "connecting";
const unsub = onTransportStateChange((next) => {
current = next;
});
unsub();
return current;
},
subscribe,
getTransportStateSnapshot,
() => "connecting" as TransportState,
);

Expand Down
109 changes: 109 additions & 0 deletions apps/web/src/hooks/useTransportState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, create, type ReactTestRenderer } from "react-test-renderer";

import type { TransportState } from "../wsTransport";
import { useTransportState } from "./useTransportState";

const { createWsNativeApiMock, getTransportStateSnapshotMock, onTransportStateChangeMock } =
vi.hoisted(() => ({
createWsNativeApiMock: vi.fn(),
getTransportStateSnapshotMock: vi.fn(),
onTransportStateChangeMock: vi.fn(),
}));

vi.mock("../wsNativeApi", () => ({
createWsNativeApi: createWsNativeApiMock,
getTransportStateSnapshot: getTransportStateSnapshotMock,
onTransportStateChange: onTransportStateChangeMock,
}));

const transportStateListeners = new Set<(state: TransportState) => void>();

let currentState: TransportState = "connecting";
let latestState: TransportState | null = null;
let renderer: ReactTestRenderer | null = null;
let consoleErrorSpy: ReturnType<typeof vi.spyOn> | null = null;
const originalConsoleError = console.error;

function HookHarness() {
latestState = useTransportState();
return null;
}

function emitTransportState(nextState: TransportState) {
currentState = nextState;
for (const listener of new Set(transportStateListeners)) {
listener(nextState);
}
}

async function mountHook() {
await act(async () => {
renderer = create(<HookHarness />);
});
}

async function unmountHook() {
if (!renderer) {
return;
}

await act(async () => {
renderer?.unmount();
});
renderer = null;
}

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((message, ...args) => {
if (typeof message === "string" && message.includes("react-test-renderer is deprecated")) {
return;
}
originalConsoleError.call(console, message, ...args);
});

currentState = "connecting";
latestState = null;
renderer = null;
transportStateListeners.clear();

createWsNativeApiMock.mockReset();
getTransportStateSnapshotMock.mockReset().mockImplementation(() => currentState);
onTransportStateChangeMock.mockReset().mockImplementation((listener) => {
transportStateListeners.add(listener);
listener(currentState);
return () => {
transportStateListeners.delete(listener);
};
});
});

afterEach(async () => {
await unmountHook();
consoleErrorSpy?.mockRestore();
consoleErrorSpy = null;
transportStateListeners.clear();
vi.restoreAllMocks();
});

describe("useTransportState", () => {
it("reads the current state from a pure snapshot and subscribes once", async () => {
await mountHook();

expect(createWsNativeApiMock.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(getTransportStateSnapshotMock.mock.calls.length).toBeGreaterThan(0);
expect(onTransportStateChangeMock).toHaveBeenCalledTimes(1);
expect(latestState).toBe("connecting");
});

it("updates when the transport emits a new state", async () => {
await mountHook();

await act(async () => {
emitTransportState("open");
});

expect(latestState).toBe("open");
});
});
27 changes: 11 additions & 16 deletions apps/web/src/hooks/useTransportState.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import { useSyncExternalStore } from "react";

import { createWsNativeApi, onTransportStateChange } from "../wsNativeApi";
import {
createWsNativeApi,
getTransportStateSnapshot,
onTransportStateChange,
} from "../wsNativeApi";
import type { TransportState } from "../wsTransport";

function subscribe(callback: () => void): () => void {
createWsNativeApi();
return onTransportStateChange(() => callback());
}

function getServerSnapshot(): TransportState {
return "connecting";
}

export function useTransportState(): TransportState {
return useSyncExternalStore(
(callback) => {
createWsNativeApi();
return onTransportStateChange(() => callback());
},
() => {
let state: TransportState = "connecting";
const unsubscribe = onTransportStateChange((nextState) => {
state = nextState;
});
unsubscribe();
return state;
},
getServerSnapshot,
);
return useSyncExternalStore(subscribe, getTransportStateSnapshot, getServerSnapshot);
}
8 changes: 8 additions & 0 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export function onTransportStateChange(listener: (state: TransportState) => void
};
}

/**
* Read-only snapshot of the current transport state.
* Returns "connecting" until the transport is initialised.
*/
export function getTransportStateSnapshot(): TransportState {
return instance?.transport.getState() ?? "connecting";
}

/**
* Read-only snapshot of transport connection metrics.
* Returns null if the transport is not initialised yet.
Expand Down
Loading