Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deps/cloudxr/webxr_client/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
109 changes: 35 additions & 74 deletions deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IWERLoadResult>;
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(() => {
Expand All @@ -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(<T extends Node>(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(<T extends Node>(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);
});
});
96 changes: 20 additions & 76 deletions deps/cloudxr/webxr_client/helpers/LoadIWER.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IWERLoadResult> | null = null;

export interface IWERLoadResult {
Expand Down Expand Up @@ -61,79 +58,26 @@ async function loadIWER(): Promise<IWERLoadResult> {
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<void>(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<void>(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 };
Expand Down
3 changes: 2 additions & 1 deletion deps/cloudxr/webxr_client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion deps/cloudxr/webxr_client/webpack.chunkNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion deps/cloudxr/webxr_client/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -91,7 +101,7 @@ module.exports = {
transpileOnly: true,
},
},
exclude: /node_modules/,
exclude: [/node_modules/, /[\\/]helpers[\\/]LoadIWER\.ts$/],
},
{
test: /\.css$/,
Expand Down
Loading