Skip to content

[FEATURE gxt-dual-backend] GXT dual-backend rendering (opt-in preview) - #21340

Draft
lifeart wants to merge 17 commits into
emberjs:mainfrom
lifeart:glimmer-next-fresh
Draft

lifeart wants to merge 17 commits into
emberjs:mainfrom
lifeart:glimmer-next-fresh

Conversation

@lifeart

@lifeart lifeart commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

GXT rendering backends for Ember (opt-in preview)

This branch is main (493985e956) plus 8 commits and 4 more on top, head e4f2cd0e6d; how to review lists them. The 8 commits are the original series with its 310 follow-up commits and 18 later ones folded in, rebased four times onto a newer main; the third rebase also folded in 9 fixes. Of the 4 commits on top, two fix known reds that came with main's tests in the third rebase, one extends compat's backtracking assertion to Glimmer components, and one regenerates the baselines. The history is kept on these branches of lifeart/ember.js:

  • gxt-pre-squash-b1bf210aef: the per-fix history, 318 commits on the old base a9501d5d40;
  • gxt-followups: the 18 later follow-ups, on top of the squashed head 030a6f51c1; its tip 81060259b3 has the same tree as 92689181a5;
  • gxt-pre-rebase2-92689181a5: the head 92689181a5, the 8 commits on 54d1bf526d;
  • gxt-pre-rebase3-bc3fda94ea: the head after the second rebase, the 8 commits on 4019de38a7;
  • gxt-pre-rebase4-5d4854653d: the previous head 5d4854653d, the 8 commits and 3 on top, on 21a5f6c228;
  • gxt-ember-easy-fixes: 8 of the 9 fixes folded in with the third rebase, on top of 92689181a5;
  • gxt-ember-hard-fixes: the ninth fix, 79060b45c0, and a baseline commit, on top of 92689181a5;
  • gxt-pre-rebase-68874e540b: the earlier 312-commit history, which also carried a demo app and an RFC draft. Neither is part of this PR.

A commit SHA in this description is a commit of gxt-pre-squash-b1bf210aef, unless it is one of main's, one of this branch's 12 commits, or the text names another branch. What the 8 commits contain summarises the follow-ups and fixes by area. The first experiment was #20711.

Summary

This PR adds @lifeart/gxt as an opt-in, build-time rendering backend for ember-source. It comes in two flavors:

flavor selected by what it is
compat GXT_MODE=true (Vite dev and test pages), EMBER_RENDER_BACKEND=gxt (rollup dists) Classic Ember on gxt: classic components, curly invocation, the classic object model. The @glimmer/* rendering and reactivity modules are replaced by packages/@ember/-internals/gxt-backend/.
native EMBER_RENDER_BACKEND=gxt-native (Vite and rollup) Modern Ember with no Glimmer VM: classic Ember source, with only the VM-touching modules swapped for gxt-backend/native/. Ember tags are gxt cells, so there is one reactive system.

The default build is classic and unchanged in behaviour. GXT code in classic packages sits behind the __GXT_MODE__ build constant, which the classic and native builds fold to false. bundle-size-check classic fails if live GXT bridge code reaches the classic dist. The classic suites give the same results as on main, plus the tests this PR adds to shared test files, which also pass on classic. The classic dists are not byte-identical to main's; the backwards compatibility section says why.

Opting in is a build-time choice; this PR publishes nothing.

  • native: scripts/build-gxt-native-package.mjs --pack <dir> builds ember-source-gxt-native. An app installs it as ember-source ("ember-source": "npm:ember-source-gxt-native@<version>") and adds the Vite preset emberGxtNative() from ember-source/vite. It supports Vite only: no ember-cli and no @embroider/vite.
  • compat: scripts/build-gxt-package.mjs builds ember-source-gxt (dist-gxt-package/). The emberSourceGxt smoke scenarios install it as ember-source.

Native scope. In scope: @glimmer/component and template-only components; strict .gjs / .gts templates and loose resolver templates; the router, outlets and LinkTo; helpers and modifiers, including custom managers; @tracked, @cached and tracked collections; services, renderComponent, runtime compilation through @ember/template-compiler, and the debug render tree.

Out of scope: classic @ember/component, <Input> / <Textarea> / {{mut}} two-way bindings, ArrayProxy / ObjectProxy content reactivity, {{unbound}}, engines / {{mount}}, SSR, and .hbs files. Using a classic component or <Input> / <Textarea> throws. The native test run skips 286 tests, each with a reason in scripts/gxt-test-runner/native-skip.json or in the test source.

Current status

The numbers are from the fork's CI on the tree of the head, e4f2cd0e6d, with @lifeart/gxt 0.0.86. The runs tested 5228fb89ee, which adds to the head only the CI trigger lines and a scratch workflow that compares the classic dists with main's. The CI section explains how these runs are made.

Comparing each number with earlier descriptions would mix this PR's changes with main's. Since the previous description's head 5d4854653d, these tests changed:

  • Green with the backtracking commit 1a56745208: 3 compat tests: "when a shared dependency is changed during children's rendering" and its "(tracked)" variant (curly components), and "asserts when a shared dependency is changed during rendering, and keeps original context" (template-only components).
  • Tests that main added or rewrote:
    • compat: 56 more test rows and 15 more modules. main moved classic-component tests into components/classic/ (new module names, the same results), kept copies there of tests whose harness it changed, and added tests, such as eight Inherited from Handlebars (legacy behavior) modules. All pass except 4 new known reds (see the known gaps) and the known red "renders with dot path and updates attributes", which now fails twice: as its classic copy and as main's Glimmer rewrite. main's 2 new rehydration-stats tests are skipped, as their classic siblings are.
    • native: main rewrote 18 tests that native skipped as classic-component tests to a custom-managed component with positional arguments, under the same names. Their skips are dropped: 10 pass and 8 are known reds. main's 3 new native tests pass.

So compat went from 10,141 / 10,377 to 10,195 / 10,433 tests and from 1,038 to 1,053 modules, and native from 7,192 / 7,203 to 7,187 / 7,206. No test that main left unchanged went from passing to failing; the adaptations of the fourth rebase fix those that main's new harness and deprecations broke at first.

Tests

suite result
compat full (5 shards, merged) 10,195 / 10,433 tests, 1,053 modules, 0 timeouts, 0 harness errors. All 238 failing rows are known reds listed in test-results/gxt-baseline.json. baseline-gate: OK.
— [integration] modules 1,935 / 2,156
— everything else 8,260 / 8,277, including 314 tests in 70 GXT … modules and 48 tests in the two modules converted from vitest (Compat: utils.ts and @glimmer/component-gxt: reactive identity), all added by this PR and all green
compat smoke (PR CI, 14 modules, 4 shards) 274 / 275; the failure is a known red
native suite (4 shards, merged) 7,187 / 7,206, 684 modules, 19 known reds, 286 skipped. baseline-gate: OK.
classic Basic Test 9,386 tests: 9,365 pass, 21 skip, 0 fail. main has 9,295 / 9,274 / 21 / 0; the 91 extra tests are added to shared test files by this PR.
classic Production build 9,220: 9,178 pass, 42 skip, 0 fail (main: 9,131 / 9,089 / 42 / 0). Two of the 91 added tests are development-only.
classic Firefox 9,379: 9,354 pass, 25 skip, 0 fail (main: 9,288 / 9,263 / 25 / 0)
other classic jobs All deprecation, optional-feature and stable-decorator variants, BrowserStack, Linting, type checks (current version and 5.2–5.9, 6.0, 7.0), Node, Blueprint and package preparation tests (with main's new publint and attw check) pass.
smoke apps 38 / 38 jobs (19 scenarios, each also with deprecations removed), including the 4 emberSourceGxt jobs (compat package boot and consumable)
native package consumer The packed ember-source-gxt-native plus the npm @lifeart/gxt 0.0.86 tarball, installed in a fresh app outside the repo: 15/15 acceptance checks; prod and dev each boot 17/17; the prod bundle is byte-identical to the harness measurement (74,660 br).

main's counts are from its own CI run on 493985e956 (emberjs/ember.js run 41671740543). Its one red job, a smoke app whose browser did not connect to testem, is an unrelated flake. The differences, +91 (+89 in the production builds), are this PR's tests in shared test files, as on the previous base.

The runner runs nested QUnit modules on their own as well as inside their parent module, so the compat and native totals count the tests of nested modules twice: 648 of the compat tests and 135 of the native tests.

Compat known reds and skips

Counting a nested module's tests once, 212 distinct tests fail:

  • 61 render on the server into a SimpleDOM document or check Glimmer's serialization (the node jit and serialization modules). GXT renders browser DOM only.
  • 120 expect Glimmer to reject an invalid template at compile time: the compile errors and … keyword syntax errors modules, strict-mode names that are not in scope, and two keyword tests. GXT either accepts these templates or rejects them with its own message (see errors).
  • 31, in 22 modules, are behaviour gaps. The known gaps list them by cause.

baseline-gate reports 222 known reds, because it keys failures by module and test name, and two SSR parent modules list each failing test of their three child modules under one name.

125 test rows are skipped. 21 of them are skipped on classic too. The other 104 are GXT-only skips, each with its reason in the test source:

  • 59 rehydration-suite tests that assert rehydration stats or Glimmer's serialization markers (GXT_UNSUPPORTED in integration-tests/lib/modes/rehydration/gxt-delegate.ts). The delegate fabricates no markers.
  • Tests of Glimmer VM internals (integration-tests/lib/test-helpers/gxt-unsupported.ts): the list-update steps of {{#each}} swaps, serialization markers, and dynamic variables other than outletState.
  • The compiled-template meta of a compile option, and visit() with _renderMode: rehydration, which asserts under GXT.

Bytes

The same modern app (smoke-tests/modern-app) is built from each rollup dist (GXT native run 42054852391). The boot JS includes Ember, gxt and the app, compressed with brotli. Every variant passes 17/17 boot checks.

app classic Glimmer VM gxt-native difference
strict (.gjs) 81,304 74,660 (gxt runtime: 17,901) −6,644 (−8.2 %)
loose (resolver) 81,438 74,909 (gxt runtime: 18,032) −6,529 (−8.0 %)

Whole dist trees (every .js file under dist/<mode> concatenated, then brotli; GXT dual-build run 42054851974), with their budgets in scripts/bundle-budgets.json:

classic compat native
prod 203,993 249,264 (+22.2 %) 169,726 (−16.8 %)
prod budget 213,836 260,202 177,500
dev 407,062 689,484 (+69.4 %) 358,748 (−11.9 %)
dev budget 426,719 717,769 374,399

The budgets were last re-baselined at the measured size + 5 %, the method the file documents (81060259b3, on lifeart:gxt-followups); each backend's _comment gives the measurements. The check fails above 5 % over budget. The head's trees are at 95.2 % to 96.1 % of their raw, gzip and brotli budgets.

Since 5d4854653d, the prod trees grew by 171 (classic), 681 (compat) and 11 (native) bytes brotli, and the modern-app builds by 15 to 169. The classic code differs from main's as before (see backwards compatibility), so its growth is main's code; compat's is main's code and the fourth rebase's adaptations. The backtracking commit changes only the dev trees: on 5d4854653d it added 1,818 bytes to compat's, which grew by 3,731 in all.

assert-native-dist passes for prod and dev: the native app closure contains no Glimmer VM module and no template compiler, and has a single reactive system. Its forbidden list, GXT_NATIVE_FORBIDDEN in scripts/gxt-alias-map.mjs, also names the five compat Glimmer host modules of gxt-backend. Of @glimmer/runtime it allows only lib/dom/sanitized-values, lib/dom/normalize and lib/dom/props, Glimmer's own attribute code, and lib/debugger-callback, which imports nothing. Five entry points outside the app closure reach Glimmer VM packages: the @ember/template-compiler runtime-compilation entries, ember-template-compiler, and the @ember/-internals/glimmer index.

Runtime speed is not claimed beyond the krausest measurement below.

Memory (krausest, CI)

These numbers predate the squash and all four rebases, and were not measured again. Run 39096142262 built the krausest (js-framework-benchmark) app from the classic and native dists of the pre-squash commit 6b8675867f (tree 9013968e8a, on the old main a9501d5d40), with npm @lifeart/gxt 0.0.86; a scratch commit on top of it added the harness. The head's GXT code also has the lint fixes, the gated-code moves and the removal of the classic leftovers, which do not change GXT behaviour, and the later fixes and adaptations, which do and whose effect here is not measured. Medians of 24 reps, in MB (10⁶ bytes):

classic Glimmer VM gxt-native difference
heap after creating 10k rows 52.21 40.91 −11.30 (−21.6 %)
heap after clearing them 4.72 4.51 −0.21
leak per 10k-row create/clear cycle 0.002 0.026 +0.024

With gxt 0.0.85, the same code measured 52.15 MB after creating 10k rows, 6.07 MB after clearing them and a leak of 0.657 MB per cycle (run 39081573945).

In run 39096142262, native's script time is lower than classic's on 8 of the 9 krausest ops, from −2.5 % on create10k to −75.8 % on update10th. clear1k is 8.9 % slower. create10k is close to parity: native was slower than classic there in run 39081573945.

How it was measured: one job on a GitHub-hosted ubuntu-latest runner, with Playwright's Chromium; each rep opens a fresh page per variant, in alternating order; every op is checked, and its DOM must equal classic's in the same rep. The heap is Runtime.getHeapUsage (used size) after gc; the leak is the slope over 5 create/clear cycles. The harness (scripts/krausest-bench.mjs, smoke-tests/krausest-app/ and a workflow) is not part of this PR: it was moved out in 3800cb4ae0, on lifeart:gxt-pre-rebase-68874e540b.

What the 8 commits contain

How to review describes each commit. This section summarises by area the follow-ups and the 9 fixes folded into the 8 commits, the adaptations of the fourth rebase, and the 3 fixes on top.

SHAs in this section are commits of lifeart:gxt-pre-squash-b1bf210aef, except in the parts marked after the squash (commits of lifeart:gxt-followups), third rebase (commits of lifeart:gxt-ember-easy-fixes, and 79060b45c0 of lifeart:gxt-ember-hard-fixes) and on top (commits of this branch). The fourth rebase parts are in commits 2–6, whose messages list them. Each fix commit message gives the root cause, and fixes that change behaviour come with tests that failed before them. e4f2cd0e6d takes the baselines from the GXT Full and GXT native runs 42050816110 and 42050816407 of the same code. Everything below is compat unless it says native.

Rendering correctness

  • Content mustaches render what their value is (text, updated in its text node; a SafeString's HTML, parsed in its element's namespace; a DOM node; a fragment's nodes; or a component) at a template's top level, inside elements and for helper results (c02e65b8f5, e49a0f17ba, 0fefcbd515, ac92f3aa7a, 2567a776db). Build-time .gjs / .gts templates do the same inside elements (6915dbdedf), and at their top level through gxt 0.0.84's content-binding hook (e463bd06f0).
  • Attributes. A bound attribute updated to null or undefined is removed (972ddf69ee). class is an attribute, and classes merged through ...attributes follow Glimmer (c6355fb7b5). An attribute that names a property of its element is set through the property (2e4812f25c). A triple curly in an attribute renders its value (64bd42705d). A bound style is the style attribute with its text as written, not the text the CSSOM re-serializes, and null, undefined, false or '' remove it (2b824a9b26, with gxt 0.0.85, compat and native).
  • Template text. A compat template's text is Glimmer's: whitespace text between values and between elements is kept, a block on a line of its own adds no newline, ~ strips whitespace, and an entity in an attribute value is decoded once (0e46f9b991, with gxt 0.0.85).
  • URL sanitization calls Glimmer's own sanitized-values in every path that writes a URL attribute, in development and production, in compat and native (f93c20f33e, ac68721b69, 1ce514a0ab). Before, compat used outdated copies of Glimmer's lists, and production builds wrote attributes forwarded through ...attributes, attributeBindings, LinkTo or <Input> unsanitized.
  • Dynamic components and curried values.
    • A dynamic component follows its definition, including one made from a reference or at the start of a template (a0023cc485, 42f32b7cf2, 7c6547ed96).
    • A curried component keeps its instance while its curried value stays, and updates when a curried argument changes (1a336f6b9c, 3a5e2d1fc5).
    • A content mustache renders each component value as an invocation of its own, which is destroyed when the value is replaced (a5270a0181, d998d6eb75). A yielded curried value that changes renders its new component (51cdbcc1e1, 6257592af4).
    • (component …) in an argument or attribute position is a curried component (84dad24674). Invocation positionals are appended to curried ones (c0e84a86c4). A curried (modifier …) argument stays one value while its definition is the same (76a039ee5d).
  • Component and helper values. A component or helper class in content position renders. A component value called with arguments in append position gets them. The block of a dynamic invocation of a template-only component renders. A name called with arguments that is neither a helper nor a component throws. A string is not resolved as a component in strict mode or in <this.x> (ea8a56928c, f1065adada, 0904bf8e41, 872f3ab467, d8ae7bbe7e). Third rebase: native {{get}} reads a property of a primitive, such as a string's length, as Glimmer's isDict allows (b9056d70f5).
  • Blocks and block params. Angle-bracket block params are bound per invocation of the block, and nested blocks read the outer block's params (5e6bced0b5). {{else}}, <:else> and <:inverse> are one block (a37eb299c5). A tag naming a block param invokes the yielded component (6438cd7a77). A block param in a quoted attribute is its value (83810390af). <@comp as |v|> passes its block and block params (47db2a5080). The whitespace between an invocation's named blocks is not a default block that replaces <:default> (cf1a6aa14c).
  • .gjs / .gts components. A class with a class-body <template> renders through its component manager (ba66721c72), as does a class with a registered template invoked lexically (7240def4a3). The nearest template of a class chain wins (76385ea3b9, compat and native). A <template> component invoked by name receives ...attributes, renders its blocks, and does not get its attributes as arguments (0f0976c613, 58984137b5, f4c40b7be2). Decorators in gxt-compiled files import decorator-transforms' runtime, so no dt7948 global is needed (5100fa24dd, d874899199, compat and native).
  • renderComponent runs the custom manager of a plain-object definition (06ca2f1fd6), so the [integration] suites, whose harness renders every loose template that way, render with a real context. A root updates in place instead of being re-rendered as a whole on any tag change (6f5611dab1), is a root of its owner's renderer (d78683b33d), renders before the cursor's nextSibling (7476e88553), accepts a <template> value (7c9095995c), uses a hasSubOwner manager's owner (59a0e407ed), and creates SVG elements inside an <svg> (c4126ba773).
    • Fourth rebase: main's rendering tests render each template as a root of BaseRenderer#render. Such a root renders through the glimmer host's renderRoot, at its cursor and without renderComponent's clear and replace bookkeeping, inside a render pass, and with a classic root's context cells, so its function-valued and ArrayProxy-valued properties re-render when set. A root whose first render failed stays in its renderer until the renderer is destroyed (compat and native).
  • {{#in-element}} follows its destination and leaves it with its block, as does a component inside it (555a6f15cf, d6c2ee0b9b).
  • SVG. A block inside <svg> keeps the SVG namespace on update, and an SVG text child updates (073609e6a7).
  • Lists. A keyed {{#each}} row renders the new object bound to its key (3109d6465f), and each object bound to it after that, also when the list grows at the same time (d049134601, with gxt 0.0.85). {{#each-in}} honours key= (b608afded1). {{#each}} over a native iterable re-iterates when what it read changes (a392191922). An {{#if}} branch or {{#each}} row rendered on update resolves names in its template's owner (5d7fb77fd7).
  • Modifiers. Each modifier on an element has its own instance (63cc148519). A modifier sees its element's content when it is installed (c0b147aa14), reads its current arguments (7e8bef68b4), updates in the owner its element rendered in (c81f8daf88), and is destroyed when its definition changes (9badc5318c).

Component lifecycle

  • A component is destroyed with the block it renders in, in the run loop that removes the block, and a block's components are destroyed in the order of their nodes. Components still rendered when a test ends are destroyed (9b84fde952, fd04350576, 81676cff0c).
  • A component's template renders in the block it is invoked in, so tearing down an enclosing {{#if}} or {{#each}} reaches its modifiers. Modifiers are destroyed in the order their elements close (05b5302447, 1083384678). Third rebase: so are native ones, which are queued and destroyed in that order at the end of the render pass or root teardown (fa74262259), instead of gxt's parent-first order.
  • One instance per invocation. Glimmer components (b5eda4885e) and classic components (c7ac744eb5, cf4f6349ba) were pooled per class, so an instance could be handed to another call site or {{#each}} row.
  • A tagless classic component is destroyed with its block, and stays alive while its nodes are in the document (406548a43e, ec4f1ae128).
  • An {{#each}} row that renders a component removes the component's nodes when it goes (eddfdcfa8a). A modifier is destroyed with its block however it was installed or updated (dc33bfa368). A content binding owns the helper it renders and destroys it when the value changes (841bbaba0a).
  • Fourth rebase: a classic component's lifecycle events are sent as Glimmer's curly manager sends them (sendCoreViewEvent: the listeners, then the method), so main's new Evented#trigger deprecation does not fire for them; user code's trigger calls stay deprecated. A classic component destroyed with its block runs willDestroyElement while its element is in the document.

Reactivity

  • Writes during render. A cell written in a modifier's install or update renders in the same run loop, as Glimmer's commit phase does (5f939592a4). Writes in didUpdate and didRender render until stable, up to the rerender limit, instead of stopping after three passes (03e0958b74).
  • dirtyTagFor reaches the DOM (d551ddebb1). It no longer reads the property it dirties (3c1b5da11a), and creating a property's tag no longer reads the property (b430e9d9d8). A content binding re-runs before the bindings its evaluation created (6ec3d03b1a).
  • Lazy reads. Values that Glimmer reads lazily or never are no longer read eagerly: (hash) members, curried arguments, the fn callback, the arguments <Input> / <Textarea> do not use, the getters of a rendered component, a route template's controller getters, and a dynamic component's value (03d48b04c3, 04b148cc76, 9c5cc9b98d, a8029df1c2, 50e2ddf5ee, 9702662b7e, c338dbf8fe). Some eager reads remain; see the gaps. Third rebase: native {{hash}} is one object of stable identity with a lazy getter per member, so a yielded (hash x=this.model.x) updates, an unused member is not read, and a custom tag lets @computed('args.hash.key') follow it (20d2d745a1).
  • Arguments. Glimmer component arguments read (mut) and (readonly) values, and (mut @arg) writes through the argument (5256bf4be0). A dependent key through a component argument follows what the argument reads (c9376e3978). Template-only component arguments update whatever invoked them (5f3a7db10f, 0f77b1e682). A renderComponent root argument is read as Glimmer's getProp reads it (958f1e8980).
    • Third rebase: a (fn …) result passed to a helper is not called at the argument site, because the results of internal helpers are marked as helper results, as those of user helpers are (ca628df3d3).
    • On top: a component whose custom manager does not replace its arguments in updateComponent reads their new values (764861e503). Its reads were memoized per render pass, and nothing dirtied the tags of a computed property over args.positional or args.named; the argument effect now seeds the memos and dirties the changed keys' tags before the manager hooks run.
    • Fourth rebase: an internal component's arguments detect (mut) and (readonly) values without reading other properties of the value, so <LinkTo> renders a model that throws on unknown properties.
  • Caches. createCache, and so @cached, re-computes when a gxt cell its function read changes, including this.args of a Glimmer component (528cf966aa). A binding is evaluated once when it is created (d69fdb8eda). On top: a cache keeps its tag when a re-run consumes the same tags in the same order, and a frame that read only that cache ends with that tag, as main's validator does since Reuse the combined tag of a cache that reads the same tags again #21664 (d06e8e1b65, compat and native). Revisions and invalidation are unchanged.
  • Collections and fields. A write to a tracked collection's storage or to a cell() renders by the end of the run loop (6f5611dab1). set() on a plain field re-renders a .gjs template that reads it (ec57f0d104). Third rebase: a helper recomputes when an EmberArray argument changes in place: it is compared through objectAt and length, as a native array is by its elements (78235805f4).
  • Smaller fixes: an inline if with no else value is undefined (c4668e4ecb); {{log}} logs again when its arguments change (132cabbc59); a track frame that consumed one tag ends with that tag (fbccd086b6).

Errors

  • Errors are no longer swallowed. About 440 empty or comment-only catch blocks, and more than 100 other catches that dropped the error or only logged it, were removed or converted, in gxt-backend/ and in the GXT branches of classic packages. Each remaining catch rethrows (or handles one expected error, such as a ReferenceError while scanning a template's implicit scope, and rethrows any other); or collects the error and throws the first one once the surrounding work has run; or, around a read that Glimmer would not make or where no caller exists (see the gaps), reports it in development with warn (id ember-gxt.dropped-read), which production builds strip, while the template's own read of the value still throws. A scan of every catch in gxt-backend/ and in the classic files this PR touches finds none that drops its error unreported, apart from one in route-managers/classic/manager.ts that is identical on main.
  • Hidden bugs that the swallowing masked, fixed at their cause: dirtyTagFor read the property it dirtied (3c1b5da11a); an argument value that is a function with parameters was called as an argument thunk (b5dbcb4534); the DOM sync at the end of a run loop dropped every error (59cd9c4dc3); a binding that threw re-ran in every later sync (27e195c3a4), and gxt 0.0.85 now ends such a pass itself, so the bindings it did not reach render on their next change (a6459d9380); LinkTo's class and href reactor leaked across applications (b02ed88554, 6a88b0abdf).
  • Errors reach the caller as with Glimmer. What a render, an update, a lifecycle hook, a modifier or a helper throws reaches the caller of render, rerender(), runTask, a bare run() or autorun, or the set() that caused it (39d74cd176, 0d68d0e89c, 59cd9c4dc3, 8a97df6031, 36614af474, c6a667cb2c, 119ab64625). A render throws only its own errors, not a stale error queued by an earlier one (18037c8e71). A template that the GXT compiler cannot compile throws instead of rendering nothing (4b363558f1). Third rebase: a custom modifier whose install throws keeps the output rendered before it: the error is collected and rethrown after insert, as Glimmer does (79060b45c0). A thrown value that is not an Error is rethrown at once, unchanged. Fourth rebase: so does a component root: an error of its commit phase keeps the render and is rethrown once the render is complete.
  • Error-message parity is not a goal. Glimmer's development-only template checks were wired into the compat compile pipeline (0d54cc861d) and then removed (82bff4dd8a): they cost about 62 KB brotli in the development dist. Tests that differ from Glimmer only in error text, and tests that expect an invalid template to be rejected at compile time, are known reds.
  • Unsupported features assert. visit() with _renderMode: 'serialize' or 'rehydrate' fails an assertion that GXT supports neither (d390108302).

Debug render tree (Ember Inspector) and {{debugger}}

  • A compat renderComponent render is a root of its owner's renderer, and captureRenderTree lists it with live bounds (d78683b33d).
  • Compat components and modifiers are nodes of the tree, with name, arguments, instance, bounds and nesting (05c9fc7adc). With gxt 0.0.84, <this.X> and <@X> nodes also get their path as the name (46841d4cef).
  • Native: gxt 0.0.83 adds {{#if}} block bounds and modifier nodes (Ember native: Glimmer block bounds for {{#if}} + Inspector modifier nodes lifeart/glimmer-next#266). Third rebase: native names a renderComponent root {ROOT}, and a component bound through a strict-mode scope by its binding name, as compat does (a41caf005a).
  • {{debugger}} calls Glimmer's debugger callback each time it renders, with the template's self (null for a template-only component) and a get for this, @name, block params and paths (cc5b59558c, with gxt 0.0.85's debugger hook, compat and native). The callback state moved from the VM opcode to @glimmer/runtime/lib/debugger-callback.ts. Third rebase: in a template-only component the callback gets null: the template-only mark is found through the render proxy (2bc65d6641). One of its tests still fails (see the gaps).

Routing

  • A route manager's own wrapper (getRouteWrapper) renders its routes, and a classic route template gets the @outlet argument (7040e46e9e).
  • A route template's action keeps its controller as this (f921fb272b).
  • A Glimmer component used as a route template is created by its component manager (345e2a6969).
  • An error in the route-template re-render that a controller change runs reaches that change (8a97df6031).

Development assertions

  • Backtracking. A render asserts when it writes a tracked value it already read in that render, per consumed value as Glimmer does, for Glimmer components as well as classic ones (81d10d84e9). The message text differs from Glimmer's.
    • On top: in development, a set() or @tracked write of a member that a template of the same render read through a path ({{this.wrapper.content}}, {{@wrapper.content}}, or value=this.wrapper.content rendered by a template-only component) asserts with Glimmer's message, which names the components rendering at the read and the path (1a56745208). Before, the check knew only the objects classic components render, and a Glimmer component's template recorded nothing. The render pass records the member reads the tracking proxy sees, in the frames where Glimmer consumes a tag. A plain object's member is not covered (see the gaps).
    • Fourth rebase: backtracking messages label a component root {ROOT} and a classic root -top-level, as main's do.
  • Manager kind. A value invoked as a modifier, a helper or a component needs a manager of that kind (ec796c6bff).
  • Argument labels. In development builds, the argument references of a helper or a modifier are labelled with their template path, so the {{on}} and {{fn}} assertions name the argument, as in While rendering: this.foo (92fda285c4, with gxt 0.0.85, compat and native).
  • Third rebase: a loose-mode {{attrs.x}} fails Ember's assert-against-attrs assertion, with its message and source location (1beed434a9), not a plain Error.

Memory (native)

  • A helper or modifier call site without named arguments keeps one shared, frozen empty hash. Before, the native managers kept the fresh {} that the compiled template passes on every evaluation, one per call site (ecd9165aa4).
  • An element modifier is a class instance whose install and update are prototype methods. Before, each modifier was an object with two closures of its own (19f633b32e).
  • With gxt 0.0.85, the two lower the heap after creating 10k krausest rows from 55.64 to 52.17 MB (the bench step of run 39075448004, 12 reps; a later step of that run failed). Their test module, gxt-native | retained call-site shapes, has 4 tests.

Test harness

  • @glimmer-workspace/integration-tests assertions ignore GXT's curried-component boundary comments and its trailing block anchor (1ea465f836, 96c0216737).
  • Tests that check Glimmer VM internals are skipped under GXT, each with its reason (49746e696e, 9d67d6fb55, 2a65871b22, 59cb554612).
  • The jit delegate makes curried components as the GXT component helper does, and the GXT rehydration delegate renders the server side non-interactively, with its components and modifiers registered (597fbf26e5, a1f5f22b7d).
  • Comments, test names and skip reasons describe behaviour instead of internal plan items (9553e20b02).
  • The two vitest files that had no runner here are QUnit modules that GXT Full runs: Compat: utils.ts (the same 44 cases) and @glimmer/component-gxt: reactive identity (4 tests) (5ec62d6896).
  • The runner README describes the isolation the runner gives (one browser, one context and one tab, a fresh page load per module, web storage cleared after each) and the smoke totals (48eb40aed0): now 275 tests and 1,835 assertions in 14 modules, with one known red.
  • After the squash, native-skip.json drops 45 entries that matched no test after the first rebase (ca5c72e5c9), and two entries get the new names of their tests, so that every entry matches a skipped test (432e968543).
  • Fourth rebase: the rendering and router test cases render through main's Glimmer root, under GXT through gxtHarness.runRender, which keeps runAppend's semantics (one render pass id, the DOM sync after the run loop, and its errors). The native test case exposes this.root. GXT tests run main's deprecated A() and ObjectProxy.create inside ignoreDeprecation, or expect the deprecation.

CI

  • The compat full suite and the native suite run in parallel shards (5 and 4). scripts/gxt-test-runner/merge-runs.mjs merges the shards into the same gxt-summary.json and gxt-last-run.json as one unsharded run, which baseline-gate.mjs gates. Each workflow now takes about 6.5 minutes instead of 20–27 (16a3c98dbf).
  • The nightly GXT Full verdict comes from baseline-gate.mjs, so known reds no longer fail it and file an issue every night (ddcb06fb46).

Build and packaging

  • Classic ember-source no longer depends on @lifeart/gxt. The repo pins gxt once, as a root devDependency, and the generated ember-source-gxt and ember-source-gxt-native packages declare that exact pin as a dependency (b5f3a21daa).
  • bin/benchmark/run.mjs is back to main's 60 s per-sample timeout (0ce2abe736).
  • Lint and type-check. pnpm lint and both root tsc programs (pnpm type-check, in both decorator modes) now cover gxt-backend and @glimmer/component-gxt: ESLint went from 376 problems to 0 and tsc from 497 errors to 0 (5174ffe5e1), mostly by mechanical fixes. The exceptions are targeted: ember-local/no-barrel-imports is off in the two trees, because the GXT builds alias exact barrel specifiers; there are 34 per-line ESLint disables and 33 @ts-expect-error lines, each with its reason, and no @ts-nocheck; tsconfig.json gains 7 paths entries and rootDir: ".". The published types still leave out both trees, and the superseded scripts/gxt-check.mjs and its two tsconfigs are removed. validator.ts's debug() now reads DEBUG, not process.env.NODE_ENV, a ReferenceError in a browser.
  • After the squash, the native forbidden list names the five compat Glimmer host modules (b6a0aaa131), so assert-native-dist, bundle-modern-app.mjs --assert-native and the native package's entry prune reject a native import of any of them by name.

Gated code in classic files

  • The GXT code of glimmer/lib/base-renderer.ts, renderer.ts, templates/root.ts and component-managers/unwrap-template.ts moved, verbatim apart from imports and exports, into gxt-backend's root-template.ts, render-component.ts, classic-roots.ts and template-adapter.ts (c741f0d35a, c604f8d668, f229c5009a, b1bf210aef). The four files reach it through one bridge slot, GxtGlimmerHost, which glimmer-host.ts fills; each call site is a whole if (__GXT_MODE__) block or a top-level ternary that checks the host is installed. They went from +3,316 to +338 added lines, with a byte-identical classic dist/prod after each move. bundle-size-check classic also fails on a live getGxtGlimmerHost( or setGxtGlimmerHost( call.
  • After the squash, more gated code moved the same way: the GXT template() of @ember/template-compiler (9b68659ccd), and the GXT parts of Route#refresh, Route#setup and didTransition (17e4043436), of notifyPropertyChange (5c88a9bfe1) and of getRootViews / getChildViews (5a0630464d). The leftovers that changed the classic dist bytes are gone (cf7c4118d7, b147edb83b, bf5d10cf82, 510da727c4, a9e6d684db, 7edb8b8af2, cb3927dc51, 9fcfd0faac, 88d4488001, 66fdf8a4fb): GXT-only imports, fields, exports and locals, two ungated template() wrappers, and the classic branch of 7 if/else forks. The classic package files went from +2,004 to +1,356 added lines.
  • Fourth rebase: main's rendering tests call BaseRenderer#render directly. Under __GXT_MODE__ it now hands the render to the glimmer host, as renderComponent does (9 more lines in base-renderer.ts). CoreView stays main's. The layout entry says what is left.

The rebases onto main

  • First, onto 54d1bf526d. Three conflicts, in package.json, pnpm-lock.yaml and arranged_content_test.js. An audit of all 456 hunks in the 82 classic package files removed two GXT comments without code in shared files and gated a skip of empty [integration] sub-modules.
  • Second, onto 4019de38a7 (38 commits, 26 files): two conflicts, in package.json and pnpm-lock.yaml. The classic audit found every PR hunk in main's files gated or one of the classic changes listed under backwards compatibility.
  • Third, onto 21a5f6c228 (10 commits, 12 files): no conflict and nothing new to gate. Commit 5 updates the native allow-list and 12 native-skip.json keys for a test file main moved. The 9 fixes folded in change 13 files, all under gxt-backend/, which classic builds do not load.
  • Fourth, onto 493985e956 (44 commits, 77 files, 18 of which this PR also changes). Five conflicts, all in tests and test helpers: contextual-components-test.js and mount-test.js are main's files with this PR's new test and whole-file skip; runtime-resolver-cache-test.js and template-factory-test.js take main's expectations with GXT branches, and main's copies of their old tests in components/classic/ keep the old GXT branches; rendering.ts is main's Glimmer-root harness plus the GXT steps. One clean merge broke: main removed a ClassicComponent import that two PR tests use, and it is back. main's [CLEANUP] Render tests through a Glimmer root component #21775 (every rendering test renders through a Glimmer root) and Deprecation enabled / URL updates for 7.5.0 #21745 (the 7.5 deprecations) broke about 386 compat tests at first; the adaptations above, which commits 2–6 list, fix them. The classic audit found this PR's added and removed lines in the 18 files equal to those on the old base apart from these edits, all __GXT_MODE__-gated or GXT-only but that import, and the classic dists differ from main's as before.

Each time, pnpm-lock.yaml is main's lockfile plus this PR's 294 lines, with no line removed, and the baselines were regenerated from CI.

gxt releases consumed

Gaps listed in earlier descriptions that are closed

  • Reactivity: the tracked-collection and cell() staleness, @cached over this.args, {{#each}} over Glimmer-component rows, and standalone tracked(value).
  • Smaller compat bugs: an outer block param in a nested block, dirtyTagFor before the first render, a DOM Node value in content, the whitespace-only text that compat dropped, and the fixed debug label of internal argument references.
  • {{debugger}} as a no-op (one of its tests still fails), and the elements rendered before an error during a component's construction, which now stay (2 tests; text and nested blocks still do not).
  • A keyed {{#each}} row re-bound while the list grows, a component's yielded (hash) or (array) of its own tracked state, and six native known reds: {{element}} with modifiers, one style warning, and the {{fn}} and {{on}} assertions.
  • Since 92689181a5: 9 compat and 14 native tests with the 9 folded fixes, and the reds that came with main's cache-tag and positional-params tests.
  • Since 5d4854653d: the backtracking assertion when a child writes a member that its Glimmer or template-only parent's template read (3 tests).
  • Also: the SSR stand-ins in ApplicationInstance#visit, the nightly verdict, the silent catch blocks, the benchmark timeout, the GXT trees' exclusion from lint and type checks, the vitest files with no runner, the README's isolation claim, the packaging doc's RFC citations, most of the gated code in classic files (see the layout), the leftovers in the classic dist, and the trees above their budgets.

Known gaps and what is not supported

  • Server-side rendering. Neither flavor serializes for rehydration or rehydrates, and GXT cannot render into a SimpleDOM document (61 known reds). In compat, visit() with a serialize or rehydrate _renderMode fails a development assertion; native does not check it.
  • Compile-time validation. Glimmer's development-only template checks do not run under GXT, so a template that Glimmer rejects at compile time (keyword misuse, if / unless arity, named-block rules, strict-mode names not in scope) can compile and render wrong or empty output. GXT's own compile errors are thrown, with GXT's text.
  • Message-only differences, such as the wording of the backtracking assertion and of compile errors, are known reds and are not planned to change.
  • Open compat gaps that need a gxt change. Each has a written design; none is released.
    • Argument thunks are told apart from function values by their shape (an arrow function with no parameters), not by a compiler marker, so some callbacks are called at the argument site. Since the third rebase, the results of internal helpers such as fn are marked and passed through.
    • A curried component rendered directly in an {{#if}} or {{#each}} body is rendered once; a changed value creates an instance that is never rendered.
    • {{#each … key="@key"}} and key="@index", which key items by position, move rows instead of keeping their nodes and updating them (compat and native).
    • A root template is built detached, so a lookup such as getElementById during render misses elements rendered before it.
    • A runtime-compiled template reads a plain object's member, such as {{this.person.name}}, as a plain property read that compat does not see, so writing it in the same render does not assert (the dynamic-components backtracking-message test). This needs gxt's cell-read hook (feat(host-hooks): DEV onCellRead seam for compat backtracking detection lifeart/glimmer-next#275, unreleased) and a runtime-compile flag that emits property taps, which changes every runtime-compiled template.
  • Open compat gaps on the Ember side:
    • Custom modifiers install while their element is being built, before it is inserted. Glimmer installs them after the render transaction.
    • The destroyable of a curried (helper …) is never destroyed.
    • Compat makes a plain object's property reactive once a template reads it (gxt installs an accessor), so some updates render that Glimmer would skip: for example, a keyed {{#each}} updates untracked items whose key did not change.
    • About 15 eager reads of values Glimmer reads lazily or never remain: (hash) member paths, a route context's sibling getters, {{#each}} backing fields, and argument effects. An error there is reported in development (ember-gxt.dropped-read).
    • 1a56745208 also records two kinds of read that Glimmer does not consume, so it can assert where Glimmer would not: an argument that compat evaluates eagerly but no template uses, such as value=this.obj.x, and a JavaScript read through a tracking proxy inside a tracking frame, such as a getter that reads this.args.obj.x. No test hits either.
    • Two code paths have no caller to throw to, so their errors are only reported in development: the fallback DOM sync every 16 ms when no run loop syncs, and a classic hook run by the sweep of unclaimed instances during an initial-render sync no property change drove.
    • When a render throws, compat does not log Glimmer's development Error occurred message (1 test).
    • Two {{fn}} tests on the result of mut fail: mut asserts that it was not passed a path. The cause is not verified.
    • One {{debugger}} test fails, "basic debugger statement" in its template-only variant: the {{#if}} keeps its false branch on the third render, so the callback runs twice instead of three times. This is read from the CI failure; the cause is not verified.
  • Compat known reds from tests main added in the fourth rebase (4):
    • "the render stack of a backtracking message starts with -top-level": a child's constructor writes a @tracked field that its Glimmer parent's template rendered ({{this.value}}) under a classic root, and compat does not assert. 1a56745208 records only member reads through the tracking proxy of an object a template path returned.
    • "{{outlet}} in a component asserts": compat's own outlet machinery renders {{outlet}} in a component under any route instead of asserting below a non-classic route.
    • Two loose raw-block tests: the gxt compiler has no Handlebars raw blocks ({{{{raw}}}}…{{{{/raw}}}}).
  • Native known reds (19):
    • {{#each-in key=}} (5) and {{#each}} @key (1);
    • a dynamic modifier set after the initial render, whose install and destroy hooks do not run;
    • the argument-less-helper assertion;
    • the backtracking assertion in a modifier constructor, whose message differs;
    • Evented.detect;
    • the args proxy: a downstream change also re-renders the upstream component;
    • 8 contextual-component tests that main rewrote to a custom-managed component with positional arguments: native does not update the positional arguments of such a curried component (4), renders nothing for one given positional arguments as an argument (2), and resolves one invoked with arguments in a mustache as a helper (2).
  • Layout. Classic package source files gain 1,365 lines in 30 files, all in commit 4, nearly all __GXT_MODE__-gated; before the moves they gained about 5.0K.
    • The four renderer files gain 267: base-renderer.ts +162, renderer.ts +65, templates/root.ts +29 and unwrap-template.ts +11. template-compiler's template.ts +16, route.ts +29 and router.ts +47.
    • The largest gated blocks left are in metal: tracked.ts (+231), property_events.ts (+140) and computed.ts (+125). They sit on hot paths (every set, @tracked read and write, and computed-property cache miss), some run before gxt-backend is installed, and they share module-local state with the classic code, so a bridge hook would add a dispatch to each path and still need the code as a fallback.
    • Also left in place: inline forks in metal's property_set.ts, tags.ts and properties.ts; the router's stashParamNames and outlet-update skip, which use router locals; the run loop's flush budget, afterRender wrapper and end-of-loop sync (+71); and small hooks in environment.ts, helpers/element.ts, object/core.ts, promise-proxy-mixin.ts, application/instance.ts and the classic route manager.
    • One leftover stays: the side-effect-only import of @glimmer/manager in templates/root.ts. Removing it would not change the classic prod dist, but it breaks the native rollup build with a circular dependency.

Every known red is listed in test-results/gxt-baseline.json and test-results/native-baseline.json. Every skip has its reason in the test source or in native-skip.json.

Dependencies

CI

On emberjs/ember.js, only PR Title Lint has run for the head; the other workflows are waiting for approval to run. The results above come from the fork, lifeart/ember.js:

  • The fork's CI PR, CI run for glimmer-next-fresh (do not merge) lifeart/ember.js#2 (base main a9501d5), ran the PR workflows up to 0de3263761 and is now closed.
  • Since then, each change is gated by pushing a scratch branch: the change plus one commit that adds the branch to the push triggers of the five workflows (7 lines in .github/workflows/), deleted after the run. For the head's tree, the scratch branch was ci-scratch-rebase4-f, e4f2cd0e6d plus that commit (5228fb89ee), and the runs are CI 42054852312, GXT Full 42054852478, GXT native 42054852391, GXT Smoke 42054852337 and GXT dual-build 42054851974.
  • That commit also adds a scratch workflow, .github/workflows/scratch-dist-diff.yml (run 42054852136, one job), which builds the classic dists of main, of main plus commit 1 and of the head, and of the previous head and its base plus commit 1, and compares their lines with chunk names normalized (see backwards compatibility).
  • In the CI run, Perf script still works, Notify Discord and Publish alpha from default branch are skipped on a push, and Publish channel to s3 fails because the fork has no AWS credentials. The other 64 jobs passed; Basic Test passed on the re-run of the failed jobs, after its browser did not connect to testem in the first attempt. All 20 jobs of the four GXT runs passed.

The workflows:

  • CI (classic): the jobs are unchanged. The two emberSourceGxt smoke scenarios build the compat package first.
  • GXT Smoke (pull requests): the upstream contract check, and 14 compat modules in 4 shards, each gated by baseline-gate.mjs --subset against test-results/gxt-baseline.json.
  • GXT dual-build matrix (pull requests): rollup builds classic, gxt and gxt-native; bundle-size-check fails above 5 % over budget, and for classic also on live GXT bridge code; assert-native-dist checks the native builds.
  • GXT native (pull requests): the modern app from the classic and native dists, strict and loose, with boot checks, the VM-free assertion and bytes; the native package and its consumer acceptance; and the native suite in 4 shards, merged and gated against test-results/native-baseline.json.
  • GXT Full (nightly): the compat full suite in 5 shards, merged and gated the same way. It runs on schedule or dispatch only, and files an issue when the merged gate fails.

Baseline gating. runner.mjs exits 1 whenever any test fails, known reds included, so it cannot be the verdict on its own. baseline-gate.mjs passes when there are no timeouts and no harness errors, every module in the baseline ran, every failing test is listed as failing in the baseline, and the runner wrote its summary. Known reds that start passing are reported, with a prompt to regenerate the baseline, and never fail the gate.

How to review

The diff against main (493985e956) is 302 files, +101,333 / −1,021. Of the added lines, 13,925 are the two baseline JSON files and 294 are the lockfile.

The 8 commits, in review order:

  1. 8ed8cd63a6 build and tooling for the GXT render backends (19 files): Vite, rollup and babel flavors, the alias map and the native forbidden list (scripts/gxt-alias-map.mjs), build constants, the gxt pin, the lockfile, bundle budgets, and the lint and type-check settings for the GXT trees.
  2. 188b506a54 the GXT compat backend (67 files): gxt-backend/ without native/, including the glimmer host modules, the code moved out of template-compiler, routing, metal and views utils, @glimmer/component-gxt, the backend's own tests, the 5 compat fixes of the third rebase, and the compat adaptations of the fourth.
  3. 7113aef3a1 the native layer and its package (94 files): gxt-backend/native/, the package script and Vite preset, assert-native-dist, the modern-app harness, the 4 native fixes of the third rebase, and the native adaptations of the fourth.
  4. 9078e4047e GXT hooks in the classic packages (30 files): every change to classic package source. Each hunk is __GXT_MODE__-gated code or a GXT-only declaration, apart from the {{debugger}} callback move and the one leftover import listed under known gaps.
  5. be2242505b test infrastructure for the GXT backends (32 files): the runner, shard merge and baseline gate, index.html / native.html, internal-test-helpers, and the integration-tests GXT delegate.
  6. 47108b67c0 GXT-specific tests and test changes (32 files).
  7. 21233844d9 GXT workflows, smoke apps and scenarios (25 files).
  8. 4614753b53 test baselines and the packaging design doc (3 files).

On top of them:

  • d06e8e1b65 a cache keeps its tag when it consumes the same tags again (2 files: the compat and native validators);
  • 764861e503 a custom-managed component reads its changed arguments (2 files: manager.ts and a gxt-backend test);
  • 1a56745208 a write of a member a Glimmer component's template read asserts (3 files: gxt-bridge.ts, manager.ts and validator.ts);
  • e4f2cd0e6d regenerate the test baselines (3 files: the two baselines and the runner README's smoke counts).

Each commit message describes what the commit holds and names its main fixes and their causes. Only the head's tree, e4f2cd0e6d, was built and tested for these numbers; the PR is meant to be reviewed and merged as a whole, and the commits before it may not build on their own.

Suggested order:

  1. Classic risk. Read commit 4, which holds every change to classic package source.
    • The four renderer files are now mostly single gated calls into the bridge's glimmer host, with an assertion that the host is installed.
    • The larger gated blocks are in metal/lib/tracked.ts, property_events.ts and computed.ts, runloop/index.ts, components/abstract-input.ts, object/core.ts and routing/router.ts. application/instance.ts is main's visit() code apart from the GXT assertion.
    • The one intended classic change is the {{debugger}} callback state. It moves without a gate from the debugger opcode into @glimmer/runtime/lib/debugger-callback.ts, so that the GXT backends call the same callback; the opcode reads it from there (cc5b59558c).
  2. What each flavor resolves to. Commit 1: rollup.config.mjs, vite.config.mjs and scripts/gxt-alias-map.mjs.
  3. The native layer. Commit 3, about 6.6K lines of non-test source. This is the smaller design and the long-term direction.
  4. The compat backend. Commit 2, about 47.0K lines of non-test source, including the 3.3K lines moved out of the renderer files and the 0.6K moved out of template-compiler, routing, metal and views utils after the squash. compile.ts and manager.ts (about 14.5K lines each) are most of it; read them by their section headers.
  5. Tests and CI. Commits 5–8, and the 4 commits on top. The per-fix commits, each with its root cause, are on the history branches listed at the top; what the 8 commits contain cites them by area.

Run locally

# dists (classic is the default) and their checks
npx rollup --config rollup.config.mjs
EMBER_RENDER_BACKEND=gxt npx rollup --config rollup.config.mjs
EMBER_RENDER_BACKEND=gxt-native npx rollup --config rollup.config.mjs
node scripts/bundle-size-check.mjs <classic|gxt|gxt-native>
node scripts/assert-native-dist.mjs --dist dist            # after the gxt-native build

# compat test page (index.html) and the gate
GXT_MODE=true pnpm vite --port 5180
node scripts/gxt-test-runner/runner.mjs --full --baseline test-results/gxt-baseline.json; \
  node scripts/gxt-test-runner/baseline-gate.mjs --baseline test-results/gxt-baseline.json --runner-exit $?

# native test page (native.html) and the gate
EMBER_RENDER_BACKEND=gxt-native pnpm vite --port 5181
node scripts/gxt-test-runner/runner.mjs --full --url http://localhost:5181/native.html --min-modules 5 \
  --baseline test-results/native-baseline.json; \
  node scripts/gxt-test-runner/baseline-gate.mjs --baseline test-results/native-baseline.json --runner-exit $?

# modern app from the dists: bytes, boot checks, VM-free assertion
node scripts/bundle-modern-app.mjs --from-dist --build-dists --boot --assert-native
node scripts/bundle-modern-app.mjs --from-dist --flavor loose --boot

# lint and type-check, which cover gxt-backend and @glimmer/component-gxt too
pnpm lint
pnpm type-check

# the native package and a consumer app outside the repo
node scripts/build-gxt-native-package.mjs --pack <dir>
npm pack @lifeart/gxt@0.0.86 --pack-destination <dir>
node smoke-tests/gxt-native-package/run.mjs --app <dir outside the repo> \
  --package <dir>/ember-source-gxt-native-*.tgz --gxt <dir>/lifeart-gxt-0.0.86.tgz --dev --no-typescript

The runner also accepts --smoke (the 14 CI modules, with baseline-gate.mjs --subset), --shard i/N (CI merges the shards with merge-runs.mjs and gates the merged run), --filter <text> and --auto-serve; see scripts/gxt-test-runner/README.md.

Backwards compatibility and risk

  • No public API change; classic is the default. The classic suites match main's counts on 493985e956, plus the 91 tests this PR adds to shared test files (89 in production builds), which pass on classic too. Classic type checks pass on every supported TypeScript version.
  • Gated code. The classic hooks, all in commit 4, are __GXT_MODE__-gated and fold away at build time. bundle-size-check classic rejects live GXT bridge code in the classic dist.
  • Classic dists are not byte-identical to main's. Measured on 493985e956 by the dist-diff run 42054852136:
    • This PR's rollup config strips non-license comments from every production dist (shouldPrintComment), so the prod tree is compared with main built with commit 1 only, which adds that rule and leaves the dev tree's lines unchanged: 1,206,007 bytes raw and 204,064 brotli, against this PR's 1,205,816 and 203,993.
    • Its code is main's plus two changes: the {{debugger}} callback module (cc5b59558c), from which the opcode reads the callback (getDebuggerCallback()), which is intended, and one alias line, const template = templateFactory;, because an ES module cannot re-export a binding conditionally. The other differences are import and export lines: GXT-only static imports in metal change the module graph, so Rollup puts metal's chain-tags module in the observers chunk (92 shared chunks instead of 93).
    • The classic dev tree is 5,349 bytes (raw) larger than main's: the same code differences, an empty statement left by a folded __GXT_MODE__ gate in ComponentRootState, GXT comments in kept code, and @ember/component docs that main's dev tree does not have.
    • The previous head 5d4854653d had the same code differences on its base, line for line; in dev, three comment lines differ, reworded in the fourth rebase.
    • Behaviour is unchanged: the classic suites match main.
  • No new runtime dependency for ember-source. @lifeart/gxt is a root devDependency, so a classic install does not pull it. The generated ember-source-gxt and ember-source-gxt-native packages declare the exact pin as a real dependency.
  • Both GXT flavors are previews and are not covered by SemVer. For classic apps the risk is limited to the build-time folding of the gated hooks, the comment stripping, the alias line and chunk layout above, and the moved {{debugger}} callback state. For apps that opt in, the risk is that of a preview, with the gaps listed above.

🤖 Generated with Claude Code

@lifeart lifeart changed the title [experiment]: Demo app for glimmer-next renderer [FEATURE glimmer-next-demo] Demo app for glimmer-next renderer Apr 25, 2026
lifeart added a commit to lifeart/ember.js that referenced this pull request May 5, 2026
The $_dc_ember dynamic-component path tracks the rendered Ember instance
via __gxtDcCaptureCallback so that swap-outs (`{{component this.name}}`
when this.name changes) can fire willDestroy on the previous instance.

That callback was only invoked from renderClassicComponent. If the
resolved class went down the glimmerish branch
(renderGlimmerComponent — tagless/Glimmer.Component shapes), the
callback never fired and _dcEmberInstance stayed null, so
destroyCurrentDcInstance() short-circuited at swap time and the
previous instance never got its willDestroy hook.

Cumulative-state symptom (PR emberjs#21340): "component helper destroys
underlying component when it is swapped out" asserts
`{ 'foo-bar': 1, 'foo-bar-baz': 0 }` after the first swap but observes
`{ 'foo-bar': 0, ... }`. The classic-componentTemplate path resolves
foo-bar through renderGlimmerComponent in some cumulative-state code
paths (likely after pool reuse / stale state).

Fix: mirror the callback fire+clear from renderClassicComponent into
renderGlimmerComponent. setInstanceCapture (stack-based) is already
called above; this adds the global-callback path used by $_dc_ember.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lifeart added a commit to lifeart/ember.js that referenced this pull request May 7, 2026
The 8 _gxtEffect(...) call sites in gxt-backend/manager.ts each install a
GXT formula (sourceTag + tag + opcode) but ignored the cleanup function
returned by effect(). When a component instance / DOM element was torn
down between tests, those formulas stayed live in the global formula
tracker. Across hundreds of cumulative tests, leaked formulas
accumulated.

The leak surface explains the cumulative-state cascade observed in PR
emberjs#21340: a single @tracked-property dirty in the failing test propagated
to every leaked formula, each of which re-evaluated its closure
(re-reading `get person`, which constructs a fresh Person and dirties
five more tags), saturating the main thread with __gxtExternalSchedule
calls (493k+ in 10 minutes) and starving the 16ms interval-fallback sync.

Wrap each call site with `_gxtEffectWithOwner(owner, cb)`, which:
  * Calls _gxtEffect(cb) and captures its cleanup.
  * Increments / decrements globalThis.__gxtActiveEffectCount (a
    diagnostic counter — useful for catching future leaks via
    QUnit.testDone instrumentation; cost is one ++/-- per effect).
  * Registers the cleanup on the owning destroyable via
    ./destroyable.registerDestructor, so when Ember tears down the
    component / element the formula's sourceTag, tag, and opcode all
    destruct, releasing the formula tracker entry.

Owner mapping per call site:
  * manager.ts:1593 (createComponentInstance arg-cell effect) -> instance
  * manager.ts:2369 (updateInstanceWithNewArgs arg-cell effect) -> instance
  * manager.ts:5627 (createRenderContext path 4) -> instance || renderContext
  * manager.ts:5748 (createRenderContext path 3) -> instance || renderContext
  * manager.ts:9467 (renderCustomElement modifier ON_CREATED) -> el
  * manager.ts:9742 (renderLinkToElement modifier ON_CREATED) -> instance || el
  * manager.ts:10451 (Input/Textarea modifier ON_CREATED) -> instance || el
  * manager.ts:10879 (renderClassicComponent wrapper ON_CREATED) -> instance || wrapper

Verification:
  * Component Tracked Properties module-isolation: 17/17 (no regression).
  * Smoke suite (14 modules / 333 tests): 333/333 PASS.
  * Diagnostic short repro (chromium loading just the Component Tracked
    Properties module): __gxtActiveEffectCount is now stable at 2 across
    13 consecutive testDone events including the previously cascading
    "tracked properties rerender when updated outside of a runloop" test
    (was: count grew unboundedly per testDone before fix).

The compile.ts:8165 site is intentionally not patched here: that
reference to _gxtEffect is currently a free identifier wrapped in
try/catch (silent ReferenceError), so it never installs a leaking
effect in practice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lifeart added a commit to lifeart/ember.js that referenced this pull request May 23, 2026
…dlers + lower-footprint args

Prevents the laptop-freeze observed during parallel agent test runs: each runner
spawned 1 chromium + ~8 child processes, and concurrent runs accumulated 17+
chrome-headless-shell at 55%+ CPU each. Symptom captured in PR emberjs#21340 session.

Additions:
- Lockfile at /tmp/gxt-test-runner.lock — refuses concurrent runs (with stale-pid detection)
- SIGINT/SIGTERM/SIGHUP handlers + uncaughtException handler — guaranteed browser.close()
- Idempotent cleanup() helper via cleanupRan flag
- Lower-footprint chromium args: --disable-gpu, --disable-extensions, --disable-audio-output,
  --disable-software-rasterizer, --no-sandbox, --disable-features=...IsolateOrigins,
  site-per-process

Default behavior unchanged for single-runner invocations. Concurrent runs now refuse
to start instead of compounding resource exhaustion.
@lifeart
lifeart force-pushed the glimmer-next-fresh branch 3 times, most recently from 109ea71 to 99f0b68 Compare June 11, 2026 03:53
lifeart added a commit to lifeart/ember.js that referenced this pull request Jun 11, 2026
…machinery

Closes the @glimmer/component import-identity fork on the GXT side (PR
emberjs#21340 Known-limitations bullet) with the documented Option-2 fallback,
while leaving protocol extraction as the committed Option-1 target.

- packages/@glimmer/component-gxt/: workspace-private sibling. `.` re-exports
  @glimmer/component 2.x's public surface (default Component); `./reactive`
  re-exports createTag/CURRENT_TAG/tracked/cached/createCache/getValue from
  the @ember/-internals/gxt-backend shims so its reactive symbols are
  identity-equal with the GXT build (not a second copy). Thin re-export, not
  reimplementation. Excluded from the root tsconfig like the gxt-backend
  shims (specifiers resolve only under the GXT rollup alias map); own
  tsconfig + ambient .d.ts keep it independently type-checkable.
- identity test (__tests__/identity.test.ts) + demo vitest config: asserts
  === between symbols reached through @glimmer/component-gxt and through the
  shims, and demonstrates the fork by showing they differ from the classic
  @glimmer/validator copy.
- scripts/ember-cli-gxt.mjs: `enable` now runs the import-identity guard
  (scans package.json deps + optional app-source grep, excluding the -gxt
  sibling) and refuses with the RFC-mandated message + non-zero exit on a
  direct @glimmer/component; `status` reports guard state. Self-contained node.
- RFC §6: records the fallback as implemented/tested in-repo, notes real
  shipping still needs the ember-source-gxt packaging from §5.5.

Gates (light gate: new files + script + tsconfig exclude; no gxt-backend
runtime source touched): root `tsc --noEmit` 22/22 unchanged; package-local
tsc clean; identity test 4/4; ember-cli-gxt status/enable-refusal/clean-enable
node-smoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lifeart added a commit to lifeart/ember.js that referenced this pull request Jun 11, 2026
… outcome)

Addresses the last open Known-limitations bullet of PR emberjs#21340: the
FastBoot/SSR pipeline blockers.

Phase 4.1 (root-context isolation in gxt-backend/compile.ts): mapped every
piece of root-scoped singleton state and classified (a)/(b)/(c). Outcome is
analysis-only, not a refactor. The genuinely root-scoped state in compile.ts
(_gxtRootContext, _gxtTopOutletRef, _gxtEngineInstances) is a thin,
already-bridged layer, but the real "one root" boundary -- GXT's module-global
node counter, rehydration stack, and setupGlobalScope -- lives in upstream
@lifeart/gxt and cannot be isolated from compile.ts. Threading N roots through
compile.ts while the upstream counter stays global would be cosmetic and
regression-prone on the backend's most load-bearing file. Documented the
smallest correct shape (upstream seedable counter + a synchronous
withRootContext swap API) for when an SSR driver exists.

Phase 4.2 (nested-engine outlet cursor-ID translation): located the lossy
point -- GxtPartialRehydrationDelegate re-renders the sub-tree from scratch
(innerHTML='') because GXT's node counter exposes only resetNodeCounter()
(reset to 0), with no setNodeCounter(n) seed, so a mid-tree boundary cannot
resume the parent's counter namespace on the client. Documented the fix shape
(upstream seedable counter + boundary base-offset in withRehydration; delegate
reads the boundary data-node-id).

No code change; documentation-only, so the full GXT gate is not triggered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lifeart lifeart changed the title [FEATURE glimmer-next-demo] Demo app for glimmer-next renderer [FEATURE gxt-dual-backend] GXT dual-backend rendering (opt-in preview) Jun 11, 2026
@lifeart
lifeart force-pushed the glimmer-next-fresh branch 2 times, most recently from 96acc59 to e401aaa Compare June 12, 2026 12:10
@NullVoxPopuli

Copy link
Copy Markdown
Contributor

📊 Size report

Tarball size — 1.2 MB → 1.3 MB

dist/dev   3%↑

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 2.1 MB / 491.9 kB 3%↑2.1 MB / 4%↑513.4 kB
Show files (29 files)
File Before (Size / Brotli) After (Size / Brotli)
./packages/@ember/-internals/glimmer/index.js 2.2 kB / 680 B 189%↑6.3 kB / 197%↑2 kB
./packages/@ember/-internals/views/lib/system/utils.js 4.9 kB / 1.3 kB 27%↑6.2 kB / 29%↑1.7 kB
./packages/@ember/array/proxy.js 10.5 kB / 2.6 kB 3%↑10.9 kB / 4%↑2.7 kB
./packages/@ember/component/index.js 44.4 kB / 10.2 kB 2%↑45.4 kB / 4%↑10.6 kB
./packages/@ember/object/core.js 31.9 kB / 7.8 kB 19%↑37.9 kB / 23%↑9.5 kB
./packages/@ember/object/promise-proxy-mixin.js 3.6 kB / 1.2 kB 35%↑4.9 kB / 33%↑1.5 kB
./packages/@ember/routing/route.js 59.6 kB / 13.2 kB 0.7%↑60 kB / 0.9%↑13.3 kB
./packages/@ember/routing/router.js 52 kB / 11.7 kB 2%↑52.9 kB / 2%↑12 kB
./packages/@ember/template-compiler/lib/template.js 8.9 kB / 2.4 kB -73%↓2.4 kB / -67.2%↓802 B
./packages/@glimmer/manager/index.js 1 kB / 296 B 54%↑1.6 kB / 77%↑524 B
./packages/@glimmer/reference/index.js 5.8 kB / 1.7 kB -90.1%↓579 B / -85.3%↓251 B
./packages/@glimmer/validator/index.js 1.6 kB / 614 B 50%↑2.3 kB / 38%↑847 B
./packages/shared-chunks/api-{hash}.js 10 kB / 2.1 kB 147%↑24.8 kB / 156%↑5.4 kB
./packages/shared-chunks/chain-tags-DeS1LfvJ.js 7.6 kB / 2.2 kB —
./packages/shared-chunks/computed-{hash}.js 34 kB / 7.9 kB 19%↑40.5 kB / 24%↑9.8 kB
./packages/shared-chunks/curly-{hash}.js 21.5 kB / 5.1 kB 14%↑24.5 kB / 14%↑5.8 kB
./packages/shared-chunks/element-{hash}.js 3.3 kB / 1.1 kB 27%↑4.2 kB / 30%↑1.4 kB
./packages/shared-chunks/gxt-bridge-B1C5ZBct.js — 4.4 kB / 1.5 kB
./packages/shared-chunks/index-{hash}.js 78.5 kB / 19 kB 21%↑94.7 kB / 27%↑24.1 kB
./packages/shared-chunks/internal-{hash}.js 4.2 kB / 1.3 kB 21%↑5.1 kB / 25%↑1.7 kB
./packages/shared-chunks/iterable-CrV7najk.js — 5.3 kB / 1.6 kB
./packages/shared-chunks/observers-{hash}.js 6.8 kB / 1.6 kB 128%↑15.5 kB / 158%↑4 kB
./packages/shared-chunks/property_set-{hash}.js 4.3 kB / 1.4 kB 38%↑6 kB / 44%↑2 kB
./packages/shared-chunks/setup-{hash}.js 2.8 kB / 907 B 716%↑22.6 kB / 624%↑6.6 kB
./packages/shared-chunks/template-{hash}.js 491 B / 203 B 118%↑1.1 kB / 98%↑401 B
./packages/shared-chunks/textarea-{hash}.js 14.5 kB / 3.5 kB 8%↑15.7 kB / 10%↑3.9 kB
./packages/shared-chunks/tracked-{hash}.js 6 kB / 1.8 kB 52%↑9.2 kB / 54%↑2.8 kB
./packages/shared-chunks/tracked-data-C-M4hkW8.js 841 B / 374 B —
./packages/shared-chunks/unrecognized-{hash}.js 17.6 kB / 4.4 kB -0.64%↓17.5 kB / -0.38%↓4.4 kB

dist/prod   3%↑

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 1.9 MB / 450 kB 3%↑2 MB / 4%↑469.7 kB
Show files (28 files)
File Before (Size / Brotli) After (Size / Brotli)
./packages/@ember/-internals/glimmer/index.js 2.1 kB / 669 B 193%↑6.2 kB / 200%↑2 kB
./packages/@ember/-internals/views/lib/system/utils.js 4.6 kB / 1.2 kB 28%↑5.9 kB / 30%↑1.6 kB
./packages/@ember/array/proxy.js 9 kB / 2.2 kB 4%↑9.3 kB / 4%↑2.3 kB
./packages/@ember/component/index.js 40.2 kB / 9.4 kB 2%↑41.1 kB / 3%↑9.7 kB
./packages/@ember/object/core.js 26.1 kB / 6.4 kB 8%↑28.2 kB / 13%↑7.2 kB
./packages/@ember/object/promise-proxy-mixin.js 3.6 kB / 1.2 kB 35%↑4.9 kB / 33%↑1.5 kB
./packages/@ember/routing/route.js 54.1 kB / 12 kB 0.6%↑54.5 kB / 1%↑12.1 kB
./packages/@ember/routing/router.js 44.1 kB / 10.2 kB 2%↑45 kB / 3%↑10.5 kB
./packages/@ember/template-compiler/lib/template.js 8.9 kB / 2.4 kB -73%↓2.4 kB / -67.2%↓802 B
./packages/@glimmer/manager/index.js 1 kB / 297 B 54%↑1.6 kB / 76%↑522 B
./packages/@glimmer/reference/index.js 5.7 kB / 1.7 kB -89.9%↓579 B / -84.9%↓250 B
./packages/@glimmer/validator/index.js 1.7 kB / 668 B 46%↑2.4 kB / 29%↑865 B
./packages/shared-chunks/api-{hash}.js 24.3 kB / 5.3 kB -75.5%↓5.9 kB / -72.6%↓1.5 kB
./packages/shared-chunks/chain-tags-xANM849e.js 6.9 kB / 2 kB —
./packages/shared-chunks/computed-{hash}.js 28 kB / 6.6 kB 25%↑35 kB / 31%↑8.6 kB
./packages/shared-chunks/curly-{hash}.js 17.2 kB / 4.3 kB 17%↑20.2 kB / 16%↑5 kB
./packages/shared-chunks/element-{hash}.js 2.7 kB / 915 B 34%↑3.6 kB / 36%↑1.2 kB
./packages/shared-chunks/gxt-bridge-B1C5ZBct.js — 4.4 kB / 1.5 kB
./packages/shared-chunks/index-{hash}.js 66.4 kB / 16.3 kB 24%↑82.4 kB / 31%↑21.2 kB
./packages/shared-chunks/internal-{hash}.js 3.8 kB / 1.2 kB 5%↑4 kB / 4%↑1.3 kB
./packages/shared-chunks/iterable-CKueMdcC.js — 5.2 kB / 1.5 kB
./packages/shared-chunks/observers-{hash}.js 6.8 kB / 1.6 kB 118%↑14.8 kB / 145%↑3.8 kB
./packages/shared-chunks/property_set-{hash}.js 3 kB / 980 B 37%↑4 kB / 40%↑1.4 kB
./packages/shared-chunks/setup-{hash}.js 2.6 kB / 861 B 762%↑22.3 kB / 654%↑6.5 kB
./packages/shared-chunks/textarea-{hash}.js 12.5 kB / 3.2 kB 10%↑13.7 kB / 11%↑3.5 kB
./packages/shared-chunks/tracked-data-BBQSEQ2o.js 841 B / 377 B —
./packages/shared-chunks/tracked-{hash}.js 3.8 kB / 1.2 kB 70%↑6.4 kB / 69%↑2.1 kB
./packages/shared-chunks/unrecognized-{hash}.js 17.1 kB / 4.4 kB -0.65%↓17 kB / -0.37%↓4.3 kB

smoke-tests/v2-app-template/dist   2%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 312.4 kB / 83.6 kB 4%↑324.3 kB / 4%↑86.7 kB
./assets/modules-{hash}.js 33.9 kB / 10.2 kB -14.4%↓29 kB / -10.8%↓9.1 kB
Total (Includes all files) 347.8 kB / 94.5 kB 2%↑354.8 kB / 2%↑96.5 kB

smoke-tests/v2-app-hello-world-template/dist   3%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 158.7 kB / 43.6 kB 3%↑163 kB / 3%↑45.1 kB
Total (Includes all files) 159.1 kB / 43.8 kB 3%↑163.3 kB / 3%↑45.2 kB

🤖 This report was automatically generated by wyvox/pkg-size

lifeart added a commit to lifeart/glimmer-next that referenced this pull request Jun 12, 2026
The vite compiler plugin's .ts/.js content sniff (/hbs\s*`/) false-positived
on doc-comment prose containing the byte sequence hbs-backtick — e.g.
"components were authored in paired `.hbs` and `.js` files", where the
closing backtick of the inline-code span follows "hbs" directly. The plugin
then routed the entire module through the template transform and mangled its
exports (emberjs/ember.js#21340: "FunctionBasedHelper is not exported" broke
the GXT vite build on the PR merge ref after an upstream api-docs sweep).

The sniff now requires a genuine tag position via negative lookbehind
(excluding `.hbs`, 'hbs', "hbs", `hbs and identifier tails like stubhbs`),
extracted as hasHbsTaggedTemplate() with unit coverage.

Gates: tsc clean, vitest full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Jun 13, 2026
…integration APIs (#222)

* feat(host): registerHostHooks() formal host-integration API

Replaces the optional globalThis.__gxt* extension slots with module-local
hook slots behind an explicit registration API (HOST_HOOKS table). Covered
hooks: toBool, scheduleRevalidate (__gxtExternalSchedule), registerListMarker,
rebindEachItem, registerObjectValueOwner, getCurrentTemplateThis,
isFunctionalHelper/markFunctionalHelper (the EmberFunctionalHelpers Set), and
dynamicEval (the globalThis.$_eval fallback). Every runtime call site reads
the hook slot first and falls back to the historical global, so hosts running
the legacy wiring keep working unchanged (no lockstep upgrade).

Gates: tsc clean, vitest 3817/3817, qunit 1219/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(host): registerHostManagers() formal manager-registration API

Companion to registerHostHooks: replaces the legacy contract of hunting down
the module-original $_MANAGERS object reference (bridge accessors + deferred
microtask retries on the host side) and mutating it from outside. The
function lives in the same module instance the runtime call sites close
over, so registration always reaches the canonical table regardless of how
the host resolved the package — no object-identity hazards. Slots are
replaced wholesale (hosts carry extra private state on their manager
objects).

Gates: tsc clean, vitest 3819/3819, qunit 1219/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(compiler): don't treat hbs-extension prose as a tagged template

The vite compiler plugin's .ts/.js content sniff (/hbs\s*`/) false-positived
on doc-comment prose containing the byte sequence hbs-backtick — e.g.
"components were authored in paired `.hbs` and `.js` files", where the
closing backtick of the inline-code span follows "hbs" directly. The plugin
then routed the entire module through the template transform and mangled its
exports (emberjs/ember.js#21340: "FunctionBasedHelper is not exported" broke
the GXT vite build on the PR merge ref after an upstream api-docs sweep).

The sniff now requires a genuine tag position via negative lookbehind
(excluding `.hbs`, 'hbs', "hbs", `hbs and identifier tails like stubhbs`),
extracted as hasHbsTaggedTemplate() with unit coverage.

Gates: tsc clean, vitest full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Jun 13, 2026
…228)

Two independent regressions shipped in 0.0.66 broke the Ember dual-backend
integration (emberjs/ember.js#21340). Both root-caused by bisect against the
Ember GXT suite (each: 266 fail, Helpers: 666 fail under 0.0.66; 0/0 here).

1. Frame mode (#226) wrongly engaged for Ember hosts. frameHostHooksInstalled()
   detected the host via three globalThis.__gxt* slots, but the Ember
   integration migrated exactly those channels off globalThis into
   registerHostHooks (HOST_HOOKS module-local table). Worse, HOST_HOOKS is
   duplicated across entry chunks, so even a direct HOST_HOOKS probe from the
   frame-gate chunk sees an empty copy. Fix: registerHostHooks sets a single
   cross-instance globalThis sentinel (__gxtHostHooksInstalled) — restoring the
   cross-module-instance visibility the legacy __gxt* slots had — and
   frameHostHooksInstalled checks it (plus the legacy slots for pre-API hosts).
   Frame mode stays ON for standalone consumers; Ember bails to v1.

2. The drain-overhead diet (#227) broke the Ember formula-bridge reactivity
   (stale values, wrong arg order) despite its "semantics unchanged" claim.
   Reverted pending a re-attempt with the Ember integration suite as a gate —
   it was a modest constant-factor refactor, no functional dependency.

Validated against the full Ember GXT suite (both probes and gates): the six
affected suites (each/Helpers/dynamic-component/strict-mode/engine/rehydration)
return to 0 failures. gxt's own suite: 3905 functional tests pass.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Jun 15, 2026
Two independent regressions shipped in 0.0.66 broke the Ember dual-backend
integration (emberjs/ember.js#21340). Both root-caused by bisect against the
Ember GXT suite (each: 266 fail, Helpers: 666 fail under 0.0.66; 0/0 here).

1. Frame mode (#226) wrongly engaged for Ember hosts. frameHostHooksInstalled()
   detected the host via three globalThis.__gxt* slots, but the Ember
   integration migrated exactly those channels off globalThis into
   registerHostHooks (HOST_HOOKS module-local table). Worse, HOST_HOOKS is
   duplicated across entry chunks, so even a direct HOST_HOOKS probe from the
   frame-gate chunk sees an empty copy. Fix: registerHostHooks sets a single
   cross-instance globalThis sentinel (__gxtHostHooksInstalled) — restoring the
   cross-module-instance visibility the legacy __gxt* slots had — and
   frameHostHooksInstalled checks it (plus the legacy slots for pre-API hosts).
   Frame mode stays ON for standalone consumers; Ember bails to v1.

2. The drain-overhead diet (#227) broke the Ember formula-bridge reactivity
   (stale values, wrong arg order) despite its "semantics unchanged" claim.
   Reverted pending a re-attempt with the Ember integration suite as a gate —
   it was a modest constant-factor refactor, no functional dependency.

Validated against the full Ember GXT suite (both probes and gates): the six
affected suites (each/Helpers/dynamic-component/strict-mode/engine/rehydration)
return to 0 failures. gxt's own suite: 3905 functional tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lifeart
lifeart force-pushed the glimmer-next-fresh branch from 78a9661 to bfee346 Compare July 4, 2026 10:11
@lifeart
lifeart force-pushed the glimmer-next-fresh branch from 68874e5 to 8679ee1 Compare October 13, 2026 09:08
lifeart and others added 12 commits November 5, 2026 17:59
Adds the two GXT flavors next to the classic build, without changing the
classic one:

- `EMBER_RENDER_BACKEND=gxt` (rollup) / `GXT_MODE=true` (vite): the compat
  backend. A shared alias table (scripts/gxt-alias-map.mjs) swaps the
  @glimmer/* reactivity and manager packages for the gxt-backend shims and
  drops the Glimmer VM packages from the dist. `@glimmer/tracking` is not
  aliased: it is metal's `tracked` / `cached` in every build.
- `EMBER_RENDER_BACKEND=gxt-native`: the native layer. Classic Ember source
  with `__GXT_MODE__ = false`; only the modules that touch the Glimmer VM are
  aliased to gxt-backend/native (same table, native section), and templates
  compile with the gxt compiler.

`__GXT_MODE__` / `__GXT_CLASSIC_COMPONENTS__` are build-time constants
(scripts/replace-build-constant.mjs, declared in types/gxt-ambient.d.ts) that
both bundlers inline, so classic dists fold every GXT branch away.

Other pieces: `@lifeart/gxt` pinned to exactly 0.0.86 as a root
devDependency (ember-source does not install it; only the GXT packages
declare it), playwright for the GXT browser tests, the bundle-size check and
budgets (with a guard against GXT code left live in the classic dist), and
the compat package assembler (scripts/build-gxt-package.mjs). Production
dists keep only the `@__PURE__`-style and license comments (rollup
`shouldPrintComment`).

gxt-backend and @glimmer/component-gxt are linted and type-checked with the
rest of the repo: `pnpm lint`, with overrides scoped to the two trees
(barrel imports are allowed there, because the alias map matches exact
specifiers; an empty catch block is an error), and both root tsc programs
(tsconfig `paths` for the specifiers the trees use that no workspace link
provides; the stable-decorators program leaves out one test of legacy-only
decorator forms). The published types still leave the two trees out.

The compat test configs compile templates with Glimmer's whitespace rules
(gxt's EMBER_WHITESPACE flag), so a template's text is Glimmer's; the gxt
compiler imports decorator-transforms' runtime itself, so a decorated .gjs
module needs no `dt7948` global; a test module the gxt compiler cannot
compile becomes a failing synthetic module instead of a page that fails to
load.

On top of main's build: the classic test build keeps main's
`treeshake: false`; `@glimmer/component` resolves to its source in the GXT
configs; the compat package's renamed-modules map leaves out main's internal
mixin copies (`*-internal.js`), as packageMeta does.

The per-fix history, including the lint and type-check coverage
(5174ffe), is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: the native forbidden list also covers the
compat Glimmer host modules, and the bundle budgets are re-baselined (at the
measured floor + 5%) after the GXT leftovers left the classic dist.
The history of these follow-ups is on lifeart:gxt-followups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
packages/@ember/-internals/gxt-backend renders Ember with glimmer-next
(@lifeart/gxt) instead of the Glimmer VM:

- compile.ts / ast-transforms.ts: the Ember template dialect on the gxt
  compiler (runtime `precompileTemplate`, `plugins.ast`, the Ember keyword
  helpers) and the gxt runtime patches the dialect needs. A template the gxt
  compiler cannot compile throws with gxt's message (Glimmer's exact error
  text is not a goal);
- manager.ts / ember-gxt-wrappers.ts / helper-manager.ts: component, helper
  and modifier managers (classic curly components behind
  `__GXT_CLASSIC_COMPONENTS__`), the `$_tag` / `$_maybeHelper` / `$_dc`
  wrappers compiled templates call;
- validator.ts / reference.ts / destroyable.ts (+ the `-vm-compat` test
  surfaces): @glimmer/validator, /reference and /destroyable on gxt cells,
  including main's standalone `trackedValue` on the shim's tags;
- gxt-bridge.ts: the side-effect-free registry the classic packages reach
  the backend through, so classic builds never load it. Its glimmer-host
  slot is filled by glimmer-host.ts with the GXT code of the glimmer
  package: the root template (root-template.ts), `renderComponent` and the
  debug render tree (render-component.ts), the classic roots' render,
  destruction, bounds and forced re-render (classic-roots.ts) and the
  template adapter (template-adapter.ts);
- outlet.ts: route outlets. Main renders routes through route managers and a
  `RootOutlet` component; the compat backend keeps its own outlet machinery
  (the `<ember-outlet>` element, the root template's outlet path) driven by
  the `{ render, outlets }` chain that `Route#setup` records through the
  bridge's route-outlet hooks, and invokes any other route manager's wrapper
  as main's outlet does.

Also @glimmer/component-gxt and the backend's own QUnit tests (__tests__),
which run on the GXT test page.

Main fixes since the first version of this series, each with a test:

- No re-render loops: reading a helper's value no longer writes reactive
  state (helper args follow Glimmer's args proxy), and (hash)/(array)
  arguments keep one identity while their members do not change.
- Reactivity: every write to a @Tracked property re-renders (a gxt cell
  accessor no longer shadows the tracked one); a trackedArray changed in
  place, a property dirtied with dirtyTagFor and a cell written in a
  modifier's install all render by the end of the run loop; creating a
  binding evaluates its expression once; Glimmer component arguments, (mut),
  (readonly), dependent keys through arguments and createCache follow what
  they read; a render asserts when it writes a tracked value it already read.
- Instances: one helper instance per call site and render scope; one
  component instance per invocation (custom-managed and classic); each
  modifier on an element has its own instance; components, modifiers and
  helpers are destroyed with the block that rendered them, in node order, in
  the run loop that removes it.
- Rendering: attributes are not arguments and reach a component only through
  `...attributes`, with Glimmer's precedence; a null/undefined/false
  attribute or class is removed; URL attributes use Glimmer's own
  sanitization; a <template> component invoked by name, a class with a
  class-body template and a class with a registered template render through
  their component manager, with blocks, block params and `...attributes`; a
  content mustache renders what its value is (text, node, component, helper,
  curried value) and keeps its component while the curried value stays;
  dynamic components follow their definition; {{#each}} rows, {{#each-in}}
  keys, {{#in-element}}, SVG namespaces, named and else blocks, {{log}} and
  {{debugger}} behave as in Glimmer.
- renderComponent: a root updates in place instead of being rendered again
  on every tag change, is a root of its owner's renderer, renders into a
  cursor, an <svg> or with a custom-managed plain-object definition, and is
  part of the debug render tree.
- Errors: no error is swallowed any more. A render throws only its own
  errors, an update's errors (its hooks, a new branch's component, the DOM
  sync at the end of a run loop) reach the code that ran the run loop, and
  empty catch blocks are gone.
- Lint and type-check clean (ESLint 376 -> 0, tsc 497 -> 0 problems); the two
  vitest files became QUnit modules on the GXT test page.

The per-fix history, including the lint/type-check work and the moves of
gated code from the glimmer package into the host modules
(5ec62d6..b1bf210), is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: the GXT `template()` of @ember/template-compiler
(template-function.ts), the GXT re-render trigger and dependent recompute of
metal's property events (property-events.ts), the GXT parts of Route#refresh,
Route#setup and didTransition (outlet hooks), the view-tree rebuild of
getRootViews / getChildViews and a classic root's GXT render state
(classic-roots.ts) moved here from the classic files.
The history of these follow-ups is on lifeart:gxt-followups.

Also folded in after the third rebase onto main: a loose-mode
`{{attrs.x}}` raises Ember's assert-against-attrs assertion with its
source location; `{{debugger}}` in a template-only component sees the
template-only self through the render proxy; a `(fn ...)` result passed
to a helper is not invoked early; a helper recomputes when an EmberArray
argument changes in place; a custom modifier whose install throws keeps
the DOM already rendered (the error is rethrown after insert, as Glimmer
does). Their history is on lifeart:gxt-ember-easy-fixes and
lifeart:gxt-ember-hard-fixes.

Adapted in the rebase onto main 493985e, where a rendering test
renders its template through a Glimmer root (`BaseRenderer#render`) and
`Evented` is deprecated:

- a root of `BaseRenderer#render` renders through the glimmer host's
  `renderRoot`, at its cursor and without renderComponent's clear,
  replace and dedupe bookkeeping, and is returned to the caller;
- a classic component's lifecycle events are sent as Glimmer's curly
  manager sends them (`sendCoreViewEvent`: the listeners, then the
  method), without the `Evented#trigger` deprecation: the innermost of
  the backend's per-instance `trigger` wrappers sends a dispatch the
  backend marks, and an instance without a wrapper gets it directly; a
  `trigger` call from user code, also one inside a hook, stays the
  deprecated method;
- a component root gets the context cells of a classic root, so its
  function-valued and ArrayProxy-valued properties re-render when set;
- a component root renders inside a render pass, so a write of a value
  it already read raises the backtracking assertion;
- an error of a component root's commit phase (a modifier's install)
  keeps the render and is rethrown once the render is complete;
- backtracking messages label a component root `{ROOT}` and a classic
  root `-top-level`;
- a classic component destroyed with its block runs willDestroyElement
  while its element is still in the document;
- the arguments of an internal component detect `(mut)` and `(readonly)`
  values without reading other properties of an argument's value;
- a root whose first render failed stays in its renderer until the
  renderer is destroyed;
- the backend's tests run main's deprecated `A()` and
  `ObjectProxy.create` inside `ignoreDeprecation`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gxt-backend/native replaces only the Ember modules that touch the Glimmer VM
(renderer, outlet, managers, validator / reference / destroyable, template
factories, runtime helpers) with gxt-native implementations; everything else
is classic Ember source built with `__GXT_MODE__ = false`.

Main renders routes through route managers. Native aliases
`route-managers/root-outlet` to native/root-outlet.ts: the root outlet state
holds a native `OutletView` whose levels are live views of main's
`OutletState`s (owner, controller, tracked context as `@model`, the route
template looked up once per route bucket), and `renderComponent` renders a
`RootOutlet` through the owner's `appendOutletView`. The classic route
wrapper and the classic route manager's `RouteTemplate` (both Glimmer VM
internal components) are plain records, the route-manager registry asserts
through `@ember/debug` instead of `@glimmer/global-context`, `base-renderer`
maps to the native renderer, and owners with live roots are listed in main's
`renderers` list for `captureRenderTree`. Like main, the root outlet adds no
render-tree node.

Packaging: scripts/build-gxt-native-package.mjs and
scripts/gxt-native-package (ember-source-gxt-native, which pins the root's
exact @lifeart/gxt), assert-native-dist (the VM-free dist check), and the
modern-app bundle/boot harness (scripts/bundle-modern-app*.mjs,
smoke-tests/modern-app) plus the packaged consumer smoke test
(smoke-tests/gxt-native-package). Following main: the package and the
harness's dist snapshots carry ember-source's `sideEffects`;
@glimmer/component builds a dev and a prod dist of its own, and the package,
the preset and the dist assertion pick the one of the mode; the consumer app
compiles for main's browser targets (lib/browsers.cjs), so its bundle stays
byte-identical to the harness measurement; the renamed-modules map leaves out
main's internal mixin copies (`*-internal.js`), as packageMeta does.

Main fixes since the first version of this series:

- Decorated .gjs/.gts components boot in a consumer app: the package's vite
  preset makes gxt's decorator output import decorator-transforms' runtime
  instead of reading a `dt7948` global nothing installs.
- URL attributes are sanitized with Glimmer's own rules; a bound attribute
  that is a property of its element is set through the property, and a
  null/undefined one is removed.
- A component without a template yields its block (Glimmer's default
  `{{yield}}` template), and the nearest template of a class chain wins,
  registered or class-body.
- Retained memory per row: a call site without named arguments keeps one
  shared empty hash instead of the fresh `{}` gxt passes on every
  evaluation, and an element modifier is a class instance instead of two
  closures over its state.
- `{{debugger}}` calls Glimmer's debugger callback; the package build warns
  about a local gxt only when there is one.

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: assert-native-dist knows the compat Glimmer host
modules.
The history of these follow-ups is on lifeart:gxt-followups.

Also folded in after the third rebase onto main: `{{get}}` reads
properties of primitives (Glimmer's `isDict`); `{{hash}}` members are lazy
getters that stay reactive, with a custom tag for computed keys through
them; modifiers are destroyed child-first, in element-closing order; the
debug render tree names a renderComponent root `{ROOT}` and a component
bound through a strict-mode scope by its binding. Their history is on
lifeart:gxt-ember-easy-fixes.

Adapted in the rebase onto main 493985e: a root whose first render
failed is torn down and stays registered until its owner is destroyed;
the native rendering test case exposes the render root as `this.root`,
which main's tests destroy; a native test expects the deprecation of
main's `A()`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The places where classic Ember hands over to the compat backend. Every hook
is behind the build-time `__GXT_MODE__` constant and reaches gxt-backend only
through gxt-bridge, so the classic dists fold them away; the surrounding code
is main's.

- glimmer: base-renderer, renderer, templates/root.ts and unwrap-template.ts
  call the bridge's glimmer host (gxt-backend's root template,
  `renderComponent`, classic-root render / destroy / bounds, template
  adapter) from whole `if (__GXT_MODE__)` blocks or a top-level ternary, and
  assert that it is installed; a VM-free renderer state under GXT; GXT-aware
  `template()`, internal components, Input, element helper,
  scheduleRevalidate. root.ts keeps one side-effect-only import
  (@glimmer/manager) that the native build's module order needs.
- metal: the single gxt cell behind `@tracked`, the GXT re-render trigger in
  `notifyPropertyChange`, the CP re-entrancy guards, `set` hooks.
- routing: `Route#setup` / `teardownViews` record and drop the route's GXT
  render state, `_setOutlets` renders through the GXT outlet machinery (and
  skips main's incremental mid-transition passes, which have nothing new to
  render there), QP / meta fixes; the classic route manager keeps a raw gxt
  route template uninvoked, and the classic outlet component is registered
  as the wrapper the GXT outlets render themselves.
- runtime / object / runloop / application / views / template-compiler:
  smaller GXT hooks (observer flush budget, proxy unknownProperty during
  renders, settled PromiseProxy fast path, view-tree rebuild, GXT
  `template()`); `visit()` asserts that GXT supports neither the serialize
  nor the rehydrate render mode.

One classic change: the `{{debugger}}` callback moves from the VM opcode
module to @glimmer/runtime/lib/debugger-callback.ts (the opcode module
re-exports `setDebuggerCallback` / `resetDebuggerCallback`), so a renderer
without the VM calls the same callback.

Main fixes since the first version of this series: the hooks no longer
swallow errors (an error of the DOM sync at the end of a run loop is thrown
from the run loop's last step instead of being dropped; `template()`'s
implicit scope keeps only the expected eval failures); a render throws only
its own render errors; `renderComponent` of a plain-object definition runs
its custom manager, and a root with a `hasSubOwner` manager renders with the
manager's owner; a Glimmer component used as a route template is created by
its manager. Most of the GXT code these hooks used to carry inline now lives
in gxt-backend (the moves were c741f0d..b1bf210 on
lifeart:gxt-pre-squash-b1bf210aef).

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: the moves out of template-compiler, routing,
metal and views utils, and the removal of the classic leftovers (the
side-effect-only imports of renderer.ts, the input's GXT methods, the GXT
locals of ClassicRootState / set / tracked / ArrayProxy, barrel imports in
place of main's deep paths). The classic prod dist is main's plus the
`{{debugger}}` callback module and one alias line.
The history of these follow-ups is on lifeart:gxt-followups.

Adapted in the rebase onto main 493985e: main's rendering tests call
`BaseRenderer#render` directly, and under GXT it hands the render to the
glimmer host (`renderRoot`), as `renderComponent` does. `CoreView` stays
main's: gxt-backend sends the lifecycle events itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- scripts/gxt-test-runner: the Playwright + QUnit runner (one module per
  page load, baselines, smoke list, native allow-list and skips, contract
  tests, baseline gate; the gate's `--subset` mode compares only the modules
  a shard ran). `--shard` splits the sorted module list round-robin, and
  merge-runs.mjs merges the shard outputs into the summary an unsharded run
  writes, so CI gates the merged run and a baseline can be regenerated from
  it. The native allow-list follows main's test files;
- index.html: the GXT test-page setup (behind `__GXT_MODE__`) on main's page;
  native.html: the gxt-native test page, which also runs main's
  `setupTestHelpers()` and loads QUnit by package name like index.html;
- internal-test-helpers: GXT compile through the compat compiler, the GXT
  teardown harness (a module-level value, so classic test builds keep none
  of it; it throws what destroying a modifier or a helper throws), GXT
  artifact stripping in equalTokens / equalInnerHTML / qunit-dom `hasHtml`,
  GXT run helpers (`runTask` / `runAppend` sync the DOM after their run loop
  and rethrow its errors); the router test case exposes its root element to
  GXT;
- integration-tests: the GXT rehydration delegate (two plain GXT renders,
  the server one not interactive; serialization-dependent tests skipped with
  a reason) on main's `RenderDelegate` / `RenderHandle` interface; the tests
  that check Glimmer VM internals (list-update steps, serialization markers,
  dynamic variables other than outletState) skipped under GXT with a reason;
  GXT marker comments (`<!---->`, curried-component boundaries) ignored by the
  assertions; the jit delegate makes a curried component the way the GXT
  `component` helper does; no empty component sub-modules under GXT.

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: native-skip.json drops the entries that no
longer match a test after the rebase onto main (tests main moved, rewrote
or renamed).
The history of these follow-ups is on lifeart:gxt-followups.

After the third rebase onto main, which moved fragment-components-test.js
into components/classic/ and renamed its module, the native allow-list
names the new path and the module's 12 native-skip.json keys carry the
new module name.

Adapted in the rebase onto main 493985e: the rendering and router
test cases render through main's Glimmer root, under GXT through
`gxtHarness.runRender`, which keeps the semantics of `runAppend` (one
render pass id, the DOM sync after the run loop and its errors), and
`runAppend` starts a render pass for a classic `-top-level` root. Main's
two new rehydration-stats tests of a curly-invoked Glimmer component are
unsupported in the GXT delegate, as their classic siblings are.
native-skip.json drops the 18 skips of tests that main rewrote from
classic components to a custom-managed component under the same names,
and two skip reasons name the `{ROOT}` label. The README's smoke counts
are those of the rebased suite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GXT branches in shared tests (cache-counter parity, debug render tree,
resolver cache, helper registration through the runtime `hbs`), GXT skips
(hot reload, `{{mount}}`, classic `append`, the rehydration render mode, the
compile-options meta test), and renames that keep the names `array` and
`element` out of module scope in the single-chunk GXT test bundle.

New regression tests, which run on both backends, for the behaviours the
compat backend had wrong: has-block-params in nested blocks; the
(hash)/(array) identity tests (helpers/gxt-hash-array-identity-test.js,
because main rewrote array-test / hash-test as .gjs); attributes are not
arguments and URL attributes forwarded through `...attributes` are
sanitized, also on update (angle-bracket invocation, attribute bindings,
Input, LinkTo); a null/undefined/false attribute or class is removed and a
triple curly in an attribute renders its value (content-test); `<template>`
components invoked by name and classes with a class-body template
(template-tag-by-name-test.gjs, class-body-template-test.gjs,
template-tag-*-test.gjs); a `{{component}}` mustache passed as an argument
is curried; each `{{on}}` and each custom modifier on one element is its own
instance; renderComponent of a custom-managed plain-object definition, and a
root that updates in place, evaluates each binding once and throws only its
own errors. The named-outlets assertion test runs again (its method lacked
`@test`), with the column the compiler reports.

The rebase onto main keeps main's rewritten arranged-content tests, with the
`array` rename on top.

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Adapted in the rebase onto main 493985e: contextual-components-test.js
is main's file plus this series' new test, and mount-test.js keeps the
GXT skip of the whole file around main's version; the runtime resolver
cache and template factory tests take main's expectations with GXT
branches (no root definition, one `-top-level` hit under the debug
render tree), and main's copies of the old tests in components/classic/
keep the GXT branches the old tests had; angle-bracket-invocation-test.js
imports `ClassicComponent` again for the two tests that use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The GXT smoke (contract + sharded smoke modules, each shard gated against
the compat baseline), dual-build, gxt-native (modern-app boot, package,
native suite against its baseline) and full compat workflows. The full and
native suites run in parallel shards (5 and 4), and a combine job merges
them and gates the merged run with baseline-gate.mjs: the verdict is "no
test red that the baseline lists green, no baseline module missing, no
timeout or harness error", so known reds do not fail a run. The setup
action's optional Playwright install; the ember-source-gxt scenarios and the
v2 app template for them, whose boot smoke covers build-time .gjs components
(forwarded attributes, blocks, a class-body template, set() on a plain
field, decorators without a global) and compiles with Glimmer's whitespace
rules.

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test-results/gxt-baseline.json (compat, 10135/10376, 1037 modules) and
native-baseline.json (native, 7190/7203, 684 modules), regenerated from the
merged sharded runs of the GXT full and gxt-native workflows on this series
rebased onto main; and docs-internal-gxt-packaging-design.md.

Against the baselines before the rebase, every change comes from main or
from the test files this series converted, and no test turned red:

- main deleted the Enumerable and MutableEnumerable test modules, added
  `@each support`, `array computed macros deprecation` and (compat only)
  `DataAdapter releaseMethods`, all green, and changed the test or
  assertion counts of 17 existing modules in each suite (deprecation
  expectations, part of `EmberArray.@each support` moving to
  `@each support`, one more `Ember.A` test);
- main renamed one known red, `custom ember array properties rerender when
  updated` in Helper Tracked Properties (its key now carries `@test` twice,
  so its name starts with `@test`); it is still red under compat;
- the compat baseline gains the eight QUnit modules converted from vitest
  (`Compat: utils.ts` and its six children, `@glimmer/component-gxt:
  reactive identity`), all green.

The second rebase onto main changes only main's five new tests of a
cache's tag (emberjs#21664) in `@glimmer/validator: tracking > tracking cache`,
which both suites run (QUnit also counts them in the parent module). Four
are green. `it keeps its tag if it consumes the same tags in the next run`
is a known red in both baselines: the GXT validators do not reuse a cache's
tag when the cache consumes the same tags again, so an outer frame sees a
new tag after each re-run.

The third rebase onto main moves six classic-component test files into
components/classic/ and renames their modules (` (classic component)`);
their tests and results are unchanged. Main's rewrite of the
positional-params tests of `Component Manager - Curly Invocation` to a
native class whose manager's `updateComponent` is a no-op turns three of
them into compat known reds: the compat custom-manager arguments do not
update a native component's positional params unless `updateComponent`
writes them (native passes them). The fixes folded in with this rebase turn
9 compat and 14 native known reds green, among them `custom ember array
properties rerender when updated`.

The note in the compat file lists the run; the known reds are the failing
tests listed per module. A test that fails only because its expected error
message is Glimmer's exact text is a known red: the GXT backends throw their
own messages.

The per-fix history is on lifeart:gxt-pre-squash-b1bf210aef.

Folded in after the squash: the native baseline after the skip prune.
The history of these follow-ups is on lifeart:gxt-followups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Glimmer's validator now reuses a cache's tag when a re-run of the cache
consumes the same tags in the same order (`combineForCache`), and a frame
that consumed only a cache's tag ends with that tag. Both GXT validators
built a new combined tag on every run, so a frame that read such a cache
got a new tag each time ("it keeps its tag if it consumes the same tags
in the next run" failed in the compat and native suites).

- native: a cache's frame keeps its previous combinator when the frame's
  tracker holds the same leaves in the same order. A frame keeps the last
  combinator consumed into it when its tracker holds exactly that
  combinator's leaves, in order (its frame reads only that cache). gxt
  trackers still hold only leaf cells.
- compat: a cache keeps a tag of the tags its fn() consumed, reused while
  they stay the same and in order. A manual frame that read the cache and
  holds exactly those tags ends with it. The cache still forwards its
  tags, not its tag, to the frames and gxt trackers reading it.

The reused tag has the same members as the one it replaces, so revisions
and invalidation are unchanged; only its identity is kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A component whose custom manager creates it with its arguments and does
not replace them in `updateComponent` (a native class with a no-op
`updateComponent`) kept rendering its first arguments after a change
("positional params are updated if they change" in its three variants).

The component reads its arguments through the live `named` / `positional`
objects of `buildCustomManagedArgs`, which memoize each read per render
pass. An argument change re-renders without a new render pass, so the
component's reads, and the recompute of a computed property over them,
returned the memoized first values. A computed property over
`args.positional.[]` / `args.positional.0` / `args.named.x` also stayed
valid: its chain tags are the tags of those keys of the live objects, and
nothing dirtied them. Glimmer's args proxy reads the argument references
and tags those keys with the references' tags.

On a change, the custom-managed arg effect now hands the values it read
to the memos, and dirties the tags of the changed keys that exist (an arg
propagation, as for other component arguments), before it dispatches the
manager hooks and recomputes the dependent properties. A manager that
replaces `args` in `updateComponent` behaves as before.

A gxt-backend test covers the named arguments of such a component, in a
plain getter and in a computed property, across two changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…erts

In DEBUG, a `set()` or `@tracked` write during a render of a member that
a template of the same render read through a path now raises the
backtracking assertion with Glimmer's message: the components that were
rendering at the read and the template path ("`content` was first used:
- While rendering: -top-level x-outer this.wrapper.content"). That covers
`{{this.wrapper.content}}`, `{{@wrapper.content}}`, and an argument such
as `value=this.wrapper.content` that a template-only component renders.
Before, a child of a Glimmer component could write a member the parent's
template had rendered with no assertion, and a tracked member asserted
with "(unknown)" in place of the render tree.

The check (`checkBacktracking`) only knew the objects that classic
components render (`markTemplateRendered`), and it named the path after
a classic component's property. A Glimmer or template-only component
records nothing, and a template compiled at runtime reads a member with
no tap that reports the read.

Such a template reads `this.wrapper` through the proxy of its render
context and `.content` through the tracking proxy of the object that
read returned. The tracking proxy reads the member's cell, as Glimmer's
`getProp` consumes the member's tag. The render pass now records those
member reads in the frames where Glimmer consumes them (validator.ts:
inside a tracking frame, not untracked, not at commit). Each record
keeps the components on the parent-view stack, with a custom-managed
component named as it was invoked, and the path. `checkBacktracking`
asserts on a write of a recorded member when no classic component
rendered the object; the classic case keeps its message. compile.ts
still adds the template-only components to the tree. The read `set()`
makes of the value it replaces is not counted. The records are dropped
with the pass. All of it is DEBUG-only.

A member of a plain object (`{{this.person.name}}` with
`person = { name }`) is not covered: it is a plain property read that no
proxy sees.

Adapted in the rebase onto main 493985e, where a rendering test
renders through a Glimmer root labeled `{ROOT}`: the message names the
root as the other backtracking messages do (`renderRootLabel()`: `{ROOT}`
for a component root, `-top-level` for a classic one), and the render
pass that now brackets a component root's render holds the member
records. validator.ts imports both bridge members (`renderRootLabel` and
`installBacktrackingPart`).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both baselines are the summaries of the GXT full and GXT native runs of
this series rebased onto main 493985e.

- compat: 10195/10433 green across 1053 modules (was 10141/10377 across
  1038). Main renders the template of a rendering test through a Glimmer
  root, moved classic-component tests into components/classic/ (new
  module names, the same results) and added tests. All of them are green
  except 4 known reds that main added: "the render stack of a
  backtracking message starts with -top-level" (a classic root's
  backtracking message), "{{outlet}} in a component asserts" (Route
  manager - {{outlet}} under a non-classic level) and the two loose
  raw-block tests of the jit suite's "Inherited from Handlebars (legacy
  behavior): raw blocks". Main's two new rehydration-stats tests of a
  curly Glimmer component are skipped as unsupported, as their
  classic-component siblings are. The backtracking assertion for a
  member that a Glimmer component's template read turns 3 known reds
  green: "when a shared dependency is changed during children's
  rendering" and its "(tracked)" variant (curly components), and
  "asserts when a shared dependency is changed during rendering, and
  keeps original context" (template-only components). 222 known reds
  remain (was 220).
- native: 7187/7206 green across 684 modules (was 7192/7203). Main's 3
  new tests are green. The 18 tests that main rewrote from classic
  components to a custom-managed component with positional arguments run
  again (their skips named the classic versions): 10 are green and 8 are
  known reds. Native does not update the positional arguments of a
  curried custom-managed component (4), renders nothing for such a
  component given positional arguments as an argument (2), and resolves a
  curried component invoked with arguments in a mustache as a helper (2).
  19 known reds remain (was 11).

No test that main left unchanged went from green to red. The smoke
counts of the runner's README follow the new baseline: one known red,
274/275 tests and 1,834/1,835 assertions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lifeart
lifeart force-pushed the glimmer-next-fresh branch from 5d48546 to e4f2cd0 Compare November 6, 2026 01:43
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
In the Ember dialect a component's named args are emitted as
`$_args({ key: () => value }, …)`, where reactive values are zero-arg
getters the arg-access layer (ArgProxyHandler / the DEV $_args getter)
invokes on read. The layer historically invoked ANY function-valued arg,
so it could not tell a compiler-emitted reactive getter apart from a
function that is itself the arg value — a host-forwarded action or a
{{fn}}/helper bound result. Those got invoked on every read
(emberjs/ember.js#21340, "Components test: contextual components ::
renders with dot path and updates attributes": 4/8/15/22 instead of
1/1/2/3).

The compiler now wraps the reactive getters it emits in buildArgsObject
with `$_argThunk`, and the arg layer invokes ONLY branded thunks; every
other function value passes through unchanged. The marker is a plain
string-keyed property (like `$_nt`), minification-proof; `isArgThunk` is
exported so a host can retire its `fn.length`/`fn.prototype` shape
heuristic.

Gated to the Ember dialect (WITH_EMBER_INTEGRATION + IS_GLIMMER_COMPAT_MODE):
that is the only layer that forwards bare function values into gxt args,
so standalone gxt keeps byte-identical codegen and its
invoke-every-getter arg semantics.

- src/core/dom.ts: $_argThunk / isArgThunk; brand-only invocation in
  ArgProxyHandler + the DEV $_args getter (gated).
- compiler: emit $_argThunk around reactive arg getters (reactiveGetter /
  zero-arg arrow / $__cached memoized getter) in buildArgsObject.
- register ARG_THUNK in both symbol registries, GXT_RUNTIME_SYMBOLS.
- tests: emission + gate + runtime predicate + AOT auto-import guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
A dynamic modifier whose name is a `this.*` path was emitted as a plain
member read (`$s.dyn`). On an EmberObject with a non-@Tracked `dyn` that
read taps no tag, so the modifier formula is treated as const and a later
`set(ctx, 'dyn', X)` never re-installs the modifier — it is never
destroyed/created. Under EMBER_PROPERTY_TAGS the modifier-name path now
goes through the same `$__tag(obj, "key")` chain as a value path
(buildPathExpression → buildEmberMemberChain), so resolution is reactive.
Standalone keeps the plain read.

Unblocks the native emberjs/ember.js#21340 test "Basic Custom Modifier
Manager: 3.22 :: destroys a dynamic modifier that was set after the
initial render".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
The compiler reduced a thrown parse error to a one-line `message`
(`parseError.message.split('\n')[0]` + token/expected hints) and
discarded the original, so a host could only re-wrap the reduced string,
losing the `@glimmer/syntax` SyntaxError's code-frame and position. Both
the compiler and the Ember host pin the same `@glimmer/syntax`, so the
original error's message/code-frame/position already match what the host
would produce.

Preserve it on `CompilerError.originalError` (threaded through
`enrichError` from all three catch branches) so the host can rethrow it
unchanged when there is a single error. Synthesized diagnostics (unknown
binding, W-codes) leave it undefined.

Unblocks the emberjs/ember.js#21340 compile-time error group
(`compile errors` + `general syntax errors`, ~21 tests) once the Ember
host rethrows `originalError`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
`(mut this.foo)` / `(mut @bar)` pass the path as a string the host
parses to build a setter, but a lexical JS head (`state.arg1`, a block
param) cannot be resolved by name at runtime, so it was dropped and the
host's `mut` asserted "You can only pass a path to mut". A lexical-head
mut (head is a known binding, dotted tail) now lowers to the existing
`(mut (get obj key))` machinery — `__mutGet(() => head, "rest")` — a
parent getter + key the host writes through with `set(parent, key, v)`.
The parent getter is buildValue's own read of the head, so it yields the
real parent object whatever the binding's compiled shape, and a dotted
tail is passed as __mutGet's dotted key. this./@ heads and non-compat
builds are unchanged. Mirrored in the SubExpression (visitors/index.ts)
and mustache (visitors/mustache.ts) handlers.

Unblocks the emberjs/ember.js#21340 tests "Helpers test: {{fn}} :: can
be used on the result of `mut`" and its "… with a falsy value" variant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
The `(mut <lexical path>)` lowering from the previous commit built its
`__mutGet` helper with
`helper('__mutGet', [parentGetter, literal(restKey)], range, pathRange)`,
omitting the `named` Map argument. `range` (a SourceRange) therefore landed
in the `named: Map<string, SerializedValue>` slot, so at serialize time
`value.named.values()` threw (reported as compile error E001) and the three
lexical-head lowering tests in mut-lexical-head.test.ts failed with a stray
errors entry. It is also a `tsc` type error (TS2345: SourceRange not
assignable to Map), which failed the `tsc &&` step of `pnpm dev:playwright`,
so the Playwright webServer could not start (exit code 2) — the same root
cause.

Pass `new Map()` as the named args, matching the sibling `(mut (get obj key))`
__mutGet calls, in both the SubExpression (index.ts) and mustache (mustache.ts)
handlers.

Part of the fix for the emberjs/ember.js#21340 tests "Helpers test:
{{fn}} :: can be used on the result of `mut`" and its "… with a falsy
value" variant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
DRAFT. The root template is built detached and inserted only after the
whole template evaluates, so an in-element whose destination is a sibling
rendered earlier in the same render (document.getElementById) sees null
and the host's _emberInElement asserts. gxt already defers in-element to
the mount hook for the native runtime, but the Ember compat path resolves
the destination during build, so the fix must make the root render stream
nodes into their parent as they are built.

This commit adds the target-behaviour regression spec as todos; the
streaming implementation is described in the PR and relies on CI /
the emberjs/ember.js#21340 test "[integration] jit :: Application test:
debug render tree :: in-element in tree" for validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
…ction

Adds `HOST_HOOKS.onCellRead(object, key)`, fired from the property-tag
tap `$__tag` for every reactive read of an object/function head — the
compiler-emitted equivalent of Glimmer's "consumed tags". A host
snapshots the (object, key) pairs read during a render pass and raises
the backtracking assertion when a later non-tracked set() dirties one of
them — detection compat silently lost for Glimmer (template-only)
components, whose reads were never recorded (manager.ts checkBacktracking
only fired for template-rendered objects, which handleCustomManagedComponent
never populated).

Scope: this is the gxt-side SEAM only. It is hook-presence gated (a single
branch, zero-cost when absent) and installed only by a DEBUG host, so it
is a no-op for standalone gxt and every existing test. The snapshot +
assertion logic lives Ember-side and is validated there; the three
emberjs/ember.js#21340 backtracking tests that never see the assertion
("Components test: curly components :: when a shared dependency is
changed during children's rendering", "Components test: dynamic
components :: component helper emits useful backtracking re-render
assertion message" and "Components test: template-only components
(glimmer components) :: asserts when a shared dependency is changed
during rendering, and keeps original context") go green once the Ember
consumer lands and pins a gxt release carrying this hook. Fired before
the getProp delegation so the read is captured whichever tag path runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
gxt rewrote `@index` to `@identity` at compile time (W003), so the list
keyed rows by VALUE and a reorder moved/re-created rows — `@index` yielded
the wrong index and nodes "changed" on reorder. Now:

- compiler (serializers/control.ts normalizeEachKey): emit `@index`
  verbatim, drop the `@index -> @identity` rewrite + W003.
- runtime (control-flow/list.ts):
  - setupKeyForItem: `@index` keys each row by its POSITION, so a reorder
    leaves every key in place — the diff keeps (never moves) the row node
    and rebindReusedRow updates its bound item.
  - the index block param is a const formula equal to the row's fixed
    position (not the bound item's current position).
  - exclude `@index` from the property-name stale-key eviction (a
    positional key has no `item['@index']`, which would wrongly evict
    every reused row on reorder).

Standalone gxt has no body-proxy, so a reused row's displayed item only
updates through the host `rebindEachItem` seam (fired here); node identity
+ positional index are the standalone-observable core. tests: compiler
passthrough + runtime (nodes stay vs @identity moves, positional index,
rebind seam fires).

For the emberjs/ember.js#21340 tests "[integration] jit :: Updating ::
The each helper yields the index of the current item current item when
using the @index key" and the native "Syntax test: {{#each}} keys and DOM
identity :: @key keys items by position".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart added a commit to lifeart/glimmer-next that referenced this pull request Nov 6, 2026
Native `{{#each-in obj key=KEY}}` forwarded `key=` but it was resolved
against the lowered `[key,value]` PAIR, so `@key`/path keys resolved to
`undefined` (rows collapsed) and `@index` degraded to `@identity`. Now:

- compiler (visitors/block.ts + serializers/value.ts): a non-identity
  `key=` is forwarded to `$__eachIn(obj, "key")`, and the lowered `each`
  keys by `@identity` on the returned pairs ($__eachIn owns the keying).
  `@identity`/default stays the historic 1-arg `$__eachIn(obj)`.
- runtime (helpers/each-in.ts): `$__eachIn(obj, key)` resolves the key
  against each ENTRY (Glimmer toEachInIterator / Ember keyEachInEntries):
  `@identity` keys by the entry (value change → new pair → new row);
  `@key`/`@index`/path key by the entry key / position / resolved path
  value, retaining the pair and updating its value in place while the row
  key is unchanged. Per-mode caches keyed by the row key directly (object
  or primitive, by reference — no String() collapse) so distinct key modes
  over one object don't clobber.

Standalone gxt observes the KEYING (which rows kept/recreated); the
retained row's displayed value re-reads through the host body-proxy
(Ember), validated there. tests: unit ($__eachIn per-mode stability +
in-place update), compiler (key forwarding), render (native: path key
retains the row node on same-id replace vs @identity recreates).

For the five native emberjs/ember.js#21340 tests of "Syntax test:
{{#each-in}} with the key option" (the default value key, key="@key",
key="@index", a path key, and key="@index" over a Map keyed by objects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifeart and others added 5 commits November 6, 2026 03:16
…ed like keywords

Tests of what Glimmer does with a template's lexical names, where the GXT
compat backend rewrote the template source with regular expressions before
it compiled it. The classic build runs them too: its results are the
expected ones.

Runtime `template()` (runtime-template-compiler-scope-test.ts):

- with `scope: () => ({ this: state })`, text such as `(this one)` stays
  text, and a `this` path passed as an argument (`{{shout this.cls}}`)
  reads `state`;
- with `eval`, a word of text is not looked up through `eval` (a global
  getter of that name is not read), and a helper named in PascalCase
  (`{{Greeting}}`) is in scope;
- a block keyword that the scope shadows (`{{#unless}}`, `{{#each}}`) is
  an invocation of the scope's component: its `{{else}}` block, positional
  arguments and named arguments are the component's (named arguments, not
  HTML attributes). The positional arguments are read by a component with a
  custom manager, since a template-only component takes none.

Block params named like keywords (block-param-names-test.js):

- outside the block that declares `component`, `{{component "foo-bar"}}` is
  the keyword;
- inside it, the block param is read as an argument (`{{concat "got "
  input}}`);
- a block invocation of a block param named `input` keeps its `{{else}}`
  block and named arguments, and evaluates the block param's value once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ads `this`

With `template(source, { scope: () => ({ this: state }) })` the template's
`this` is `state`. Every `this` path now reads it: an argument
(`{{shout this.cls}}`), a hash value, a block's value
(`{{#let this.x as |y|}}`) and a block-form tag (`<this.Foo>…</this.Foo>`).
Text such as `(this one)` stays text.

Root cause: template-function.ts renamed the `this` head in the template
source, with `/(\{\{[#/!]?\s*|\(\s*|<\s*)this\b/g` → `__gxtExplicitThis`.
A regular expression over the source cannot tell a path from text. It
rewrote `(this one)` in text, and it missed every `this` that does not
directly follow `{{`, `(` or `<`, which then read the rendering context. It
also missed the closing tag `</this.Foo>`, which no longer matched its
renamed opening tag.

Replacement: `gxtExplicitThisTransform` (ast-transforms.ts), an AST plugin
that `template()` passes to the compile. On the parsed template it turns
each `this` path head and each `this`-headed tag into the alias that the
scope binds to `state`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ree variables

`template(source, { eval })` takes from the caller's `eval` the template's
free variables: the heads of its paths and component tags that no block
param in scope binds, as `getTemplateLocals` of @glimmer/syntax finds them.
A word of text, an attribute name or a comment is no longer looked up, so a
global getter of that name no longer runs. A helper or component named in
PascalCase in mustache position (`{{Greeting}}`) is now found.

Root cause: seven regular expressions over the source collected the names.
The last one took every lowercase word (`/\b([a-z][a-zA-Z0-9_]*)\b/g`), text
included. The others needed a lowercase name, or a PascalCase one after `<`
or before `.`, so `{{Greeting}}` was not looked up and the strict-mode
template raised "not in scope".

Replacement: `gxtTemplateLocals` (ast-transforms.ts) walks the parsed
template with its block-param scopes (`walkGxtScopes`). A name that is not
a plain JS identifier (`has-block`, a reserved word) is not looked up.
The template is parsed by the GXT compiler's own parser: gxt does not export
it, but hands it to AST transforms as `env.syntax.parse`, and
template-function.ts takes it from there once. The compat build gets no
second template parser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lock

Some block params are named like a keyword that the compat backend lowers by
name (`component`, `input`, `textarea`, `mount`, `outlet`, `link-to`), or
have a name that starts with `-`. Inside its block, such a block param is
now read as the block param everywhere: an argument
(`{{concat "got " input}}`), a sub-expression, a block invocation
(`{{#input}}`) and a tag. Outside the block, the name is the keyword again
(`{{component "foo-bar"}}` after the block).

Root cause: `compile()` (ember-template-compiler.ts) renamed these block
params on every compile, with `/\sas\s*\|([^|]+)\|/g`. It then rewrote
`{{NAME` across the whole template, with
``new RegExp(`(\{\{\{?)(?!#|/)(\s*)${name}(\b)`, 'g')``. Three problems
followed:
- the rename leaked outside the block, so `{{component "foo-bar"}}` read an
  unbound name;
- `\b` also matched `{{input-foo}}`;
- only mustache heads followed the renamed block param, so
  `{{concat "got " input}}` read an unbound name.
`precompileTemplate()` and `template()` did not rename at all.

Replacement: `gxtBlockParamNamesTransform` (ast-transforms.ts) renames the
block param and every read of it in its scope, on the AST. compile.ts runs
it for every runtime compile, before the user's AST plugins and the dialect
transforms, as the source rewrite ran before parsing. compile.ts gives the
dialect transforms the block-param names under their new names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and blocks

The scope can shadow a block keyword (`if`, `unless`, `each`, `each-in`,
`let`, `with`, `input` or `textarea`). Then
`{{#each items name="b"}}A{{else}}B{{/each}}` invokes the scope's value as a
component, with its positional and named arguments, its block and its
`{{else}}` block, and evaluates each argument once.

Root cause: compile.ts (`if` to `with`) and `compile()` in
ember-template-compiler.ts (`input`, `textarea`) rewrote
`{{#NAME …}}…{{/NAME}}` into `<Alias …>…</Alias>`, with
``new RegExp(`\{\{#(${name})(\s[^}]*)?\}\}`, 'g')``. This had four effects:
- arguments became HTML attributes (`items=""`, and `name="b"` instead of
  `@name`);
- `{{else}}` inside an element is a parse error;
- `[^}]*` stopped at a `}` in an argument;
- the pattern matched in comments too.
For a block param named `input`, `compile()` also turned
`{{#let EXPR as |input|}}` into `{{#let EXPR EXPR as |input Alias|}}`, which
evaluated EXPR twice.

Replacement: `gxtShadowedKeywordTransform` (ast-transforms.ts) renames the
head of each block keyword that the scope shadows to the keyword's alias,
unless a block param in scope rebinds the name. compile.ts binds the alias
to the same value. The positional arguments become the `__pos<i>__` /
`__posCount__` arguments that the GXT compiler passes for a curly
component's positional params, since it drops a lexical block's positional
params. The GXT compiler then invokes the alias with its block, its
`{{else}}` block and its named arguments. A block param named `input` or
`textarea` is renamed in its block (previous commit), so its block
invocation needs no copy of the `{{#let}}`. Both source rewrites are
deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants