Skip to content

[FEATURE glimmer-next-demo] Demo app for glimmer-next renderer#21340

Draft
lifeart wants to merge 503 commits intoemberjs:mainfrom
lifeart:glimmer-next-fresh
Draft

[FEATURE glimmer-next-demo] Demo app for glimmer-next renderer#21340
lifeart wants to merge 503 commits intoemberjs:mainfrom
lifeart:glimmer-next-fresh

Conversation

@lifeart
Copy link
Copy Markdown
Contributor

@lifeart lifeart commented Apr 24, 2026

GXT dual-backend rendering (opt-in preview)

Re-created from #20711 with an updated
architecture split, first-class package layout, baseline-gated CI, and a
draft RFC.

Summary

This PR adds Glimmer-Next / GXT (@lifeart/gxt) as an opt-in, build-time
alternate rendering backend for ember-source, sitting behind
EMBER_RENDER_BACKEND=gxt (production bundles) and GXT_MODE=true (the Vite
dev loop). The split happens strictly at the @glimmer/* + ember-template-compiler
boundary — everything above that line is shared @ember/* code, everything
below it is backend-specific. Classic Glimmer remains the default with no
behavior change and no public API change
; GXT is tree-shaken out of the
classic bundle. A draft RFC (rfcs/text/0000-gxt-dual-backend.md) accompanies
the implementation and is intended to be promoted to an emberjs/rfcs PR.

Motivation

  • Smaller runtime model for client-only apps. GXT is closure-based and has
    no VM opcodes, no wire format, and no template JIT — just reactive cells and
    direct DOM adapters. For apps that do not need SSR or Glimmer-VM-only
    addons, this is a meaningful architectural simplification.
  • Upstream the @lifeart/gxt compat work into mainstream Ember so that
    consumers can evaluate a second backend without a fork. The compat layer is
    Ember-owned code; GXT itself stays an external dependency.
  • Dual-backend posture lets the community measure GXT in real apps without
    asking the Glimmer team to maintain a second runtime or rewriting GXT's
    reactive core onto VM opcodes (which are architecturally incompatible — see
    RFC §Motivation).
  • Zero-cost to classic consumers. The classic bundle is byte-for-byte
    identical to pre-PR output on targeted modules; nothing is conditionally
    compiled in the hot path.

What's in this PR

~432 commits, ~106k insertions across ~219 files. Organized by area:

New package — packages/@ember/-internals/gxt-backend/

First-class home for the compat layer (moved out of the previous
packages/demo/compat/ scratch location). Declared as a private package with
a full exports map in its package.json. Key files:

  • manager.ts — the heart of the adapter. Ember component / helper /
    modifier managers translated onto GXT's reactive + lifecycle primitives.
    Large, but organized by internal headers (best reviewed section by section).
  • compile.ts — template compiler bridge: accepts the Ember .hbs / .gts
    input shape and produces a GXT template factory. Paired with
    gxt-template-compiler-plugin.mjs and gxt-template-factory.ts.
  • reference.ts, validator.ts, destroyable.ts — seam shims for
    @glimmer/reference, @glimmer/validator, @glimmer/destroyable.
  • glimmer-tracking.ts, glimmer-application.ts, glimmer-util.ts,
    glimmer-env.ts, glimmer-syntax.ts — drop-in substitutes for the
    corresponding @glimmer/* packages.
  • ember-template-compiler.ts, runtime-hbs.ts, gxt-with-runtime-hbs.ts,
    test-compile.ts — template-compiler entry points across production and
    test harnesses.
  • outlet.gts, link-to.gts, ember-routing.ts — router integration.
  • helper-manager.ts, ember-gxt-wrappers.ts — helper manager adapter and
    Ember-side wrappers for GXT primitives.
  • debug.ts, debug-render-tree.ts, ember-inspector-adapter.ts,
    ember-inspector-hook.ts — partial Ember Inspector parity surface
    (follow-up work — see RFC §8).
  • __tests__/ — direct unit tests for the adapter, including a
    rehydration-delegate suite.

Vendored packages/@glimmer/manager/index.ts

Gained no-op stubs for the GXT hook symbols (onTag, onComponent,
onModifier) plus namespace-import-friendly re-exports so that tracked.ts
and internal.ts resolve identically on both backends without conditional
compilation. On classic, the stubs are unreachable and stripped by
tree-shaking.

Classic-side integration hooks

Edits under packages/@ember/-internals/glimmer/,
packages/@ember/-internals/metal/, packages/@ember/object/,
packages/@ember/routing/, and packages/@ember/runloop/ add the narrow set
of hooks GXT needs to observe and participate (CP re-render cascades,
notifyPropertyChange gating, outlet re-render instrumentation, runloop
scheduling boundaries). Every change is a no-op on the classic build path;
they exist only so GXT has something to bind to.

Demo app — packages/demo/

Vite-based demo that exercises the GXT backend end-to-end (vite.config.mts,
src/, tests.html). This is the fastest way to poke at the backend in a
browser and is also what the test runner drives under the hood.

Build-time aliasing

  • rollup.config.mjs gained an EMBER_RENDER_BACKEND=gxt branch that swaps
    @glimmer/* and ember-template-compiler aliases for the gxt-backend
    entry points. Default remains classic.
  • vite.config.mjs gained the same aliasing under GXT_MODE=true, driving
    the dev loop and the Playwright test runner.

RFC draft

  • rfcs/text/0000-gxt-dual-backend.md — SemVer posture, feature support
    matrix, FastBoot/engines disposition, @glimmer/component disposition,
    Ember Inspector parity plan, numeric exit criteria for leaving preview.
  • rfcs/text/0000-gxt-dual-backend-addon-matrix.md — best-effort
    top-20-addon compat snapshot (7 pass / 4 classic-only / 9 untested;
    every "pass" is inference, not yet verification).

CI

  • .github/workflows/gxt-dual-build.yml — builds both backends on every PR,
    runs bundle-size check per backend, uploads artifacts.
  • .github/workflows/gxt-smoke.yml — 4-shard Playwright smoke suite on every
    PR, required check, finishes in under 5 minutes.
  • .github/workflows/gxt-full.yml — nightly full suite, compares against
    test-results/gxt-baseline.json, opens a regression issue on green→red.

Tooling

  • scripts/gxt-test-runner/ — Playwright + QUnit runner replacing the
    earlier stuck-detection prototype. QUnit.on('runEnd', …) is the only
    completion signal; hangs are recorded as timeouts, never baseline passes.
    Includes runner.mjs, diff.mjs, categorize.mjs, contract-tests.mjs,
    and smoke-modules.json.
  • scripts/bundle-size-check.mjs + scripts/bundle-budgets.json — CI gate
    on both backends' bundle sizes.
  • scripts/ember-cli-gxt.mjs — consumer-facing CLI plugin:
    ember-cli-gxt enable|disable|status.
  • test-results/gxt-baseline.json — committed baseline that the nightly
    runner diffs against to catch regressions.

Backwards compatibility

  • Classic (default) build is byte-for-byte identical to pre-PR output on
    the targeted modules. No @glimmer/* import was moved, renamed, or routed
    through a seam layer — classic is still classic.
  • Public @ember/* API surface is unchanged on both backends; 12 contract
    tests in scripts/gxt-test-runner/contract-tests.mjs verify that both
    backends export the same symbols with matching signatures.
  • Everything is gated behind EMBER_RENDER_BACKEND=gxt / GXT_MODE=true.
    Nothing in this PR is reachable on a default build.

Opt-in usage

Local dev loop:

# Terminal 1: GXT-aliased dev server
GXT_MODE=true pnpm vite --port 5180

# Terminal 2: GXT smoke tests
node scripts/gxt-test-runner/runner.mjs --smoke

Production bundle:

EMBER_RENDER_BACKEND=gxt npx rollup --config rollup.config.mjs

Or via the CLI plugin: node scripts/ember-cli-gxt.mjs enable.

Test parity

  • Smoke suite: 333/333 on both backends across the 14 session-targeted
    modules (components, angle-bracket invocation, curly, template-only,
    contextual, built-in helpers, custom helpers, modifiers, tracked state,
    {{each}}, {{if}}/{{unless}}, {{let}}, computed, observers).
  • Full baseline (Phase 0 snapshot, committed as
    test-results/gxt-baseline.json):
    5,327 / 5,938 (~89.7%) passing on GXT.
  • Remaining failures are triaged into 5 buckets: rehydration/SSR (393),
    Glimmer JIT-specific internals (77), Ember Inspector / debug-render-tree
    (58), engine/route-transition edge cases (41), miscellaneous (42).
  • The branch has continued to close failures past the Phase 0 snapshot.
    The ~300 most recent commits on glimmer-next-fresh are targeted
    fix(gxt): commits against rehydration, query-params, contextual
    components, computed-property cell setup, custom modifiers, and more.
    git log upstream/main..HEAD shows the full record; the baseline file
    should be refreshed before merge.
  • CI gates regressions green→red against the committed baseline on every
    nightly run.

Known limitations / follow-ups

  • FastBoot / SSR pipeline bridge is not in this PR. GXT has a working
    rehydration subsystem (see
    packages/@ember/-internals/gxt-backend/rehydration-delegate.ts and
    recent fix(gxt): rehydration — … commits), but the classic FastBoot
    marker-translation path has two open architectural blockers: root-context
    isolation inside compile.ts (RFC Phase 4.1) and lossy cursor-ID
    translation for nested engine outlets (Phase 4.2). The delegate ships as
    an opt-in escape hatch, not as the default SSR path.
  • @glimmer/component import-identity question. The published package
    directly imports @glimmer/manager + @glimmer/reference; if an app
    installs @glimmer/component@2.x alongside ember-source-gxt, symbol
    identity for Tag / createTag / CURRENT_TAG / getCustomTagFor forks.
    RFC §6 documents two resolution options (sibling @glimmer/component-gxt
    vs. protocol-package extraction); neither is implemented here.
  • Embroider strict-mode validation is TBD. The backend has not been
    exercised against a fully strict-mode Embroider build.
  • Bundle-size audit follow-up. Current measurement (Phase 3,
    rollup.config.mjs output): GXT prod is ~3.48 MB raw vs. classic's
    ~2.05 MB — approximately 70% larger raw, 68% larger gzip. Dominated
    by @lifeart/gxt's reactive core + bundled template compiler with no
    tree-shaking applied yet
    . A rollup-plugin-visualizer sweep (RFC Phase
    2.5) is the recommended next step; until it lands, the 70% premium should
    be read as a worst-case upper bound, not a final number.

RFC status

Draft at rfcs/text/0000-gxt-dual-backend.md (plus the addon matrix
companion), marked Stage: Accepted for the purposes of tracking branch
work. The intent is to promote it to a real RFC PR against emberjs/rfcs
before a preview tag ships — an Ember core team scheduling question, noted
in the RFC's own follow-ups table.

How to review

Suggested order, shortest path to "is this sane?":

  1. RFCrfcs/text/0000-gxt-dual-backend.md (motivation, SemVer posture,
    exit criteria). Then the addon matrix companion for the ecosystem picture.
  2. Package shapepackages/@ember/-internals/gxt-backend/package.json
    and the exports map. Confirms the public entry points the rest of Ember
    is expected to reach through.
  3. manager.ts — the heart of it. Large, but organized by internal
    section headers; follow those rather than reading top-to-bottom.
  4. compile.ts — template-compiler bridge. Same guidance: follow the
    internal headers.
  5. Classic-side diffs under -internals/metal/, -internals/glimmer/,
    @ember/object/, @ember/routing/, @ember/runloop/. These are small,
    narrowly scoped, and each should read as a no-op on classic.
  6. CI workflows.github/workflows/gxt-*.yml plus
    scripts/gxt-test-runner/README.md and scripts/bundle-budgets.json.
  7. test-results/gxt-baseline.json — don't read it, but confirm the
    regression gate is in place.

Not in scope

  • Flipping the default backend. Classic stays the default. A default-flip
    is a future RFC consideration, gated on the numeric exit criteria in RFC §10.
  • Ember Inspector full parity. A partial adapter is included
    (ember-inspector-adapter.ts, ember-inspector-hook.ts,
    debug-render-tree.ts) but full parity is follow-up work pending GXT's
    internal component-tree API stabilization.
  • JIT-specific integration tests. 77 failures in the Phase 0 bucket are
    Glimmer-VM JIT internals that are architecturally incompatible with GXT
    (no opcodes, no JIT). These are explicitly not targeted for parity.
  • Republishing as ember-source-gxt on npm. The RFC discusses the
    side-channel package story; this PR only lands the dual-build capability
    inside the monorepo.

lifeart and others added 30 commits April 24, 2026 15:57
Rewrite the gxt-backend validator.ts shim so it matches classic
@glimmer/validator semantics required by the `@glimmer/validator: tracking`
(60/60) and `@glimmer/validator: validators` (44/44) test suites.

Key changes:
 - New discriminated tag shapes (TYPE_DIRTYABLE/UPDATABLE/COMBINATOR/
   CONSTANT/VOLATILE/CURRENT) with a monotonic `\$REVISION` clock.
 - track()/beginTrackFrame/endTrackFrame now push onto a unified frame
   stack that isTracking() walks; untrack() pushes an untrack frame.
 - dirtyTag asserts dirtyable, bumps \$REVISION, and calls the live
   globalContext.scheduleRevalidate binding.
 - updateTag asserts TYPE_UPDATABLE (throws otherwise) and records a
   buffered subtag so previously-dirtied subtags don't retroactively
   invalidate the parent; cycle detection happens in currentTagRevision
   so tests that expect throw at validateTag time work correctly.
 - createCache throws on non-function input in DEBUG; getValue/isConst
   throw on non-cache inputs; isConst returns true for caches that
   consumed zero tags (constant caches).
 - runInTrackingTransaction tracks consumed tags per-transaction so
   dirtying a previously-consumed tag throws, while untrack() inside a
   transaction properly suppresses consumption bookkeeping.
 - trackedData getter/setter now go through classic consume/dirty so
   track frames and createCache observe mutations.
 - CURRENT_TAG, CONSTANT_TAG, VOLATILE_TAG are proper object tags
   (object identity for ALLOW_CYCLES.set compatibility).

Back-compat with existing gxt-backend infrastructure is preserved:
 - dirtyTagFor still bumps the classic-tag-bridge cell and fires
   registered classic reactors; its tag's revision is written into a
   legacy WeakMap read by currentTagRevision's fallback path.
 - Legacy updateTag deps for GXT-produced tags use eager propagation
   via _legacyTagDeps (no buffering) so classic CPs invalidate chained
   property tags immediately — required for observer flush, aliases,
   and two-way binding tests to pass.
 - untrack() delegates to gxt's native untrack so GXT's own tracker
   is also suppressed, preserving the baseline ComputedProperty.get
   behavior.

Before: tracking 16/60, validators 12/44.
After:  tracking 60/60, validators 44/44, smoke 318/333 (matches baseline).
… regression

Task emberjs#54 (reference.ts) and emberjs#53 (validator.ts) landed +36 unit tests and
+88 unit tests respectively but together broke 15 smoke tests around
<Input>, <Textarea>, {{input}}, {{textarea}} and {{get}} with (mut) —
the mutable-ref / two-way binding path regressed under the new
reference.ts contract. Bisect: destroyable.ts alone = 333/333 clean;
reference.ts added = 318/333; further validator.ts didn't change the
failure set.

Keeping destroyable.ts fix (commit f7a2561dcb, +17 Destroyables tests).
Reverting reference.ts and validator.ts to 736fc2ed8f baseline so
classic Input/mut stays intact. Re-dispatch to fix reference.ts without
regressing mutable-ref later.

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

Port @glimmer/reference iterable.ts (ArrayIterator, IteratorWrapper,
uniqueKeyFor, makeKeyFor) into gxt-backend/reference.ts without touching
the existing createConstRef/createComputeRef/valueForRef/updateRef paths
that Ember's two-way binding (Input/Textarea/mut) depends on.

- createIteratorRef now returns an OpaqueIterator with .next()/.isEmpty()
  instead of a plain array, matching the @glimmer/reference contract.
- createIteratorItemRef backed by a cell so consumers re-run on update.
- New exports: uniqueKeyFor, IterableReference (class wrapper).

Results:
- @glimmer/reference: IterableReference  0/24 -> 24/24
- References                              2/24 -> 2/24 (unchanged; blocked
  on createComputeRef changes which are out of scope per additive policy)
- Smoke                                   333/333 -> 333/333

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

Additive-only changes to validator.ts that fix +76 @glimmer/validator
tracking and validators tests without regressing smoke (333/333).

- Tag markers (_isDirtyableOnly/_isUpdatable/_isNonDirtyable/_isVolatile/
  _isCurrent) let dirtyTag/updateTag throw on unsupported targets while
  leaving existing callers (via createTag/tagFor) untouched.
- dirtyTag notifies @glimmer/global-context.scheduleRevalidate, keeps
  CURRENT_TAG's cell synced, and backs the debug runInTrackingTransaction
  backtracking-detection flow.
- validateTag/currentTagRevision recognise volatile, current, combined,
  and cyclic dependency graphs (ALLOW_CYCLES WeakMap respected).
- Buffered updateTag semantics gated behind _isUpdatable so alias/
  computed chain-tag wiring is unaffected.
- createCache/getValue/isConst gain assertion wrappers; createCache
  tracks whether the cached function was constant after first eval.
- Manual track frames, track(), and isTracking() properly propagate
  scope (incl. untrack) and emit combined tags tied to the actually-
  consumed tag set.
- trackedData getter/setter integrates with the debug tracking
  transaction and bumps the global revision counter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap createConstRef/createUnboundRef/createComputeRef with marker-based
identity tags and route childRefFor through @glimmer/global-context
getProp/setProp for const/compute/readonly/invokable parents. Memoize
compute refs via the classic-compliant createCache() from validator.ts
so @glimmer/reference tests observe correct get/set counts.

Fixes createReadOnlyRef, createDebugAliasRef (debugLabel, inner) signature,
createInvokableRef to pass through to inner ref, and updateRef to avoid
writing to read-only cell getters.

References: 1/12 -> 24/24. Smoke 333/333.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add primitive-target and duplicate-manager guards to set*Manager
exports so Managers test module error-path assertions pass.
Guards are additive and leave dispatch unchanged (smoke 333/333).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…object, async observer flush

- track() now exposes _consumed array on returned tags so compat tests
  can verify tag consumption
- getValue() accepts any object with a .value getter, not just _isCacheObj
- dirtyTagFor() calls scheduleRevalidate to trigger backburner run loop,
  ensuring async observers (dependentKeyCompat) flush properly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ables 22/22

Add dual-mode destruction: synchronous (default) for compat tests and
application code, deferred for the canonical @glimmer/destroyable test
suite which explicitly validates the two-phase DESTROYING→DESTROYED split.

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

Register ArrayProxy content arrays in a separate WeakMap so that when
replaceContent() fires notifyPropertyChange(content, '[]'), the component
cell is dirtied with the proxy value (not the raw content array). This
preserves the proxy reference in the cell, preventing breakage when tests
access proxy.content after mutations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…racked get validation

- trackedArray: sort()/reverse() now return the proxy (not raw target) so `arr.sort() === arr`
- trackedObject: shallow-copy own properties so mutations don't leak to original; add ownKeys/getOwnPropertyDescriptor traps for Object.keys()
- track(): install debug transaction for backtracking detection (read-then-write within same frame); suppress __gxtTriggerReRender to prevent infinite recursion from notifyPropertyChange inside getters
- trackedData setter: use __emberAssertDirect so expectAssertion() catches backtracking errors

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

CustomComponentManager, CustomModifierManager: factory-based lazy delegate
with capabilities validation (must use componentCapabilities/modifierCapabilities).
CustomHelperManager: validate delegate capabilities in getDelegateFor.
setComponentManager/setModifierManager: wrap factory in Custom*Manager.

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

- Accept `number` type for keyName parameter and normalize to string early,
  fixing "should set a number key" and "should set an array index" tests
- Remove GXT-mode silent skip for destroyed objects so the assertion fires
  correctly (trySet with tolerant=true still works for teardown paths)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without this, strict-mode scope values (component definitions, keyword
shadows) were silently dropped during template compilation, making them
invisible to the runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…20 ALL PASSING"

This reverts commit 5ee4bfe1b7a969310e67bac05eed0ec8a44a7047.
…(15/15)

Wrap delegate.getValue() in a backtracking frame with the helper's debug
name so read-then-write errors surface the correct helper identity. Cache
getValue results via createCache and make helper args reactive via classic
tag tracking so rerenders skip redundant recomputation while arg mutations
still invalidate.

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

GXT 0.0.53 does not implement the block params rewrite transform
(rewriteBlockParamsCompat) despite IS_GLIMMER_COMPAT_MODE being set.
Re-introduce transformBlockParamsInTemplate() as a pre-processing step
that converts `<Foo as |x|>{{x}}</Foo>` to
`<Foo @__hasBlockParams__="default">{{this.$_bp0}}</Foo>`.

Also fix named block detection: GXT compiles named block children as
lazy functions (`() => $_tag(':header', ...)`), but the slot builder
only checked for `__isNamedBlock` on objects, not functions. Now
evaluates functions whose source contains `$_tag(':` to detect named
block markers.

Fixes 3 failing tests in GXT Integration - AngleBracket Invocation:
- "it can yield values from template"
- "it can yield multiple values"
- "it renders named blocks"

Smoke: 333/333 (no regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ict Mode 11/11

GXT's cell tracking wraps component objects in Proxies (via
wrapNestedObjectForTracking) when accessed through `this.Foo`.
setComponentTemplate stores templates keyed by the ORIGINAL object,
but the component handler received the Proxy, causing WeakMap misses.

Added _proxyToRaw unwrapping in canHandle, handle (template-only path),
getComponentTemplate, and a safety-net fallback for unmanaged components.

Before: 99/191 Strict Mode tests (513/615 assertions)
After:  105/191 Strict Mode tests (519/615 assertions)
Smoke:  333/333 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GXT-compiled templates import $_tag, $_maybeHelper, $_dc directly from
@lifeart/gxt, bypassing Ember's container-based helper/component
resolution. Add a Vite plugin (gxtEmberWrapperRedirect) that redirects
these imports to ember-gxt-wrappers.ts. Also add helper-only detection
in $_tag_ember to short-circuit dashed helpers through $_maybeHelper
instead of the component manager's handle() path.

Result: "Helpers can receive injections" passes (1/4). Remaining 3 tests
fail due to template IIFE rendering chain integration (the GXT $_fin
result is not properly inserted into the DOM by renderTemplateWithContext).
Smoke: 333/333.

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

Remove __GXT_MODE__ guards that were skipping mandatory setter setup
(tags.ts) and mandatory setter writes (properties.ts), restoring DEBUG
assertions for watched properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ed/alias watching"

This reverts commit 1df60cba5f796720ae9909397d18dfed412b8420.
…Proxy (13/13)

The reactive args wrapping in wrapped.createHelper eagerly read
args.positional/named, which triggered computeArgs before any getValue
call and broke caching for invokeHelper's frozen SimpleArgsProxy.
Guard the wrapping with Object.isFrozen(args) so invokeHelper (which
freezes args in DEBUG) uses the outer createCache from invoke.ts,
while the template path still gets reactive arg tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual model getter (get() { return ctrl.model }) on outlet
render context with plain property + cellFor tracking. The manual getter
prevented GXT formula tracking: formulas saw no cell reads, marked
themselves isConst, and never re-evaluated on route transitions.

Also unconditionally update the context model cell in the in-place
outlet re-render path (previously skipped when a getter was present).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ns (+3 tests)

Three fixes in compile.ts:

1. Leading-dash component names: GXT's PascalCase conversion strips leading
   dashes from names like `-inner-component`. Added fallback to try
   `-${kebabName}` when initial lookup fails in $_tag_ember.

2. @arg block invocations: {{#@inner}}content{{/@inner}} compiled to empty
   array by GXT. Added pre-transform to rewrite to
   {{#component @inner}}content{{/component}}.

3. $_c_ember getter/string handling: Added resolution for @-prefixed string
   comp args and getter functions that resolve to CurriedComponent.

Before: 42/51 contextual component tests passing
After:  45/51 contextual component tests passing
Smoke:  333/333

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

In GXT mode, controller QP changes via set() silently skipped
markObjectAsDirty because meta.isPrototypeMeta(controller) returned
a false positive (meta.proto === meta.source === controller). Two fixes:

1. property_events.ts: In GXT mode, allow notifyPropertyChange through
   the isPrototypeMeta guard (respecting isInitializing only).
2. route.ts: Call metaFor(controller) in setup() to ensure writable
   instance meta exists before QP observers are activated.

Fixes 4/15 Query Params model-dependent state tests.

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

@ember/test-helpers emulation tests call `toplevelView.setOutletState(...)`
with a fresh `compile()` template while keeping the route name stable
('index'). The outlet re-render fast-path only compared route names and
nested outlet structure, so it would update model cells instead of
rendering the new template — leaving the DOM empty.

Track the last rendered route template identity and force a full
re-render when the template reference changes.

Fixes all 3 "it basically works" tests in the "@ember/test-helpers
emulation test" modules. Smoke suite: 333/333.

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

Tests that used `schedule('afterRender', () => this.set(...))` inside
`didInsertElement` (e.g. "afterRender set", "afterRender set on parent")
showed stale DOM because runAppend unconditionally cleared
__gxtPendingSyncFromPropertyChange before syncNow(), causing gxtSyncDom to
be skipped (__gxtHadPendingSync derives from that flag). The afterRender
callback fired and the property was set, but the DOM never updated.

Fix: tag property changes that originated from a `schedule('afterRender',
cb)` callback via a transient __gxtInAfterRender flag (wrapped at
schedule() entry). If any such change occurred during the run, runAppend
preserves __gxtPendingSyncFromPropertyChange so syncNow processes it.
Init-phase property changes (e.g. Textarea's internal bindings) still get
cleared, so Textarea tests keep passing.

Lifecycle test module: 2/5 -> 4/5. Smoke: 333/333 stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The side-channel classic-tag reactor registered per LinkTo element was
unsubscribing after just 2 detached ticks. Because the <a> element is
created BEFORE the template inserts it into the DOM, a burst of
dirtyTagFor calls during router transition setup (which legitimately
happens before/during the element's first insertion) would blow past
that threshold and unsubscribe the reactor before the element ever saw
a classic-tag-driven href/class update.

Track a hasBeenConnected flag and only count detached ticks AFTER the
element has been in the DOM at least once. While pre-connected, keep
firing the callback so the latest reactive href/active/class state
lands on the element the moment it is inserted. Cap pre-connection
firings to 256 so never-inserted elements don't leak a reactor.

Fixes .transitioning-in .transitioning-out CSS classes tests (0/2 → 2/2);
no regressions on the 333-test smoke suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, slot ctx

Fixes 3 failing tests in `Helpers test: {{yield}} helper` (12/15 → 15/15).

Three changes to compile.ts:

1. `$_hasBlock`/`$_hasBlockParams` accept `(slots, blockName)` calling
   convention. GXT emits `$_hasBlock.bind(this, $slots)(name)` for
   `{{has-block "name"}}`, so the old single-arg signature was receiving
   `$slots` as `blockName` and always returning false.

2. `<:inverse>` aliases `<:else>` per Glimmer v2 normalize. When a caller
   provides either, register both keys on the component's slots map so
   `{{yield to="else"}}` and `{{yield to="inverse"}}` both find the block
   regardless of which name the invoker wrote.

3. Nested-component yields (the `emberjs#3220` regression): when an outer
   component's block wraps `{{yield}}` in a literal HTML tag inside a
   child component's slot (e.g. `{{#inner}}<span>{{yield}}</span>{{/inner}}`),
   GXT's `$_slot`/`H` creates a fresh child context with no rendering
   context linkage, crashing `$_tag` with `Cannot read properties of null
   (reading 'element')`. Populate the fresh ctx's rendering-context
   property from `__gxtRootContext` before delegating to GXT's $_tag.

Smoke: 333/333.  yield helper: 15/15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inkTo + QP href

Refresh the cached slot's mutable arg backings on every cache HIT in
handleManagedComponent, so two different templates sharing an owner (e.g.,
index.hbs and about.hbs, each with <LinkTo @route=...>) stop leaking the
previous template's args into the new render. Closures in renderFn and the
syncCallback now read through a shared argGetters/namedRefSlots record that
is rewritten per invocation; the LinkTo renderer reads args/fw via the same
slot so block children and forwarded attrs pick up the new template too.

LinkTo: 84 → 89/99 (+5). Query Params: 115 → 118/131 (+3).
Routing: 102 → 109/113 (+7). Smoke 333/333, no regressions in
Components (313/328) or Rendering (93/186).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three remaining AngleBracket Invocation failures all involved yielded
contextual components (`{{yield (hash baz=(component "foo-bar/baz"))}}`)
invoked with angle brackets `<fb.baz class="..." title="...">`. Two seams
were broken:

1. Template: `<fb.baz>` compiled to `$_dc(() => fb.baz, ...)` where `fb`
   was a free JS variable (GXT doesn't bind angle-bracket block params as
   closure parameters, unlike curly form). The getter always threw.
   Fix: extend transformBlockParamsInTemplate to rewrite `<param.prop>` /
   `</param.prop>` tag names to `<this.$_bp0.prop>`, mirroring the
   existing mustache transform.

2. Runtime: ember-gxt-wrappers' $_dc_ember forwards $_args to
   renderComponent with fw=null, so class/title/etc. on the invocation
   never reach the target's `...attributes`. Fix: wrap $_dc in compile.ts
   to extract tagProps from Symbol.for('gxt-props'), build a real fw
   [props, attrs, events] triple, and call managers.component.handle
   directly for curried components. Narrowed to getters that reference
   `$_bp` so other $_dc callers (e.g. `{{component this.foo}}`) remain
   unchanged.

AngleBracket Invocation: 37/40 → 40/40. Smoke suite: 333/333.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lifeart and others added 30 commits May 4, 2026 19:03
Root cause of the Basic Test 41min hang:

index.html had a TEMPORARY force-enable of __GXT_LEAK_DEBUG__ for CI
debugging. With the flag on, validator.ts emits a console.log per
LEAKED reactor on EVERY _fireClassicReactors call, which is invoked
from EVERY dirtyTagFor.

CI evidence (run 25228548798): at end of test 9, there are 18 leaked
reactors. Tests 10-852 each fire dirtyTagFor thousands of times. Each
fire produces ~18 console.log lines (one per LEAK reactor). That's
~50K-1M console.log calls per test * 800 tests = 10s of millions of
log lines.

testem intercepts every console.log and emits over websocket. The
channel saturates, the browser's renderer becomes unresponsive,
chrome-headless stops emitting any output (last activity at 19:17
in the log), socket eventually disconnects. testem's
browser_disconnect_timeout (1200s = 20min) fires, prints "Browser
timeout exceeded" and reports test results that QUnit had buffered.

Comparison: working clean run (a42b371, 13min) emitted 1519
leak-debug lines; broken run (a511c1c, 41min) emitted only 92
because the browser hung saturating the websocket.

The leak debug remains opt-in via ?gxtLeakDebug=true URL param for
local diagnosis. CI runs no longer set the flag.

Smoke (14 modules / 333 tests) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that index.html no longer force-enables __GXT_LEAK_DEBUG__,
the standalone leak-debug.mjs harness must opt in via URL param
to keep producing diagnostic output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a render error throws out of run() (escaping flushRenderErrors at the
end of runTask/runAppend), the gxt-backend's catch path
(manager.ts:8993, captureRenderError + rethrow) leaves a duplicate copy
in _renderErrors. The user-observed throw is already surfacing via the
escaping exception (caught by assert.throws); the duplicate would
otherwise re-throw on the NEXT runTask() flushRenderErrors call.

Repro: error-handling-test "it can recover resets the transaction when
an error is thrown during initial render"
  1. assert.throws(() => render(...))  // user observes "silly mistake"
  2. runTask(() => set(switch, false)) // BUG: re-throws stale copy

Fix: in runAppend/runTask catch, call __gxtClearRenderErrors before
rethrowing so the duplicate doesn't survive into the next test step.

Smoke: 333/333. Errors thrown during render module: 4/4 isolated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two `Strict Mode - renderComponent: multiple calls to render in to
the same element appear as siblings` tests failed in CI's Basic Test
because the GXT _renderComponentGxt path destroyed the prior render
synchronously on every re-entry, so two helper invocations sharing one
target left only the last visible. Replace the single-result-per-target
model with a per-target list of GxtRenderEntry records, each tracking
its own (firstNode, lastNode) DOM range. Re-renders clear and re-fill
only their own range; cross-runloop calls (the eager-tracks-with-parent
case) still tear down the prior entry; same-runloop calls coexist as
siblings. Render directly into the target when no foreign sibling
content is present, otherwise render into a tagName-matched detached
host and prepend the resulting nodes — preserving the documented
"subsequent renders are prepended" semantic without disturbing prior
entries' nodes.

Adds a spurious-double-fire guard for the loose-mode helper-lookup case
(`<Loose />` template invokes its `{{a-helper}}` mustache twice in one
render pass): when a renderComponent call arrives in the same runloop
with the SAME (component, owner) pair as an existing entry, return the
existing result instead of creating a sibling. Documented sibling
patterns (e.g. `{{render A 'a' owner}}\n{{render A 'a'}}` where the
second omits owner) keep distinct identities and proceed normally.

Also fixes a strict-mode scope-store collision in compile.ts: the
__gxtScope_<hash> global key was hashed only on sorted scope-key NAMES,
so two strict-mode templates that happened to share the same scope-key
shape (e.g. `scope: () => ({ Child, data })` with different `data`
trackedObjects) overwrote each other's stored values, leaving every
render reading the same `data`. Include the templateString in the hash
source so distinct templates get distinct storage slots.

Brings Basic Test fails from 6 to 4: both renderComponent siblings
tests pass. No regressions: full strict-mode 255/255, smoke 333/333,
all components 328/328.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Async paths (flushAfterInsertQueue lifecycle callbacks, deferred sync
flushes) can push into _renderErrors AFTER runAppend's flushRenderErrors
ran. The stale error then re-throws on the NEXT test's runAppend, producing
the "Died on test #N: <error>" bleed seen in cumulative-state runs where
the trace points at user-land code from a DIFFERENT test than the one
QUnit reports as failing.

Symptom in repro-cumulative.mjs:
  test "overriding didReceiveAttrs does not trigger deprecation" fails
  with "Cannot read 'value' of undefined" — but its didReceiveAttrs body
  is `assert.equal(1, this.get('foo'))`. The actual `.value` access is
  in the PRECEDING test ("didReceiveAttrs fires after .init()") at
  curly-components-test.js:3384 (this.attrs.bar.value + 1).

Fix: drain _renderErrors at QUnit.testStart so each test begins with an
empty queue. A real error in the current test still surfaces normally
through runAppend/runTask flushRenderErrors. This unmasks the underlying
attrs-undefined issue rather than letting it bleed across tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In cumulative-state runs (894 modules in one browser context — what
testem-ci does with `parallel: 1` + single-page test bundle), the test
"didReceiveAttrs fires after .init() but before observers become active"
fails on user-land `this.attrs.bar.value + 1`. The error bleeds via
flushRenderErrors into the next test ("overriding didReceiveAttrs does
not trigger deprecation"), reported as "Died on test emberjs#2".

Root cause hypothesis: `props.attrs = attrs` is applied via
`factory.create(props)` and Ember's `initialize()` copies it onto the
instance, but during cumulative-state runs certain race paths
(re-entrant createRenderContext, force-rerender, pool reuse) can leave
`instance.attrs` undefined or stale when the initial didReceiveAttrs
hook fires. The user-visible crash is then a TypeError reading `.value`
on `undefined` rather than the expected attrs entry.

Fix: re-assert `instance.attrs = attrs` (the local map built earlier in
createComponentInstance, which always carries the correct
{value, update} shape for this invocation's arg keys) right before the
initial didReceiveAttrs hook fires, but only when instance.attrs is
missing or empty. No-op for healthy cases. Combined with commit
5a0ccd3 (testStart render-error drain), the underlying error now
surfaces on the test where it actually originates instead of bleeding.

Also includes pre-existing [DIAG_LH] instrumentation gated behind
__GXT_DIAG_LH (only fires under repro-cumulative.mjs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The $_dc_ember string/curried paths capture and restore a previous
__gxtDcCaptureCallback value via try/finally:

  const _prevCapture = globalThis.__gxtDcCaptureCallback;
  globalThis.__gxtDcCaptureCallback = captureInstance;
  try { renderComponent(...) }
  finally { globalThis.__gxtDcCaptureCallback = _prevCapture; }

In cumulative-state runs the saved _prevCapture can be a closure from a
prior test whose ctx-scoped destructor never fired. Restoring that stale
closure means the next render's renderClassicComponent path (line 10788)
hands the new instance to a dead test's captureInstance, leaving the
current test's _dcEmberInstance null. destroyCurrentDcInstance() then
short-circuits at swap time and willDestroy never fires for the
swapped-out component.

Defensive fix: drain the global capture callback at QUnit.testStart so
each test's $_dc_ember setup starts from a clean (null) baseline. Pairs
with commit 794933c (mirror callback in renderGlimmerComponent) and
eafd578 (defensive ensure-attrs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Whitespace-only formatting fixes flagged by `lint:format` (prettier
--check). No code changes.
Upstream commit 19eb408 doubled the bench workload (Create5000→Create10000)
and added a final clearItems4 phase, pushing total wall-time close to the
prior 60s per-sample budget. The Perf job's "errored while waiting for
clearItems4End" timeout was fired by tracerbench's last-marker observer
right at the boundary — not a behavioural regression in renderer.ts.

Raising to 120s gives headroom on slower CI runners.
Two cumulative-state hardening changes from a deeper root-cause investigation
of the remaining stuck failures (emberjs#43 didReceiveAttrs attrs-undefined and emberjs#44
component-helper willDestroy-on-swap):

1. extractArgKeys now filters 'attrs' and 'args' alongside 'class'/'classNames'.
   These are reserved Ember component-internal property names. If an arg of
   either name reaches the Object.defineProperty arg-getter loop, it shadows
   instance.attrs (or instance.args) on the component instance — breaking
   user-land `this.attrs.<key>.value` reads in didReceiveAttrs. Filtering at
   the source makes this impossible regardless of how the args object got
   populated, complementing the eafd578 ensure-attrs defensive belt.

2. Removed two `(globalThis as any).__gxtDcCaptureCallback = komp.__dcCaptureInstance`
   blocks in handle(). `__dcCaptureInstance` is never written anywhere in the
   codebase, so the typeof checks always returned false — pure dead code.
   Removing them also eliminates them as suspect sources of cross-test
   __gxtDcCaptureCallback contamination during investigation.
Replaces globalThis.__gxtDcCaptureCallback with a non-enumerable
__gxtDcCapture property on the per-render args object. The callback's
lifetime now equals the render operation that owns it, eliminating the
cross-test leak surface that had required two prior defensive workarounds.

Producer:
- $_dc_ember stashes captureInstance on gxtArgs for the initial render
  and re-stashes on each swap; finally-blocks delete after the render.

Forwarders:
- renderComponent copies gxtArgs.__gxtDcCapture → mergedArgs after
  extractArgsAndSlots (which strips _-prefixed enumerable keys; non-
  enumerable props bypass that loop).
- handle()'s CurriedComponent and __stringComponentName branches
  propagate from outer args → inner mergedArgs / wrappedArgs before the
  recursive handle() call, so capture survives nested resolution.

Consumers:
- renderClassicComponent and renderGlimmerComponent read from
  args.__gxtDcCapture, fire it once with the new Ember instance, and
  delete the property. Both render paths consume uniformly so dynamic-
  component swaps capture regardless of whether the resolved class is
  classic or tagless.

Removed:
- All globalThis.__gxtDcCaptureCallback save/restore/clear sites.
- The QUnit testStart hook in index.html that cleared the global
  (commit f68f778) — no longer needed since there is no global.

The renderGlimmerComponent capture (added in commit 794933c) and the
two unrestored-write callsites in handle() that had been removed earlier
in this session as dead code remain consistent with the new design.
The 120s bump (commit a4a0337) was insufficient — empirically a single
sample on the GHA runner takes 107-120s on this branch (per-sample tracerbench
ETA reported "01m:47s remaining" right at the start, and the bench actually
timed out at the new 120s budget). 240s gives 2× headroom while keeping
green-path total wall time bounded.

Hygiene followup (deferred): the unconditional `import * as _gxt from
'@lifeart/gxt'` in glimmer/lib/renderer.ts:19 plus its 8 module-level
destructured constants is the structural source of the experiment-bundle
bloat that pushes the bench over budget. Deferring those behind a runtime
__GXT_MODE__ check would reclaim the budget headroom permanently.
…hang

benchmark-app (classic Ember, no GXT) hung indefinitely at the clearItems4
phase, regardless of --sampleTimeout (60s/120s/240s all timed out).

Root cause: the root outlet template is unconditionally marked
__gxtCompiled=true at templates/root.ts:398, so isGxtTemplate(template)
in ClassicRootState.render returns true for every Ember app — including
classic apps that never load the gxt runtime. The GXT branch then routes
revalidations through __gxtRootOutletRerender, which at root.ts:1110
calls `parentElement.innerHTML = ''` followed by renderOutletState.
Without the gxt runtime initialized, the re-render never completes,
leaving the DOM wiped and the renderer cycling on stale tags.

Fix: short-circuit templateIsGxt to false when __GXT_MODE__ is unset,
matching the seven existing __GXT_MODE__ gates in this file. Tests run
with index.html setting globalThis.__GXT_MODE__=true so the GXT branch
remains active there; benchmark-app and any other classic-Ember consumer
fall through to the upstream renderMain() path unchanged.
OutletView's constructor unconditionally called @lifeart/gxt's cellFor
and replaced the Glimmer Reference with a raw OutletState cast as
Reference<OutletState | undefined>. In classic-Ember consumers
(benchmark-app and any embroider build that doesn't set __GXT_MODE__)
this meant `state.ref` was no longer a real Reference and setOutletState
had no Glimmer-side invalidation hook. Combined with the templateIsGxt
gate from commit 95848fe this was insufficient: even with classic
templates routed through renderMain, the underlying outlet's tag never
dirtied so subsequent revalidations cycled without progress —
manifesting as the persistent hang at clearItems4 in the Perf job.

Make OutletView dual-mode:
- Classic mode (no __GXT_MODE__): restore upstream's createComputeRef +
  outletStateTag pattern. setOutletState calls updateRef to dirty the
  tag and trigger Glimmer VM revalidation. Identical to upstream/main.
- GXT mode: keep cellFor + raw-state-as-ref + the
  __gxtRootOutletRerender chain.

The `ref` field is typed Reference<OutletState | undefined> in both
modes; the GXT branch's runtime value is the raw OutletState (cast),
matching prior behavior.
Three more classic-mode safety gates following the templateIsGxt + OutletView
fixes (commits 95848fe, dcccf1f). The Perf job in classic-mode
benchmark-app still hangs at clearItems4; these are correctness gates that
restore upstream classic-Ember behavior independent of whether they fix the
hang directly.

1. renderer.ts ClassicRootState.destroy: gate `destroyElementSync(result!)`
   on __GXT_MODE__. The result is a Glimmer-VM RenderResult (no GXT
   bookkeeping); calling GXT's destructor on it is at best a wasted
   traversal, and we can't rule out worse behavior on accumulated state.

2. metal/tracked.ts setter: gate the un-gated `dirtyTagFor(this, SELF_TAG)`
   and `dirtyTagFor(this, key)` (duplicate of what the upstream setter()
   above already does). Upstream classic Ember does NOT dirty SELF_TAG on
   @Tracked set — broadening invalidation as this branch did breaks the
   narrow-invalidation contract and can amplify revalidation work over
   large {{#each}} bodies.

3. metal/tracked.ts getter: gate the un-gated extra `consumeTag(tagFor(...))`
   on __GXT_MODE__. The upstream `getter()` already consumes the per-key
   tag; the additional consume only matters for GXT's compat createCache
   shim. Hot path on {{#each}} over a 2998-row array.

After these, classic-mode @Tracked behavior matches upstream/main exactly,
and the renderer's per-root teardown does no GXT work for non-GXT roots.
Removes the static `import * as _gxt from '@lifeart/gxt'` from
renderer.ts and views/outlet.ts. Classic-Ember consumers (benchmark-app,
embroider builds with no GXT_MODE) no longer pull GXT's ~50KB runtime
into their bundle and no @lifeart/gxt module-load side effects run on
classic-mode boot.

Mechanism:
- gxt-backend/manager.ts (only loaded in GXT mode via the validator
  alias) does `import * as __lifeartGxtNamespace from '@lifeart/gxt'`
  and stashes the namespace on `globalThis.__lifeartGxt`.
- renderer.ts and outlet.ts read GXT symbols through a lazy accessor
  (`_gxtLib()` / `(globalThis as any).__lifeartGxt`) — undefined in
  classic mode where every callsite is already gated on __GXT_MODE__.

This is the architectural follow-on to commits 95848fe
(templateIsGxt gate), dcccf1f (OutletView dual-mode), and 9cbbcc5
(remaining un-gated GXT additions). Direct attempt at the persistent
classic-mode bench hang at clearItems4 in the Perf job — even after the
runtime gates, the static @lifeart/gxt import was still loading the
runtime in classic mode, leaving room for module-load side effects to
contribute to the hang.
ensureLifecycleErrorCapture wrapped Component.prototype._trigger and
each instance's destroy with `catch(e) { captureRenderError(e); throw e; }`.
That pattern routes the SAME Error instance into TWO error paths:
1. The synchronous throw bubbles to the immediate caller (e.g.,
   `assert.throws(() => this.render(...))`) — assertion passes.
2. captureRenderError pushes a copy into _renderErrors.

The next `runTask` then calls flushRenderErrors, which throws the stale
duplicate. From the test runner's perspective the error appears AFTER
the test's earlier assertions completed → QUnit emits "Died on test #N".
This is the failure mode of `Errors thrown during render: it can recover
resets the transaction when an error is thrown during initial render`.

The inline captureRenderError calls already present in gxt-backend/manager.ts
at the actual swallow points (e.g. __gxtDestroyUnclaimedPoolEntries at
~line 4253, helper-instance cleanup at ~4271) cover the case the wrappers
were meant to handle — errors that downstream try/catch{ignore} blocks
would otherwise drop. Reduce the wrappers to identity functions so
synchronously-thrown errors propagate exactly once, no duplicates.

The wrap structure (with .__gxtCaptureWrapped marker) is kept so other
instrumentation that depends on it still works.
Property changes that fire from outside any runloop (setTimeout, Promise
resolutions, fetch callbacks, etc.) had no immediate flush trigger — they
only set __gxtPendingSync and waited for the 16ms-interval fallback. The
interval has a per-test budget (max 3 consecutive syncs) intended to
prevent infinite re-render loops, and in cumulative-state runs (894
modules in one browser context) the budget can be exhausted before the
async deadline expires, leaving the test hanging on a DOM that never
updates.

This is the failure mode of `Component Tracked Properties: tracked
properties rerender when updated outside of a runloop` — setTimeout
schedules `this.count++`, the test waits 200ms for the DOM to read '1',
the budget-throttled interval doesn't fire in time, and `done()` never
runs.

Fix: when neither runTask nor a test transition owns the flush, queue
a microtask that calls __gxtSyncDomNow. The microtask preserves the
existing "no double-sync inside runTask" invariant by re-checking the
guards before flushing. __gxtSyncDomNow's existing re-entrancy guard
covers the case where another path already started a sync.

Module isolation: 333/333 smoke tests still pass; "Component Tracked
Properties" (17/17) and "Errors thrown during render" (4/4) continue
to pass — verified locally with the gxt-test-runner module filter.
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>
The gxt-backend's `_renderErrors` queue is process-global. Most paths
flush or clear it (runAppend/runTask catches, flushRenderErrors), but
silent-capture sites (flushAfterInsertQueue, __gxtDestroyUnclaimedPool-
Entries Phase 3, lifecycle destroy captures) can enqueue an error AFTER
the test that produced it has already escaped its synchronous throw via
assert.throws. The leaked entry then re-throws on the NEXT test's first
flushRenderErrors call, causing a "Died on test #N" cumulative-state
failure that does not reproduce in module isolation.

Add a defensive __gxtClearRenderErrors() to the QUnit.testStart hook
(adjacent to resetTracking) so each test starts with an empty queue.

Verified:
- Module isolation: 4/4 (Errors thrown during render)
- Smoke suite: 333/333 across 14 modules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two un-gated GXT additions dominated classic-mode bench cost, causing the
CI Perf job's clearItems4End marker to exceed --sampleTimeout 240s on the
2998-row teardown.

property_events.ts: notifyPropertyChange ran a per-call WeakMap+Set
bookkeeping cycle (WeakMap.get/has/set + Set allocation + Set.add +
try/finally + Set.delete + WeakMap.get) on every call. The marker is
consumed only by computed.ts's GXT cell re-entrance guard, which never
fires in classic mode (no GXT formulas). Gate the entire block on
__GXT_MODE__; classic mode pays zero per-call overhead.

templates/root.ts statically imported * from '@lifeart/gxt' at module
scope, dragging the GXT runtime into every classic-mode bundle (root.ts
is reached through setup-registry.ts on the boot path). Replace with the
same _gxtLib() accessor renderer.ts uses (reads globalThis.__lifeartGxt
stashed by gxt-backend at its own load time) so the bundler can
tree-shake @lifeart/gxt out of classic builds.

Verified locally on glimmer-next-fresh:
- smoke 333/333 in 16.2s
- Tracked Properties module-isolation 33/36 (3 pre-session Helper failures unchanged)
- Component Tracked Properties: 24/24 (was 1200s timeout pre-session)
- Errors thrown during render: 4/4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to dcd000c, addressing two more un-gated GXT additions in
classic-mode hot paths uncovered by a metal/* audit, plus dead-code
removal flagged by a bundle audit.

computed.ts: ComputedProperty._set unconditionally bracketed
notifyPropertyChange with `_beginCPSet`/`_endCPSet` — a per-call
WeakMap.get + (sometimes) Set+WeakMap.set + Set.add + WeakMap.get +
Set.delete + (sometimes) WeakMap.delete cycle. The marker is consumed
only by `_inCPSetFor` inside ComputedProperty.get's GXT re-entrance
short-circuit, which never fires in classic mode (no GXT formulas
re-evaluate synchronously through dirtyTagFor cascades). Gate at the
call site so classic CP sets pay zero bookkeeping overhead.

computed.ts: ComputedProperty.get's three GXT short-circuits
(`_inCPSetFor` lookup, `__gxtCPInvalidationSet` re-entrance, and
`__gxtInTriggerReRender` cold-cache guard) all evaluate to false in
classic mode but each costs a globalThis property read. Wrap the
whole block in a single `__GXT_MODE__` gate so classic CP get cache
misses skip three lookups in favor of one.

renderer.ts: delete `_tryGxtRender`/`_getGxtTemplate`/`_buildGxtContext`
(~145 lines) — the inline NOTE explained these were disabled because
the GXT runtime needs $slots/$fw/block params that Ember's component
integration can't inject. Zero callers; only referenced by each
other. Saves parse cost in both modes.

Verified locally on glimmer-next-fresh (after dcd000c):
- smoke 333/333 in 17s
- Tracked Properties module-isolation 33/36 (3 pre-session Helper failures unchanged)
- computed-related modules 147/148 (the 1 failure pre-dates this commit;
  reproduced under git stash)

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

GXT's handleOpcodeError (reactive.ts:350) splices a failing opcode out
of its op array and previously only logged in dev mode. The host now
subscribes via the new setOpcodeErrorReporter hook (added in @lifeart/gxt
9d6c174) and forwards opcode errors into _renderErrors so they surface
through the existing flushRenderErrors path — no longer silently dropped.

Phase 2 of the Cluster C error-propagation migration. Purely additive:
no host wrapper deletes yet, no test depends on this firing today (all
canary suites green: smoke 333/333, Tracked Properties 33/36,
Errors thrown during render 4/4). Subsequent phases delete the
host-side capture wrappers that this hook makes redundant.

Requires @lifeart/gxt with setOpcodeErrorReporter exported. Until a
new GXT release ships, the linked dist (via dev pipeline) carries the
hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5bdffd7 imported setOpcodeErrorReporter as a named export — but the
registry's @lifeart/gxt@0.0.61 (the version pinned in package.json)
predates the hook. Without local dist linking, CI installs from registry
and the import fails type-check.

Use namespace import + runtime typeof check so the hook activates when
the linked GXT dist provides it (dev / future-bumped registry version)
and no-ops on older versions. Once @lifeart/gxt@0.0.62+ ships and
package.json bumps, this can revert to a normal named import.

Verified: smoke 333/333 unchanged.

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

12 of the 27+ captureRenderError sites in manager.ts and compile.ts were
"capture + throw" pairs around glue-layer errors that the Ember integration
itself constructs (component-not-found, helper-not-found, invalid manager
capabilities, ambiguous helper-as-arg). Each captured into _renderErrors
defensively in case some intermediate caller swallowed the throw — but with
the throw immediately following on the same path, that defense is redundant
in modern callers. Convert each to plain `throw err` and drop the capture.

manager.ts (7 sites): two helper-as-component not-found paths (emberjs#5, emberjs#6, emberjs#7),
two helper-manager capability validation errors (emberjs#8, emberjs#9), two
modifier-manager capability validation errors (emberjs#12, emberjs#13).

compile.ts (5 sites): stale-formula curried-component name resolution (emberjs#19),
ambiguous helper-as-named-arg assertion (emberjs#20), __stringComponentName
resolution miss (emberjs#21), unknown block-form pascal-case helper (emberjs#23, emberjs#24).

Pattern-2 sites (capture + return-early — relies on queue to surface error
later while letting current call continue) are NOT touched in this commit:
manager.ts:7915, 7920, 8773 (modifier-as-helper detection, custom component
manager fallback) and compile.ts:8293 (helper-with-block fallback). Those
need the upstream Fix B reporter or a different surfacing strategy and stay
for now.

Verified locally: smoke 333/333, Tracked Properties 33/36 baseline,
Errors thrown during render 4/4 — no regressions.

Per the plan, Phase 3 step 4 must precede step 5 (delete outer wraps at
manager.ts:9118, 10812). Doing them in reverse order would re-create the
double-fire that broke commit 22cdaf9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 5 attempted to delete the captureRenderError(e) calls at
manager.ts:9110, 10804 (the renderClassicComponent / handleClassicComponent
outer wraps). The deletion broke 'Errors thrown during render: it can
recover resets the transaction when an error is thrown during initial
render' — qunit-fixture content remained after the init throw.

Root cause: the capture isn't just queueing for later flush. It also
sets __gxtRenderErrorCount, which renderer.ts:921 reads as
hadRenderPhaseErrors to distinguish init() throws (clear the DOM) from
didInsertElement throws (keep DOM intact for Glimmer recovery semantics).
Without the count, renderer.ts skips the DOM clear and the test fails.

Step 5 cannot land in isolation. It needs Phase 4 Fix B first — a
component-render reporter in GXT's render-core.ts that provides an
equivalent signal independent of _renderErrors. Once Fix B exists, the
host can route render-phase errors via the reporter and renderer.ts can
read a different signal (or the new reporter can directly drive the
DOM-clear decision).

This commit only adds explanatory NOTEs at both sites so future
attempts don't re-discover this dependency from a broken canary.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant