From f9426f2e7c5042a8afe83f4e2c786183e953b16f Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 21 Oct 2026 10:50:27 -0700 Subject: [PATCH 1/4] chore: move client-rendering viewer wiring into its own hook --- src/ansys/visor/visor-client/src/App.tsx | 168 +----------------- .../visor-client/src/hooks/useClientVtk.ts | 132 ++++++++++++++ .../visor-client/src/hooks/useVisorArgs.ts | 50 ++++++ 3 files changed, 191 insertions(+), 159 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/hooks/useClientVtk.ts create mode 100644 src/ansys/visor/visor-client/src/hooks/useVisorArgs.ts diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index d43ee812..bd45ba5b 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -1,64 +1,16 @@ import { UiScaffold, UiScaffoldUtil } from './components/UiScaffold.tsx'; -import { CSSProperties, RefObject, useEffect, useRef, useState } from 'react'; -import { 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 { CSSProperties, RefObject, useEffect, useRef } from 'react'; import { randomInt } from './utils/JsHelpers'; - -type VisorArgs = { - host: string; - port: number; - aspectRatio: number; - pixelDensity: number; - darkMode: boolean; - basePath: string; -}; +import { useVisorArgs } from './hooks/useVisorArgs'; +import { useClientVtk } from './hooks/useClientVtk'; function App() { - const visorArgs: RefObject = useRef(null!); - if (visorArgs.current == null) { - // Get current args object from window. - // If the args object was not found on - // the window, create a new object. - const args = (visorArgs.current = (window as any).__visorArgs ?? {}); - // Delete args object from window if it existed there. - // BHB update 2025-11-21: don't delete the args object here, - // as they might actually need to be preserved across App.tsx remounts. - // Need to investigate. - // delete (window as any).__visorArgs; - // Set default values for args if any were not set. - args.host ??= location.hostname; - if (args.port == null) { - if (location.port === '') { - args.port = location.protocol === 'https:' ? 443 : 80; - } else { - args.port = parseInt(location.port); - } - } - args.aspectRatio ??= 800 / 600; - args.pixelDensity ??= 1000; - args.basePath ??= ''; - // Sanity-check each argument's value. - if ((typeof args.host as any) !== 'string' || args.host === '') { - throw new Error(`host must be a non-empty string`); - } else if ((typeof args.port as any) !== 'number' || args.port <= 0) { - throw new Error(`port must be a number greater than 0`); - } else if ((typeof args.aspectRatio as any) !== 'number' || args.aspectRatio <= 0) { - throw new Error(`aspectRatio must be a number greater than 0`); - } else if ((typeof args.pixelDensity as any) !== 'number' || args.pixelDensity <= 0) { - throw new Error(`pixelDensity must be a number greater than 0`); - } - } + const visorArgs = useVisorArgs(); const visorContainerStyle: CSSProperties = - visorArgs.current.aspectRatio > 0 + visorArgs.aspectRatio > 0 ? { position: 'relative', - aspectRatio: `${visorArgs.current.aspectRatio}`, + aspectRatio: `${visorArgs.aspectRatio}`, maxHeight: '100%', margin: '0 auto', } @@ -70,24 +22,11 @@ function App() { margin: '0 auto', }; const uiScaffoldUtil: RefObject = useRef(null!); - const [visorState, setVisorFrontend] = useState(null!); - const [schemaMismatchError, setSchemaMismatchError] = - useState(null); - const wasmView = useRef(null); + const { visorFrontend: visorState, schemaMismatchError } = useClientVtk(visorArgs); useEffect(() => { (async () => { - if (wasmView.current == null) { - const { host, port } = visorArgs.current; - 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: `ws${secure ? 's' : ''}://${host}:${port}/ws`, - }); - wasmView.current.addServerUpdatedListener(onServerUpdateAsync); - await onServerUpdateAsync(); + if (visorState == null) { return; } else if (uiScaffoldUtil.current == null) { throw new Error(`uiScaffoldUtil.current should not be null here`); @@ -95,7 +34,7 @@ function App() { (window as any).__visorState = visorState; ////////////////////////////////////////////////////////////////////////// // Set UiScaffold params. - uiScaffoldUtil.current.pixelDensity = visorArgs.current.pixelDensity; + uiScaffoldUtil.current.pixelDensity = visorArgs.pixelDensity; })(); return () => { // Cleanup @@ -103,95 +42,6 @@ function App() { }; }, [visorState]); - 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' - ); - let sceneDetails: VisorSceneDetails; - try { - sceneDetails = new VisorSceneDetails(sceneDetailsJson); - } catch (err) { - if (err instanceof SchemaVersionMismatchError) { - setSchemaMismatchError(err); - return; - } - throw err; - } - const renderer = await WasmRenderer.createAsync( - wasmView.current.vtkScene, - requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), - wasmView.current.trameTriggerAsync - ); - newFrontend = new VisorFrontend( - renderer, - sceneDetails.vtkInfo.sceneGraph, - sceneDetails.appState.scene, - wasmView.current.trameTriggerAsync - ); - // Release the frontend being replaced, here and not later: the - // `VtkScene` behind both renderers is the same object across a - // rebuild, so an unreleased subscription stays live and the next - // gesture is reported once per surviving frontend. There is - // deliberately no `await` between the new frontend subscribing (the - // constructor above) and the old one releasing, so the window in - // which two subscriptions coexist contains no suspension point. - if (oldFrontend != null) { - oldFrontend.releaseCameraSettledListener(); - } - - if (visorArgs.current.darkMode != null) { - // Explicit Dash prop takes precedence over the server's dark_mode value. - sceneDetails.appState.ui.setDarkTheme(visorArgs.current.darkMode); - } - await newFrontend.setAppStateAsync(sceneDetails.appState, false); - (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.current.darkMode != null) { - // Explicit Dash prop takes precedence over the theme stored in saved state. - newAppState.ui.setDarkTheme(visorArgs.current.darkMode); - } - const oldFrontend: VisorFrontend = (window as any).__visorState; - await oldFrontend.setAppStateAsync(newAppState, true); - } - return (
{schemaMismatchError != null ? ( diff --git a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts new file mode 100644 index 00000000..4921ea5b --- /dev/null +++ b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react'; +import { 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 { VisorArgs } from './useVisorArgs'; + +/** What the client-mode hook gives App. */ +export type ClientVtk = Readonly<{ + visorFrontend: VisorFrontend | null; + schemaMismatchError: SchemaVersionMismatchError | null; +}>; + +/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state. */ +export function useClientVtk(visorArgs: VisorArgs): ClientVtk { + const [visorFrontend, setVisorFrontend] = useState(null); + const [schemaMismatchError, setSchemaMismatchError] = + useState(null); + const wasmView = useRef(null); + + useEffect(() => { + (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: `ws${secure ? 's' : ''}://${host}:${port}/ws`, + }); + wasmView.current.addServerUpdatedListener(onServerUpdateAsync); + await onServerUpdateAsync(); + } + })(); + }, []); + + 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' + ); + let sceneDetails: VisorSceneDetails; + try { + sceneDetails = new VisorSceneDetails(sceneDetailsJson); + } catch (err) { + if (err instanceof SchemaVersionMismatchError) { + setSchemaMismatchError(err); + return; + } + throw err; + } + const renderer = await WasmRenderer.createAsync( + wasmView.current.vtkScene, + requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), + wasmView.current.trameTriggerAsync + ); + newFrontend = new VisorFrontend( + renderer, + sceneDetails.vtkInfo.sceneGraph, + sceneDetails.appState.scene, + wasmView.current.trameTriggerAsync + ); + // Release the frontend being replaced, here and not later: the + // `VtkScene` behind both renderers is the same object across a + // rebuild, so an unreleased subscription stays live and the next + // gesture is reported once per surviving frontend. There is + // deliberately no `await` between the new frontend subscribing (the + // constructor above) and the old one releasing, so the window in + // which two subscriptions coexist contains no suspension point. + if (oldFrontend != null) { + oldFrontend.releaseCameraSettledListener(); + } + + if (visorArgs.darkMode != null) { + // Explicit Dash prop takes precedence over the server's dark_mode value. + sceneDetails.appState.ui.setDarkTheme(visorArgs.darkMode); + } + await newFrontend.setAppStateAsync(sceneDetails.appState, false); + (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/useVisorArgs.ts b/src/ansys/visor/visor-client/src/hooks/useVisorArgs.ts new file mode 100644 index 00000000..ecd9679f --- /dev/null +++ b/src/ansys/visor/visor-client/src/hooks/useVisorArgs.ts @@ -0,0 +1,50 @@ +import { RefObject, useRef } from 'react'; + +/** Arguments the host page passes on window.__visorArgs, after defaults. */ +export type VisorArgs = { + host: string; + port: number; + aspectRatio: number; + pixelDensity: number; + darkMode: boolean; + basePath: string; +}; + +/** Read window.__visorArgs once, fill defaults, validate, and return the same object on every render. */ +export function useVisorArgs(): VisorArgs { + const visorArgs: RefObject = useRef(null!); + if (visorArgs.current == null) { + // Get current args object from window. + // If the args object was not found on + // the window, create a new object. + const args = (visorArgs.current = (window as any).__visorArgs ?? {}); + // Delete args object from window if it existed there. + // BHB update 2025-11-21: don't delete the args object here, + // as they might actually need to be preserved across App.tsx remounts. + // Need to investigate. + // delete (window as any).__visorArgs; + // Set default values for args if any were not set. + args.host ??= location.hostname; + if (args.port == null) { + if (location.port === '') { + args.port = location.protocol === 'https:' ? 443 : 80; + } else { + args.port = parseInt(location.port); + } + } + args.aspectRatio ??= 800 / 600; + args.pixelDensity ??= 1000; + args.basePath ??= ''; + // Sanity-check each argument's value. + if ((typeof args.host as any) !== 'string' || args.host === '') { + throw new Error(`host must be a non-empty string`); + } else if ((typeof args.port as any) !== 'number' || args.port <= 0) { + throw new Error(`port must be a number greater than 0`); + } else if ((typeof args.aspectRatio as any) !== 'number' || args.aspectRatio <= 0) { + throw new Error(`aspectRatio must be a number greater than 0`); + } else if ((typeof args.pixelDensity as any) !== 'number' || args.pixelDensity <= 0) { + throw new Error(`pixelDensity must be a number greater than 0`); + } + } + return visorArgs.current; +} From 1ade2754dc5cf4ac62baf96bf97ef10e41c3c517 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 21 Oct 2026 11:52:15 -0700 Subject: [PATCH 2/4] feat: attach camera sync reporting in the client-rendering hook only --- .../visor-client/src/CameraSyncReporter.ts | 19 +- .../visor/visor-client/src/VisorFrontend.tsx | 54 ++--- .../visor-client/src/hooks/useClientVtk.ts | 28 ++- .../UseClientVtkCameraSync.test.tsx | 198 ++++++++++++++++++ .../jest-tests/VisorFrontendRelease.test.ts | 65 ++++++ 5 files changed, 310 insertions(+), 54 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx create mode 100644 src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts diff --git a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts index a67a6b85..43d9933b 100644 --- a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts +++ b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts @@ -8,13 +8,10 @@ import type { /** * The `sync_camera` trigger: the client's half of the server-tracked camera. * - * This module exists apart from `VisorFrontend` so that it can be tested. A - * `VisorFrontend` cannot be constructed under jest -- it needs a real scene - * graph node, the module-global variable manager and a renderer, and it ends - * in `Object.freeze` -- so a listener body - * written inline there would be pinned by nothing, and the payload shape is - * precisely the part no server-side gate can check. `CameraGestureTracker` - * was split out for the same reason in the previous increment. + * This module is the client-mode reporter, attached by the client-mode hook + * to each renderer it builds, and kept apart so that the listener body and + * the payload shape -- precisely the part no server-side gate can check -- + * are tested directly. * * What reaches here is already debounced: `addCameraSettledListener` fires * once per settle window, never once per camera event, and it carries that @@ -87,10 +84,10 @@ export function buildSyncCameraPayload( * Subscribe to settled camera windows and report each one to the server. * * Returns the remover from `addCameraSettledListener`, **verbatim**. The - * caller owns it and must call it when the frontend holding it is replaced: - * the `VtkScene` survives a client rebuild, so a frontend that is discarded - * without releasing leaves its subscription live and every later gesture is - * reported once per rebuild that has ever happened. Each of those reports is + * caller owns it and must call it when the renderer it was attached to is + * replaced: the `VtkScene` survives a client rebuild, so a renderer that is + * discarded without releasing leaves its subscription live and every later + * gesture is reported once per rebuild that has ever happened. Each of those reports is * individually valid, which is why no gate can see the fault. * * The sender is required. A frontend with no transport is not a state this diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index b81db53f..57f7240f 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -10,7 +10,6 @@ import { StateInput } from './state/appstate/VisorStateCommon.tsx'; import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; import { IRenderer, VisorCameraState } from './renderer/IRenderer'; import type { TrameTriggerSender } from './renderer/IRenderer'; -import { attachCameraSyncReporter } from './CameraSyncReporter'; import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx'; import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx'; import { OrientationWidget } from './widgets/orientationWidget.ts'; @@ -84,26 +83,6 @@ export class VisorFrontend { }; this.domElement = renderer.domElement; this.getCameraStateAsync = () => renderer.getCameraStateAsync(); - // Report each settled camera window to the server. The remover is - // held rather than discarded because the `VtkScene` behind the - // renderer survives a client rebuild: a frontend replaced without - // releasing leaves its subscription live, and every later gesture is - // then reported once per rebuild that has ever happened. Every one of - // those reports is individually valid, so nothing fails -- the record - // is simply written several times and no gate can tell. - // - // Assigned to a `#private` field and exposed through a field-assigned - // method: `Object.freeze(this)` below does not reach `#private` state, - // but it would make a public field assigned after construction throw - // in the browser and in no gate. - this.#cameraSettledRemover = attachCameraSyncReporter(renderer, triggerSender); - this.releaseCameraSettledListener = () => { - const remover = this.#cameraSettledRemover; - // Cleared first, so a second call is a no-op rather than a second - // removal against a map the next frontend now owns. - this.#cameraSettledRemover = null; - remover?.(); - }; this.defaultActorColor = []; // Reports one variable slot's range; errors are logged, not rethrown, // since the client already applied the range locally. @@ -186,12 +165,25 @@ export class VisorFrontend { return result; }; - // Extended viewer click listener that also triggers geometry picking - renderer.addViewerClickedListener((_nodeId, _ctrlKey, _shiftKey, normX, normY) => { - if (selectionMode !== 'part') { - this.pickGeometryAsync(normX, normY).catch(() => {}); + // Extended viewer click listener that also triggers geometry picking. + // The remover is held because the `VtkScene` behind the renderer + // survives a client rebuild: a listener left registered keeps picking + // for a frontend that has been replaced. `#private` because + // `Object.freeze(this)` below makes a public field assigned after + // construction throw. + this.#viewerClickedRemover = renderer.addViewerClickedListener( + (_nodeId, _ctrlKey, _shiftKey, normX, normY) => { + if (selectionMode !== 'part') { + this.pickGeometryAsync(normX, normY).catch(() => {}); + } } - }); + ); + this.release = () => { + const remover = this.#viewerClickedRemover; + // Cleared first, so a second call is a no-op. + this.#viewerClickedRemover = null; + remover?.(); + }; this.sceneGraph = sceneGraph; this.treeViewUtilPromise = treeViewUtilPromise; this.panelTopLeftUtilPromise = panelTopLeftUtilPromise; @@ -532,14 +524,10 @@ export class VisorFrontend { } #unit: string; - #cameraSettledRemover: (() => void) | null; + #viewerClickedRemover: (() => void) | null; darkMode: boolean; - /** - * Release this frontend's settled-camera subscription. Called by the - * rebuild on the frontend it is replacing, before the replacement is - * handed out. Idempotent. - */ - releaseCameraSettledListener: () => void; + /** Remove every listener this frontend registered on its renderer; a second call does nothing. */ + release: () => void; render: () => Promise; resizeAsync: () => Promise; sceneGraph: VisorSceneNodeExtended; diff --git a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts index 4921ea5b..d2a57119 100644 --- a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts +++ b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts @@ -7,6 +7,7 @@ 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 { VisorArgs } from './useVisorArgs'; /** What the client-mode hook gives App. */ @@ -21,6 +22,7 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { const [schemaMismatchError, setSchemaMismatchError] = useState(null); const wasmView = useRef(null); + const cameraSyncRemover = useRef<(() => void) | null>(null); useEffect(() => { (async () => { @@ -73,28 +75,34 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), wasmView.current.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, + wasmView.current.trameTriggerAsync + ); + releaseOldReporter?.(); newFrontend = new VisorFrontend( renderer, sceneDetails.vtkInfo.sceneGraph, sceneDetails.appState.scene, wasmView.current.trameTriggerAsync ); - // Release the frontend being replaced, here and not later: the - // `VtkScene` behind both renderers is the same object across a - // rebuild, so an unreleased subscription stays live and the next - // gesture is reported once per surviving frontend. There is - // deliberately no `await` between the new frontend subscribing (the - // constructor above) and the old one releasing, so the window in - // which two subscriptions coexist contains no suspension point. - if (oldFrontend != null) { - oldFrontend.releaseCameraSettledListener(); - } if (visorArgs.darkMode != null) { // Explicit Dash prop takes precedence over the server's dark_mode value. sceneDetails.appState.ui.setDarkTheme(visorArgs.darkMode); } await newFrontend.setAppStateAsync(sceneDetails.appState, false); + if (oldFrontend != null) { + oldFrontend.release(); + } (window as any).__visorState = newFrontend; setSchemaMismatchError(null); setVisorFrontend(newFrontend); // rebuild/remount React UI 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 new file mode 100644 index 00000000..4624c4f2 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx @@ -0,0 +1,198 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useClientVtk } from '../hooks/useClientVtk'; +import type { VisorArgs } from '../hooks/useVisorArgs'; +import type { CameraOrigin, VisorCameraState } from '../renderer/IRenderer'; + +/** + * The client-mode hook reports each settled camera window to the server from + * exactly one renderer. Every renderer it builds sits on the same `VtkScene`, + * so a reporter left attached across a rebuild reports each later gesture + * once more, and every one of those reports is individually valid. + * + * The doubles stand in for that shared scene: every renderer registers its + * settle listeners into one map, and a settle fans out to whoever is still + * registered there. + * + * Expected values are hand-written literals. + */ + +const mockScene = { + settledListeners: new Map<() => void, (origin: CameraOrigin) => void>(), + serverUpdated: null as null | (() => Promise), + trameTriggerAsync: jest.fn(), + renderersBuilt: 0, + settleDuringFirstSeed: false, +}; + +jest.mock('../wasm/RemoteVtkScene', () => ({ + __esModule: true, + default: { getInstanceAsync: async () => mockMakeWasmView() }, +})); + +jest.mock('../renderer/WasmRenderer', () => ({ + WasmRenderer: { createAsync: async () => mockMakeRenderer() }, +})); + +/** Fan one settle out to every listener still registered on the shared scene. */ +function mockSettle(origin: CameraOrigin) { + for (const callback of mockScene.settledListeners.values()) { + callback(origin); + } +} + +/** 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 records every trigger. */ +function mockMakeWasmView() { + return { + trameGetState: () => false, + clearObserversAndEventListeners: () => {}, + addServerUpdatedListener: (handler: () => Promise) => { + mockScene.serverUpdated = handler; + return () => {}; + }, + addSetStateListener: () => () => {}, + trameTriggerAsync: mockScene.trameTriggerAsync, + vtkScene: { resizeAsync: async () => undefined }, + }; +} + +function mockCameraSnapshot(): VisorCameraState { + return { + distance: 20.0, + orthographic: false, + orthographicScale: 21.0, + unitsPerPixel: 22.0, + viewPortHeight: 23.0, + position: [11.0, 12.0, 13.0], + focalPoint: [14.0, 15.0, 16.0], + viewUp: [0.0, 1.0, 0.0], + clippingRange: [17.0, 18.0], + parallelProjection: false, + viewAngle: 30.0, + parallelScale: 19.0, + }; +} + +/** + * A renderer over the shared scene. `resizeAsync` is the last call the state + * seed makes; on the first renderer it can fire a settle there, inside the seed. + */ +function mockMakeRenderer() { + const index = mockScene.renderersBuilt++; + return { + domElement: document.createElement('div'), + addCameraSettledListener: (callback: (origin: CameraOrigin) => void) => { + const remover = () => { + mockScene.settledListeners.delete(remover); + }; + mockScene.settledListeners.set(remover, callback); + return remover; + }, + addViewerClickedListener: () => () => {}, + getCameraStateAsync: async () => mockCameraSnapshot(), + resizeAsync: async () => { + if (index === 0 && mockScene.settleDuringFirstSeed) { + mockSettle('programmatic'); + } + }, + }; +} + +const ARGS = { + host: 'localhost', + port: 8080, + aspectRatio: 1, + pixelDensity: 1000, + basePath: '', +} as VisorArgs; + +function syncCameraSendCount(): number { + return mockScene.trameTriggerAsync.mock.calls.filter((call) => call[0] === 'sync_camera') + .length; +} + +/** Let every pending promise chain, including the reporter's read and send, run out. */ +async function drain() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + mockScene.settledListeners.clear(); + mockScene.serverUpdated = null; + mockScene.renderersBuilt = 0; + mockScene.settleDuringFirstSeed = false; + mockScene.trameTriggerAsync.mockReset(); + mockScene.trameTriggerAsync.mockImplementation(async (name: string) => + name === 'get_visor_scene_details_json' ? mockSceneDetailsPayload() : undefined + ); +}); + +afterEach(() => { + delete (window as any).__visorState; +}); + +describe('useClientVtk camera sync', () => { + test('one gesture settle after two rebuilds sends one sync_camera', async () => { + const { result } = renderHook(() => useClientVtk(ARGS)); + await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); + await act(async () => { + await mockScene.serverUpdated!(); + }); + await act(async () => { + await mockScene.serverUpdated!(); + }); + + await act(async () => { + mockSettle('gesture'); + await drain(); + }); + + expect(syncCameraSendCount()).toBe(1); + }); + + test('a settle during the first build state seed is reported once', async () => { + mockScene.settleDuringFirstSeed = true; + + const { result } = renderHook(() => useClientVtk(ARGS)); + await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); + await act(async () => { + await drain(); + }); + + expect(syncCameraSendCount()).toBe(1); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts b/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts new file mode 100644 index 00000000..c96ec94c --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts @@ -0,0 +1,65 @@ +import { VisorFrontend } from '../VisorFrontend'; +import type { IRenderer } from '../renderer/IRenderer'; +import type VisorVtkSceneNode from '../state/appstate/vtkInfo/VisorVtkSceneNode.tsx'; +import { deliveredScene } from './deliveredSceneFixture.ts'; + +/** + * `release()` removes the viewer-click listener the frontend registered on its + * renderer. The `VtkScene` behind a client renderer survives a rebuild, so a + * replaced frontend that is never released keeps picking geometry on every + * click. A second release removes nothing further. + */ + +function makeSceneGraphNode() { + return { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }; +} + +/** The renderer members the constructor reaches; each click registration returns its own remover. */ +function makeRendererDouble() { + return { + domElement: document.createElement('div'), + addViewerClickedListener: jest.fn(() => jest.fn()), + }; +} + +/** A frontend over the double, and the remover of the one listener its constructor registered. */ +function makeFrontend() { + const renderer = makeRendererDouble(); + const frontend = new VisorFrontend( + renderer as unknown as IRenderer, + makeSceneGraphNode() as unknown as VisorVtkSceneNode, + deliveredScene(), + jest.fn(async () => undefined) + ); + const remover = renderer.addViewerClickedListener.mock.results[0].value as jest.Mock; + return { frontend, remover }; +} + +describe('VisorFrontend.release', () => { + test('release removes the viewer-click listener the constructor registered', () => { + const { frontend, remover } = makeFrontend(); + + frontend.release(); + + expect(remover).toHaveBeenCalledTimes(1); + }); + + test('a second release removes nothing further', () => { + const { frontend, remover } = makeFrontend(); + + frontend.release(); + frontend.release(); + + expect(remover).toHaveBeenCalledTimes(1); + }); +}); From c10e9f87f13173fbf23ca791ef3c9305e9a0a6a7 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 21 Oct 2026 13:11:30 -0700 Subject: [PATCH 3/4] chore: share frontend build between modes --- .../visor-client/src/buildFrontendAsync.ts | 28 +++++ .../visor-client/src/hooks/useClientVtk.ts | 64 +++++----- .../jest-tests/AppDarkModeOverride.test.tsx | 113 ++++++++++++++++++ .../src/jest-tests/AppSchemaMismatch.test.tsx | 54 +++++++++ .../src/jest-tests/BuildFrontend.test.ts | 29 +++++ 5 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/buildFrontendAsync.ts create mode 100644 src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx create mode 100644 src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx create mode 100644 src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts diff --git a/src/ansys/visor/visor-client/src/buildFrontendAsync.ts b/src/ansys/visor/visor-client/src/buildFrontendAsync.ts new file mode 100644 index 00000000..cc3f072b --- /dev/null +++ b/src/ansys/visor/visor-client/src/buildFrontendAsync.ts @@ -0,0 +1,28 @@ +import { VisorFrontend } from './VisorFrontend.tsx'; +import VisorSceneDetails from './state/appstate/VisorSceneDetails.tsx'; +import type { StateInput } from './state/appstate/VisorStateCommon.tsx'; +import type { IRenderer, TrameTriggerSender } from './renderer/IRenderer'; + +/** Build and seed a frontend from delivered scene details; throws SchemaVersionMismatchError before any renderer exists. */ +export async function buildFrontendAsync( + sceneDetailsJson: StateInput, + createRenderer: (sceneDetails: VisorSceneDetails) => Promise, + triggerSender: TrameTriggerSender, + darkMode: boolean | undefined +): Promise { + const sceneDetails = new VisorSceneDetails(sceneDetailsJson); + const renderer = await createRenderer(sceneDetails); + const frontend = new VisorFrontend( + renderer, + sceneDetails.vtkInfo.sceneGraph, + sceneDetails.appState.scene, + triggerSender + ); + + if (darkMode != null) { + // Explicit Dash prop takes precedence over the server's dark_mode value. + sceneDetails.appState.ui.setDarkTheme(darkMode); + } + await frontend.setAppStateAsync(sceneDetails.appState, false); + return frontend; +} diff --git a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts index d2a57119..2ca3cddc 100644 --- a/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts +++ b/src/ansys/visor/visor-client/src/hooks/useClientVtk.ts @@ -1,13 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import { VisorFrontend } from '../VisorFrontend.tsx'; -import VisorSceneDetails, { - SchemaVersionMismatchError, -} from '../state/appstate/VisorSceneDetails.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 { VisorArgs } from './useVisorArgs'; /** What the client-mode hook gives App. */ @@ -60,9 +59,34 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { const sceneDetailsJson = await wasmView.current.trameTriggerAsync( 'get_visor_scene_details_json' ); - let sceneDetails: VisorSceneDetails; + const wasm = wasmView.current; try { - sceneDetails = new VisorSceneDetails(sceneDetailsJson); + 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); @@ -70,36 +94,6 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { } throw err; } - const renderer = await WasmRenderer.createAsync( - wasmView.current.vtkScene, - requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation), - wasmView.current.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, - wasmView.current.trameTriggerAsync - ); - releaseOldReporter?.(); - newFrontend = new VisorFrontend( - renderer, - sceneDetails.vtkInfo.sceneGraph, - sceneDetails.appState.scene, - wasmView.current.trameTriggerAsync - ); - - if (visorArgs.darkMode != null) { - // Explicit Dash prop takes precedence over the server's dark_mode value. - sceneDetails.appState.ui.setDarkTheme(visorArgs.darkMode); - } - await newFrontend.setAppStateAsync(sceneDetails.appState, false); if (oldFrontend != null) { oldFrontend.release(); } 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 new file mode 100644 index 00000000..7bcd0459 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx @@ -0,0 +1,113 @@ +import { render, waitFor } from '@testing-library/react'; +import App from '../App'; + +/** + * A `darkMode` passed by the host page wins over the theme the server + * delivers: the stylesheet applied when the scene is first built is the one + * for the page's theme. + * + * Expected values are hand-written literals. + */ + +jest.mock('../wasm/RemoteVtkScene', () => ({ + __esModule: true, + default: { getInstanceAsync: async () => mockMakeWasmView() }, +})); + +jest.mock('../renderer/WasmRenderer', () => ({ + WasmRenderer: { createAsync: async () => mockMakeRenderer() }, +})); + +jest.mock('../components/UiScaffold.tsx', () => ({ + UiScaffold: (props: { onLoad: (util: object) => void }) => { + props.onLoad({}); + return null; + }, +})); + +/** A minimal v4 scene-details payload whose delivered theme is light. */ +function mockSceneDetailsPayload() { + return { + schemaVersion: 4, + ui: { darkTheme: false }, + 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; it delivers the light-themed payload. */ +function mockMakeWasmView() { + return { + trameGetState: () => false, + clearObserversAndEventListeners: () => {}, + addServerUpdatedListener: () => () => {}, + addSetStateListener: () => () => {}, + trameTriggerAsync: async (name: string) => + name === 'get_visor_scene_details_json' ? mockSceneDetailsPayload() : undefined, + vtkScene: { resizeAsync: async () => undefined }, + }; +} + +/** The renderer members the build and the state seed reach. */ +function mockMakeRenderer() { + return { + domElement: document.createElement('div'), + addCameraSettledListener: () => () => {}, + addViewerClickedListener: () => () => {}, + resizeAsync: async () => undefined, + }; +} + +const globals = globalThis as unknown as { fetch: unknown }; +let originalFetch: unknown; + +beforeEach(() => { + (window as any).__visorArgs = { darkMode: true }; + // jsdom has no `fetch`; each theme stylesheet is served as a marker string. + originalFetch = globals.fetch; + globals.fetch = jest.fn(async (url: string) => ({ + text: async () => (url.endsWith('theme-dark.css') ? 'dark-sheet' : 'light-sheet'), + })); +}); + +afterEach(() => { + globals.fetch = originalFetch; + document.getElementById('__visorThemeStyle')?.remove(); + delete (window as any).__visorArgs; + delete (window as any).__visorState; +}); + +describe('App darkMode override', () => { + test('a darkMode prop applies its theme over the delivered darkTheme', async () => { + render(); + + await waitFor(() => + expect(document.getElementById('__visorThemeStyle')?.innerHTML).toBe('dark-sheet') + ); + }); +}); 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 new file mode 100644 index 00000000..33b814de --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import App from '../App'; + +/** + * A scene-details payload at the wrong schema version is shown to the user as + * a message in place of the scene, and no renderer is built for it. + * + * Expected values are hand-written literals. + */ + +const mockCreateAsync = jest.fn(); + +jest.mock('../wasm/RemoteVtkScene', () => ({ + __esModule: true, + default: { getInstanceAsync: async () => mockMakeWasmView() }, +})); + +jest.mock('../renderer/WasmRenderer', () => ({ + WasmRenderer: { createAsync: (...args: unknown[]) => mockCreateAsync(...args) }, +})); + +jest.mock('../components/UiScaffold.tsx', () => ({ + UiScaffold: () => null, +})); + +/** The client-mode scene connection; its scene details carry schema version 3. */ +function mockMakeWasmView() { + return { + trameGetState: () => false, + clearObserversAndEventListeners: () => {}, + addServerUpdatedListener: () => () => {}, + addSetStateListener: () => () => {}, + trameTriggerAsync: async (name: string) => + name === 'get_visor_scene_details_json' ? { schemaVersion: 3 } : undefined, + vtkScene: { resizeAsync: async () => undefined }, + }; +} + +afterEach(() => { + delete (window as any).__visorState; +}); + +describe('App schema mismatch', () => { + test('a version 3 payload shows the mismatch message and creates no renderer', async () => { + render(); + + const message = await screen.findByText( + 'Scene details schema version mismatch: payload is version 3, client expects version 4.' + ); + + expect(message).toBeTruthy(); + expect(mockCreateAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts b/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts new file mode 100644 index 00000000..e89599d7 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts @@ -0,0 +1,29 @@ +import { buildFrontendAsync } from '../buildFrontendAsync'; +import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; + +/** + * Scene details at the wrong schema version are rejected before any renderer + * is created for them. + * + * Expected values are hand-written literals. + */ + +describe('buildFrontendAsync', () => { + test('scene details at schema version 3 reject with SchemaVersionMismatchError before any renderer is created', async () => { + const createRenderer = jest.fn(); + const sender = jest.fn(async () => undefined); + + let thrown: unknown = null; + try { + await buildFrontendAsync('{"schemaVersion":3}', createRenderer, sender, undefined); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(SchemaVersionMismatchError); + const error = thrown as SchemaVersionMismatchError; + expect(error.payloadVersion).toBe(3); + expect(error.expectedVersion).toBe(4); + expect(createRenderer).not.toHaveBeenCalled(); + }); +}); From 279ad21d1197a6835ddd1eccf9e61f479306ab1b Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:25:09 +0000 Subject: [PATCH 4/4] chore: adding changelog file 184.added.md [dependabot-skip] --- doc/changelog.d/184.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/184.added.md diff --git a/doc/changelog.d/184.added.md b/doc/changelog.d/184.added.md new file mode 100644 index 00000000..af29791d --- /dev/null +++ b/doc/changelog.d/184.added.md @@ -0,0 +1 @@ +[Remote rendering 4.2a] Move CLIENT mode viewer wiring out of app