@playcanvas/web-components 0.10.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcanvas/web-components",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "author": "PlayCanvas <support@playcanvas.com>",
5
5
  "homepage": "https://playcanvas.com",
6
6
  "description": "Web Components for the PlayCanvas Engine",
package/src/app.ts CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  import { AssetElement } from './asset';
70
70
  import { AsyncElement } from './async-element';
71
71
  import { EntityElement } from './entity';
72
+ import { LoadingBar } from './loading-bar';
72
73
  import { MaterialElement } from './material';
73
74
  import { ModuleElement } from './module';
74
75
  import { parseBool, parseEnum } from './parse';
@@ -78,6 +79,11 @@ import { parseBool, parseEnum } from './parse';
78
79
  * {@link https://developer.playcanvas.com/user-manual/web-components/tags/pc-app/ | `<pc-app>`} elements.
79
80
  * The AppElement interface also inherits the properties and methods of the
80
81
  * {@link HTMLElement} interface.
82
+ *
83
+ * @fires {ProgressEvent} progress - Fired while the application preloads its assets. `loaded` and
84
+ * `total` are asset counts, not bytes, and an asset that fails to load still counts as loaded.
85
+ * Fired at least once per boot, and the final event always has `loaded` equal to `total`. Does
86
+ * not bubble.
81
87
  */
82
88
  class AppElement extends AsyncElement {
83
89
  /**
@@ -97,6 +103,10 @@ class AppElement extends AsyncElement {
97
103
 
98
104
  private _highResolution = true;
99
105
 
106
+ private _loadingBar = true;
107
+
108
+ private _bar: LoadingBar | null = null;
109
+
100
110
  private _hierarchyReady = false;
101
111
 
102
112
  private _picker: Picker | null = null;
@@ -111,6 +121,9 @@ class AppElement extends AsyncElement {
111
121
 
112
122
  private _hoveredEntity: EntityElement | null = null;
113
123
 
124
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
125
+ private _pickToken = 0;
126
+
114
127
  private _pointerHandlers: { [key: string]: EventListener | null } = {
115
128
  pointermove: null,
116
129
  pointerdown: null,
@@ -119,13 +132,27 @@ class AppElement extends AsyncElement {
119
132
 
120
133
  private _app: AppBase | null = null;
121
134
 
135
+ private _loadProgress = 0;
136
+
122
137
  /**
123
- * The PlayCanvas application instance. Available once the element is ready await
124
- * {@link whenReady} or the element's `ready()` promise before accessing it.
125
- * @returns The application instance.
138
+ * The PlayCanvas application instance. `null` until the element is ready, and again once it
139
+ * has been removed from the document — await {@link whenReady} or the element's `ready()`
140
+ * promise before accessing it.
141
+ * @returns The application instance, or `null`.
126
142
  */
127
- get app(): AppBase {
128
- return this._app!;
143
+ get app(): AppBase | null {
144
+ return this._app;
145
+ }
146
+
147
+ /**
148
+ * The asset preload progress of the application, as a fraction from 0 to 1. It is 0 until
149
+ * preloading begins (and again once the element has been removed from the document), and 1
150
+ * once preloading has finished — including when there was nothing to preload. Read this to
151
+ * initialize a loading UI; subsequent updates arrive via the `progress` event.
152
+ * @returns The preload progress.
153
+ */
154
+ get loadProgress(): number {
155
+ return this._loadProgress;
129
156
  }
130
157
 
131
158
  /**
@@ -141,6 +168,12 @@ class AppElement extends AsyncElement {
141
168
  }
142
169
 
143
170
  async connectedCallback() {
171
+ // Created before the first await, so the bar is visible while modules and the graphics
172
+ // device are created, and exists before any disconnect could need to clean it up
173
+ if (this._loadingBar && !this._bar) {
174
+ this._bar = new LoadingBar(this);
175
+ }
176
+
144
177
  // Get all pc-module elements that are direct children of the pc-app element
145
178
  const moduleElements = this.querySelectorAll<ModuleElement>(':scope > pc-module');
146
179
 
@@ -234,11 +267,12 @@ class AppElement extends AsyncElement {
234
267
  createOptions.batchManager = BatchManager;
235
268
  createOptions.xr = XrManager;
236
269
 
237
- this._app = new AppBase(this._canvas);
238
- this.app.init(createOptions);
270
+ const app = new AppBase(this._canvas);
271
+ this._app = app;
272
+ app.init(createOptions);
239
273
 
240
- this.app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
241
- this.app.setCanvasResolution(RESOLUTION_AUTO);
274
+ app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
275
+ app.setCanvasResolution(RESOLUTION_AUTO);
242
276
 
243
277
  this._pickerCreate();
244
278
 
@@ -248,7 +282,7 @@ class AppElement extends AsyncElement {
248
282
  assetElement.createAsset();
249
283
  const asset = assetElement.asset;
250
284
  if (asset) {
251
- this.app!.assets.add(asset);
285
+ app.assets.add(asset);
252
286
  }
253
287
  });
254
288
 
@@ -261,20 +295,46 @@ class AppElement extends AsyncElement {
261
295
  // Create all entities
262
296
  const entityElements = this.querySelectorAll<EntityElement>('pc-entity');
263
297
  Array.from(entityElements).forEach((entityElement) => {
264
- entityElement.createEntity(this.app!);
298
+ entityElement.createEntity(app);
265
299
  });
266
300
 
267
301
  // Build hierarchy
268
302
  entityElements.forEach((entityElement) => {
269
- entityElement.buildHierarchy(this.app!);
303
+ entityElement.buildHierarchy(app);
270
304
  });
271
305
 
272
306
  this._hierarchyReady = true;
273
307
 
308
+ // Forward the engine's preload lifecycle as DOM ProgressEvents on this element. The
309
+ // listener must be attached before preload() is called: an asset that is already loaded
310
+ // ticks synchronously inside it.
311
+ const total = app.assets.list({ preload: true }).length;
312
+ let loaded = 0;
313
+ const onPreloadProgress = () => {
314
+ loaded += 1;
315
+ this._loadProgress = loaded / total;
316
+ this._bar?.progress(loaded, total);
317
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded, total }));
318
+ };
319
+ app.on('preload:progress', onPreloadProgress);
320
+
321
+ this._loadProgress = total === 0 ? 1 : 0;
322
+ this._bar?.progress(0, total);
323
+ this.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: 0, total }));
324
+
274
325
  // Load assets before starting the application
275
- this.app.preload(() => {
326
+ app.preload(() => {
327
+ // Scope the counter to this preload pass, so a later app.preload() call by user code
328
+ // cannot push `loaded` past `total`
329
+ app.off('preload:progress', onPreloadProgress);
330
+ this._loadProgress = 1;
331
+
276
332
  // Start the application
277
- this.app!.start();
333
+ app.start();
334
+
335
+ // Dismiss the bar only once a frame has actually rendered; ready fires before the
336
+ // first rAF tick
337
+ app.once('frameend', () => this._bar?.complete());
278
338
 
279
339
  // Handle window resize to keep the canvas responsive
280
340
  window.addEventListener('resize', this._onWindowResize);
@@ -287,10 +347,13 @@ class AppElement extends AsyncElement {
287
347
  this._pickerDestroy();
288
348
 
289
349
  // Clean up the application
290
- if (this.app) {
291
- this.app.destroy();
350
+ if (this._app) {
351
+ this._app.destroy();
292
352
  this._app = null;
293
353
  }
354
+ this._loadProgress = 0;
355
+ this._bar?.destroy();
356
+ this._bar = null;
294
357
 
295
358
  // Remove event listeners
296
359
  window.removeEventListener('resize', this._onWindowResize);
@@ -312,10 +375,18 @@ class AppElement extends AsyncElement {
312
375
  const { width, height } = this.app!.graphicsDevice;
313
376
  this._picker = new Picker(this.app!, width, height);
314
377
 
315
- // Create bound handlers but don't attach them yet
316
- this._pointerHandlers.pointermove = this._onPointerMove.bind(this) as EventListener;
317
- this._pointerHandlers.pointerdown = this._onPointerDown.bind(this) as EventListener;
318
- this._pointerHandlers.pointerup = this._onPointerUp.bind(this) as EventListener;
378
+ // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
379
+ // each is wrapped to discard the promise - a listener must not return one, and nothing
380
+ // awaits the result.
381
+ const listener = (handler: (event: PointerEvent) => Promise<void>): EventListener => {
382
+ return (event: Event) => {
383
+ handler.call(this, event as PointerEvent);
384
+ };
385
+ };
386
+
387
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
388
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
389
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
319
390
 
320
391
  // Listen for pointer listeners being added/removed
321
392
  ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
@@ -369,31 +440,51 @@ class AppElement extends AsyncElement {
369
440
  return { x, y };
370
441
  }
371
442
 
372
- _onPointerMove(event: PointerEvent) {
373
- if (!this._picker || !this.app) return;
374
-
443
+ /**
444
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
445
+ *
446
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
447
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
448
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
449
+ * async variant works on both backends and does not block the main thread on a GPU read.
450
+ *
451
+ * @param event - The pointer event to pick under.
452
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
453
+ */
454
+ private async _pickNode(event: PointerEvent): Promise<GraphNode | null> {
375
455
  const camera = this.app!.root.findComponent('camera') as CameraComponent;
376
- if (!camera) return;
456
+ if (!camera) return null;
377
457
 
378
- // Use the helper to convert event coordinates into canvas/picker coordinates.
379
458
  const { x, y } = this._getPickerCoordinates(event);
380
459
 
381
- this._picker.prepare(camera, this.app!.scene);
382
- const selection = this._picker.getSelection(x, y);
460
+ this._picker!.prepare(camera, this.app!.scene);
461
+ const selection = await this._picker!.getSelectionAsync(x, y);
462
+ if (selection.length === 0) return null;
463
+
464
+ const item = selection[0];
465
+ return item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
466
+ }
467
+
468
+ async _onPointerMove(event: PointerEvent) {
469
+ if (!this._picker || !this.app) return;
470
+
471
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
472
+ // newest pick may update the hover state - an older one describes a pointer position the
473
+ // user has already left.
474
+ const token = ++this._pickToken;
475
+ const node = await this._pickNode(event);
476
+ if (token !== this._pickToken || !this._picker) return;
383
477
 
384
478
  // Get the currently hovered entity by walking up the hierarchy
385
479
  let newHoverEntity: EntityElement | null = null;
386
- if (selection.length > 0) {
387
- const item = selection[0];
388
- let currentNode: GraphNode | null = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
389
- while (currentNode !== null) {
390
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
391
- if (entityElement) {
392
- newHoverEntity = entityElement;
393
- break;
394
- }
395
- currentNode = currentNode.parent;
480
+ let currentNode = node;
481
+ while (currentNode !== null) {
482
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
483
+ if (entityElement) {
484
+ newHoverEntity = entityElement;
485
+ break;
396
486
  }
487
+ currentNode = currentNode.parent;
397
488
  }
398
489
 
399
490
  // Handle enter/leave events
@@ -415,51 +506,31 @@ class AppElement extends AsyncElement {
415
506
  }
416
507
  }
417
508
 
418
- _onPointerDown(event: PointerEvent) {
509
+ async _onPointerDown(event: PointerEvent) {
419
510
  if (!this._picker || !this.app) return;
420
511
 
421
- const camera = this.app!.root.findComponent('camera') as CameraComponent;
422
- if (!camera) return;
512
+ let currentNode = await this._pickNode(event);
513
+ if (!this._picker) return; // the element disconnected while the pick was in flight
423
514
 
424
- // Convert the event's pointer coordinates
425
- const { x, y } = this._getPickerCoordinates(event);
426
-
427
- this._picker.prepare(camera, this.app!.scene);
428
- const selection = this._picker.getSelection(x, y);
429
-
430
- if (selection.length > 0) {
431
- const item = selection[0];
432
- let currentNode: GraphNode | null = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
433
- while (currentNode !== null) {
434
- const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
435
- if (entityElement && entityElement.hasListeners('pointerdown')) {
436
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
437
- break;
438
- }
439
- currentNode = currentNode.parent;
515
+ while (currentNode !== null) {
516
+ const entityElement = this.querySelector(`pc-entity[name="${currentNode.name}"]`) as EntityElement;
517
+ if (entityElement && entityElement.hasListeners('pointerdown')) {
518
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
519
+ break;
440
520
  }
521
+ currentNode = currentNode.parent;
441
522
  }
442
523
  }
443
524
 
444
- _onPointerUp(event: PointerEvent) {
525
+ async _onPointerUp(event: PointerEvent) {
445
526
  if (!this._picker || !this.app) return;
446
527
 
447
- const camera = this.app!.root.findComponent('camera') as CameraComponent;
448
- if (!camera) return;
449
-
450
- // Convert CSS coordinates to picker coordinates
451
- const { x, y } = this._getPickerCoordinates(event);
528
+ const node = await this._pickNode(event);
529
+ if (!node || !this._picker) return;
452
530
 
453
- this._picker.prepare(camera, this.app!.scene);
454
- const selection = this._picker.getSelection(x, y);
455
-
456
- if (selection.length > 0) {
457
- const item = selection[0];
458
- const node = item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
459
- const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
460
- if (entityElement && entityElement.hasListeners('pointerup')) {
461
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
462
- }
531
+ const entityElement = this.querySelector(`pc-entity[name="${node.name}"]`) as EntityElement;
532
+ if (entityElement && entityElement.hasListeners('pointerup')) {
533
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
463
534
  }
464
535
  }
465
536
 
@@ -589,6 +660,31 @@ class AppElement extends AsyncElement {
589
660
  return this._highResolution;
590
661
  }
591
662
 
663
+ /**
664
+ * Sets whether the application shows its built-in loading bar while it boots and preloads its
665
+ * assets. Enabled by default; setting `false` removes the bar immediately, while setting
666
+ * `true` has no effect until the element is next connected. The bar can be themed with the
667
+ * CSS custom properties `--pc-loading-bar-color`, `--pc-loading-bar-background` and
668
+ * `--pc-loading-bar-height`.
669
+ * @param value - The loading bar flag.
670
+ */
671
+ set loadingBar(value: boolean) {
672
+ this._loadingBar = value;
673
+ if (!value && this._bar) {
674
+ this._bar.destroy();
675
+ this._bar = null;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Gets whether the application shows its built-in loading bar while it boots and preloads
681
+ * its assets.
682
+ * @returns The loading bar flag.
683
+ */
684
+ get loadingBar() {
685
+ return this._loadingBar;
686
+ }
687
+
592
688
  /**
593
689
  * Sets the stencil flag.
594
690
  * @param value - The stencil flag.
@@ -606,7 +702,7 @@ class AppElement extends AsyncElement {
606
702
  }
607
703
 
608
704
  static get observedAttributes() {
609
- return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution'];
705
+ return ['alpha', 'antialias', 'backend', 'depth', 'stencil', 'high-resolution', 'loading-bar'];
610
706
  }
611
707
 
612
708
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -626,6 +722,9 @@ class AppElement extends AsyncElement {
626
722
  case 'high-resolution':
627
723
  this.highResolution = parseBool(newValue, true);
628
724
  break;
725
+ case 'loading-bar':
726
+ this.loadingBar = parseBool(newValue, true);
727
+ break;
629
728
  case 'stencil':
630
729
  this.stencil = parseBool(newValue, true);
631
730
  break;
package/src/asset.ts CHANGED
@@ -99,6 +99,13 @@ const processBufferView = (
99
99
  * @attribute {number} pixels-per-unit - For a `sprite` asset, the number of pixels per world unit.
100
100
  * @attribute {'simple' | 'sliced' | 'tiled'} render-mode - For a `sprite` asset, how the sprite is
101
101
  * rendered when resized.
102
+ *
103
+ * @fires {Event} load - Fired each time the asset finishes loading, including a `lazy` asset
104
+ * loaded later and any subsequent reloads. Does not bubble — listen on this element, or use a
105
+ * capture-phase listener on an ancestor to observe every asset.
106
+ * @fires {ErrorEvent} error - Fired when the asset fails to load, with the engine's error in
107
+ * `message`. Does not bubble. The element still becomes ready — readiness means the load settled,
108
+ * not that it succeeded.
102
109
  */
103
110
  class AssetElement extends AsyncElement {
104
111
  private _lazy: boolean = false;
@@ -149,6 +156,16 @@ class AssetElement extends AsyncElement {
149
156
  this.destroyAsset();
150
157
  }
151
158
 
159
+ private _onAssetLoad() {
160
+ this.dispatchEvent(new Event('load'));
161
+ }
162
+
163
+ private _onAssetError(err: string | Error) {
164
+ this.dispatchEvent(new ErrorEvent('error', {
165
+ message: err instanceof Error ? err.message : String(err)
166
+ }));
167
+ }
168
+
152
169
  createAsset() {
153
170
  const id = this.getAttribute('id') || '';
154
171
  const src = this.getAttribute('src') || '';
@@ -186,6 +203,11 @@ class AssetElement extends AsyncElement {
186
203
  }
187
204
 
188
205
  this.asset.preload = !this._lazy;
206
+
207
+ // Forward the engine asset's load outcome as DOM events on this element, like <img>.
208
+ // Attached before the asset joins the registry, which is what starts a preloaded load.
209
+ this.asset.on('load', this._onAssetLoad, this);
210
+ this.asset.on('error', this._onAssetError, this);
189
211
  }
190
212
 
191
213
  /**
@@ -249,6 +271,9 @@ class AssetElement extends AsyncElement {
249
271
 
250
272
  destroyAsset() {
251
273
  if (this.asset) {
274
+ // A caller that keeps the Asset alive must not dispatch on a removed element
275
+ this.asset.off('load', this._onAssetLoad, this);
276
+ this.asset.off('error', this._onAssetError, this);
252
277
  // Deregister first so unload() can still notify the registry
253
278
  this.asset.registry?.remove(this.asset);
254
279
  this.asset.unload();
@@ -20,12 +20,22 @@ class AsyncElement extends HTMLElement {
20
20
  });
21
21
  }
22
22
 
23
- get closestApp(): AppElement {
24
- return this.parentElement?.closest('pc-app') as AppElement;
23
+ /**
24
+ * The nearest ancestor `<pc-app>` element, or `null` if this element has no `<pc-app>`
25
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
26
+ * @returns The closest app element, or `null`.
27
+ */
28
+ get closestApp(): AppElement | null {
29
+ return this.parentElement?.closest('pc-app') as AppElement | null ?? null;
25
30
  }
26
31
 
27
- get closestEntity(): EntityElement {
28
- return this.parentElement?.closest('pc-entity') as EntityElement;
32
+ /**
33
+ * The nearest ancestor `<pc-entity>` element, or `null` if this element has no `<pc-entity>`
34
+ * ancestor. The search starts at the parent, so an element never resolves to itself.
35
+ * @returns The closest entity element, or `null`.
36
+ */
37
+ get closestEntity(): EntityElement | null {
38
+ return this.parentElement?.closest('pc-entity') as EntityElement | null ?? null;
29
39
  }
30
40
 
31
41
  /**
@@ -73,12 +73,13 @@ class ComponentElement extends AsyncElement {
73
73
  }
74
74
 
75
75
  /**
76
- * The PlayCanvas component instance. Available once the element is ready await
77
- * {@link whenReady} or the element's `ready()` promise before accessing it.
78
- * @returns The component instance.
76
+ * The PlayCanvas component instance. `null` until the element is ready, and also for an
77
+ * element that is not a descendant of a `<pc-entity>` — await {@link whenReady} or the
78
+ * element's `ready()` promise before accessing it.
79
+ * @returns The component instance, or `null`.
79
80
  */
80
- get component(): Component {
81
- return this._component!;
81
+ get component(): Component | null {
82
+ return this._component;
82
83
  }
83
84
 
84
85
  /**
package/src/entity.ts CHANGED
@@ -76,12 +76,13 @@ class EntityElement extends AsyncElement {
76
76
  private _entity: Entity | null = null;
77
77
 
78
78
  /**
79
- * The PlayCanvas entity instance. Available once the element is ready await
80
- * {@link whenReady} or the element's `ready()` promise before accessing it.
81
- * @returns The entity instance.
79
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once it has
80
+ * been removed from the document — await {@link whenReady} or the element's `ready()`
81
+ * promise before accessing it.
82
+ * @returns The entity instance, or `null`.
82
83
  */
83
- get entity(): Entity {
84
- return this._entity!;
84
+ get entity(): Entity | null {
85
+ return this._entity;
85
86
  }
86
87
 
87
88
  createEntity(app: AppBase) {
@@ -92,18 +93,20 @@ class EntityElement extends AsyncElement {
92
93
  return;
93
94
  }
94
95
 
95
- // Create a new entity
96
- const entity = new Entity(this.getAttribute('name') || this._name, app);
96
+ // Seed from the cached fields rather than re-reading the attributes. Every observed
97
+ // attribute is routed through its property setter by attributeChangedCallback, so the field
98
+ // already holds the parsed attribute value - and it also holds anything assigned through the
99
+ // property API before the app booted, which reading the attribute back would discard.
100
+ const entity = new Entity(this._name, app);
97
101
  this._entity = entity;
98
102
 
99
- entity.enabled = parseBool(this.getAttribute('enabled'), true);
100
- entity.setLocalPosition(parseVec3(this.getAttribute('position'), Vec3.ZERO, 'position'));
101
- entity.setLocalEulerAngles(parseVec3(this.getAttribute('rotation'), Vec3.ZERO, 'rotation'));
102
- entity.setLocalScale(parseVec3(this.getAttribute('scale'), Vec3.ONE, 'scale'));
103
+ entity.enabled = this._enabled;
104
+ entity.setLocalPosition(this._position);
105
+ entity.setLocalEulerAngles(this._rotation);
106
+ entity.setLocalScale(this._scale);
103
107
 
104
- const tags = parseTags(this.getAttribute('tags'));
105
- if (tags.length > 0) {
106
- entity.tags.add(tags);
108
+ if (this._tags.length > 0) {
109
+ entity.tags.add(this._tags);
107
110
  }
108
111
  }
109
112
 
@@ -124,7 +127,15 @@ class EntityElement extends AsyncElement {
124
127
  connectedCallback() {
125
128
  // Wait for app to be ready
126
129
  const closestApp = this.closestApp;
127
- if (!closestApp) return;
130
+ if (!closestApp) {
131
+ // An entity outside an application is inert and never becomes ready, so awaiting it
132
+ // hangs. Warn rather than fail silently, naming the parent it requires, as every other
133
+ // misplaced element does.
134
+ const name = this.getAttribute('name');
135
+ const label = name ? ` '${name}'` : '';
136
+ console.warn(`pc-entity${label} must be a descendant of pc-app - entity not created`);
137
+ return;
138
+ }
128
139
 
129
140
  // If app is already running, create entity immediately
130
141
  if (closestApp.hierarchyReady) {
@@ -146,10 +157,15 @@ class EntityElement extends AsyncElement {
146
157
 
147
158
  disconnectedCallback() {
148
159
  if (this.entity) {
149
- // Notify all children that their entities are about to become invalid
150
- const children = this.querySelectorAll('pc-entity');
160
+ // Notify all children that their entities are about to become invalid. Both fields have
161
+ // to be reset here, not just _entity: a descendant's own disconnectedCallback runs after
162
+ // this one and skips its reset behind the `if (this.entity)` guard, because we have
163
+ // already nulled the entity it tests. Leaving _built set would make buildHierarchy bail
164
+ // on re-insertion, so the descendant would get a fresh entity that is never parented.
165
+ const children = this.querySelectorAll<EntityElement>('pc-entity');
151
166
  children.forEach((child) => {
152
- (child as EntityElement)._entity = null;
167
+ child._entity = null;
168
+ child._built = false;
153
169
  });
154
170
 
155
171
  // Destroy the entity