From 1a9f91f6d7bafd20525a51c545a7542061d4a2aa Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 27 Oct 2026 14:16:06 -0700 Subject: [PATCH 1/3] chore: move each mode frontend lifetime into own viewer classes --- .../visor-client/src/frontend/ClientViewer.ts | 151 ++++++++++++++++ .../src/frontend/CurrentFrontend.ts | 50 ++++++ .../visor-client/src/frontend/ServerViewer.ts | 141 +++++++++++++++ .../visor-client/src/hooks/useClientVtk.ts | 124 ++----------- .../visor-client/src/hooks/useServerVtk.ts | 130 +------------- .../jest-tests/UseClientVtkRebuild.test.tsx | 167 ++++++++++++++++++ 6 files changed, 530 insertions(+), 233 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/frontend/ClientViewer.ts create mode 100644 src/ansys/visor/visor-client/src/frontend/CurrentFrontend.ts create mode 100644 src/ansys/visor/visor-client/src/frontend/ServerViewer.ts create mode 100644 src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx diff --git a/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts b/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts new file mode 100644 index 00000000..8a14893f --- /dev/null +++ b/src/ansys/visor/visor-client/src/frontend/ClientViewer.ts @@ -0,0 +1,151 @@ +import type { VisorFrontend } from '../VisorFrontend.tsx'; +import VisorSceneDetails, { + SchemaVersionMismatchError, +} from '../state/appstate/VisorSceneDetails.tsx'; +import VisorAppState from '../state/appstate/VisorAppState.tsx'; +import RemoteVtkScene from '../wasm/RemoteVtkScene'; +import { WasmRenderer } from '../renderer/WasmRenderer'; +import { requireWasmAnnotation } from '../renderer/RendererAnnotation'; +import { attachCameraSyncReporter } from '../CameraSyncReporter'; +import { buildFrontendAsync } from '../buildFrontendAsync'; +import { trameWebSocketUrl } from '../trameWebSocketUrl'; +import type { VisorArgs } from '../hooks/useVisorArgs'; +import { + CurrentFrontend, + type FrontendBuild, + type FrontendChangeListener, +} from './CurrentFrontend'; + +/** A client-mode frontend over the shared wasm scene; releasing it removes only the frontend's own listeners. */ +class ClientFrontendBuild implements FrontendBuild { + constructor(frontend: VisorFrontend) { + this.frontend = frontend; + } + + readonly frontend: VisorFrontend; + + /** Release the frontend; the renderer is kept, because its dispose clears the scene the next build shares. */ + release(): void { + this.frontend.release(); + } +} + +/** 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. */ + static async connectAsync( + visorArgs: VisorArgs, + onChange: FrontendChangeListener + ): Promise { + const { host, port } = visorArgs; + const secure = window.location.protocol === 'https:'; + const wasm = await RemoteVtkScene.getInstanceAsync({ + wasmIdsStateKey: 'wasm_ids', + refNameStateKey: 'wasm_ref_name', + wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`, + webSocketUrl: trameWebSocketUrl(host, port), + }); + const viewer = new ClientViewer(wasm, visorArgs.darkMode, onChange); + wasm.addServerUpdatedListener(viewer.#onServerUpdated); + await viewer.#rebuildAsync(); + return viewer; + } + + private constructor( + wasm: RemoteVtkScene, + darkMode: boolean | undefined, + onChange: FrontendChangeListener + ) { + this.#wasm = wasm; + this.#darkMode = darkMode; + this.#current = new CurrentFrontend(onChange); + } + + readonly #wasm: RemoteVtkScene; + readonly #darkMode: boolean | undefined; + readonly #current: CurrentFrontend; + #cameraSyncRemover: (() => void) | null = null; + readonly #onServerUpdated = () => this.#rebuildAsync(); + readonly #onSetState = (payload: unknown) => this.#applySetStateAsync(payload); + + /** Rebuild from fresh scene details; the update and set_state listeners are registered again however it ends. */ + async #rebuildAsync(): Promise { + const wasm = this.#wasm; + const perfLogging = wasm.trameGetState('perf_logging') === true; + const t0 = perfLogging ? performance.now() : 0; + if (perfLogging) console.log(`[PERF] onServerUpdateAsync start`); + + wasm.clearObserversAndEventListeners(); + let newFrontend: VisorFrontend; + // The listeners cleared above are registered again however this + // block ends, so a failed rebuild still receives the next server + // update, and `set_state` keeps reaching the current frontend. + try { + const sceneDetailsJson = await wasm.trameTriggerAsync('get_visor_scene_details_json'); + try { + newFrontend = await buildFrontendAsync( + sceneDetailsJson, + (sceneDetails) => this.#createRendererAsync(sceneDetails), + wasm.trameTriggerAsync, + this.#darkMode + ); + } catch (err) { + if (err instanceof SchemaVersionMismatchError) { + this.#current.showMismatch(err); + return; + } + throw err; + } + // Dash snapshots the frontend named here; it is written before + // React commits the new frontend, as App writes it only after. + (window as any).__visorState = newFrontend; + this.#current.swapTo(new ClientFrontendBuild(newFrontend)); + } finally { + wasm.addServerUpdatedListener(this.#onServerUpdated); + wasm.addSetStateListener(this.#onSetState); + } + setTimeout(() => { + wasm.vtkScene.resizeAsync(); + }, 100); + if (perfLogging) { + const handlerMs = performance.now() - t0; + console.log(`[PERF] onServerUpdateAsync total=${handlerMs.toFixed(2)}ms`); + wasm.trameTriggerAsync('perf_report_server_update', { handlerMs }).catch(() => {}); + } + } + + /** Create the build's renderer and move the camera-sync reporter onto it, with no await between attach and remove. */ + async #createRendererAsync(sceneDetails: VisorSceneDetails): Promise { + const wasm = this.#wasm; + const renderer = await WasmRenderer.createAsync( + wasm.vtkScene, + requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), + wasm.trameTriggerAsync + ); + // Report each settled camera window to the server, from exactly + // one renderer. The `VtkScene` behind both renderers is the same + // object across a rebuild, so a subscription left live reports + // every later gesture once per surviving renderer, each report + // individually valid. There is deliberately no `await` between + // attaching the new reporter and removing the old one, so no + // suspension point sees two. + const releaseOldReporter = this.#cameraSyncRemover; + this.#cameraSyncRemover = attachCameraSyncReporter(renderer, wasm.trameTriggerAsync); + releaseOldReporter?.(); + return renderer; + } + + /** Apply a set_state payload to the current frontend, the darkMode prop winning over the stored theme. */ + async #applySetStateAsync(payload: any): Promise { + const newAppState = new VisorAppState(payload?.appState); + if (this.#darkMode != null) { + // Explicit Dash prop takes precedence over the theme stored in saved state. + newAppState.ui.setDarkTheme(this.#darkMode); + } + const frontend = this.#current.frontend; + if (frontend == null) { + throw new Error(`set_state arrived before any frontend was built`); + } + await frontend.setAppStateAsync(newAppState, true); + } +} diff --git a/src/ansys/visor/visor-client/src/frontend/CurrentFrontend.ts b/src/ansys/visor/visor-client/src/frontend/CurrentFrontend.ts new file mode 100644 index 00000000..83e28b91 --- /dev/null +++ b/src/ansys/visor/visor-client/src/frontend/CurrentFrontend.ts @@ -0,0 +1,50 @@ +import type { VisorFrontend } from '../VisorFrontend.tsx'; +import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; + +/** Receives the frontend App should show and the schema mismatch to show instead, if any. */ +export type FrontendChangeListener = ( + frontend: VisorFrontend | null, + mismatch: SchemaVersionMismatchError | null +) => void; + +/** A built frontend and what must be released with it when it is replaced. */ +export interface FrontendBuild { + readonly frontend: VisorFrontend; + /** Remove everything this build registered; idempotent. */ + release(): void; +} + +/** The current frontend build: swaps in a new one, releases the old, keeps it on a schema mismatch. */ +export class CurrentFrontend { + /** Report every change of frontend or mismatch through onChange; required. */ + constructor(onChange: FrontendChangeListener) { + this.#onChange = onChange; + } + + readonly #onChange: FrontendChangeListener; + #build: FrontendBuild | null = null; + + /** The current frontend, or null before the first build. */ + get frontend(): VisorFrontend | null { + return this.#build?.frontend ?? null; + } + + /** Release the previous build, then report build.frontend with no mismatch. */ + swapTo(build: FrontendBuild): void { + this.#build?.release(); + this.#build = build; + this.#onChange(build.frontend, null); + } + + /** Keep the current frontend and report it with the mismatch. */ + showMismatch(err: SchemaVersionMismatchError): void { + this.#onChange(this.frontend, err); + } + + /** Release the current build and report no frontend and no mismatch. */ + clear(): void { + this.#build?.release(); + this.#build = null; + this.#onChange(null, null); + } +} diff --git a/src/ansys/visor/visor-client/src/frontend/ServerViewer.ts b/src/ansys/visor/visor-client/src/frontend/ServerViewer.ts new file mode 100644 index 00000000..63621e6c --- /dev/null +++ b/src/ansys/visor/visor-client/src/frontend/ServerViewer.ts @@ -0,0 +1,141 @@ +import type { Trame } from '@kitware/trame/src/trame.js'; +import type { VisorFrontend } from '../VisorFrontend.tsx'; +import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; +import { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanvas'; +import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer'; +import type { TrameTriggerSender } from '../renderer/IRenderer'; +import { buildFrontendAsync } from '../buildFrontendAsync'; +import { FrontendRebuilder } from './FrontendRebuilder'; +import { + CurrentFrontend, + type FrontendBuild, + type FrontendChangeListener, +} from './CurrentFrontend'; + +/** State key the server bumps after each scene change; must equal server_state_publisher.py's. */ +export const SCENE_VERSION_KEY = 'scene_version'; + +/** A server-mode frontend and the renderer it was built over, released together when replaced. */ +class ServerFrontendBuild implements FrontendBuild { + constructor(frontend: VisorFrontend, renderer: ServerRenderer) { + this.frontend = frontend; + this.#renderer = renderer; + } + + readonly frontend: VisorFrontend; + readonly #renderer: ServerRenderer; + + /** Remove the frontend's renderer listeners, then dispose the renderer. */ + release(): void { + this.frontend.release(); + this.#renderer.dispose(); + } +} + +/** A server-mode view over the probe's trame: streams frames and rebuilds the frontend on every scene_version change. */ +export class ServerViewer { + /** Subscribe the canvas, watch scene_version and start the first build; all parameters required. */ + constructor(trame: Trame, darkMode: boolean | undefined, onChange: FrontendChangeListener) { + this.#trame = trame; + this.#darkMode = darkMode; + this.#current = new CurrentFrontend(onChange); + this.#state = stateOf(trame); + // Subscribed before anything can make the server render, so the frame + // that render produces reaches the canvas. + this.#canvas = new ServerCanvas(sessionOf(trame), RCA_AREA_NAME); + this.#sender = (triggerName, payload) => trame.trigger(triggerName, [payload]); + const rebuilder = new FrontendRebuilder( + () => this.#buildAsync(), + (err) => console.error('[VISOR] server-mode rebuild failed', err) + ); + // `watch` also calls back at once, which is the initial build. + this.#unwatch = this.#state.watch([SCENE_VERSION_KEY], () => rebuilder.request()); + } + + readonly #trame: Trame; + readonly #darkMode: boolean | undefined; + readonly #current: CurrentFrontend; + readonly #state: ServerStateSource; + readonly #canvas: ServerCanvas; + readonly #sender: TrameTriggerSender; + readonly #unwatch: () => void; + #disposed = false; + + /** Stop rebuilding, release the current build and the canvas; a build in progress drops its result. */ + dispose(): void { + this.#disposed = true; + this.#unwatch(); + this.#current.clear(); + this.#canvas.dispose(); + } + + /** One build: theme, scene-details fetch, frontend over a new renderer; becomes current unless disposed. */ + async #buildAsync(): Promise { + // A build requested before dispose but started after it sends nothing. + if (this.#disposed) { + return; + } + if (this.#darkMode != null) { + await sendDarkThemeAsync(this.#trame, this.#darkMode); + } + // No arguments: the trigger takes none, and a sender would pass one. + const sceneDetailsJson = await this.#trame.trigger('get_visor_scene_details_json'); + const built: { renderer: ServerRenderer | null } = { renderer: null }; + let newFrontend: VisorFrontend; + try { + newFrontend = await buildFrontendAsync( + sceneDetailsJson, + async () => { + built.renderer = new ServerRenderer(this.#canvas, this.#state, this.#sender); + return built.renderer; + }, + this.#sender, + this.#darkMode + ); + } catch (err) { + built.renderer?.dispose(); + if (err instanceof SchemaVersionMismatchError) { + // The current frontend is kept, as in client mode. + if (!this.#disposed) { + this.#current.showMismatch(err); + } + return; + } + throw err; + } + const renderer = built.renderer!; + if (this.#disposed) { + // Built after dispose ran, so dispose could not release it. + renderer.dispose(); + return; + } + this.#current.swapTo(new ServerFrontendBuild(newFrontend, renderer)); + } +} + +/** The wslink session of a connected trame client. */ +function sessionOf(trame: Trame): RcaSession { + const session = trame.client?.getConnection()?.getSession(); + if (session == null) { + throw new Error(`trame has no open session`); + } + return session; +} + +/** The state of a connected trame client. */ +function stateOf(trame: Trame): ServerStateSource { + const state = trame.state; + if (state == null) { + throw new Error(`trame has no state`); + } + return state; +} + +/** Tell the server the theme the page shows, so its rendered background matches; a failed send is logged. */ +async function sendDarkThemeAsync(trame: Trame, dark: boolean): Promise { + try { + await trame.trigger('set_dark_theme', [{ dark }]); + } catch (err) { + console.error('[VISOR] set_dark_theme send failed', err); + } +} diff --git a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts index 7d2bd8af..43d97d72 100644 --- a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts +++ b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts @@ -1,13 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { VisorFrontend } from '../VisorFrontend.tsx'; -import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; -import VisorAppState from '../state/appstate/VisorAppState.tsx'; -import RemoteVtkScene from '../wasm/RemoteVtkScene'; -import { WasmRenderer } from '../renderer/WasmRenderer'; -import { requireWasmAnnotation } from '../renderer/RendererAnnotation'; -import { attachCameraSyncReporter } from '../CameraSyncReporter'; -import { buildFrontendAsync } from '../buildFrontendAsync'; -import { trameWebSocketUrl } from '../trameWebSocketUrl'; +import type { VisorFrontend } from '../VisorFrontend.tsx'; +import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; +import { ClientViewer } from '../frontend/ClientViewer'; import { VisorArgs } from './useVisorArgs'; /** What the client-mode hook gives App. */ @@ -21,118 +15,24 @@ export function useClientVtk(visorArgs: VisorArgs, active: boolean): ClientVtk { const [visorFrontend, setVisorFrontend] = useState(null); const [schemaMismatchError, setSchemaMismatchError] = useState(null); - const wasmView = useRef(null); - const cameraSyncRemover = useRef<(() => void) | null>(null); + const viewer = useRef(null); useEffect(() => { if (!active) { return; } (async () => { - if (wasmView.current == null) { - const { host, port } = visorArgs; - const secure = window.location.protocol === 'https:'; - wasmView.current = await RemoteVtkScene.getInstanceAsync({ - wasmIdsStateKey: 'wasm_ids', - refNameStateKey: 'wasm_ref_name', - wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`, - webSocketUrl: trameWebSocketUrl(host, port), - }); - wasmView.current.addServerUpdatedListener(onServerUpdateAsync); - await onServerUpdateAsync(); + if (viewer.current == null) { + viewer.current = await ClientViewer.connectAsync( + visorArgs, + (frontend, mismatch) => { + setSchemaMismatchError(mismatch); + setVisorFrontend(frontend); + } + ); } })(); }, [active]); - async function onServerUpdateAsync() { - if (wasmView.current == null) { - throw new Error(`wasmView should not be null here`); - } - const perfLogging = wasmView.current.trameGetState('perf_logging') === true; - const t0 = perfLogging ? performance.now() : 0; - if (perfLogging) console.log(`[PERF] onServerUpdateAsync start`); - - wasmView.current.clearObserversAndEventListeners(); - const oldFrontend: VisorFrontend = (window as any).__visorState; - let newFrontend: VisorFrontend; - // The listeners cleared above are registered again however this - // block ends, so a failed rebuild still receives the next server - // update, and `set_state` keeps reaching the frontend named by - // `__visorState`, which is switched only after the first pass. - try { - const sceneDetailsJson = await wasmView.current.trameTriggerAsync( - 'get_visor_scene_details_json' - ); - const wasm = wasmView.current; - try { - newFrontend = await buildFrontendAsync( - sceneDetailsJson, - async (sceneDetails) => { - const renderer = await WasmRenderer.createAsync( - wasm.vtkScene, - requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), - wasm.trameTriggerAsync - ); - // Report each settled camera window to the server, from exactly - // one renderer. The `VtkScene` behind both renderers is the same - // object across a rebuild, so a subscription left live reports - // every later gesture once per surviving renderer, each report - // individually valid. There is deliberately no `await` between - // attaching the new reporter and removing the old one, so no - // suspension point sees two. - const releaseOldReporter = cameraSyncRemover.current; - cameraSyncRemover.current = attachCameraSyncReporter( - renderer, - wasm.trameTriggerAsync - ); - releaseOldReporter?.(); - return renderer; - }, - wasm.trameTriggerAsync, - visorArgs.darkMode - ); - } catch (err) { - if (err instanceof SchemaVersionMismatchError) { - setSchemaMismatchError(err); - return; - } - throw err; - } - if (oldFrontend != null) { - oldFrontend.release(); - } - (window as any).__visorState = newFrontend; - setSchemaMismatchError(null); - setVisorFrontend(newFrontend); // rebuild/remount React UI - } finally { - wasmView.current.addServerUpdatedListener(onServerUpdateAsync); - wasmView.current.addSetStateListener(onSetStateAsync); - } - const view = wasmView.current; - setTimeout(() => { - view.vtkScene.resizeAsync(); - }, 100); - if (perfLogging) { - const handlerMs = performance.now() - t0; - console.log(`[PERF] onServerUpdateAsync total=${handlerMs.toFixed(2)}ms`); - wasmView.current - .trameTriggerAsync('perf_report_server_update', { handlerMs }) - .catch(() => {}); - } - } - - async function onSetStateAsync(payload: any) { - if (wasmView.current == null) { - throw new Error(`wasmView should not be null here`); - } - const newAppState = new VisorAppState(payload?.appState); - if (visorArgs.darkMode != null) { - // Explicit Dash prop takes precedence over the theme stored in saved state. - newAppState.ui.setDarkTheme(visorArgs.darkMode); - } - const oldFrontend: VisorFrontend = (window as any).__visorState; - await oldFrontend.setAppStateAsync(newAppState, true); - } - return { visorFrontend, schemaMismatchError }; } diff --git a/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts index 80f0679a..ebd151e5 100644 --- a/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts +++ b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts @@ -1,40 +1,16 @@ import { useEffect, useState } from 'react'; import type { Trame } from '@kitware/trame/src/trame.js'; import type { VisorFrontend } from '../VisorFrontend.tsx'; -import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; -import { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanvas'; -import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer'; -import type { TrameTriggerSender } from '../renderer/IRenderer'; -import { buildFrontendAsync } from '../buildFrontendAsync'; -import { FrontendRebuilder } from '../frontend/FrontendRebuilder'; +import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; +import { ServerViewer } from '../frontend/ServerViewer'; import type { VisorArgs } from './useVisorArgs'; -/** State key the server bumps after each scene change; must equal server_state_publisher.py's. */ -export const SCENE_VERSION_KEY = 'scene_version'; - /** What the server-mode hook gives App. */ export type ServerVtk = Readonly<{ visorFrontend: VisorFrontend | null; schemaMismatchError: SchemaVersionMismatchError | null; }>; -/** A built server-mode frontend and the renderer it was built over, released together when replaced. */ -class ServerFrontendBuild { - constructor(frontend: VisorFrontend, renderer: ServerRenderer) { - this.#frontend = frontend; - this.#renderer = renderer; - } - - readonly #frontend: VisorFrontend; - readonly #renderer: ServerRenderer; - - /** Remove the frontend's renderer listeners, then dispose the renderer. */ - release(): void { - this.#frontend.release(); - this.#renderer.dispose(); - } -} - /** Over the probe's session: stream frames, and rebuild the frontend on every scene_version change; idle when trame is null. */ export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerVtk { const [visorFrontend, setVisorFrontend] = useState(null); @@ -46,102 +22,14 @@ export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerV return; } // Under StrictMode the effect runs twice in development: each run - // disposes only the canvas and builds it created, and a build that - // finishes after cleanup disposes its own renderer. - let cancelled = false; - let current: ServerFrontendBuild | null = null; - const state = stateOf(trame); - // Subscribed before anything can make the server render, so the frame - // that render produces reaches the canvas. - const canvas = new ServerCanvas(sessionOf(trame), RCA_AREA_NAME); - const sender: TrameTriggerSender = (triggerName, payload) => - trame.trigger(triggerName, [payload]); - const buildAsync = async (): Promise => { - // A build requested before cleanup but started after it sends nothing. - if (cancelled) { - return; - } - if (visorArgs.darkMode != null) { - await sendDarkThemeAsync(trame, visorArgs.darkMode); - } - // No arguments: the trigger takes none, and a sender would pass one. - const sceneDetailsJson = await trame.trigger('get_visor_scene_details_json'); - const built: { renderer: ServerRenderer | null } = { renderer: null }; - let newFrontend: VisorFrontend; - try { - newFrontend = await buildFrontendAsync( - sceneDetailsJson, - async () => { - built.renderer = new ServerRenderer(canvas, state, sender); - return built.renderer; - }, - sender, - visorArgs.darkMode - ); - } catch (err) { - built.renderer?.dispose(); - if (err instanceof SchemaVersionMismatchError) { - // The current frontend is kept, as in client mode. - if (!cancelled) { - setSchemaMismatchError(err); - } - return; - } - throw err; - } - const renderer = built.renderer!; - if (cancelled) { - // Built after cleanup ran, so cleanup could not dispose it. - renderer.dispose(); - return; - } - current?.release(); - current = new ServerFrontendBuild(newFrontend, renderer); - setSchemaMismatchError(null); - setVisorFrontend(newFrontend); - }; - const rebuilder = new FrontendRebuilder(buildAsync, (err) => - console.error('[VISOR] server-mode rebuild failed', err) - ); - // `watch` also calls back at once, which is the initial build. - const unwatch = state.watch([SCENE_VERSION_KEY], () => rebuilder.request()); - return () => { - cancelled = true; - unwatch(); - current?.release(); - current = null; - canvas.dispose(); - setVisorFrontend(null); - setSchemaMismatchError(null); - }; + // disposes only the viewer it created, and a build that finishes + // after dispose disposes its own renderer. + const viewer = new ServerViewer(trame, visorArgs.darkMode, (frontend, mismatch) => { + setSchemaMismatchError(mismatch); + setVisorFrontend(frontend); + }); + return () => viewer.dispose(); }, [trame]); return { visorFrontend, schemaMismatchError }; } - -/** The wslink session of a connected trame client. */ -function sessionOf(trame: Trame): RcaSession { - const session = trame.client?.getConnection()?.getSession(); - if (session == null) { - throw new Error(`trame has no open session`); - } - return session; -} - -/** The state of a connected trame client. */ -function stateOf(trame: Trame): ServerStateSource { - const state = trame.state; - if (state == null) { - throw new Error(`trame has no state`); - } - return state; -} - -/** Tell the server the theme the page shows, so its rendered background matches; a failed send is logged. */ -async function sendDarkThemeAsync(trame: Trame, dark: boolean): Promise { - try { - await trame.trigger('set_dark_theme', [{ dark }]); - } catch (err) { - console.error('[VISOR] set_dark_theme send failed', err); - } -} 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 new file mode 100644 index 00000000..01508bd5 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx @@ -0,0 +1,167 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useClientVtk } from '../hooks/useClientVtk'; +import type { VisorArgs } from '../hooks/useVisorArgs'; + +/** + * 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. + * + * Expected values are hand-written literals. + */ + +const mockScene = { + serverUpdated: null as null | (() => Promise), + payloads: [] as unknown[], + fetches: 0, + renderers: [] as { liveViewerClicks: Set<() => void> }[], +}; + +jest.mock('../wasm/RemoteVtkScene', () => ({ + __esModule: true, + default: { getInstanceAsync: async () => mockMakeWasmView() }, +})); + +jest.mock('../renderer/WasmRenderer', () => ({ + WasmRenderer: { createAsync: async () => mockMakeRenderer() }, +})); + +/** A minimal v4 scene-details payload: one root node and a complete wasm annotation. */ +function mockSceneDetailsPayload() { + return { + schemaVersion: 4, + ui: {}, + scene: { unit: '', orthographicEnabled: null }, + vtkInfo: { + sceneGraph: { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + rendererAnnotation: { + rendererKind: 'wasm', + nodes: {}, + widgets: { + orientationWidgetId: 1, + crossSectionPlaneId: 2, + crossSectionPlaneWidgetId: 3, + crossSectionPlaneRepresentationId: 4, + boundingBoxAlgorithmId: 5, + boundingBoxOutlineActorId: 6, + boundingBoxAxesActorId: 7, + }, + }, + }, + }; +} + +/** The client-mode scene connection: keeps the latest server-updated listener and answers each fetch in turn. */ +function mockMakeWasmView() { + return { + trameGetState: () => false, + clearObserversAndEventListeners: () => {}, + addServerUpdatedListener: (handler: () => Promise) => { + mockScene.serverUpdated = handler; + return () => {}; + }, + addSetStateListener: () => () => {}, + trameTriggerAsync: async (name: string) => + name === 'get_visor_scene_details_json' + ? mockScene.payloads[mockScene.fetches++] + : undefined, + vtkScene: { resizeAsync: async () => undefined }, + }; +} + +/** A renderer that records the viewer-click listeners still registered on it. */ +function mockMakeRenderer() { + const record = { liveViewerClicks: new Set<() => void>() }; + mockScene.renderers.push(record); + return { + domElement: document.createElement('div'), + addCameraSettledListener: () => () => {}, + addViewerClickedListener: () => { + const remover = () => { + record.liveViewerClicks.delete(remover); + }; + record.liveViewerClicks.add(remover); + return remover; + }, + getCameraStateAsync: async () => undefined, + resizeAsync: async () => undefined, + }; +} + +const ARGS = { + host: 'localhost', + port: 8080, + aspectRatio: 1, + pixelDensity: 1000, + basePath: '', +} as VisorArgs; + +/** Mount the hook and wait for its first frontend. */ +async function mountBuiltAsync() { + const hook = renderHook(() => useClientVtk(ARGS, true)); + await waitFor(() => expect(hook.result.current.visorFrontend).not.toBeNull()); + return hook; +} + +/** Deliver one server update and let its rebuild finish. */ +async function serverUpdateAsync() { + await act(async () => { + await mockScene.serverUpdated!(); + }); +} + +beforeEach(() => { + mockScene.serverUpdated = null; + mockScene.payloads = []; + mockScene.fetches = 0; + mockScene.renderers = []; +}); + +afterEach(() => { + delete (window as any).__visorState; +}); + +describe('useClientVtk rebuild', () => { + test('a client rebuild releases the old frontend', async () => { + mockScene.payloads = [mockSceneDetailsPayload(), mockSceneDetailsPayload()]; + await mountBuiltAsync(); + + await serverUpdateAsync(); + + expect(mockScene.renderers.length).toBe(2); + expect(mockScene.renderers[0].liveViewerClicks.size).toBe(0); + }); + + test('a mismatch on client rebuild sets schemaMismatchError and keeps the frontend', async () => { + mockScene.payloads = [mockSceneDetailsPayload(), { schemaVersion: 3 }]; + const { result } = await mountBuiltAsync(); + const first = result.current.visorFrontend; + + await serverUpdateAsync(); + + expect(result.current.schemaMismatchError?.payloadVersion).toBe(3); + expect(result.current.visorFrontend).toBe(first); + }); + + test('after a client rebuild window.__visorState is the new frontend', async () => { + mockScene.payloads = [mockSceneDetailsPayload(), mockSceneDetailsPayload()]; + const { result } = await mountBuiltAsync(); + const first = result.current.visorFrontend; + + await serverUpdateAsync(); + + expect(result.current.visorFrontend).not.toBe(first); + expect((window as any).__visorState).toBe(result.current.visorFrontend); + }); +}); From 70f03851823bf6c0d10d788322f223c86187f383 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:06:42 +0000 Subject: [PATCH 2/3] chore: adding changelog file 195.added.md [dependabot-skip] --- doc/changelog.d/195.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/195.added.md diff --git a/doc/changelog.d/195.added.md b/doc/changelog.d/195.added.md new file mode 100644 index 00000000..d5b012c9 --- /dev/null +++ b/doc/changelog.d/195.added.md @@ -0,0 +1 @@ +[Remote rendering 4.4b] move viewer wiring to custom classes From 19fd6d9a9b851066dfd22e9f42eb55a320cffd46 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:01:29 +0000 Subject: [PATCH 3/3] chore: adding changelog file 195.added.md [dependabot-skip] --- doc/changelog.d/195.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/195.added.md b/doc/changelog.d/195.added.md index d5b012c9..50258ef3 100644 --- a/doc/changelog.d/195.added.md +++ b/doc/changelog.d/195.added.md @@ -1 +1 @@ -[Remote rendering 4.4b] move viewer wiring to custom classes +Remote rendering 4.4b: move viewer wiring to custom classes