@displayxr/inline3d 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.5.1 — 2026-09-06
9
+
10
+ ### Fixed
11
+
12
+ - **Undock: `ended` now means the viewer exited.** The browser contract (browser-pvt#25) resolves
13
+ `layer.undock()` on a successful LAUNCH and reports the viewer's exit separately as the XRSession's
14
+ `undockend` event; 1.4.0/1.5.0 derived `ended` from the launch promise, so it resolved a frame after
15
+ the window opened. The helper now arms an `undockend` listener before launching and resolves
16
+ `ended` on it; the browser's DOMException names (NotAllowedError, NotSupportedError,
17
+ SecurityError, InvalidStateError, OperationError) map onto the four contract names. The
18
+ fallback path is unchanged (`detached === true`, `ended` immediate).
19
+ - README: vendoring note — `inline3d-mode-switch.js` is a static dependency of `inline3d.js`.
20
+
8
21
  ## 1.5.0 — 2026-09-06
9
22
 
10
23
  ### Added
@@ -135,9 +135,19 @@ function launchProtocol(url) {
135
135
 
136
136
  const UNDOCK_ERRORS = ['not-installed', 'src-not-allowed', 'no-activation', 'busy'];
137
137
 
138
+ // The browser refuses with DOMException names (patch 0130); each maps onto one contract name.
139
+ const DOM_ERROR_NAMES = {
140
+ NotAllowedError: 'no-activation', // no transient user activation
141
+ NotSupportedError: 'not-installed', // no registered viewer for this type
142
+ SecurityError: 'src-not-allowed', // src outside the allowlist
143
+ InvalidStateError: 'busy', // an undock is already live in this frame
144
+ OperationError: 'not-installed', // the viewer failed to launch
145
+ };
146
+
138
147
  /** Give a rejection one of the four contract names, keeping the browser's own where it has one. */
139
148
  function undockError(e, fallbackName, message) {
140
- const name = e && UNDOCK_ERRORS.includes(e.name) ? e.name : fallbackName;
149
+ const name =
150
+ e && UNDOCK_ERRORS.includes(e.name) ? e.name : (e && DOM_ERROR_NAMES[e.name]) || fallbackName;
141
151
  const err = new Error(message || (e && e.message) || `[inline3d] undock failed (${name}).`);
142
152
  err.name = name;
143
153
  if (e) err.cause = e;
@@ -166,8 +176,10 @@ let inFlight = false;
166
176
  * @param {number} [opts.margin] the page's fit margin, when it overrides the default.
167
177
  * @param {string} [opts.title]
168
178
  * @returns {Promise<{ended:Promise<void>, viewer:UndockType, detached?:boolean}>}
169
- * `ended` resolves when the viewer exits (fallback path: immediately, with
170
- * `detached === true` a protocol launch is fire-and-forget and the page never hears back).
179
+ * Resolves once the viewer has LAUNCHED (API path: `layer.undock()` resolved, i.e. the viewer
180
+ * process was spawned; it never waits for the viewer). `ended` resolves when the viewer exits -
181
+ * the API path hears that as the XRSession's `undockend` event; the fallback path never hears
182
+ * back, so there `ended` resolves immediately and `detached === true`.
171
183
  * Rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
172
184
  */
173
185
  export function undock(target, opts) {
@@ -184,7 +196,11 @@ export function undock(target, opts) {
184
196
  }
185
197
  if (inFlight) return Promise.reject(undockError(null, 'busy', '[inline3d] an undock is already in flight.'));
186
198
 
187
- const layer = layerResolver ? layerResolver(target) : null;
199
+ // The resolver hands back `{layer, session}` (the session carries the `undockend` event); a bare
200
+ // layer is accepted too, in which case the viewer's exit is simply not observable.
201
+ const found = layerResolver ? layerResolver(target) : null;
202
+ const layer = found && typeof found === 'object' && 'layer' in found ? found.layer : found;
203
+ const session = found && typeof found === 'object' && 'session' in found ? found.session : null;
188
204
  const viewer = opts.type;
189
205
 
190
206
  // ── fallback: the OS protocol ──────────────────────────────────────────────────────────
@@ -211,41 +227,53 @@ export function undock(target, opts) {
211
227
  if (opts.margin !== undefined) init.margin = opts.margin;
212
228
  if (opts.title) init.title = opts.title;
213
229
 
230
+ // THE BROWSER CONTRACT (browser-pvt#25 / patch 0130): `layer.undock(init)` RESOLVES ON A
231
+ // SUCCESSFUL LAUNCH - as soon as the viewer process is spawned - and never waits for it; every
232
+ // refusal is a prompt rejection (NotAllowedError / NotSupportedError / SecurityError /
233
+ // InvalidStateError / OperationError). The viewer's exit arrives separately, as the `undockend`
234
+ // event on the XRSession. One live undock per frame, so the NEXT `undockend` after a
235
+ // successful launch is this one's - no correlation id needed. The listener is armed BEFORE the
236
+ // launch so a viewer that exits immediately cannot slip between the two.
237
+ let endedResolve = null;
238
+ const ended = new Promise((resolve) => {
239
+ if (!session || typeof session.addEventListener !== 'function') {
240
+ // No session to listen on: the launch still works, the exit is simply not observable -
241
+ // so `ended` resolves at launch (as the fallback path does) rather than holding the
242
+ // one-live-undock guard for ever.
243
+ endedResolve = resolve;
244
+ resolve();
245
+ return;
246
+ }
247
+ const onEnd = () => {
248
+ session.removeEventListener('undockend', onEnd);
249
+ resolve();
250
+ };
251
+ session.addEventListener('undockend', onEnd);
252
+ endedResolve = () => {
253
+ session.removeEventListener('undockend', onEnd);
254
+ resolve();
255
+ };
256
+ });
257
+
214
258
  let call;
215
259
  try {
216
260
  call = Promise.resolve(layer.undock(init));
217
261
  } catch (e) {
218
262
  // A synchronous throw is the same failure as a rejection; one .catch() should cover both.
263
+ if (endedResolve) endedResolve();
219
264
  return Promise.reject(undockError(e, 'src-not-allowed'));
220
265
  }
221
266
  inFlight = true;
222
- const settled = call.finally(() => {
267
+ ended.then(() => {
223
268
  inFlight = false;
224
269
  });
225
-
226
- // TWO PROMISE SHAPES, ONE ANSWER. `undock()` either rejects PROMPTLY — every refusal
227
- // (not-installed / src-not-allowed / no-activation / busy) is decided before any window
228
- // exists — or it stays pending until the viewer exits. A short race tells them apart without
229
- // inventing an event: whatever has not rejected by then launched. The launch has already
230
- // happened synchronously above, so this wait costs the user nothing.
231
- const LAUNCH_MS = 150;
232
- const launchProbe = settled.then(
233
- () => 'launched',
270
+ return call.then(
271
+ () => ({ ended, viewer, detached: false }),
234
272
  (e) => {
273
+ // Refused before any window existed: nothing is in flight and nothing will end.
274
+ inFlight = false;
275
+ if (endedResolve) endedResolve();
235
276
  throw undockError(e, 'not-installed');
236
277
  }
237
278
  );
238
- // The probe LOSES the race whenever the viewer stays open, and a rejection arriving after that
239
- // would otherwise be an unhandled one. Marked handled here; the same rejection still reaches
240
- // the caller through `ended`, which is where a late failure belongs.
241
- launchProbe.catch(() => {});
242
- return Promise.race([
243
- launchProbe,
244
- new Promise((resolve) => window.setTimeout(() => resolve('launched'), LAUNCH_MS)),
245
- ]).then(() => ({
246
- // A rejection AFTER the launch window is the viewer failing later, and it belongs on `ended`.
247
- ended: settled.then(() => undefined),
248
- viewer,
249
- detached: false,
250
- }));
251
279
  }
package/js/inline3d.js CHANGED
@@ -59,13 +59,15 @@ setUndockLayerResolver((el) => {
59
59
  // The canvas itself, then a woven canvas INSIDE the element (a card wrapping its tile), then
60
60
  // the element sitting inside a window's own container (a button in the tile's box). Anything
61
61
  // further away is not this window's rect and takes the fallback.
62
- for (const win of m._windows.values()) if (win.canvas === el && win.layer) return win.layer;
62
+ // The session rides along: the viewer's exit is the XRSession's `undockend` event.
63
+ const hit = (win) => ({ layer: win.layer, session: m.session });
64
+ for (const win of m._windows.values()) if (win.canvas === el && win.layer) return hit(win);
63
65
  for (const win of m._windows.values()) {
64
- if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return win.layer;
66
+ if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return hit(win);
65
67
  }
66
68
  for (const win of m._windows.values()) {
67
69
  const box = win.canvas.parentElement;
68
- if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return win.layer;
70
+ if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return hit(win);
69
71
  }
70
72
  return null;
71
73
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",