Scope entity references to the enclosing entity hierarchy - #430
Merged
Conversation
An exact entity-name match now resolves lexically through the entity hierarchy first - the closest entity-fronting ancestor's inclusive subtree, then each outer one, then the containing pc-app - before the existing document-wide selector/id/name lookup runs unchanged as the fallback. A <template> prefab with one root pc-entity can therefore wire its joints and script attributes by bare name and stay self-contained when cloned: every clone's references bind within itself, where the document-wide lookup bound them all to the first instance. The scoped phase queries only pc-entity[name=...], so selectors and ids keep their document-wide meaning and a non-entity element can never divert it. resolveEntity now takes the resolving element instead of a tag string - it scopes the lookup and names the message - and resolves once instead of twice on the failure path. All warning messages are byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The six capsule segments and five 6dof flex joints move from ~90 lines of addComponent calls in wiener-storm.mjs into a <template> in the example page, wired by bare entity names: names resolve against the nearest enclosing entity first, so every clone's joints bind that clone's own segments - one template, many wieners. The single root pc-entity is what keeps each clone's names self-contained. The script now clones the template per throw, scales the clone's positions and capsules to its random size before it upgrades, appends it under the owning application's pc-scene (entities parent to the application root, keeping the spawn pose world space), and launches once the clone's components report ready. The throw is guarded: a pending set tracks clones until the launch commits to the live list, readiness is raced against a destroy promise so a clone removed mid-flight cannot strand the await, and teardown removes pending and live clones alike. What has no declarative surface stays in script: the solver tuning, the stop-ERP stiffening, the bone riding and the launch solve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #429
Entity references resolved document-wide, so a
<template>whose content cross-references its own entities could not be cloned more than once: every clone's references bound to the first instance's entities — silently, because the lookup succeeds, on the wrong element. The unresolved-reference warnings from #425/#426 cannot see this failure class at all.The contract
An exact scoped entity-name match wins first; otherwise the existing document resolver interprets the reference as selector, id, or name — unchanged.
When an element resolves a reference, an exact
pc-entityname match is looked up lexically through the entity hierarchy first: the closest entity-fronting ancestor's inclusive subtree, then each outer entity-fronting ancestor, then the containing<pc-app>. Only then does the existing document-wide selector/id/name triple run, verbatim, as the fallback (and it is the entire behavior when no resolving element is supplied).Deliberate consequences:
pc-entity[name=...], so a non-entity element can never block or divert it, and selectors/ids keep theirdocument.querySelector/getElementByIdmeaning in practice (an entity whose exact name is the ref text scopes, by design — the docs say so rather than claiming selectors "never" scope).pc-entity— the<template>boundary disappears after cloning, so a multi-root clone's names would resolve through the shared parent into an earlier clone. Documented on the elements that resolve references.idshadowing an entity name, and a name likeHeadlosing to<head>matched as a type selector.No timing changes: a cloned subtree's entities are all created synchronously during
appendChild(tree-order upgrade), while component elements defer behindready(), so every entity in a clone exists before any reference resolves — regardless of order inside the template.What changed
findEntityElement/getEntitygain an optionalfrom: Element;resolveEntityrequires it, derives the message tag from it (byte-identical at all 16 call sites), and now looks up once instead of twice on the failure path. All warning strings are unchanged.resolveEntitycall sites (pc-joint,pc-button,pc-scrollbar,pc-scroll-view) passthis; the scriptConversiontype carries the declaring element soentity:references scope the same way.pc-nodealready scopes its name search to the enclosing subtree in the entity graph — this extends the same semantics to DOM references.Behavior changes
Only bare-name references with a resolving element can resolve differently, and only when the name is ambiguous today:
id, or an HTML tag name as type selector) resolves the in-scope entity name first.pc-entitybacking no entity yet wins over a farther live one, reported with the existing timing cause rather than silently skipped (pinned by a test, so a future "helpful" fallback cannot land silently).Unique references — the overwhelming case, including all shipped examples — resolve identically.
The example (second commit)
The AR Wiener Storm chain — six capsule segments and five 6dof flex joints, previously ~90 lines of
addComponentcalls — becomes a<template>wired by bare names, cloned per throw. The script scales each clone to its random size before it upgrades, appends it under the owning app's<pc-scene>(entities parent to the application root, keeping the spawn world-space), and launches once the clone's components report ready. The spawn is guarded: clones stay in a pending set until the launch commits, readiness is raced against a destroy promise (a removed element'sready()never settles), and teardown removes pending and live clones alike.Verified live in the browser (
?simmode): four concurrent clones, every joint bound within its own clone (none cross-bound), all constraints created, adjacent-segment gaps matching authored spacing × each clone's scale, hits registering, and retirement keeping DOM elements, engine entities and the live list in lockstep.Test plan
get-entity.test.ts: nine new scoped-resolution cases — nearest-scope-first, inclusive scope root, the<pc-app>ring (distinguished from the document by an entity-less decoy ahead of the app), in-scope name over document id, selector/id forms staying document-wide from inside a scope, the no-skip rule with its verbatim warning, document fallback (connected and disconnected callers), an inexpressible-in-CSS reference surviving the scoped phase, and an escaped name resolving through it.template-clone.test.ts: the headline case — a prefab cloned twice, each clone's joint bound to its own entities and explicitly not the first clone's (fails against the old resolver, verified).pc-jointandpc-scripteach gain a nearest-enclosing-match case (init + setter paths;entity:via the attributes JSON).🤖 Generated with Claude Code