Skip to content

fix(core): each mounted sub-composition shows its own variable values - #5158

Closed
miguel-heygen wants to merge 22 commits into
mainfrom
fix/variable-bindings-per-instance
Closed

miguel-heygen wants to merge 22 commits into
mainfrom
fix/variable-bindings-per-instance

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Repeated sub-composition mounts now bind text, image sources, CSS values, and variable-driven color grading to each instance's values. An empty nested instance falls back to the top-level defaults instead of inheriting its outer instance's values.

Ownership is assigned internally by compilation and runtime mounting, independently of whether an instance has a variable-table entry. Live clipboard serialization strips that served metadata. Authored HTML is unchanged; the only public addition is the runtime/variable-scope subpath below.

The runtime also loads external children discovered inside fetched or template-mounted parents during the same load call. It resolves child composition URLs against the project root at every nesting level, attempts each host once, preserves mounted parent identities, shares the compiler's cycle/depth checks (both compare the same key: the path resolved from the project root, without query or hash, so ./compositions/a.html and compositions/a.html are one file), and keeps at most four loads in flight: a pool, so one slow composition does not hold back the ones queued behind it. Stylesheets from mounted compositions are inserted in host document order (a nested composition's right after its parent's, before the parent's later siblings) regardless of which fetch finishes first, and preview and render now emit sub-composition links and styles in the same order instead of breadth-first, each composition's links right before its own styles. So a page-wide rule (a same-named @keyframes, a font face, a stylesheet link) resolves the same way on every path. One position function in core decides that order for the compiler and the runtime. Neighbouring head styles still merge into one sheet, but a merge no longer crosses a link, so a stylesheet link keeps its place in the cascade. Page-level <template> hosts follow the same order: preview sorts their styles with the rest, and render, which mounts those hosts at runtime, tags each compiled link and style with its host's runtime id (data-hf-head-host) so the runtime places a template's styles among them by page order. The runtime builds the position index once per mount pass, not per inserted node. Scripts keep their existing inlining order; a stylesheet two compositions both link sits once, in the first one's slot. An external host now always loads its file: a same-named <template id="X-template"> in the page no longer replaces it, which matches preview and render (they always inlined the file and its declared variables). Studio strips the variable-host mark through the constant core stamps it with, exported as @hyperframes/core/runtime/variable-scope.

Preview, render and runtime mounting now share one owner for runtime composition ids (assignCompositionHostIds in runtime/compositionHostIds.ts). A host keeps its authored id unless another element in the document already uses it, or another host mounts the same composition; in either case it takes the lowest free <id>__hfN. All three paths assign one discovery level at a time: the top-level hosts first, then the hosts that their content reveals, and so on. So a card reused at two depths keeps card at the shallower level, and the deeper copy becomes card__hf1. Before this, the compiler numbered both copies and the runtime did not. Each host is assigned once per load. assignBundledRuntimeCompositionIds now takes the document instead of a host list, so no caller can seed a different set of hosts.

Known unsupported shape (unchanged from main): a page-level <template id="X-template"> used by an empty host that sits inside an external composition. Preview expands template hosts only at the top level, so that nested host stays empty in preview, and render and mount give it different ids. It needs nested template expansion in the bundler and the producer, which is a separate change.

Validation on Linux:

  • Bindings: 19 tests; loader: 75 tests; bundler: 154 tests; clipboard serialization: 1 test. Each changed test file passed three consecutive runs.
  • Existing file-inlining tests, core typechecks, runtime and Studio builds, and repository lint passed. Fallow exited successfully with minor duplication findings.
  • Removing compiler/runtime ownership stamps, restoring the old resolver, removing clipboard cleanup, and removing the load concurrency bound each failed their witnesses. Restoring the batch-of-four loop makes the queued-load witness time out. Both nested-child discovery tests fail on main.
  • The same nested-directory fixture agrees across the runtime loader and bundler. Its loader witness fails with parent-relative resolution. Headless Chrome also verifies both outputs without missing requests or page errors.
  • Chrome at 1 second verifies separate file/template instance text and CSS values, plus top-level fallback for the empty nested instance, without page errors.
  • After merging main (macOS): producer parity and runtime-browser tests 70/70, producer compiler tests 140/140.
  • Id parity (macOS): the three-path parity table now covers a card reused at two depths, an anonymous host, an inline non-host element using the id, a root template host, and two copies inside one scene, each followed by a late card. Preview, render and mount must match exactly. The four new shapes fail on the previous head. Disabling "suffix every copy when repeated" fails the two-copies row, and assigning hosts one at a time instead of per level fails the two-depths row. Core runtime and compiler 2477/2477; producer parity and runtime browser 74/74, three runs; producer compiler 140/140.
  • Shared stylesheet, self-include and same-named template (Linux): a loader test runs both fetch orders and expects one shared link in the first host's slot; skipping the duplicate without moving it fails the order where the first host finishes last. A parity row has a scene include itself as ./compositions/scene.html; with the old raw-string cycle check, preview and render inline a second copy and the row fails. A parity row has a late card whose id also names a page template; the old template-first loader mounts the template and fails it, and a loader test pins that the file is fetched. Core runtime and compiler 2478/2478, producer parity and runtime browser 76/76, three runs each; producer compiler 140/140; studio-server preview 114/114.
  • Nested style order (Linux): a parity test nests a card inside one scene beside a second scene, each defining @keyframes pop; preview, render and mount must all list them s1, card, s2. On the previous head preview and render gave s1, s2, card and the test fails. Core runtime and compiler 2505/2505, producer parity, scriptOrder and runtime browser 84/84, producer compiler 140/140, three runs each.
  • Head order for links: parity rows for nested links and for flat compositions that each have a link and a style. Preview, render and mount must give the same link and style sequence; on the previous head preview and render listed every link before any style, and both rows fail. A third row puts a page template host before an external host; render is read after its runtime mounted the template. On the previous head it fails. A loader test with 10 hosts (30 head inserts) counts full-document index builds; inserting once per node built 30, now it is under 10. Core runtime and compiler 2520/2520, producer parity, scriptOrder and runtime browser 87/87, producer compiler 140/140, three runs each.

Before

Same author-only fixture on main. Repeated instances resolve through authored root identities.

Before

After

File instances show File A and File B; template instances show Template A and Template B; the empty nested instance shows Top.

After

data-var-text, data-var-src and color grading resolved per-instance values
through the nearest data-composition-id. In a compiled render that is the
sub-composition's own root, which keeps its authored id, while the values
are keyed by the host's id, so every instance fell back to the defaults.
An authored root directly inside a host now defers to the host.
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 118 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

A composition root with no per-instance values of its own now defers to its
direct parent, the host, instead of recognising hosts by their source
attribute. This also covers inline-template hosts and keeps a nested host's
own values, and is tested for text and src bindings.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 7, 2026 06:48

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-instance variable scoping looks right to me. variableScopeOf skips past the authored root to the nearest mounted host, the compiler, bundler and runtime all mark that host, and color grading and bindings share the one helper, so they can't drift. The nested-host and empty-instance tests cover the cases I'd worry about. The three changed test files pass locally at this head (244/244).

Blocker: nested data-composition-src is resolved against the parent file, but the rest of the system resolves it against the project root.

compositionLoader.ts:738 does new URL(src, ancestry.at(-1) ?? document.baseURI), so a child path is read relative to the file that contains it. Everywhere else that path is root-relative at every nesting level:

  • The compiler resolves nested hosts with the same resolveHtml(src) it uses for top-level ones (inlineSubCompositions.ts:360, htmlBundler.ts:1032, resolve(sourceDir, relativePath)).
  • The render pre-flight says so explicitly (producer/src/services/htmlCompiler.ts:264): "data-composition-src is always resolved root-relative, even from within a nested sub-composition".
  • collectSubCompositionSrcs in parsers/src/assetResolution.ts:71 says the same: "Callers must resolve each value against the PROJECT ROOT, never the referencing file's directory".

Repro at this head: index.html mounts compositions/scene.html, which mounts data-composition-src="compositions/card.html" (the normal layout). The loader fetches:

http://localhost:3000/compositions/scene.html
http://localhost:3000/compositions/compositions/card.html

The second request 404s, the host is emptied and external_composition_load_failed fires. A render of the same project shows the card, so the preview and the render disagree. On main the child isn't fetched at all in this pass, so the nested discovery is the new part and it lands on the wrong path.

Fix: resolve against document.baseURI for every level, and keep ancestry only for the cycle and depth checks (nestedCompositionRefusal works the same on root-resolved hrefs). The first new loader test then needs its child as scenes/inner.html rather than inner.html. Please also add a nested case with a relative path in a subdirectory, because the current tests only use absolute URLs or files at the root.

Nits (optional)

  • authoredSource.ts:101 hardcodes "data-hf-variable-host". Importing VARIABLE_HOST_ATTR keeps the strip in step with the stamp if the name ever changes.
  • The 4-at-a-time loop at compositionLoader.ts:729 waits for the slowest load in each batch before starting the next four. It's fine as a bound, but a small pool that starts the next fetch as soon as one finishes would avoid one slow scene holding up the rest.

Verdict: REQUEST CHANGES
Reasoning: The variable-scoping fix is sound, but the new nested-child loading resolves paths relative to the parent file, while the compiler and render resolve them from the project root, so normal nested projects 404 in preview.

— Rames

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed in fe51808. Nested composition URLs now use the existing payload resolver with the project document base at every level. Ancestry is used only for cycle and depth checks. The loader and bundler share a nested-directory fixture; the loader fails with the old parent-relative resolver while the bundler succeeds. Both changed suites passed three consecutive runs, and headless Chrome loaded the same card in preview and bundled output without missing requests or page errors.

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at fe518089. Scope: the fix commit over a0e24481, which Rames reviewed (4 files), plus CI at this head. I didn't re-review the full PR beyond that. This is a comment, not an approval.

The root-path fix works. mountExternalCompositions now resolves each child's data-composition-src through resolvePayloadSourceUrl(src, null), so every level resolves against document.baseURI. The ancestry is kept only for the cycle and depth checks (compositionLoader.ts:739-742), which is the fix Rames asked for. The targeted suites pass locally: compositionLoader, htmlBundler and applyVariableBindings, 246/246. I reverted line 739 to the old new URL(src, ancestry.at(-1) ?? document.baseURI) and two of the new tests failed ("loads external children in one pass…" and "matches bundled root-relative child paths…"), so the fix is pinned.

Blocker: CI. The required Test is red because Producer: unit tests fails, and this PR causes it.

  • htmlCompiler.parity.test.ts:102 throws mount diagnostic external_composition_load_failed for compositions/card.html, with ENOENT … open '/tmp/hf-compiler-parity-*/http:/localhost:3000/compositions/card.html'. It's raised from compositionLoader.ts:750.
  • The same failure is on a0e24481, and main is green, so it comes from this PR's loader change rather than from this fix commit.
  • The loader now hands the producer's file-backed fetch an absolute http://localhost:3000/… URL. The producer joins that onto its temp dir as if it were a path. Either the producer's fetch needs to map same-origin absolute URLs back to project paths, or the loader needs to keep passing a root-relative path there.
  • Until this is fixed, external sub-compositions don't mount in the producer's compile path. That's exactly what the parity test exists to catch.

Studio: timeline viewport gate is also red at this head, but it was green at a0e24481. I haven't attributed it; it may be a flake.

Verdict: the root-path blocker is fixed. One blocker remains: the producer parity failure, which turns the required Test red.

— Somu

…ids and order

The producer parity test now mounts through the real file server, so the
loader's root-relative URLs reach real files. Late nested hosts get their
runtime id from core's shared identity map (a repeated composition is
suffixed on every instance, a single one keeps its authored id when free),
replacing the producer's private copy. The runtime loader still fetches
sub-compositions concurrently but mounts them in document order, so their
stylesheets land in <head> in a stable cascade order.
…d styles by host

Mounting one sub-composition at a time made a later host wait for an earlier
host's fetch, which hangs when that fetch depends on the later mount. Mounts
run concurrently again; each injected link and style is inserted before the
head nodes of any host later in the document, so the cascade order no longer
depends on which fetch finishes first. The parity test gains a scene that
mounts the same card twice, which pins the suffix-every-copy rule.
# Conflicts:
#	packages/core/src/compiler/htmlBundler.test.ts
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

@jrusso1020 thanks for the review at a0e2448. Your points were in the review body (there are no inline threads), so here is each one with the commit that addresses it. Current head: d2db3a5, all checks green.

Blocker: nested data-composition-src resolved against the parent file. Fixed in fe51808. Every nesting level now resolves through resolvePayloadSourceUrl(src, null) against the document base, and ancestry is only used for the cycle and depth checks. The subdirectory case you asked for is the shared fixture nestedCompositionPath.testFixture.ts: compositions/scene.html mounts compositions/cards/card.html. The loader test fails with the old parent-relative resolver, and the bundler test pins the same root-relative answer. @somanshreddy confirmed the fix at fe51808.

Nit: authoredSource.ts hardcoded "data-hf-variable-host". Done in d2db3a5. Studio now imports VARIABLE_HOST_ATTR from a new @hyperframes/core/runtime/variable-scope subpath (added through package-subpaths.json), so the strip and the stamp share one name.

Nit: 4-at-a-time batches wait for the slowest load. Done in a335439. The loader now runs four workers, and each one takes the next host as soon as its current load finishes. New test "starts a queued load as soon as any earlier load finishes": it holds the first of five loads until the fifth has started. Against the old batch loop that test times out.

Also changed since your review:

  • Somu's blocker (the producer parity test read http://... URLs as file paths): ca6ed2c mounts the parity case through the engine's real file server, so the loader's absolute same-origin URLs resolve the way they do in production.
  • 05ba8f5 keeps mounts concurrent and inserts each composition's stylesheets at its host's position in the document. That fixed the producer runtime SVG ordering test, which had deadlocked when mounts were made serial.
  • Merged main in cc20c4d. The only conflict was new tests next to each other in htmlBundler.test.ts; I kept both sides.

Could you re-review at d2db3a5?

@github-actions

Copy link
Copy Markdown
Contributor

Fallow audit report

Quality gate failed

Found 3 findings: Dead code 1, Duplication 2.

Dead code (1)
Severity Rule Location Description
major fallow/unused-export packages/core/src/compiler/compositionAssembly.ts:355 Export 'countPageRoot' is never imported by other modules
Duplication (2)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/runtime/compositionLoader.ts:634 Code clone dup:781b2c18 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/compositionLoader.ts:685 Code clone dup:781b2c18 (10 lines, 2 instances)

Generated by fallow.

Gate outcomes: failed audit-verdict.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 3 important · 0 nits — head dd6fd8af

The nested-path change request is resolved: mountExternalCompositions resolves every level through resolvePayloadSourceUrl(src, null) against document.baseURI, and keeps the ancestry only for cycle and depth checks. The merge with main is what broke this head.

Blocker: the main merge breaks main's own tests, and required Test is red. Test (runtime) fails on Linux and Tests on windows-latest: core fails on Windows. I reproduced it locally:

  • inlineSubCompositions.reuse.test.ts:57 still calls assignBundledRuntimeCompositionIds with a host list, so it fails with TypeError: document.querySelectorAll is not a function. This PR changed the function to take a document.
  • pageBuild.golden.test.ts fails both the preview and the render goldens (from #5954). Nested badge__hfN styles now merge into the card's <style>, and a <script data-hyperframes-preview-runtime> has moved into the head between styles.

Update the call site. For the goldens, confirm which differences are the intended page-order change, then regenerate. Fallow audit is also failing.

Important:

  1. Render orders head styles differently from preview and mount when a host has no id or shares one. Compiled assets are tagged with the host's id (htmlCompiler.ts:1059-1061). headOrder maps id → host, and the last element with a given id wins (compositionLoader.ts:200-210). A no-id host's assets get no tag at all. Repro: a tpl template host followed by a no-id external host. Preview and mount give style:tpl, link:ext, style:ext; render gives link:ext, style:ext, style:tpl. On main, both preview and render put template styles last, so they agreed.
  2. Cycle and depth limits from the compiler are lost at runtime. The compiler leaves a refused nested host with data-composition-src on it (inlineSubCompositions.ts:791-797). Its inlined parent only carries data-composition-file, so mountExternalCompositions treats the refused host as top-level with an empty ancestry (compositionLoader.ts:695-707) and mounts it again. With a.html ↔ b.html, Studio mount shows AB, while bundled preview and render plus the runtime show ABAB (main showed ABA). Separately, a level where every host is refused gets no id in the bundler (htmlBundler.ts:570): a self-including scene.html gives scene, scene in preview but scene, scene__hf1 in mount.
  3. A template host nested inside an external composition gets different ids on each path. Repro: a page tpl host with #tpl-template, and scene.html also containing a tpl host. Mount gives tpl, tpl__hf1; render (producer, then runtime) gives tpl__hf1, tpl__hf2, because the unassigned nested copy still counts as waiting. The PR lists this shape as unsupported in preview, but render and mount disagreeing breaks the PR's own one-policy claim.
Scope

I read the loader, compositionHostIds, and the producer and bundler head-emission diffs, and checked the pool (in-order results, no worker rejects), duplicate-link ownership, variableScopeOf, and Studio stripping of data-hf-variable-host: all fine. The important items were reproduced with scratch tests on the PR's preview/render/mount parity harness, not by running real renders. I didn't read the whole parity test file.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Closing: this conflicts with main, has had no update for over 5 days, and no open issue or report names who it hurts. Reopen if someone hits it; the branch is kept.

@bdudelsack

Copy link
Copy Markdown

We hit this. Mounting the same block several times inside a scene (index.html → scene → block) with per-instance data-variable-values:

  • getVariables() returns only the declared defaults
  • the script's scoped root resolves to the first instance, so every instance writes into the first one
  • the timeline registers once under the authored id

Same block mounted directly from index.html works. Reproduced with 0.8.226 and 0.8.228 in preview and snapshot.

Our workaround: omit data-composition-id on the block's inner root and register the timeline under root.closest("[data-composition-id]")'s id. Preview, snapshot and render are then correct, but lint fails with root_missing_composition_id and missing_or_empty_sub_composition.

Use case: one avatar block (person + emotion schedule as variables) reused across all scenes of an explainer. Would appreciate a reopen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants