diff --git a/doc/changelog.d/203.added.md b/doc/changelog.d/203.added.md new file mode 100644 index 00000000..24557374 --- /dev/null +++ b/doc/changelog.d/203.added.md @@ -0,0 +1 @@ +Reuse the rendering-mode probe connection in client mode diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index faa42632..cc443680 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -25,7 +25,7 @@ function App() { }; const uiScaffoldUtil: RefObject = useRef(null!); const probe = useRenderingMode(visorArgs.host, visorArgs.port); - const client = useClientVtk(visorArgs, probe.mode === 'client'); + const client = useClientVtk(visorArgs, probe.mode === 'client' ? probe.trame : null); const server = useServerVtk(probe.mode === 'server' ? probe.trame : null, visorArgs); const active = probe.mode === 'server' ? server : client; const { visorFrontend: visorState, schemaMismatchError } = active; diff --git a/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts b/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts index 8a14893f..3b49d00a 100644 --- a/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts +++ b/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts @@ -8,7 +8,7 @@ import { WasmRenderer } from '../renderer/WasmRenderer'; import { requireWasmAnnotation } from '../renderer/RendererAnnotation'; import { attachCameraSyncReporter } from '../CameraSyncReporter'; import { buildFrontendAsync } from '../buildFrontendAsync'; -import { trameWebSocketUrl } from '../trameWebSocketUrl'; +import type { Trame } from '@kitware/trame/src/trame.js'; import type { VisorArgs } from '../hooks/useVisorArgs'; import { CurrentFrontend, @@ -32,18 +32,21 @@ class ClientFrontendBuild implements FrontendBuild { /** A client-mode view over the wasm scene: rebuilds on each server update and applies set_state to the current frontend. */ export class ClientViewer { - /** Connect the wasm scene, listen for server updates and run the first build; both parameters required. */ + /** Connect the wasm scene over the given session, listen for server updates and run the first build; all parameters required. */ static async connectAsync( visorArgs: VisorArgs, + trame: Trame, onChange: FrontendChangeListener ): Promise { const { host, port } = visorArgs; const secure = window.location.protocol === 'https:'; + const serverOrigin = `http${secure ? 's' : ''}://${host}:${port}`; const wasm = await RemoteVtkScene.getInstanceAsync({ + trame, wasmIdsStateKey: 'wasm_ids', refNameStateKey: 'wasm_ref_name', - wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`, - webSocketUrl: trameWebSocketUrl(host, port), + wasmUrl: `${serverOrigin}/wasm`, + serverOrigin, }); const viewer = new ClientViewer(wasm, visorArgs.darkMode, onChange); wasm.addServerUpdatedListener(viewer.#onServerUpdated); diff --git a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts index 43d97d72..f17d76da 100644 --- a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts +++ b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import type { Trame } from '@kitware/trame/src/trame.js'; import type { VisorFrontend } from '../VisorFrontend.tsx'; import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; import { ClientViewer } from '../frontend/ClientViewer'; @@ -10,21 +11,22 @@ export type ClientVtk = Readonly<{ schemaMismatchError: SchemaVersionMismatchError | null; }>; -/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state; idle until active. */ -export function useClientVtk(visorArgs: VisorArgs, active: boolean): ClientVtk { +/** Connect the wasm scene over the given session, rebuild on each server update, and apply set_state; idle while trame is null. */ +export function useClientVtk(visorArgs: VisorArgs, trame: Trame | null): ClientVtk { const [visorFrontend, setVisorFrontend] = useState(null); const [schemaMismatchError, setSchemaMismatchError] = useState(null); const viewer = useRef(null); useEffect(() => { - if (!active) { + if (trame == null) { return; } (async () => { if (viewer.current == null) { viewer.current = await ClientViewer.connectAsync( visorArgs, + trame, (frontend, mismatch) => { setSchemaMismatchError(mismatch); setVisorFrontend(frontend); @@ -32,7 +34,7 @@ export function useClientVtk(visorArgs: VisorArgs, active: boolean): ClientVtk { ); } })(); - }, [active]); + }, [trame]); return { visorFrontend, schemaMismatchError }; } diff --git a/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts b/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts index 3a4568e3..530e4561 100644 --- a/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts +++ b/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts @@ -24,14 +24,14 @@ export function parseRenderingMode(value: unknown): RenderingMode { throw new UnknownRenderingModeError(value); } -/** The probe's outcome: exactly one of mode and error is set once it settles; trame is set in server mode only. */ +/** The probe's outcome: exactly one of mode and error is set once it settles; trame is set together with mode. */ export type RenderingModeProbe = Readonly<{ mode: RenderingMode | null; trame: Trame | null; error: Error | null; }>; -/** Connect once on the page's ws/wss scheme, read rendering_mode, and keep the session only in server mode. */ +/** Connect once on the page's ws/wss scheme, read rendering_mode, and keep the session for that mode's scene. */ export function useRenderingMode(host: string, port: number): RenderingModeProbe { const [probe, setProbe] = useState({ mode: null, @@ -55,13 +55,7 @@ export function useRenderingMode(host: string, port: number): RenderingModeProbe return; } const mode = parseRenderingMode(trame.state?.get('rendering_mode')); - if (mode === 'client') { - // The client-mode scene opens its own connection. - trame.disconnect(); - setProbe({ mode, trame: null, error: null }); - } else { - setProbe({ mode, trame, error: null }); - } + setProbe({ mode, trame, error: null }); } catch (err) { trame.disconnect(); if (!cancelled) { diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx index fbc6de5f..1391ef9e 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx @@ -26,7 +26,8 @@ jest.mock('../components/UiScaffold.tsx', () => ({ })); jest.mock('../hooks/useRenderingMode', () => ({ - useRenderingMode: () => ({ mode: 'client', trame: null, error: null }), + // The scene connection is mocked, so the session is only handed on. + useRenderingMode: () => ({ mode: 'client', trame: {}, error: null }), })); /** A minimal v4 scene-details payload whose delivered theme is light. */ diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx index c609acdf..dc18c222 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx @@ -24,7 +24,8 @@ jest.mock('../components/UiScaffold.tsx', () => ({ })); jest.mock('../hooks/useRenderingMode', () => ({ - useRenderingMode: () => ({ mode: 'client', trame: null, error: null }), + // The scene connection is mocked, so the session is only handed on. + useRenderingMode: () => ({ mode: 'client', trame: {}, error: null }), })); /** The client-mode scene connection; its scene details carry schema version 3. */ diff --git a/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts b/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts index 762f3918..b0bf99df 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts @@ -7,8 +7,8 @@ import { /** * The probe reads `rendering_mode` from one trame connection, keeps that - * connection only when the server renders, and reports anything other than - * the two wire values as a named error. + * connection in both modes for the mode's scene, and reports anything other + * than the two wire values as a named error. * * Expected values are hand-written literals. */ @@ -63,14 +63,14 @@ describe('parseRenderingMode', () => { }); describe('useRenderingMode', () => { - test('client mode disconnects the probe and hands on no session', async () => { + test('client mode keeps the probe connection', async () => { mockProbe.renderingMode = 'client'; const { result } = renderHook(() => useRenderingMode('localhost', 8080)); await waitFor(() => expect(result.current.mode).toBe('client')); - expect(mockProbe.trame!.disconnect).toHaveBeenCalledTimes(1); - expect(result.current.trame).toBeNull(); + expect(mockProbe.trame!.disconnect).not.toHaveBeenCalled(); + expect(result.current.trame).toBe(mockProbe.trame); }); test('server mode keeps the probe connection', async () => { diff --git a/src/ansys/visor/visor-client/src/jest-tests/TrameInterface.test.js b/src/ansys/visor/visor-client/src/jest-tests/TrameInterface.test.js new file mode 100644 index 00000000..d0e7c478 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/TrameInterface.test.js @@ -0,0 +1,36 @@ +import { Trame } from '@kitware/trame/src/trame.js'; +import TrameInterface from '../wasm/TrameInterface.js'; + +/** + * `TrameInterface` wraps the session it is given: the server's calls on the + * ref name reach the callbacks added to the wrapper through that session's + * `refs`, and the wrapper opens no connection of its own. + * + * Expected values are hand-written literals. + */ + +jest.mock('@kitware/trame/src/trame.js', () => ({ + Trame: jest.fn(), +})); + +/** A connected session whose state names the ref the server calls. */ +function makeSession() { + return { + refs: {}, + client: { getConnection: () => ({ getSession: () => ({}) }) }, + state: { get: (key) => (key === 'wasm_ref_name' ? 'visor_wasm' : undefined) }, + }; +} + +describe('TrameInterface', () => { + test('wraps the given connection: its refs reach the callbacks and no Trame is constructed', async () => { + const session = makeSession(); + const update = () => {}; + + const wrapper = await TrameInterface.getInstanceAsync(session, 'wasm_ref_name'); + wrapper.addSingleCallback('update', update); + + expect(session.refs['visor_wasm'].update).toBe(update); + expect(Trame).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx index 910d06a2..ed8b4ca8 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { useClientVtk } from '../hooks/useClientVtk'; import type { VisorArgs } from '../hooks/useVisorArgs'; +import type { Trame } from '@kitware/trame/src/trame.js'; import type { CameraOrigin, VisorCameraState } from '../renderer/IRenderer'; /** @@ -141,6 +142,9 @@ const ARGS = { basePath: '', } as VisorArgs; +/** The probe's session; the scene connection is mocked, so it is only handed on. */ +const TRAME = {} as Trame; + function syncCameraSendCount(): number { return mockScene.trameTriggerAsync.mock.calls.filter((call) => call[0] === 'sync_camera') .length; @@ -168,7 +172,7 @@ afterEach(() => { describe('useClientVtk camera sync', () => { test('one gesture settle after two rebuilds sends one sync_camera', async () => { - const { result } = renderHook(() => useClientVtk(ARGS, true)); + const { result } = renderHook(() => useClientVtk(ARGS, TRAME)); await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); await act(async () => { await mockScene.serverUpdated!(); @@ -188,7 +192,7 @@ describe('useClientVtk camera sync', () => { test('a settle during the first build state seed is reported once', async () => { mockScene.settleDuringFirstSeed = true; - const { result } = renderHook(() => useClientVtk(ARGS, true)); + const { result } = renderHook(() => useClientVtk(ARGS, TRAME)); await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); await act(async () => { await drain(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx index 725db67d..6c12d79d 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx @@ -1,12 +1,13 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { useClientVtk } from '../hooks/useClientVtk'; import type { VisorArgs } from '../hooks/useVisorArgs'; +import type { Trame } from '@kitware/trame/src/trame.js'; /** * In client mode every server update rebuilds the frontend over the same wasm * scene. A successful rebuild releases the replaced frontend and names the new * one on `window.__visorState`; a schema mismatch keeps the current frontend - * and reports the error. + * and reports the error. The scene connects over the session the hook is given. * * Expected values are hand-written literals. */ @@ -16,11 +17,17 @@ const mockScene = { payloads: [] as unknown[], fetches: 0, renderers: [] as { liveViewerClicks: Set<() => void> }[], + configs: [] as { trame?: unknown }[], }; jest.mock('../wasm/RemoteVtkScene', () => ({ __esModule: true, - default: { getInstanceAsync: async () => mockMakeWasmView() }, + default: { + getInstanceAsync: async (config: { trame?: unknown }) => { + mockScene.configs.push(config); + return mockMakeWasmView(); + }, + }, })); jest.mock('../renderer/WasmRenderer', () => ({ @@ -108,9 +115,12 @@ const ARGS = { basePath: '', } as VisorArgs; +/** The probe's session; the scene connection is mocked, so it is only handed on. */ +const TRAME = {} as Trame; + /** Mount the hook and wait for its first frontend. */ async function mountBuiltAsync() { - const hook = renderHook(() => useClientVtk(ARGS, true)); + const hook = renderHook(() => useClientVtk(ARGS, TRAME)); await waitFor(() => expect(hook.result.current.visorFrontend).not.toBeNull()); return hook; } @@ -127,6 +137,7 @@ beforeEach(() => { mockScene.payloads = []; mockScene.fetches = 0; mockScene.renderers = []; + mockScene.configs = []; }); afterEach(() => { @@ -134,6 +145,13 @@ afterEach(() => { }); describe('useClientVtk rebuild', () => { + test('the scene connects over the session the hook is given', async () => { + mockScene.payloads = [mockSceneDetailsPayload()]; + await mountBuiltAsync(); + + expect(mockScene.configs).toHaveLength(1); + expect(mockScene.configs[0].trame).toBe(TRAME); + }); test('a client rebuild releases the old frontend', async () => { mockScene.payloads = [mockSceneDetailsPayload(), mockSceneDetailsPayload()]; await mountBuiltAsync(); diff --git a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js index 5b1048ca..a5e817aa 100644 --- a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js @@ -105,29 +105,33 @@ export default class RemoteVtkScene { }; /** + * Build the wasm scene over a connected trame session; every config field is required. * @param {{ + * trame:import('@kitware/trame/src/trame.js').Trame, * wasmIdsStateKey:string, * refNameStateKey:string, - * webSocketUrl:string, * wasmUrl:string, + * serverOrigin:string, * }} config * @returns {Promise} */ static getInstanceAsync = async (config) => { if (config == null) { throw new Error(`config cannot be null`); + } else if (config.trame == null) { + throw new Error(`config.trame cannot be null`); } else if (typeof config.wasmIdsStateKey !== 'string') { throw new Error(`config.wasmIdsStateKey must be a string`); } else if (typeof config.refNameStateKey !== 'string') { throw new Error(`config.refNameStateKey must be a string`); - } else if (typeof config.webSocketUrl !== 'string') { - throw new Error(`config.webSocketUrl must be a string`); } else if (typeof config.wasmUrl !== 'string') { throw new Error(`config.wasmUrl must be a string`); + } else if (typeof config.serverOrigin !== 'string') { + throw new Error(`config.serverOrigin must be a string`); } - const { refNameStateKey, wasmIdsStateKey, webSocketUrl } = config; + const { trame, refNameStateKey, wasmIdsStateKey, serverOrigin } = config; let wasmUrl = config.wasmUrl; - const trameInterface = await TrameInterface.getInstanceAsync(webSocketUrl, refNameStateKey); + const trameInterface = await TrameInterface.getInstanceAsync(trame, refNameStateKey); // trame-vtklocal registers wasm files under a versioned path. // Older versions exposed a plain string under @@ -135,7 +139,6 @@ export default class RemoteVtkScene { // keys like `__trame_vtklocal_wasm32` / `__trame_vtklocal_wasm64` with // a `url` property. Try multiple possible state keys and use the // first one we find. - const wsOrigin = webSocketUrl.replace(/^ws(s?):\/\//, 'http$1://').replace(/\/ws$/, ''); const wasmStateCandidates = [ '__trame_vtklocal_wasm_url', '__trame_vtklocal_wasm', @@ -145,11 +148,11 @@ export default class RemoteVtkScene { for (const key of wasmStateCandidates) { const val = trameInterface.getState(key); if (typeof val === 'string' && val.length > 0) { - wasmUrl = `${wsOrigin}/${val}`; + wasmUrl = `${serverOrigin}/${val}`; break; } if (val && typeof val === 'object' && typeof val.url === 'string') { - wasmUrl = `${wsOrigin}/${val.url}`; + wasmUrl = `${serverOrigin}/${val.url}`; break; } } diff --git a/src/ansys/visor/visor-client/src/wasm/TrameInterface.js b/src/ansys/visor/visor-client/src/wasm/TrameInterface.js index 20568e6d..12ff58b8 100644 --- a/src/ansys/visor/visor-client/src/wasm/TrameInterface.js +++ b/src/ansys/visor/visor-client/src/wasm/TrameInterface.js @@ -1,4 +1,6 @@ -import { Trame } from '@kitware/trame/src/trame.js'; +/** + * @typedef {import('@kitware/trame/src/trame.js').Trame} Trame + */ /** * @typedef {string|number|boolean|null|Object|Record} TrameStateValue @@ -40,9 +42,8 @@ import { Trame } from '@kitware/trame/src/trame.js'; */ /** - * @desc `TrameInterface` represents a connection to a Trame server. - * This class is simply a wrapper around a `Trame` object with the - * goal of simplifying communication with `Trame`. + * @desc `TrameInterface` wraps a connected `Trame` session with the goal of + * simplifying communication with `Trame`. It opens and closes no connection. */ export default class TrameInterface { static #allowConstruction = false; @@ -67,19 +68,16 @@ export default class TrameInterface { } /** - * @param {string} webSocketUrl + * Wrap a connected trame session and register this wrapper's callbacks under the server's ref name; opens no + * connection. + * @param {Trame} trame * @param {string} refNameStateKey * @return {Promise} */ - static async getInstanceAsync(webSocketUrl, refNameStateKey) { + static async getInstanceAsync(trame, refNameStateKey) { if (TrameInterface.#stringIsInvalid(refNameStateKey)) { throw new Error(`refNameStateKey must be a non-empty string`); } - const trame = new Trame(); - await trame.connect({ - application: 'trame', - sessionURL: webSocketUrl, - }); const connection = trame.client.getConnection(); /**@type{TrameInterface}*/