diff --git a/deps/cloudxr/webxr_client/AGENTS.md b/deps/cloudxr/webxr_client/AGENTS.md index 70817ddc83..ac2321a9cb 100644 --- a/deps/cloudxr/webxr_client/AGENTS.md +++ b/deps/cloudxr/webxr_client/AGENTS.md @@ -5,6 +5,10 @@ SPDX-License-Identifier: Apache-2.0 # WebXR client dependencies +Validate IWER/DevUI upgrades with stationary controller-gizmo taps and drags in +an active, visible XR session. Successful connection and loader tests do not +verify controller manipulation. + Keep `react` and `react-dom` on the same minor release supported by `@react-three/fiber`. Validate dependency-range changes with a clean npm install; do not bypass peer checks with `--force` or `--legacy-peer-deps`. diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts index 847d013c36..5540a02655 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts @@ -19,27 +19,25 @@ import type { IWERLoadResult } from './LoadIWER'; -type IWERTestWindow = Window & - typeof globalThis & { - IWER?: { - XRDevice: jest.Mock; - metaQuest3: object; - }; - IWER_DevUI?: { - DevUI: object; - }; - }; +jest.mock('iwer', () => ({ XRDevice: jest.fn(), metaQuest3: {} })); +jest.mock('@iwer/devui', () => ({ DevUI: jest.fn() })); describe('loadIWERIfNeeded', () => { - const testWindow = window as IWERTestWindow; let originalXRDescriptor: PropertyDescriptor | undefined; let loadIWERIfNeeded: () => Promise; + let XRDevice: jest.Mock; + let DevUI: jest.Mock; + let device: { installDevUI: jest.Mock; installRuntime: jest.Mock }; beforeEach(() => { originalXRDescriptor = Object.getOwnPropertyDescriptor(navigator, 'xr'); jest.resetModules(); // eslint-disable-next-line @typescript-eslint/no-require-imports ({ loadIWERIfNeeded } = require('./LoadIWER')); + ({ XRDevice } = jest.requireMock('iwer')); + ({ DevUI } = jest.requireMock('@iwer/devui')); + device = { installDevUI: jest.fn(), installRuntime: jest.fn() }; + XRDevice.mockReturnValue(device); }); afterEach(() => { @@ -48,99 +46,62 @@ describe('loadIWERIfNeeded', () => { } else { delete (navigator as Navigator & { xr?: XRSystem }).xr; } - delete testWindow.IWER; - delete testWindow.IWER_DevUI; + delete window.xrDevice; jest.restoreAllMocks(); }); - it('returns native immersive support without loading IWER', async () => { - const isSessionSupported = jest.fn().mockResolvedValue(true); + it('uses native immersive support without installing the emulator', async () => { Object.defineProperty(navigator, 'xr', { configurable: true, - value: { isSessionSupported }, + value: { isSessionSupported: jest.fn().mockResolvedValue(true) }, }); - const appendChild = jest.spyOn(document.head, 'appendChild'); - await expect(loadIWERIfNeeded()).resolves.toEqual({ supportsImmersive: true, iwerLoaded: false, }); - - expect(appendChild).not.toHaveBeenCalled(); + expect(XRDevice).not.toHaveBeenCalled(); }); - it('forces the fallback runtime over a native XRSystem without immersive support', async () => { + it('installs bundled DevUI over a native XRSystem without immersive support', async () => { const isSessionSupported = jest.fn().mockResolvedValue(false); Object.defineProperty(navigator, 'xr', { configurable: true, value: { isSessionSupported }, }); - - const device = { - installDevUI: jest.fn(), - installRuntime: jest.fn(), - }; - testWindow.IWER = { - XRDevice: jest.fn(() => device), - metaQuest3: {}, - }; - testWindow.IWER_DevUI = { DevUI: {} }; - - const appendChild = jest - .spyOn(document.head, 'appendChild') - .mockImplementation((node: T): T => { - if (node instanceof HTMLScriptElement) { - queueMicrotask(() => node.onload?.(new Event('load'))); - } - return node; - }); + const appendChild = jest.spyOn(document.head, 'appendChild'); await expect(loadIWERIfNeeded()).resolves.toEqual({ supportsImmersive: true, iwerLoaded: true, }); - expect(isSessionSupported).toHaveBeenNthCalledWith(1, 'immersive-vr'); expect(isSessionSupported).toHaveBeenNthCalledWith(2, 'immersive-ar'); + expect(device.installDevUI).toHaveBeenCalledWith(DevUI); expect(device.installRuntime).toHaveBeenCalledWith({ forceInstall: true }); - expect(appendChild).toHaveBeenCalledTimes(2); + expect(window.xrDevice).toBe(device); + expect(appendChild).not.toHaveBeenCalled(); }); - it('shares one IWER installation across concurrent callers', async () => { - let appendedScripts = 0; - let constructedDevices = 0; - let runtimeInstalls = 0; - - const device = { - installDevUI: jest.fn(), - installRuntime: jest.fn(() => { - runtimeInstalls++; - }), - }; - testWindow.IWER = { - XRDevice: jest.fn(() => { - constructedDevices++; - return device; - }), - metaQuest3: {}, - }; - testWindow.IWER_DevUI = { DevUI: {} }; - - jest.spyOn(document.head, 'appendChild').mockImplementation((node: T): T => { - appendedScripts++; - if (node instanceof HTMLScriptElement) { - queueMicrotask(() => node.onload?.(new Event('load'))); - } - return node; - }); - + it('shares one installation across concurrent callers', async () => { const [first, second] = await Promise.all([loadIWERIfNeeded(), loadIWERIfNeeded()]); - expect(first).toEqual({ supportsImmersive: true, iwerLoaded: true }); expect(second).toEqual(first); - expect(appendedScripts).toBe(2); - expect(constructedDevices).toBe(1); - expect(runtimeInstalls).toBe(1); + expect(XRDevice).toHaveBeenCalledTimes(1); + expect(device.installRuntime).toHaveBeenCalledTimes(1); + }); + + it('retries a failed runtime installation', async () => { + device.installRuntime.mockRejectedValueOnce(new Error('transient failure')); + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: false, + iwerLoaded: false, + }); + expect(window.xrDevice).toBeUndefined(); + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: true, + iwerLoaded: true, + }); + expect(device.installRuntime).toHaveBeenCalledTimes(2); }); }); diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index a3fd119618..d4f582c84e 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -23,9 +23,6 @@ declare global { } } -// Both versions are loaded from CDN; bump them together when upgrading IWER. -const IWER_version = '2.3.0'; -const IWER_DEVUI_version = '2.3.0'; let pendingIWERLoad: Promise | null = null; export interface IWERLoadResult { @@ -61,79 +58,26 @@ async function loadIWER(): Promise { if (!supportsImmersive) { console.info('Immersive mode not supported, loading IWER as fallback.'); - // Load IWER first - const script = document.createElement('script'); - script.src = `https://unpkg.com/iwer@${IWER_version}/build/iwer.min.js`; - script.async = true; - script.integrity = 'sha384-m8Xcl9WwdP6j/5Fv7MAs7IvvW3PmWCbNZlbF8IsJAnujyeMZi0/xgUE4mM7Tbm/j'; - script.crossOrigin = 'anonymous'; - - await new Promise(resolve => { - script.onload = async () => { - console.info('IWER loaded as fallback.'); - const IWERGlobal = (window as any).IWER || (globalThis as any).IWER; - if (!IWERGlobal) { - console.warn('IWER global not found after script load.'); - supportsImmersive = false; - resolve(); - return; - } - - // Load iwer-devui after IWER - const devUIScript = document.createElement('script'); - devUIScript.src = `https://unpkg.com/@iwer/devui@${IWER_DEVUI_version}/build/iwer-devui.min.js`; - devUIScript.async = true; - devUIScript.integrity = - 'sha384-TAu5gWlv92O6gkVclV8HYctTEswyA/0eoS6ULHjjLCShUVts4kDlUg6iLEKmL1r8'; - devUIScript.crossOrigin = 'anonymous'; - - await new Promise(devUIResolve => { - devUIScript.onload = () => { - console.info('IWER DevUI loaded.'); - devUIResolve(); - }; - devUIScript.onerror = error => { - console.warn('Failed to load IWER DevUI:', error); - devUIResolve(); - }; - document.head.appendChild(devUIScript); - }); - - try { - const device: XRDevice = new IWERGlobal.XRDevice(IWERGlobal.metaQuest3); - - const IWER_DevUI = (window as any).IWER_DevUI || (globalThis as any).IWER_DevUI; - if (IWER_DevUI?.DevUI) { - device.installDevUI(IWER_DevUI.DevUI); - console.info('IWER DevUI initialized with XR device.'); - } else { - console.warn('IWER DevUI not found after script load, continuing without DevUI.'); - } - - // This fallback runs only after the native XRSystem reports no immersive - // support. IWER 2.3 otherwise preserves that unusable navigator.xr. - await device.installRuntime({ forceInstall: true }); - window.xrDevice = device; - supportsImmersive = true; - iwerLoaded = true; - // The only log that's both unconditional (fires whether or not DevUI loaded, unlike - // "IWER DevUI initialized...") and guaranteed to follow installRuntime() actually - // succeeding (unlike "IWER DevUI initialized...", which logs before this await) - the - // one a caller (e.g. a test) should wait on to know navigator.xr is truly usable. - console.info('IWER runtime installed.'); - } catch (e) { - console.warn('IWER runtime install failed:', e); - supportsImmersive = false; - } - resolve(); - }; - script.onerror = () => { - console.warn('Failed to load IWER.'); - supportsImmersive = false; - resolve(); - }; - document.head.appendChild(script); - }); + try { + // Share the client's pointer-events module. A standalone DevUI bundle has + // its own pointer ID counter but shares the global capture map, so mouse + // captures can collide with XR controller pointers. + const [{ XRDevice, metaQuest3 }, { DevUI }] = await Promise.all([ + import(/* webpackMode: "lazy" */ 'iwer'), + import(/* webpackMode: "lazy" */ '@iwer/devui'), + ]); + const device = new XRDevice(metaQuest3); + device.installDevUI(DevUI); + + // The native XRSystem has already reported no immersive support. + await device.installRuntime({ forceInstall: true }); + window.xrDevice = device; + supportsImmersive = true; + iwerLoaded = true; + console.info('IWER runtime installed.'); + } catch (error) { + console.warn('IWER runtime install failed:', error); + } } return { supportsImmersive, iwerLoaded }; diff --git a/deps/cloudxr/webxr_client/package.json b/deps/cloudxr/webxr_client/package.json index fff83564ee..36503d553a 100644 --- a/deps/cloudxr/webxr_client/package.json +++ b/deps/cloudxr/webxr_client/package.json @@ -65,6 +65,7 @@ "uuid": "^14.0.0" }, "devDependencies": { + "@iwer/devui": "2.7.0", "@playwright/test": "^1.63.0", "@types/jest": "^30", "@types/node": "^22", @@ -83,7 +84,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "html-webpack-plugin": "^5.6.7", - "iwer": "^2.3.0", + "iwer": "^2.7.0", "jest": "^30", "jest-environment-jsdom": "^30.4.1", "prettier": "3.5.3", diff --git a/deps/cloudxr/webxr_client/webpack.chunkNames.js b/deps/cloudxr/webxr_client/webpack.chunkNames.js index 7511eed809..6cd0dd39ab 100644 --- a/deps/cloudxr/webxr_client/webpack.chunkNames.js +++ b/deps/cloudxr/webxr_client/webpack.chunkNames.js @@ -10,7 +10,8 @@ * * Routing (no per-package allowlists): * lazy ``import()`` under ``@pmndrs/xr/dist`` → ``bundle.emulator.js`` - * all other dynamic imports (UIKit → msdf, zustand, fonts, …) → eager → ``bundle.js`` + * LoadIWER's explicit lazy imports → ``bundle.emulator.js`` + * other dynamic imports (UIKit → msdf, zustand, fonts, …) → eager → ``bundle.js`` * * The MSDF web worker is inlined via ``asset/inline`` (no separate worker file). * OOB/--host-client sync downloads ``index.html``, ``bundle.js``, and diff --git a/deps/cloudxr/webxr_client/webpack.common.js b/deps/cloudxr/webxr_client/webpack.common.js index 5503d4d043..8ccdb064ee 100644 --- a/deps/cloudxr/webxr_client/webpack.common.js +++ b/deps/cloudxr/webxr_client/webpack.common.js @@ -82,6 +82,16 @@ module.exports = { module: { rules: [ ...eagerExceptEmulatorParserRules, + { + test: /[\\/]helpers[\\/]LoadIWER\.ts$/, + loader: 'ts-loader', + options: { + instance: 'iwer-loader', + transpileOnly: true, + // Preserve import() so the emulator stays in its lazy chunk. + compilerOptions: { module: 'ESNext' }, + }, + }, { test: /\.tsx?$/, use: { @@ -91,7 +101,7 @@ module.exports = { transpileOnly: true, }, }, - exclude: /node_modules/, + exclude: [/node_modules/, /[\\/]helpers[\\/]LoadIWER\.ts$/], }, { test: /\.css$/,