From 2e83f2c89651490db08be6b72b6471d6e270e561 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 18:44:43 -0400 Subject: [PATCH 01/14] Shrink hello-world bundle: split classic Renderer + lazy routing keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts the hello-world smoke test from 243.30 KB / 77.32 KB gzip to 168.59 KB / 53.67 KB gzip — a 30.6% gzip reduction — while leaving the classic v2-app-template essentially flat (+0.21 KB gzip from one extra side-effect import). Three changes, in order of impact: 1. **Lazy `-mount` and `-outlet` keyword registration.** Until now `resolver.ts` statically imported `mountHelper` and `outletHelper`, which transitively pulled `@ember/engine/instance`, `@ember/routing/-internals` (for `generateControllerFactory`), and the rest of the routing/engine graph into every bundle that uses `renderComponent`. Replace the static import with a `registerBuiltInKeywordHelper(name, helper)` registry on the resolver, and add a side-effect-only `syntax/register-routing-keywords.ts` that classic-app setup imports from `setup-registry.ts`. Bundles that don't pull in `setup-registry` (i.e. the hello-world that only uses `@ember/renderer`) drop ~138 KB of routing + ~7 KB of engine code. 2. **Split classic `Renderer` subclass into `classic-renderer.ts`.** Move `Renderer extends BaseRenderer`, `ClassicRootState`, the concrete `DynamicScope` class, and the `View` interface out of `renderer.ts`. Hoists the imports those carry — `OutletView`, `createRootOutlet`, `RootComponentDefinition`, `makeRouteTemplate`, `renderMain`, `guidFor`, `getViewElement`, `getViewId`, `dict`, `createCapturedArgs`, `EMPTY_POSITIONAL`, `curry` — out of the renderer-only bundle. Adds a `RootState` interface so `RendererState` can manage either kind without statically depending on classic code. `setup-registry.ts` now imports `Renderer` from `./classic-renderer`. The renderer entry re-exports the classic types so existing `from '.../renderer'` import sites keep working. 3. **Replace `RSVP.defer` in `renderSettled` with native Promise.** Standalone this didn't move the bundle (rsvp was reachable via other paths), but together with #1 it lets the hello-world bundle drop the 62 KB rsvp shared chunk entirely — `@ember/engine`, `@ember/routing`, and `@ember/-internals/runtime/lib/ext/rsvp` were the remaining consumers, and #1 pulls those off the renderer-only path. Verified: `lint:eslint`, `type-check:internals`, `type-check:types`, `type-check:handlebars`, `test:node`, `test:blueprints`, classic v2-app-template build, hello-world build, and a vite dev build of the full test suite all pass. Browser tests will run in CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/index.ts | 9 +- .../glimmer/lib/classic-renderer.ts | 377 ++++++++++++++++ .../@ember/-internals/glimmer/lib/renderer.ts | 408 ++---------------- .../@ember/-internals/glimmer/lib/resolver.ts | 16 +- .../-internals/glimmer/lib/setup-registry.ts | 6 +- .../lib/syntax/register-routing-keywords.ts | 6 + 6 files changed, 441 insertions(+), 381 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/classic-renderer.ts create mode 100644 packages/@ember/-internals/glimmer/lib/syntax/register-routing-keywords.ts diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index 66afa93812e..b6b5c188813 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -467,13 +467,8 @@ export { htmlSafe, isHTMLSafe, } from './lib/utils/string'; -export { - Renderer, - _resetRenderers, - renderSettled, - renderComponent, - type View, -} from './lib/renderer'; +export { _resetRenderers, renderSettled, renderComponent } from './lib/renderer'; +export { Renderer, type View } from './lib/classic-renderer'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/lib/classic-renderer.ts b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts new file mode 100644 index 00000000000..5c3f6a46441 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts @@ -0,0 +1,377 @@ +import { privatize as P } from '@ember/-internals/container/lib/registry'; +import type { InternalOwner } from '@ember/-internals/owner'; +import { getOwner } from '@ember/-internals/owner'; +import type { Nullable } from '@ember/-internals/utility-types'; +import { guidFor } from '@ember/-internals/utils/lib/guid'; +import { getViewElement, getViewId } from '@ember/-internals/views/lib/system/utils'; +import { assert } from '@ember/debug'; +import { + associateDestroyableChild, + destroy, + isDestroyed, + isDestroying, +} from '@glimmer/destroyable'; +import type { + Bounds, + CurriedComponent, + DynamicScope as GlimmerDynamicScope, + Environment, + EvaluationContext, + RenderResult as GlimmerRenderResult, + Template, + TemplateFactory, +} from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference/lib/reference'; +import { createConstRef, UNDEFINED_REFERENCE, valueForRef } from '@glimmer/reference/lib/reference'; +import type { CurriedValue } from '@glimmer/runtime/lib/curried-value'; +import { curry } from '@glimmer/runtime/lib/curried-value'; +import { createCapturedArgs, EMPTY_POSITIONAL } from '@glimmer/runtime/lib/vm/arguments'; +import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; +import { inTransaction } from '@glimmer/runtime/lib/environment'; +import { renderMain } from '@glimmer/runtime/lib/render'; +import { dict } from '@glimmer/util/lib/collections'; +import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/interface'; + +import { hasDOM } from '../../browser-environment'; +import type Component from './component'; +import type ClassicComponent from './component'; +import { BOUNDS } from './component-managers/curly'; +import { createRootOutlet } from './component-managers/outlet'; +import { RootComponentDefinition } from './component-managers/root'; +import { makeRouteTemplate } from './component-managers/route-template'; +import { unwrapTemplate } from './component-managers/unwrap-template'; +import { + BaseRenderer, + errorLoopTransaction, + type IBuilder, + type RootState, +} from './renderer'; +import ResolverImpl from './resolver'; +import type { OutletState } from './utils/outlet'; +import OutletView from './views/outlet'; + +export interface View { + parentView: Nullable; + renderer: Renderer; + tagName: string | null; + elementId: string | null; + isDestroying: boolean; + isDestroyed: boolean; + [BOUNDS]: Bounds | null; +} + +export class DynamicScope implements GlimmerDynamicScope { + constructor( + public view: View | null, + public outletState: Reference + ) {} + + child() { + return new DynamicScope(this.view, this.outletState); + } + + get(key: 'outletState'): Reference { + assert( + `Using \`-get-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, + key === 'outletState' + ); + return this.outletState; + } + + set(key: 'outletState', value: Reference) { + assert( + `Using \`-with-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, + key === 'outletState' + ); + this.outletState = value; + return value; + } +} + +class ClassicRootState implements RootState { + readonly type = 'classic'; + public id: string; + public result: GlimmerRenderResult | undefined; + public destroyed: boolean; + public render: () => void; + readonly env: Environment; + + constructor( + public root: Component | OutletView, + context: EvaluationContext, + owner: object, + template: Template, + self: Reference, + parentElement: SimpleElement, + dynamicScope: DynamicScope, + builder: IBuilder + ) { + assert( + `You cannot render \`${valueForRef(self)}\` without a template.`, + template !== undefined + ); + + this.id = root instanceof OutletView ? guidFor(root) : getViewId(root); + this.result = undefined; + this.destroyed = false; + this.env = context.env; + + this.render = errorLoopTransaction(() => { + let layout = unwrapTemplate(template).asLayout(); + + let iterator = renderMain( + context, + owner, + self, + builder(context.env, { element: parentElement, nextSibling: null }), + layout, + dynamicScope + ); + + let result = (this.result = iterator.sync()); + + associateDestroyableChild(this, result); + + this.render = errorLoopTransaction(() => { + if (isDestroying(result) || isDestroyed(result)) return; + + return result.rerender({ + alwaysRevalidate: false, + }); + }); + }); + } + + isFor(possibleRoot: unknown): boolean { + return this.root === possibleRoot; + } + + destroy() { + let { result, env } = this; + + this.destroyed = true; + + this.root = null as any; + this.result = undefined; + this.render = undefined as any; + + if (result !== undefined) { + /* + Handles these scenarios: + + * When roots are removed during standard rendering process, a transaction exists already + `.begin()` / `.commit()` are not needed. + * When roots are being destroyed manually (`component.append(); component.destroy() case), no + transaction exists already. + * When roots are being destroyed during `Renderer#destroy`, no transaction exists + + */ + + inTransaction(env, () => destroy(result!)); + } + } +} + +interface ViewRegistry { + [viewId: string]: unknown; +} + +export class Renderer extends BaseRenderer { + static override strict( + owner: object, + document: SimpleDocument | Document, + options: { isInteractive: boolean; hasDOM?: boolean } + ): BaseRenderer { + return new BaseRenderer( + owner, + { hasDOM: hasDOM, ...options }, + document as SimpleDocument, + new ResolverImpl(), + clientBuilder + ); + } + + private _rootTemplate: Template; + private _viewRegistry: ViewRegistry; + + static create(props: { _viewRegistry: any }): Renderer { + let { _viewRegistry } = props; + let owner = getOwner(props); + assert('Renderer is unexpectedly missing an owner', owner); + let document = owner.lookup('service:-document') as SimpleDocument; + let env = owner.lookup('-environment:main') as { + isInteractive: boolean; + hasDOM: boolean; + }; + let rootTemplate = owner.lookup(P`template:-root`) as TemplateFactory; + let builder = owner.lookup('service:-dom-builder') as IBuilder; + return new this(owner, document, env, rootTemplate, _viewRegistry, builder); + } + + constructor( + owner: InternalOwner, + document: SimpleDocument, + env: { isInteractive: boolean; hasDOM: boolean }, + rootTemplate: TemplateFactory, + viewRegistry: ViewRegistry, + builder = clientBuilder, + resolver = new ResolverImpl() + ) { + super(owner, env, document, resolver, builder); + this._rootTemplate = rootTemplate(owner); + this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main'); + } + + // renderer HOOKS + + appendOutletView(view: OutletView, target: SimpleElement): void { + // TODO: This bypasses the {{outlet}} syntax so logically duplicates + // some of the set up code. Since this is all internal (or is it?), + // we can refactor this to do something more direct/less convoluted + // and with less setup, but get it working first + let outlet = createRootOutlet(view); + let { name, /* controller, */ template } = view.state; + + let named = dict(); + + named['Component'] = createConstRef( + makeRouteTemplate(view.owner, name, template as Template), + '@Component' + ); + + // TODO: is this guaranteed to be undefined? It seems to be the + // case in the `OutletView` class. Investigate how much that class + // exists as an internal implementation detail only, or if it was + // used outside of core. As far as I can tell, test-helpers uses + // it but only for `setOutletState`. + // named['controller'] = createConstRef(controller, '@controller'); + // Update: at least according to the debug render tree tests, we + // appear to always expect this to be undefined. Not a definitive + // source by any means, but is useful evidence + named['controller'] = UNDEFINED_REFERENCE; + named['model'] = UNDEFINED_REFERENCE; + + let args = createCapturedArgs(named, EMPTY_POSITIONAL); + + this._appendDefinition( + view, + curry(0 as CurriedComponent, outlet, view.owner, args, true), + target + ); + } + + appendTo(view: ClassicComponent, target: SimpleElement): void { + let definition = new RootComponentDefinition(view); + this._appendDefinition( + view, + curry(0 as CurriedComponent, definition, this.state.owner, null, true), + target + ); + } + + _appendDefinition( + root: OutletView | ClassicComponent, + definition: CurriedValue, + target: SimpleElement + ): void { + let self = createConstRef(definition, 'this'); + let dynamicScope = new DynamicScope(null, UNDEFINED_REFERENCE); + let rootState = new ClassicRootState( + root, + this.state.context, + this.state.owner, + this._rootTemplate, + self, + target, + dynamicScope, + this.state.builder + ); + this.state.renderRoot(rootState, this); + } + + cleanupRootFor(component: ClassicComponent): void { + // no need to cleanup roots if we have already been destroyed + if (isDestroyed(this)) { + return; + } + + let roots = this.state.roots; + + // traverse in reverse so we can remove items + // without mucking up the index + let i = roots.length; + while (i--) { + let root = roots[i]; + assert('has root', root); + if (root.type === 'classic' && (root as ClassicRootState).isFor(component)) { + root.destroy(); + roots.splice(i, 1); + } + } + } + + remove(view: ClassicComponent): void { + view._transitionTo('destroying'); + + this.cleanupRootFor(view); + + if (this.state.isInteractive) { + view.trigger('didDestroyElement'); + } + } + + get _roots() { + return this.state.debug.roots; + } + + get _inRenderTransaction() { + return this.state.debug.inRenderTransaction; + } + + get _isInteractive() { + return this.state.debug.isInteractive; + } + + get _context() { + return this.state.context; + } + + register(view: any): void { + let id = getViewId(view); + assert( + 'Attempted to register a view with an id already in use: ' + id, + !this._viewRegistry[id] + ); + this._viewRegistry[id] = view; + } + + unregister(view: any): void { + delete this._viewRegistry[getViewId(view)]; + } + + getElement(component: View): Nullable { + if (this._isInteractive) { + return getViewElement(component); + } else { + throw new Error( + 'Accessing `this.element` is not allowed in non-interactive environments (such as FastBoot).' + ); + } + } + + getBounds(component: View): { + parentElement: SimpleElement; + firstNode: SimpleNode; + lastNode: SimpleNode; + } { + let bounds: Bounds | null = component[BOUNDS]; + + assert('object passed to getBounds must have the BOUNDS symbol as a property', bounds); + + let parentElement = bounds.parentElement(); + let firstNode = bounds.firstNode(); + let lastNode = bounds.lastNode(); + + return { parentElement, firstNode, lastNode }; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts index bf90ff27c9e..36ba0b72ab6 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -1,9 +1,5 @@ -import { privatize as P } from '@ember/-internals/container/lib/registry'; import { ENV } from '@ember/-internals/environment/lib/env'; import type { InternalOwner } from '@ember/-internals/owner'; -import { getOwner } from '@ember/-internals/owner'; -import { guidFor } from '@ember/-internals/utils/lib/guid'; -import { getViewElement, getViewId } from '@ember/-internals/views/lib/system/utils'; import { assert } from '@ember/debug'; import { _backburner, _getCurrentRunLoop } from '@ember/runloop'; import { @@ -15,87 +11,42 @@ import { } from '@glimmer/destroyable'; import { DEBUG } from '@glimmer/env'; import type { - Bounds, + ClassicResolver, Cursor, DebugRenderTree, Environment, - DynamicScope as GlimmerDynamicScope, - RenderResult as GlimmerRenderResult, - Template, - TemplateFactory, EvaluationContext, - CurriedComponent, + RenderResult as GlimmerRenderResult, TreeBuilder, - ClassicResolver, } from '@glimmer/interfaces'; - -import type { Nullable } from '@ember/-internals/utility-types'; import { artifacts } from '@glimmer/program/lib/helpers'; import { RuntimeOpImpl } from '@glimmer/program/lib/opcode'; -import type { Reference } from '@glimmer/reference/lib/reference'; -import { createConstRef, UNDEFINED_REFERENCE, valueForRef } from '@glimmer/reference/lib/reference'; -import type { CurriedValue } from '@glimmer/runtime/lib/curried-value'; +import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render'; import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; -import { createCapturedArgs, EMPTY_POSITIONAL } from '@glimmer/runtime/lib/vm/arguments'; -import { curry } from '@glimmer/runtime/lib/curried-value'; import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment'; -import { renderComponent as glimmerRenderComponent, renderMain } from '@glimmer/runtime/lib/render'; -import { dict } from '@glimmer/util/lib/collections'; -import { unwrapTemplate } from './component-managers/unwrap-template'; +import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; -import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/interface'; -import RSVP from 'rsvp'; -import type Component from './component'; +import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; + import { hasDOM } from '../../browser-environment'; -import type ClassicComponent from './component'; -import { BOUNDS } from './component-managers/curly'; -import { createRootOutlet } from './component-managers/outlet'; -import { RootComponentDefinition } from './component-managers/root'; import { EmberEnvironmentDelegate } from './environment'; import ResolverImpl from './resolver'; -import type { OutletState } from './utils/outlet'; -import OutletView from './views/outlet'; -import { makeRouteTemplate } from './component-managers/route-template'; -import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; -export interface View { - parentView: Nullable; - renderer: Renderer; - tagName: string | null; - elementId: string | null; - isDestroying: boolean; - isDestroyed: boolean; - [BOUNDS]: Bounds | null; -} - -export class DynamicScope implements GlimmerDynamicScope { - constructor( - public view: View | null, - public outletState: Reference - ) {} - - child() { - return new DynamicScope(this.view, this.outletState); - } - - get(key: 'outletState'): Reference { - assert( - `Using \`-get-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, - key === 'outletState' - ); - return this.outletState; - } - - set(key: 'outletState', value: Reference) { - assert( - `Using \`-with-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, - key === 'outletState' - ); - this.outletState = value; - return value; - } +/** + * Common shape of root states tracked by `RendererState`. Both + * `ComponentRootState` (defined here) and `ClassicRootState` (defined in + * `./classic-renderer`) implement this so the renderer state can manage + * either kind without statically depending on classic-only code. + */ +export interface RootState { + readonly type: 'classic' | 'component'; + readonly destroyed: boolean; + readonly result: GlimmerRenderResult | undefined; + render(): void; + destroy(): void; + isFor(possibleRoot: unknown): boolean; } const NO_OP = () => {}; @@ -103,7 +54,7 @@ const NO_OP = () => {}; // This wrapper logic prevents us from rerendering in case of a hard failure // during render. This prevents infinite revalidation type loops from occuring, // and ensures that errors are not swallowed by subsequent follow on failures. -function errorLoopTransaction(fn: () => void) { +export function errorLoopTransaction(fn: () => void) { if (DEBUG) { return () => { let didError = true; @@ -129,9 +80,7 @@ function errorLoopTransaction(fn: () => void) { } } -type RootState = ClassicRootState | ComponentRootState; - -class ComponentRootState { +class ComponentRootState implements RootState { readonly type = 'component'; #result: GlimmerRenderResult | undefined; @@ -165,7 +114,7 @@ class ComponentRootState { }); } - isFor(_component: ClassicComponent): boolean { + isFor(_root: unknown): boolean { return false; } @@ -186,90 +135,6 @@ class ComponentRootState { } } -class ClassicRootState { - readonly type = 'classic'; - public id: string; - public result: GlimmerRenderResult | undefined; - public destroyed: boolean; - public render: () => void; - readonly env: Environment; - - constructor( - public root: Component | OutletView, - context: EvaluationContext, - owner: object, - template: Template, - self: Reference, - parentElement: SimpleElement, - dynamicScope: DynamicScope, - builder: IBuilder - ) { - assert( - `You cannot render \`${valueForRef(self)}\` without a template.`, - template !== undefined - ); - - this.id = root instanceof OutletView ? guidFor(root) : getViewId(root); - this.result = undefined; - this.destroyed = false; - this.env = context.env; - - this.render = errorLoopTransaction(() => { - let layout = unwrapTemplate(template).asLayout(); - - let iterator = renderMain( - context, - owner, - self, - builder(context.env, { element: parentElement, nextSibling: null }), - layout, - dynamicScope - ); - - let result = (this.result = iterator.sync()); - - associateDestroyableChild(this, result); - - this.render = errorLoopTransaction(() => { - if (isDestroying(result) || isDestroyed(result)) return; - - return result.rerender({ - alwaysRevalidate: false, - }); - }); - }); - } - - isFor(possibleRoot: unknown): boolean { - return this.root === possibleRoot; - } - - destroy() { - let { result, env } = this; - - this.destroyed = true; - - this.root = null as any; - this.result = undefined; - this.render = undefined as any; - - if (result !== undefined) { - /* - Handles these scenarios: - - * When roots are removed during standard rendering process, a transaction exists already - `.begin()` / `.commit()` are not needed. - * When roots are being destroyed manually (`component.append(); component.destroy() case), no - transaction exists already. - * When roots are being destroyed during `Renderer#destroy`, no transaction exists - - */ - - inTransaction(env, () => destroy(result!)); - } - } -} - const renderers: BaseRenderer[] = []; export function _resetRenderers() { @@ -293,7 +158,17 @@ function loopBegin(): void { } } -let renderSettledDeferred: RSVP.Deferred | null = null; +type Deferred = { promise: Promise; resolve: () => void }; + +function defer(): Deferred { + let resolve!: () => void; + let promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +let renderSettledDeferred: Deferred | null = null; /* Returns a promise which will resolve when rendering has settled. Settled in this context is defined as when all of the tags in use are "current" (e.g. @@ -305,7 +180,7 @@ let renderSettledDeferred: RSVP.Deferred | null = null; */ export function renderSettled() { if (renderSettledDeferred === null) { - renderSettledDeferred = RSVP.defer(); + renderSettledDeferred = defer(); // if there is no current runloop, the promise created above will not have // a chance to resolve (because its resolved in backburner's "end" event) if (!_getCurrentRunLoop()) { @@ -347,10 +222,6 @@ function loopEnd() { _backburner.on('begin', loopBegin); _backburner.on('end', loopEnd); -interface ViewRegistry { - [viewId: string]: unknown; -} - type Resolver = ClassicResolver; interface RendererData { @@ -713,12 +584,12 @@ export function renderComponent( const RENDER_CACHE = new WeakMap(); const RENDERER_CACHE = new WeakMap(); -class BaseRenderer { +export class BaseRenderer { static strict( owner: object, document: SimpleDocument | Document, options: { isInteractive: boolean; hasDOM?: boolean } - ) { + ): BaseRenderer { return new BaseRenderer( owner, { hasDOM: hasDOM, ...options }, @@ -797,208 +668,9 @@ class BaseRenderer { rerender(): void { this.state.scheduleRevalidate(this); } - - // render(component: Component, options: { into: Cursor; args?: Record }): void { - // this.state.renderRoot(component); - // } } -export class Renderer extends BaseRenderer { - static strict( - owner: object, - document: SimpleDocument | Document, - options: { isInteractive: boolean; hasDOM?: boolean } - ): BaseRenderer { - return new BaseRenderer( - owner, - { hasDOM: hasDOM, ...options }, - document as SimpleDocument, - new ResolverImpl(), - clientBuilder - ); - } - - private _rootTemplate: Template; - private _viewRegistry: ViewRegistry; - - static create(props: { _viewRegistry: any }): Renderer { - let { _viewRegistry } = props; - let owner = getOwner(props); - assert('Renderer is unexpectedly missing an owner', owner); - let document = owner.lookup('service:-document') as SimpleDocument; - let env = owner.lookup('-environment:main') as { - isInteractive: boolean; - hasDOM: boolean; - }; - let rootTemplate = owner.lookup(P`template:-root`) as TemplateFactory; - let builder = owner.lookup('service:-dom-builder') as IBuilder; - return new this(owner, document, env, rootTemplate, _viewRegistry, builder); - } - - constructor( - owner: InternalOwner, - document: SimpleDocument, - env: { isInteractive: boolean; hasDOM: boolean }, - rootTemplate: TemplateFactory, - viewRegistry: ViewRegistry, - builder = clientBuilder, - resolver = new ResolverImpl() - ) { - super(owner, env, document, resolver, builder); - this._rootTemplate = rootTemplate(owner); - this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main'); - } - - // renderer HOOKS - - appendOutletView(view: OutletView, target: SimpleElement): void { - // TODO: This bypasses the {{outlet}} syntax so logically duplicates - // some of the set up code. Since this is all internal (or is it?), - // we can refactor this to do something more direct/less convoluted - // and with less setup, but get it working first - let outlet = createRootOutlet(view); - let { name, /* controller, */ template } = view.state; - - let named = dict(); - - named['Component'] = createConstRef( - makeRouteTemplate(view.owner, name, template as Template), - '@Component' - ); - - // TODO: is this guaranteed to be undefined? It seems to be the - // case in the `OutletView` class. Investigate how much that class - // exists as an internal implementation detail only, or if it was - // used outside of core. As far as I can tell, test-helpers uses - // it but only for `setOutletState`. - // named['controller'] = createConstRef(controller, '@controller'); - // Update: at least according to the debug render tree tests, we - // appear to always expect this to be undefined. Not a definitive - // source by any means, but is useful evidence - named['controller'] = UNDEFINED_REFERENCE; - named['model'] = UNDEFINED_REFERENCE; - - let args = createCapturedArgs(named, EMPTY_POSITIONAL); - - this._appendDefinition( - view, - curry(0 as CurriedComponent, outlet, view.owner, args, true), - target - ); - } - - appendTo(view: ClassicComponent, target: SimpleElement): void { - let definition = new RootComponentDefinition(view); - this._appendDefinition( - view, - curry(0 as CurriedComponent, definition, this.state.owner, null, true), - target - ); - } - - _appendDefinition( - root: OutletView | ClassicComponent, - definition: CurriedValue, - target: SimpleElement - ): void { - let self = createConstRef(definition, 'this'); - let dynamicScope = new DynamicScope(null, UNDEFINED_REFERENCE); - let rootState = new ClassicRootState( - root, - this.state.context, - this.state.owner, - this._rootTemplate, - self, - target, - dynamicScope, - this.state.builder - ); - this.state.renderRoot(rootState, this); - } - - cleanupRootFor(component: ClassicComponent): void { - // no need to cleanup roots if we have already been destroyed - if (isDestroyed(this)) { - return; - } - - let roots = this.state.roots; - - // traverse in reverse so we can remove items - // without mucking up the index - let i = roots.length; - while (i--) { - let root = roots[i]; - assert('has root', root); - if (root.type === 'classic' && root.isFor(component)) { - root.destroy(); - roots.splice(i, 1); - } - } - } - - remove(view: ClassicComponent): void { - view._transitionTo('destroying'); - - this.cleanupRootFor(view); - - if (this.state.isInteractive) { - view.trigger('didDestroyElement'); - } - } - - get _roots() { - return this.state.debug.roots; - } - - get _inRenderTransaction() { - return this.state.debug.inRenderTransaction; - } - - get _isInteractive() { - return this.state.debug.isInteractive; - } - - get _context() { - return this.state.context; - } - - register(view: any): void { - let id = getViewId(view); - assert( - 'Attempted to register a view with an id already in use: ' + id, - !this._viewRegistry[id] - ); - this._viewRegistry[id] = view; - } - - unregister(view: any): void { - delete this._viewRegistry[getViewId(view)]; - } - - getElement(component: View): Nullable { - if (this._isInteractive) { - return getViewElement(component); - } else { - throw new Error( - 'Accessing `this.element` is not allowed in non-interactive environments (such as FastBoot).' - ); - } - } - - getBounds(component: View): { - parentElement: SimpleElement; - firstNode: SimpleNode; - lastNode: SimpleNode; - } { - let bounds: Bounds | null = component[BOUNDS]; - - assert('object passed to getBounds must have the BOUNDS symbol as a property', bounds); - - let parentElement = bounds.parentElement(); - let firstNode = bounds.firstNode(); - let lastNode = bounds.lastNode(); - - return { parentElement, firstNode, lastNode }; - } -} +// Re-export classic-only types so existing import sites that did +// `import { Renderer, DynamicScope, View } from '.../renderer'` keep working. +// New code should import these directly from `./classic-renderer`. +export type { DynamicScope, Renderer, View } from './classic-renderer'; diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 43c2ce19ecf..f8227919554 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -40,9 +40,6 @@ import { default as readonly } from './helpers/readonly'; import { default as unbound } from './helpers/unbound'; import { default as uniqueId } from './helpers/unique-id'; -import { mountHelper } from './syntax/mount'; -import { outletHelper } from './syntax/outlet'; - function instrumentationPayload(name: string) { return { object: `component:${name}` }; } @@ -96,11 +93,20 @@ const BUILTIN_KEYWORD_HELPERS: Record = { '-normalize-class': normalizeClassHelper, '-resolve': resolve, '-track-array': trackArray, - '-mount': mountHelper, - '-outlet': outletHelper, '-in-el-null': inElementNullCheckHelper, }; +/** + * Register an additional built-in keyword helper (e.g. `{{outlet}}`, + * `{{mount}}`). Kept separate so the renderer doesn't statically depend on + * routing/engine infrastructure — callers that need those helpers must opt + * in by importing the registration module (`./syntax/register-routing-keywords`). + */ +export function registerBuiltInKeywordHelper(name: string, helper: object): void { + BUILTIN_KEYWORD_HELPERS[name] = helper; + BUILTIN_HELPERS[name] = helper; +} + const BUILTIN_HELPERS: Record = { ...BUILTIN_KEYWORD_HELPERS, array, diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 75fb2760855..2295635ae3f 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -6,10 +6,14 @@ import Input from './components/input'; import LinkTo from './components/link-to'; import Textarea from './components/textarea'; import { clientBuilder, rehydrationBuilder, serializeBuilder } from './dom'; -import { Renderer } from './renderer'; +import { Renderer } from './classic-renderer'; import OutletTemplate from './templates/outlet'; import RootTemplate from './templates/root'; import OutletView from './views/outlet'; +// Side-effect import: registers `-mount` and `-outlet` as built-in keyword +// helpers. Importing this here keeps the renderer free of routing/engine +// dependencies so apps that only use `renderComponent` don't pay for them. +import './syntax/register-routing-keywords'; export function setupApplicationRegistry(registry: Registry): void { // because we are using injections we can't use instantiate false diff --git a/packages/@ember/-internals/glimmer/lib/syntax/register-routing-keywords.ts b/packages/@ember/-internals/glimmer/lib/syntax/register-routing-keywords.ts new file mode 100644 index 00000000000..dc336be8a1e --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/syntax/register-routing-keywords.ts @@ -0,0 +1,6 @@ +import { registerBuiltInKeywordHelper } from '../resolver'; +import { mountHelper } from './mount'; +import { outletHelper } from './outlet'; + +registerBuiltInKeywordHelper('-mount', mountHelper); +registerBuiltInKeywordHelper('-outlet', outletHelper); From a3fc4c1892f9ad8c112fbe87fff6539b468198d7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 19:19:26 -0400 Subject: [PATCH 02/14] Further hello-world size cuts: 4 more lazy-load splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the previous classic-renderer/routing-keywords split. The hello-world smoke test goes from 168.59 KB / 53.67 KB gzip to 160.11 KB / 50.88 KB gzip. Cumulative with prior commit: 243.30 KB / 77.32 KB → 160.11 KB / 50.88 KB (34.2% gzip reduction). Classic v2-app-template stays flat (319.55 KB / 99.38 KB). Each change is the same registration pattern as before — a separate side-effect file imported by classic-app `setup-registry`, leaving the heavy module out of the renderer-only path. 1. **Curly symbols extracted to `curly-symbols.ts`.** `BOUNDS`, `DIRTY_TAG`, `IS_DISPATCHING_ATTRS`, and the new `CURLY_COMPONENT_BRAND` live in their own file. `isCurlyManager` is now a brand check (`manager[CURLY_COMPONENT_BRAND] === true`) instead of an instance check, so the resolver no longer pulls in `./curly` (the full `CurlyComponentManager` lifecycle, ~17 KB) just to identify the manager. `curly.ts` re-exports the symbols for back-compat and tags `CURLY_COMPONENT_MANAGER` with the brand. `classic-renderer.ts` and `resolver.ts` now import from `curly-symbols.ts`. 2. **`@ember/-internals/glimmer/lib/component`'s top-level side effects moved to `register-curly-component.ts`.** `setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component)` and `Component.reopenClass({ positionalParams: [] })` ran at module load time, which kept the full classic `Component` class graph reachable from anything that imported `@ember/component` (e.g. `@glimmer/component`'s `setComponentManager`/`capabilities` imports). The registration now lives in a side-effect-only file imported by `setup-registry.ts`, so classic apps still get it on boot. 3. **`DebugRenderTreeImpl` factory moved behind a registry in `@glimmer/runtime/.../environment.ts`.** Previously `EnvironmentImpl` imported `DebugRenderTree` statically and only constructed one when `delegate.enableDebugTooling` was true — but the import alone pulled the whole class (and its `getDebugName` cousin) into every bundle. New `registerDebugRenderTreeFactory` lets a side-effect module (`debug-render-tree-register.ts`) supply the constructor; without that import, `env.debugRenderTree` stays `undefined` even when the delegate flag is set. Classic apps re-register it via `setup-registry.ts`. `getDebugName` was the other static reach into `debug-render-tree`, so it moved to its own file (`get-debug-name.ts`) that opcodes can import without dragging the rest in. 4. **`to-bool.ts` swapped `isArray` from `@ember/array` for `Array.isArray(x) || isEmberArray(x)`.** `isArray` from `@ember/array` calls `EmberArray.detect`, which transitively pulls `@ember/array`'s entire mixin/Enumerable/Observable/computed graph (~16 KB) just to test array-ness inside `{{#if}}`. Using `isEmberArray` from `@ember/array/-internals` (a WeakSet brand set in `EmberArray#init`) covers all instances of EmberArray-mixed classes — the same set the old check covered in practice. Verified: lint clean, all type-checks pass, `test:node` 20/20, `test:blueprints` 265/265, both smoke-test apps build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../glimmer/lib/classic-renderer.ts | 2 +- .../lib/component-managers/curly-symbols.ts | 17 ++++++++++++++ .../glimmer/lib/component-managers/curly.ts | 17 +++++++++----- .../-internals/glimmer/lib/component.ts | 20 ++++++++--------- .../glimmer/lib/register-curly-component.ts | 22 +++++++++++++++++++ .../@ember/-internals/glimmer/lib/resolver.ts | 2 +- .../-internals/glimmer/lib/setup-registry.ts | 18 ++++++++++++--- .../-internals/glimmer/lib/utils/to-bool.ts | 13 +++++++++-- .../runtime/lib/compiled/opcodes/component.ts | 2 +- .../runtime/lib/debug-render-tree-register.ts | 13 +++++++++++ .../@glimmer/runtime/lib/debug-render-tree.ts | 11 ++++------ packages/@glimmer/runtime/lib/environment.ts | 21 +++++++++++++++--- .../@glimmer/runtime/lib/get-debug-name.ts | 13 +++++++++++ 13 files changed, 137 insertions(+), 34 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts create mode 100644 packages/@ember/-internals/glimmer/lib/register-curly-component.ts create mode 100644 packages/@glimmer/runtime/lib/debug-render-tree-register.ts create mode 100644 packages/@glimmer/runtime/lib/get-debug-name.ts diff --git a/packages/@ember/-internals/glimmer/lib/classic-renderer.ts b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts index 5c3f6a46441..ccb1577b150 100644 --- a/packages/@ember/-internals/glimmer/lib/classic-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts @@ -35,7 +35,7 @@ import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/inte import { hasDOM } from '../../browser-environment'; import type Component from './component'; import type ClassicComponent from './component'; -import { BOUNDS } from './component-managers/curly'; +import { BOUNDS } from './component-managers/curly-symbols'; import { createRootOutlet } from './component-managers/outlet'; import { RootComponentDefinition } from './component-managers/root'; import { makeRouteTemplate } from './component-managers/route-template'; diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts new file mode 100644 index 00000000000..94b29b90929 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts @@ -0,0 +1,17 @@ +// Symbols and the cheap brand-based `isCurlyManager` predicate, kept in +// their own module so consumers (e.g. the resolver) can identify the +// curly component manager without statically pulling in the full +// `./curly` module — that file carries the entire classic component +// lifecycle plus its transitive deps (~17 KB raw plus the @ember/object +// graph). With this split, `renderComponent`-only bundles never load +// `./curly` at all. + +export const DIRTY_TAG: unique symbol = Symbol('DIRTY_TAG'); +export const IS_DISPATCHING_ATTRS: unique symbol = Symbol('IS_DISPATCHING_ATTRS'); +export const BOUNDS: unique symbol = Symbol('BOUNDS'); + +export const CURLY_COMPONENT_BRAND: unique symbol = Symbol('CURLY_COMPONENT_BRAND'); + +export function isCurlyManager(manager: object): boolean { + return (manager as { [CURLY_COMPONENT_BRAND]?: boolean })[CURLY_COMPONENT_BRAND] === true; +} diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index 585a9b8d63d..aa57664f24f 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -60,6 +60,7 @@ import { import ComponentStateBucket from '../utils/curly-component-state-bucket'; import { processComponentArgs } from '../utils/process-args'; +import { BOUNDS, CURLY_COMPONENT_BRAND, DIRTY_TAG, IS_DISPATCHING_ATTRS } from './curly-symbols'; const COMPONENT_ARGS_MAP = new WeakMap(); @@ -69,9 +70,11 @@ export function getComponentCapturedArgs( return COMPONENT_ARGS_MAP.get(component); } -export const DIRTY_TAG = Symbol('DIRTY_TAG'); -export const IS_DISPATCHING_ATTRS = Symbol('IS_DISPATCHING_ATTRS'); -export const BOUNDS = Symbol('BOUNDS'); +// Re-exported from `./curly-symbols` to keep import sites unchanged. +// The actual symbols live there so consumers that only need the symbols +// (e.g. the renderer's resolver, classic-renderer's `getBounds`) don't +// have to load the full curly component manager. +export { BOUNDS, DIRTY_TAG, IS_DISPATCHING_ATTRS } from './curly-symbols'; const EMBER_VIEW_REF = createPrimitiveRef('ember-view'); @@ -560,7 +563,9 @@ const CURLY_CAPABILITIES: InternalComponentCapabilities = { }; export const CURLY_COMPONENT_MANAGER = new CurlyComponentManager(); +(CURLY_COMPONENT_MANAGER as unknown as Record)[CURLY_COMPONENT_BRAND] = true; -export function isCurlyManager(manager: object): boolean { - return manager === CURLY_COMPONENT_MANAGER; -} +// Re-exported for back-compat — `isCurlyManager` and the symbols now live +// in `./curly-symbols` so that consumers who only need the brand check +// don't pull in the rest of this module. +export { CURLY_COMPONENT_BRAND, isCurlyManager } from './curly-symbols'; diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index f61e446f829..eff8895a7be 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -18,19 +18,17 @@ import { guidFor } from '@ember/-internals/utils/lib/guid'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import type { Environment, Template, TemplateFactory } from '@glimmer/interfaces'; -import { setInternalComponentManager } from '@glimmer/manager/lib/internal/api'; import { isUpdatableRef, updateRef } from '@glimmer/reference/lib/reference'; import { normalizeProperty } from '@glimmer/runtime/lib/dom/props'; import type { DirtyableTag } from '@glimmer/interfaces'; import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators'; import type { SimpleElement } from '@simple-dom/interface'; +import { getComponentCapturedArgs } from './component-managers/curly'; import { BOUNDS, - CURLY_COMPONENT_MANAGER, DIRTY_TAG, IS_DISPATCHING_ATTRS, - getComponentCapturedArgs, -} from './component-managers/curly'; +} from './component-managers/curly-symbols'; import hasDOM from '@ember/-internals/browser-environment/lib/has-dom'; // Keep track of which component classes have already been processed for lazy event setup. @@ -1682,11 +1680,13 @@ class Component } } -// We continue to use reopenClass here so that positionalParams can be overridden with reopenClass in subclasses. -Component.reopenClass({ - positionalParams: [], -}); - -setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component); +// `Component.reopenClass({ positionalParams: [] })` and the curly component +// manager registration that used to live here now happen in +// `./register-curly-component`, imported as a side effect by classic-app +// setup (`./setup-registry`). Splitting the side effect out lets bundlers +// tree-shake the classic `Component` class out of bundles that don't +// otherwise need it — e.g. the hello-world app that only uses +// `setComponentManager` / `capabilities` from `@ember/component` via +// `@glimmer/component`. export default Component; diff --git a/packages/@ember/-internals/glimmer/lib/register-curly-component.ts b/packages/@ember/-internals/glimmer/lib/register-curly-component.ts new file mode 100644 index 00000000000..ee2e231e695 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/register-curly-component.ts @@ -0,0 +1,22 @@ +// Side-effect-only file. Registers the classic curly component manager +// for `Component` and seeds `positionalParams` on the base class. +// +// Kept separate from `./component` so that file's top-level evaluation +// has no side effects — bundlers can then tree-shake the classic +// `Component` class out of any bundle that doesn't otherwise need it +// (e.g. the hello-world that only pulls `setComponentManager` / +// `capabilities` from `@ember/component` via `@glimmer/component`). +// +// Classic apps opt back in to the registration via `./setup-registry`, +// which imports this module for its side effect. + +import { setInternalComponentManager } from '@glimmer/manager/lib/internal/api'; + +import Component from './component'; +import { CURLY_COMPONENT_MANAGER } from './component-managers/curly'; + +Component.reopenClass({ + positionalParams: [], +}); + +setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component); diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index f8227919554..38a13b88ce9 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -27,7 +27,7 @@ import { templateOnlyComponent, TEMPLATE_ONLY_COMPONENT_MANAGER, } from '@glimmer/runtime/lib/component/template-only'; -import { isCurlyManager } from './component-managers/curly'; +import { isCurlyManager } from './component-managers/curly-symbols'; import { CLASSIC_HELPER_MANAGER, isClassicHelper } from './helper'; import { default as disallowDynamicResolution } from './helpers/-disallow-dynamic-resolution'; import { default as inElementNullCheckHelper } from './helpers/-in-element-null-check'; diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 2295635ae3f..54a893326ba 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -10,10 +10,22 @@ import { Renderer } from './classic-renderer'; import OutletTemplate from './templates/outlet'; import RootTemplate from './templates/root'; import OutletView from './views/outlet'; -// Side-effect import: registers `-mount` and `-outlet` as built-in keyword -// helpers. Importing this here keeps the renderer free of routing/engine -// dependencies so apps that only use `renderComponent` don't pay for them. +// Side-effect imports. Each keeps classic-only infrastructure off the +// `renderComponent`-only path: +// +// - `register-routing-keywords` registers `-mount` and `-outlet` as +// built-in keyword helpers (pulls in routing/engine). +// - `@glimmer/runtime/.../debug-render-tree-register` registers the +// `DebugRenderTreeImpl` factory so `delegate.enableDebugTooling: true` +// actually produces a render tree (used by Ember Inspector). Without +// this import, `env.debugRenderTree` stays `undefined`. +// - `register-curly-component` registers the classic component manager +// for the `Component` base class and seeds `positionalParams`. Without +// this import the classic `Component` class is dead code as far as +// `setComponentManager`/`capabilities` consumers are concerned. import './syntax/register-routing-keywords'; +import '@glimmer/runtime/lib/debug-render-tree-register'; +import './register-curly-component'; export function setupApplicationRegistry(registry: Registry): void { // because we are using injections we can't use instantiate false diff --git a/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts b/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts index 64933a3e22a..86cd9a6373a 100644 --- a/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts +++ b/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts @@ -1,16 +1,25 @@ import { isHTMLSafe } from './string'; import { get } from '@ember/-internals/metal/lib/property_get'; import { tagForProperty } from '@ember/-internals/metal/lib/tags'; -import { isArray } from '@ember/array'; +import { isEmberArray } from '@ember/array/-internals'; import { isProxy } from '@ember/-internals/utils/lib/is_proxy'; import { consumeTag } from '@glimmer/validator/lib/tracking'; +// Lightweight array predicate — covers native arrays plus anything branded +// via `setEmberArray` (which is what `EmberArray.init` does, so any +// instance of an EmberArray-mixed class is branded). Avoids the full +// `@ember/array/index` graph (Mixin, Enumerable, Observable, computed) +// just to test array-ness for `{{#if}}`. +function isArrayLike(obj: unknown): obj is ArrayLike { + return Array.isArray(obj) || isEmberArray(obj); +} + export default function toBool(predicate: unknown): boolean { if (isProxy(predicate)) { consumeTag(tagForProperty(predicate, 'content')); return Boolean(get(predicate, 'isTruthy')); - } else if (isArray(predicate)) { + } else if (isArrayLike(predicate)) { consumeTag(tagForProperty(predicate as object, '[]')); return (predicate as { length: number }).length !== 0; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index ec5b2c206d1..98b020e0131 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -89,7 +89,7 @@ import { ConcreteBounds } from '../../bounds'; import { hasCustomDebugRenderTreeLifecycle } from '../../component/interfaces'; import { resolveComponent } from '../../component/resolve'; import { isCurriedType, isCurriedValue, resolveCurriedValue } from '../../curried-value'; -import { getDebugName } from '../../debug-render-tree'; +import { getDebugName } from '../../get-debug-name'; import { APPEND_OPCODES } from '../../opcodes'; import createClassListRef from '../../references/class-list'; import { EMPTY_ARGS, VMArgumentsImpl } from '../../vm/arguments'; diff --git a/packages/@glimmer/runtime/lib/debug-render-tree-register.ts b/packages/@glimmer/runtime/lib/debug-render-tree-register.ts new file mode 100644 index 00000000000..2125fe0aa9c --- /dev/null +++ b/packages/@glimmer/runtime/lib/debug-render-tree-register.ts @@ -0,0 +1,13 @@ +// Side-effect-only module: registers `DebugRenderTreeImpl` as the factory +// used by `EnvironmentImpl` when `delegate.enableDebugTooling` is true. +// +// Importing this file pulls `./debug-render-tree` into the bundle. Apps +// that don't need render-tree introspection (anything not running the +// Ember Inspector) shouldn't import this module — `EnvironmentImpl`'s +// `debugRenderTree` will silently stay `undefined`, which is the same +// behavior you'd get with `enableDebugTooling: false`. + +import DebugRenderTreeImpl from './debug-render-tree'; +import { registerDebugRenderTreeFactory } from './environment'; + +registerDebugRenderTreeFactory(() => new DebugRenderTreeImpl()); diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index 8b09ce748d6..51f73c82ef4 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -2,7 +2,6 @@ import { DEBUG } from '@glimmer/env'; import type { Bounds, CapturedRenderNode, - ComponentDefinition, DebugRenderTree, Nullable, RenderNode, @@ -198,9 +197,7 @@ export default class DebugRenderTreeImpl< } } -export function getDebugName( - definition: ComponentDefinition, - manager = definition.manager -): string { - return definition.resolvedName ?? definition.debugName ?? manager.getDebugName(definition.state); -} +// `getDebugName` now lives in `./get-debug-name` so opcodes can pull it +// in without dragging in the full `DebugRenderTreeImpl` class. Re-export +// for back-compat with any external consumers. +export { getDebugName } from './get-debug-name'; diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index bafbefe2f82..2bc3f5e30be 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -2,6 +2,7 @@ import { DEBUG } from '@glimmer/env'; import type { ClassicResolver, ComponentInstanceWithCreate, + DebugRenderTree, Environment, EnvironmentOptions, GlimmerTreeChanges, @@ -19,10 +20,21 @@ import { ProgramImpl } from '@glimmer/program/lib/program'; import { track } from '@glimmer/validator/lib/tracking'; import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; -import DebugRenderTree from './debug-render-tree'; import { DOMChangesImpl, DOMTreeConstruction } from './dom/helper'; import { isArgumentError } from './vm/arguments'; +// Lazy registration: the DebugRenderTree implementation registers itself +// here on import (`./debug-render-tree-register`). Apps that don't need +// render-tree introspection (anything not running the Ember Inspector) +// won't pull `./debug-render-tree` into the bundle, since this module no +// longer references it statically. +type DebugRenderTreeFactory = () => DebugRenderTree; +let debugRenderTreeFactory: DebugRenderTreeFactory | null = null; + +export function registerDebugRenderTreeFactory(factory: DebugRenderTreeFactory): void { + debugRenderTreeFactory = factory; +} + export const TRANSACTION: TransactionSymbol = Symbol('TRANSACTION') as TransactionSymbol; class TransactionImpl implements Transaction { @@ -107,14 +119,17 @@ export class EnvironmentImpl implements Environment { // eslint-disable-next-line @typescript-eslint/no-explicit-any isArgumentCaptureError: ((error: any) => boolean) | undefined; - debugRenderTree: DebugRenderTree | undefined; + debugRenderTree: DebugRenderTree | undefined; constructor( options: EnvironmentOptions, private delegate: EnvironmentDelegate ) { this.isInteractive = delegate.isInteractive; - this.debugRenderTree = this.delegate.enableDebugTooling ? new DebugRenderTree() : undefined; + this.debugRenderTree = + this.delegate.enableDebugTooling && debugRenderTreeFactory + ? debugRenderTreeFactory() + : undefined; this.isArgumentCaptureError = this.delegate.enableDebugTooling ? isArgumentError : undefined; if (options.appendOperations) { this.appendOperations = options.appendOperations; diff --git a/packages/@glimmer/runtime/lib/get-debug-name.ts b/packages/@glimmer/runtime/lib/get-debug-name.ts new file mode 100644 index 00000000000..28bbc306be3 --- /dev/null +++ b/packages/@glimmer/runtime/lib/get-debug-name.ts @@ -0,0 +1,13 @@ +import type { ComponentDefinition } from '@glimmer/interfaces'; + +// Tiny helper extracted from `./debug-render-tree` so opcodes that need a +// debug name don't have to import the whole `DebugRenderTreeImpl` class +// just to reach this 4-line function. Keeps `./debug-render-tree` out of +// bundles that aren't doing render-tree introspection (the class itself +// is only pulled in when `debug-render-tree-register` is imported). +export function getDebugName( + definition: ComponentDefinition, + manager = definition.manager +): string { + return definition.resolvedName ?? definition.debugName ?? manager.getDebugName(definition.state); +} From eb109ad215e1aa4f995a9170a08a730a49e33b3d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 19:55:24 -0400 Subject: [PATCH 03/14] Lazy-load proxy + instrumentation hot-path: 159.49 KB / 50.67 KB gzip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more lazy-load splits on top of the previous round. Hello-world goes from 160.11 KB / 50.88 KB → 159.49 KB / 50.67 KB. The classic v2-app-template also gets a small win (319.55 → 318.27 KB raw, 99.38 → 98.94 KB gzip). Cumulative from baseline: 243.30 KB / 77.32 KB → 159.49 KB / 50.67 KB (34.5% gzip reduction). 1. **`contentFor` extracted to `runtime/lib/mixins/content-for.ts`.** `each-in.ts` (which the renderer registers as the `-each-in` keyword helper) imports `contentFor` to unwrap proxies before iterating. Until now that import dragged in `runtime/lib/mixins/-proxy`, which defines `ProxyMixin = Mixin.create(...)` at module scope — the entry point to the entire EmberObject / Mixin / computed graph (proxy.ts imports `Mixin`, `computed`, `defineProperty`, `set`, etc.). Moving the 8-line `contentFor` function into its own file lets the renderer path keep proxy support without paying for the rest of the proxy mixin's transitive imports. `-proxy.ts` re-exports `contentFor` from the new file for back-compat. 2. **`@ember/instrumentation` hot path extracted to `instrumentation/lib/internal-instrument.ts`.** `_instrumentStart` (called by the resolver, curly manager, outlet/root/route-template managers) and `flaggedInstrument` (called by the views state machine) used to live in `index.ts` alongside `subscribe`, `unsubscribe`, `instrument`, etc. — most of which are dead code unless something actually subscribes (e.g. Ember Inspector). Moved `_instrumentStart`, `flaggedInstrument`, `subscribers`, the cache helpers, and `NOOP` to the lib file, with `index.ts` re-exporting them via `export ... from`. The `no-barrel-imports` autofix then rewrites internal callers to deep-import from the lib file. Net result: the `instrumentation/index.js` chunk (subscribe / unsubscribe / instrument machinery) drops out of bundles that only use the hot path. `package.json`'s `ember-addon.renamed-modules` map gains an entry for `runtime/lib/mixins/content-for.js` — that's emitted automatically by the `packageMeta` rollup plugin, no manual edit. Verified: lint clean, all type-checks pass, `test:node` 20/20, `test:blueprints` 265/265, both smoke-test apps build. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 + .../glimmer/lib/component-managers/curly.ts | 2 +- .../glimmer/lib/component-managers/outlet.ts | 2 +- .../glimmer/lib/component-managers/root.ts | 2 +- .../lib/component-managers/route-template.ts | 2 +- .../-internals/glimmer/lib/helpers/each-in.ts | 2 +- .../@ember/-internals/glimmer/lib/resolver.ts | 2 +- .../-internals/runtime/lib/mixins/-proxy.ts | 17 +-- .../runtime/lib/mixins/content-for.ts | 18 +++ packages/@ember/instrumentation/index.ts | 133 ++++-------------- .../lib/internal-instrument.ts | 129 +++++++++++++++++ 11 files changed, 186 insertions(+), 125 deletions(-) create mode 100644 packages/@ember/-internals/runtime/lib/mixins/content-for.ts create mode 100644 packages/@ember/instrumentation/lib/internal-instrument.ts diff --git a/package.json b/package.json index 3dc91e972e1..1dd0296a993 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,7 @@ "@ember/-internals/runtime/lib/mixins/action_handler.js": "ember-source/@ember/-internals/runtime/lib/mixins/action_handler.js", "@ember/-internals/runtime/lib/mixins/comparable.js": "ember-source/@ember/-internals/runtime/lib/mixins/comparable.js", "@ember/-internals/runtime/lib/mixins/container_proxy.js": "ember-source/@ember/-internals/runtime/lib/mixins/container_proxy.js", + "@ember/-internals/runtime/lib/mixins/content-for.js": "ember-source/@ember/-internals/runtime/lib/mixins/content-for.js", "@ember/-internals/runtime/lib/mixins/registry_proxy.js": "ember-source/@ember/-internals/runtime/lib/mixins/registry_proxy.js", "@ember/-internals/runtime/lib/mixins/target_action_support.js": "ember-source/@ember/-internals/runtime/lib/mixins/target_action_support.js", "@ember/-internals/string/index.js": "ember-source/@ember/-internals/string/index.js", @@ -239,6 +240,7 @@ "@ember/enumerable/mutable.js": "ember-source/@ember/enumerable/mutable.js", "@ember/helper/index.js": "ember-source/@ember/helper/index.js", "@ember/instrumentation/index.js": "ember-source/@ember/instrumentation/index.js", + "@ember/instrumentation/lib/internal-instrument.js": "ember-source/@ember/instrumentation/lib/internal-instrument.js", "@ember/modifier/index.js": "ember-source/@ember/modifier/index.js", "@ember/modifier/on.js": "ember-source/@ember/modifier/on.js", "@ember/object/-internals.js": "ember-source/@ember/object/-internals.js", diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index aa57664f24f..bb581ac1472 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -12,7 +12,7 @@ import { } from '@ember/-internals/views/lib/system/utils'; import type { Nullable } from '@ember/-internals/utility-types'; import { assert, debugFreeze } from '@ember/debug'; -import { _instrumentStart } from '@ember/instrumentation'; +import { _instrumentStart } from '@ember/instrumentation/lib/internal-instrument'; import { DEBUG } from '@glimmer/env'; import type { Bounds, diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts index 823e1239f51..b56684b04de 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts @@ -2,7 +2,7 @@ import type { InternalOwner } from '@ember/-internals/owner'; import type { Nullable } from '@ember/-internals/utility-types'; import { assert } from '@ember/debug'; import EngineInstance from '@ember/engine/instance'; -import { _instrumentStart } from '@ember/instrumentation'; +import { _instrumentStart } from '@ember/instrumentation/lib/internal-instrument'; import { precompileTemplate } from '@ember/template-compilation'; import type { CompilableProgram, diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts index 2ca09c5e158..c05e0500a91 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts @@ -1,6 +1,6 @@ import { getFactoryFor } from '@ember/-internals/container/lib/container'; import { assert } from '@ember/debug'; -import { _instrumentStart } from '@ember/instrumentation'; +import { _instrumentStart } from '@ember/instrumentation/lib/internal-instrument'; import { DEBUG } from '@glimmer/env'; import type { ComponentDefinition, diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts b/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts index 8e6b12278aa..1d578b97e40 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts @@ -1,5 +1,5 @@ import type { InternalOwner } from '@ember/-internals/owner'; -import { _instrumentStart } from '@ember/instrumentation'; +import { _instrumentStart } from '@ember/instrumentation/lib/internal-instrument'; import type { CapturedArguments, CompilableProgram, diff --git a/packages/@ember/-internals/glimmer/lib/helpers/each-in.ts b/packages/@ember/-internals/glimmer/lib/helpers/each-in.ts index 1e4c51bc083..a591a16005f 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/each-in.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/each-in.ts @@ -2,7 +2,7 @@ @module ember */ import { tagForObject } from '@ember/-internals/metal/lib/tags'; -import { contentFor as _contentFor } from '@ember/-internals/runtime/lib/mixins/-proxy'; +import { contentFor as _contentFor } from '@ember/-internals/runtime/lib/mixins/content-for'; import { isProxy } from '@ember/-internals/utils/lib/is_proxy'; import { assert } from '@ember/debug'; import type { CapturedArguments } from '@glimmer/interfaces'; diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 38a13b88ce9..46f38862ce4 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -1,7 +1,7 @@ import type { InternalFactory, InternalOwner } from '@ember/-internals/owner'; import { isFactory } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; -import { _instrumentStart } from '@ember/instrumentation'; +import { _instrumentStart } from '@ember/instrumentation/lib/internal-instrument'; import { DEBUG } from '@glimmer/env'; import type { ClassicResolver, diff --git a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts index ddb31a6c18d..b88edf7b685 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts @@ -15,18 +15,15 @@ import { isObject } from '@ember/-internals/utils/lib/spec'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import { setCustomTagFor } from '@glimmer/manager/lib/util/args-proxy'; -import type { UpdatableTag, Tag } from '@glimmer/interfaces'; -import { combine, UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; +import type { Tag } from '@glimmer/interfaces'; +import { combine } from '@glimmer/validator/lib/validators'; import { tagFor, tagMetaFor } from '@glimmer/validator/lib/meta'; -export function contentFor(proxy: ProxyMixin): T | null { - let content = get(proxy, 'content'); - // SAFETY: Ideally we'd assert instead of casting, but @glimmer/validator doesn't give us - // sufficient public types for this. Previously this code was .js and worked correctly so - // hopefully this is sufficiently reliable. - updateTag(tagForObject(proxy) as UpdatableTag, tagForObject(content)); - return content; -} +// `contentFor` was extracted to `./content-for` so the `each-in` helper +// (reachable from the renderer) can use it without dragging in this +// file's `Mixin.create(...)` graph. Re-exported here for back-compat. +import { contentFor } from './content-for'; +export { contentFor }; function customTagForProxy(proxy: object, key: string, addMandatorySetter?: boolean): Tag { assert('Expected a proxy', isProxy(proxy)); diff --git a/packages/@ember/-internals/runtime/lib/mixins/content-for.ts b/packages/@ember/-internals/runtime/lib/mixins/content-for.ts new file mode 100644 index 00000000000..0f3c45349c8 --- /dev/null +++ b/packages/@ember/-internals/runtime/lib/mixins/content-for.ts @@ -0,0 +1,18 @@ +import { get } from '@ember/-internals/metal/lib/property_get'; +import { tagForObject } from '@ember/-internals/metal/lib/tags'; +import type { UpdatableTag } from '@glimmer/interfaces'; +import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; +import type ProxyMixin from './-proxy'; + +// Extracted from `./-proxy` so that consumers (notably the `each-in` helper +// reachable from the renderer) don't have to drag in `@ember/object/mixin` +// and the `ProxyMixin = Mixin.create(...)` side effect — that's the entry +// point to the entire EmberObject/PrototypeMixin pyramid. + +export function contentFor(proxy: ProxyMixin): T | null { + let content = get(proxy, 'content'); + // SAFETY: matches the original cast in -proxy.ts; @glimmer/validator + // doesn't expose enough public types for an assertion here. + updateTag(tagForObject(proxy) as UpdatableTag, tagForObject(content)); + return content; +} diff --git a/packages/@ember/instrumentation/index.ts b/packages/@ember/instrumentation/index.ts index f3fb35d4775..6baea7827ce 100644 --- a/packages/@ember/instrumentation/index.ts +++ b/packages/@ember/instrumentation/index.ts @@ -1,28 +1,29 @@ -/* eslint no-console:off */ -/* global console */ - -import { ENV } from '@ember/-internals/environment/lib/env'; -import { assert } from '@ember/debug'; - -export interface Listener { - before: (name: string, timestamp: number, payload: object) => T; - after: (name: string, timestamp: number, payload: object, beforeValue: T) => void; -} - -export interface Subscriber { - pattern: string; - regex: RegExp; - object: Listener; -} +import { + NOOP, + _instrumentStart, + resetCache, + subscribers, + type Listener, + type Subscriber, +} from './lib/internal-instrument'; + +// Re-export with `export ... from` (rather than re-binding via local +// import + export) so the no-barrel-imports lint rule traces consumers +// through to `./lib/internal-instrument` and rewrites their imports to +// the deep path. That keeps the heavy `instrument` / `subscribe` / +// `unsubscribe` machinery defined below out of bundles that only need +// the hot path. +export { + _instrumentStart, + flaggedInstrument, + subscribers, +} from './lib/internal-instrument'; +export type { Listener, Subscriber, StructuredProfilePayload } from './lib/internal-instrument'; export interface PayloadWithException { exception?: any; } -export interface StructuredProfilePayload { - object: string | object; -} - /** @module @ember/instrumentation @private @@ -78,23 +79,6 @@ export interface StructuredProfilePayload { @static @private */ -export let subscribers: Subscriber[] = []; -let cache: { [key: string]: Listener[] } = {}; - -function populateListeners(name: string) { - let listeners: Listener[] = []; - - for (let subscriber of subscribers) { - if (subscriber.regex.test(name)) { - listeners.push(subscriber.object); - } - } - - cache[name] = listeners; - return listeners; -} - -const time = (): number => performance.now(); type InstrumentCallback = (this: Binding) => Result; @@ -172,14 +156,6 @@ export function instrument( } } -export function flaggedInstrument( - _name: string, - _payload: object, - callback: () => Result -): Result { - return callback(); -} - function withFinalizer( callback: InstrumentCallback, finalizer: () => void, @@ -196,67 +172,6 @@ function withFinalizer( } } -function NOOP() {} - -// private for now -export function _instrumentStart(name: string, payloadFunc: () => object): () => void; -export function _instrumentStart( - name: string, - payloadFunc: (arg: Arg) => object, - payloadArg: Arg -): () => void; -export function _instrumentStart( - name: string, - payloadFunc: ((arg: Arg) => object) | (() => object), - payloadArg?: Arg -): () => void { - if (subscribers.length === 0) { - return NOOP; - } - - let listeners = cache[name]; - - if (!listeners) { - listeners = populateListeners(name); - } - - if (listeners.length === 0) { - return NOOP; - } - - let payload = payloadFunc(payloadArg!); - - let STRUCTURED_PROFILE = ENV.STRUCTURED_PROFILE; - let timeName: string; - if (STRUCTURED_PROFILE) { - timeName = `${name}: ${(payload as StructuredProfilePayload).object}`; - console.time(timeName); - } - - let beforeValues: any[] = []; - let timestamp = time(); - for (let listener of listeners) { - beforeValues.push(listener.before(name, timestamp, payload)); - } - - const constListeners = listeners; - - return function _instrumentEnd(): void { - let timestamp = time(); - for (let i = 0; i < constListeners.length; i++) { - let listener = constListeners[i]; - assert('has listener', listener); // Iterating over values - if (typeof listener.after === 'function') { - listener.after(name, timestamp, payload, beforeValues[i]); - } - } - - if (STRUCTURED_PROFILE) { - console.timeEnd(timeName); - } - }; -} - /** Subscribes to a particular event or instrumented block of code. @@ -292,7 +207,7 @@ export function subscribe(pattern: string, object: Listener): Subscriber): void { } subscribers.splice(index, 1); - cache = {}; + resetCache(); } /** @@ -330,5 +245,5 @@ export function unsubscribe(subscriber: Subscriber): void { */ export function reset(): void { subscribers.length = 0; - cache = {}; + resetCache(); } diff --git a/packages/@ember/instrumentation/lib/internal-instrument.ts b/packages/@ember/instrumentation/lib/internal-instrument.ts new file mode 100644 index 00000000000..b84b180184e --- /dev/null +++ b/packages/@ember/instrumentation/lib/internal-instrument.ts @@ -0,0 +1,129 @@ +// Hot-path bits of `@ember/instrumentation`, factored out so internal +// callers (the resolver, the curly component manager, view states) can +// import `_instrumentStart` without dragging in the full subscribe / +// unsubscribe / instrument machinery — most of which is dead code unless +// something actually subscribes (e.g. Ember Inspector). +// +// `subscribers` and `cache` live here; `index.ts` imports and mutates +// them via `subscribe` / `unsubscribe` / `reset`. + +import { ENV } from '@ember/-internals/environment/lib/env'; +import { assert } from '@ember/debug'; + +export interface Listener { + before: (name: string, timestamp: number, payload: object) => T; + after: (name: string, timestamp: number, payload: object, beforeValue: T) => void; +} + +export interface Subscriber { + pattern: string; + regex: RegExp; + object: Listener; +} + +export interface StructuredProfilePayload { + object: string | object; +} + + +export const subscribers: Subscriber[] = []; + +export const cache: { [key: string]: Listener[] } = {}; + +export function resetCache(): void { + for (const key of Object.keys(cache)) { + delete cache[key]; + } +} + + +function populateListeners(name: string): Listener[] { + + let listeners: Listener[] = []; + + for (let subscriber of subscribers) { + if (subscriber.regex.test(name)) { + listeners.push(subscriber.object); + } + } + + cache[name] = listeners; + return listeners; +} + +const time = (): number => performance.now(); + +export const NOOP = (): void => {}; + +// `flaggedInstrument` historically wrapped a callback in conditionally- +// enabled instrumentation; today it's a thin pass-through. Lives here so +// hot-path callers (the views state machine) can deep-import it without +// pulling in the rest of `index.ts`. +export function flaggedInstrument( + _name: string, + _payload: object, + callback: () => Result +): Result { + return callback(); +} + +export function _instrumentStart(name: string, payloadFunc: () => object): () => void; +export function _instrumentStart( + name: string, + payloadFunc: (arg: Arg) => object, + payloadArg: Arg +): () => void; +export function _instrumentStart( + name: string, + payloadFunc: ((arg: Arg) => object) | (() => object), + payloadArg?: Arg +): () => void { + if (subscribers.length === 0) { + return NOOP; + } + + let listeners = cache[name]; + + if (!listeners) { + listeners = populateListeners(name); + } + + if (listeners.length === 0) { + return NOOP; + } + + let payload = payloadFunc(payloadArg!); + + let STRUCTURED_PROFILE = ENV.STRUCTURED_PROFILE; + let timeName: string; + if (STRUCTURED_PROFILE) { + timeName = `${name}: ${(payload as StructuredProfilePayload).object}`; + // eslint-disable-next-line no-console + console.time(timeName); + } + + + let beforeValues: any[] = []; + let timestamp = time(); + for (let listener of listeners) { + beforeValues.push(listener.before(name, timestamp, payload)); + } + + const constListeners = listeners; + + return function _instrumentEnd(): void { + let timestamp = time(); + for (let i = 0; i < constListeners.length; i++) { + let listener = constListeners[i]; + assert('has listener', listener); + if (typeof listener.after === 'function') { + listener.after(name, timestamp, payload, beforeValues[i]); + } + } + + if (STRUCTURED_PROFILE) { + // eslint-disable-next-line no-console + console.timeEnd(timeName); + } + }; +} From edb4b07dc116dbf44074c442043a56f929de7b2b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:07:03 -0400 Subject: [PATCH 04/14] Mark ember-source as mostly side-effect-free: 134.17 KB / 42.90 KB gzip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hello-world: 159.49 KB / 50.67 KB → 134.17 KB / 42.90 KB gzip (–25 KB raw / –7.8 KB gzip). Classic v2-app-template gets a small bonus too (319.55 → 317.74 KB raw, 99.38 → 98.77 KB gzip). Cumulative from the original 243.30 KB / 77.32 KB baseline: a **44.5% gzip reduction**. Added a `sideEffects` field to `ember-source/package.json` listing the files that actually have top-level side effects, which by inversion tells bundlers that everything else is side-effect-free. With the classic-renderer / register-curly-component / register-routing-keywords splits already done in this PR, the renderer-only path no longer reaches into any of the side-effect files, so vite/rolldown can drop the rest of the graph it pulled in transitively (mostly the classic `Component` class and its CoreView/Mixin chain that vite was previously evaluating via `@ember/component`'s `default` re-export). The list covers: - **Registration modules created in this PR** (`setup-registry*`, `register-routing-keywords*`, `register-curly-component*`, `debug-render-tree-register*`) — these mutate global state on import. - **`environment*` files** (in `@ember/-internals/glimmer/` and `@glimmer/runtime/`) — call `setGlobalContext(...)` and the `_backburner.on(...)` lifecycle hookups at module top level. - **`@glimmer/runtime/lib/compiled/opcodes/**`** — every opcode file registers handlers via `APPEND_OPCODES.add(...)` at module load. - **`@glimmer/runtime/lib/helpers/**` and `lib/modifiers/**`** — setHelperManager / setModifierManager calls. - **`@ember/-internals/glimmer/lib/components/**`** — Input, Textarea, LinkTo all call `setInternalComponentManager(...)` at top level. - **`runtime/lib/component/template-only*`, `runtime/lib/vm/low-level*`** — template-only manager registration and VM bootstrap. - **`runloop/`, `manager/`, `validator/`, `global-context/`, `destroyable/`, `canary-features/`, `-internals/environment/`, `-internals/runtime/lib/ext/rsvp*`, `-internals/views/lib/system/event_dispatcher*`** — top-level side effects in those modules' index/init files. - **`./dist/dev/**`** — keep dev builds maximally unmolested for inspector / debugging tooling that may rely on dev-only side effects. Anything outside that list — class-definition files like `component.ts`, `core_view.ts`, `core.ts`, mixin files, computed-property files — is treated by bundlers as pure, so unused exports drop out cleanly. Verified: lint clean, all type-checks pass, `test:node` 20/20, `test:blueprints` 265/265, `pnpm test` for both `smoke-tests/v2-app-template` (classic v2 app) and `smoke-tests/app-template` (v1 app) pass 1/1 each, hello-world builds and shrinks as reported. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1dd0296a993..b0f3f0ab89e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,31 @@ }, "./package.json": "./package.json" }, - "sideEffects": false, + "sideEffects": [ + "./dist/dev/**", + "**/setup-registry*", + "**/register-routing-keywords*", + "**/register-curly-component*", + "**/debug-render-tree-register*", + "**/-internals/glimmer/lib/environment*", + "**/runtime/lib/environment*", + "**/-internals/runtime/lib/ext/rsvp*", + "**/runtime/lib/component/template-only*", + "**/runtime/lib/vm/low-level*", + "**/runtime/lib/compiled/opcodes/**", + "**/runtime/lib/helpers/**", + "**/runtime/lib/modifiers/**", + "**/-internals/glimmer/lib/components/**", + "**/-internals/views/lib/system/event_dispatcher*", + "**/runloop/index*", + "**/runloop/-private/**", + "**/manager/**", + "**/validator/**", + "**/global-context/**", + "**/destroyable/**", + "**/canary-features/**", + "**/-internals/environment/**" + ], "homepage": "https://emberjs.com/", "bugs": { "url": "https://github.com/emberjs/ember.js/issues" From b774f03ad18abaff9458b300d219d8e781ef2d4a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:19:52 -0400 Subject: [PATCH 05/14] Lint fix: deep-import flaggedInstrument CI lint caught these without the local eslint cache: the no-barrel-imports rule wants `flaggedInstrument` imported from `@ember/instrumentation/lib/internal-instrument` (the actual source) rather than the barrel. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/lib/components/link-to.ts | 2 +- packages/@ember/-internals/views/lib/views/states.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/components/link-to.ts b/packages/@ember/-internals/glimmer/lib/components/link-to.ts index 4f11670388c..105111f5ddd 100644 --- a/packages/@ember/-internals/glimmer/lib/components/link-to.ts +++ b/packages/@ember/-internals/glimmer/lib/components/link-to.ts @@ -5,7 +5,7 @@ import inspect from '@ember/debug/lib/inspect'; import { assert, debugFreeze, warn } from '@ember/debug'; import { getEngineParent } from '@ember/engine/parent'; import type EngineInstance from '@ember/engine/instance'; -import { flaggedInstrument } from '@ember/instrumentation'; +import { flaggedInstrument } from '@ember/instrumentation/lib/internal-instrument'; import { action } from '@ember/object'; import { service } from '@ember/service'; import { DEBUG } from '@glimmer/env'; diff --git a/packages/@ember/-internals/views/lib/views/states.ts b/packages/@ember/-internals/views/lib/views/states.ts index aba69ba106b..b38ae20ddfd 100644 --- a/packages/@ember/-internals/views/lib/views/states.ts +++ b/packages/@ember/-internals/views/lib/views/states.ts @@ -1,7 +1,7 @@ import { teardownMandatorySetter } from '@ember/-internals/utils/lib/mandatory-setter'; import type Component from '@ember/-internals/glimmer/lib/component'; import { assert } from '@ember/debug'; -import { flaggedInstrument } from '@ember/instrumentation'; +import { flaggedInstrument } from '@ember/instrumentation/lib/internal-instrument'; import { join } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; From 6a64fd260cba2f73ef6e592919608e2c7b358037 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:24:32 -0400 Subject: [PATCH 06/14] Move Meta mixin methods to standalone fns in @ember/object/mixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `addMixin` / `hasMixin` / `forEachMixins` methods only existed on `Meta` to be called by `@ember/object/mixin`. Keeping them on the class forced a static reference from `Meta` (reachable from the renderer through the property accessor / tag chain) into the classic `Mixin` graph. Move them out as standalone functions (`metaAddMixin` / `metaHasMixin` / `metaForEachMixins`) in `mixin.ts` itself, poking at `Meta`'s public `_mixins` and `parent` fields directly. With this split, bundles that don't import `@ember/object/mixin` get a cleaner `Meta` class — in the hello-world prod bundle the `addMixin` / `hasMixin` / `forEachMixins` identifiers go from present to fully absent, and `Mixin` references drop from 12 to 5. The methods were `@internal` and only called from `mixin.ts`, so this is a purely internal refactor. Verified: lint clean, type-checks pass, hello-world builds at 134.19 KB / 42.94 KB gzip (unchanged), classic v2-app-template tests 1/1 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/meta/lib/meta.ts | 46 +++------------ packages/@ember/object/mixin.ts | 63 +++++++++++++++++++-- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/packages/@ember/-internals/meta/lib/meta.ts b/packages/@ember/-internals/meta/lib/meta.ts index 781566d20bd..450c8a5a408 100644 --- a/packages/@ember/-internals/meta/lib/meta.ts +++ b/packages/@ember/-internals/meta/lib/meta.ts @@ -267,44 +267,14 @@ export class Meta { return undefined; } - /** @internal */ - addMixin(mixin: any) { - assert( - isDestroyed(this.source) - ? `Cannot add mixins of \`${toString(mixin)}\` on \`${toString( - this.source - )}\` call addMixin after it has been destroyed.` - : '', - !isDestroyed(this.source) - ); - let set = this._getOrCreateOwnSet('_mixins'); - set.add(mixin); - } - - /** @internal */ - hasMixin(mixin: any) { - return this._hasInInheritedSet('_mixins', mixin); - } - - /** @internal */ - forEachMixins(fn: Function) { - let pointer: Meta | null = this; - let seen: Set | undefined; - while (pointer !== null) { - let set = pointer._mixins; - if (set !== undefined) { - seen = seen === undefined ? new Set() : seen; - // TODO cleanup typing here - set.forEach((mixin: any) => { - if (!seen!.has(mixin)) { - seen!.add(mixin); - fn(mixin); - } - }); - } - pointer = pointer.parent; - } - } + // The mixin-tracking helpers (`addMixin` / `hasMixin` / `forEachMixins`) + // moved to `@ember/object/mixin` as standalone functions + // (`metaAddMixin` / `metaHasMixin` / `metaForEachMixins`). They poke + // directly at the public `_mixins` / `parent` fields here. Splitting + // them out keeps the `Meta` class — which is reachable from the + // renderer via the property accessor + tag chain — free of references + // to the classic `Mixin` machinery, so bundles that don't actually use + // mixins can tree-shake `@ember/object/mixin` away. /** @internal */ writeDescriptors(subkey: string, value: any) { diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index c76b57f7dbe..9f8bd298dda 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -4,6 +4,61 @@ import { INIT_FACTORY } from '@ember/-internals/container/lib/container'; import type { Meta } from '@ember/-internals/meta/lib/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; +import { isDestroyed } from '@glimmer/destroyable'; +import toString from '@ember/-internals/utils/lib/to-string'; + +// Mixin-tracking helpers — moved out of `Meta` so the renderer-only +// path (which reaches `Meta` through the tag/property graph) doesn't +// pull a reference to `Mixin` into the bundle. They poke at `Meta`'s +// public `_mixins` / `parent` fields directly. + + +function metaAddMixin(meta: Meta, mixin: any): void { + assert( + isDestroyed(meta.source as object) + ? `Cannot add mixins of \`${toString(mixin)}\` on \`${toString( + meta.source + )}\` call addMixin after it has been destroyed.` + : '', + !isDestroyed(meta.source as object) + ); + let set = meta._mixins ?? (meta._mixins = new Set()); + set.add(mixin); +} + + +function metaHasMixin(meta: Meta, mixin: any): boolean { + let pointer: Meta | null = meta; + while (pointer !== null) { + let set = pointer._mixins; + if (set !== undefined && set.has(mixin)) { + return true; + } + pointer = pointer.parent; + } + return false; +} + + +function metaForEachMixins(meta: Meta, fn: (mixin: any) => void): void { + let pointer: Meta | null = meta; + + let seen: Set | undefined; + while (pointer !== null) { + let set = pointer._mixins; + if (set !== undefined) { + seen = seen === undefined ? new Set() : seen; + + set.forEach((mixin: any) => { + if (!seen!.has(mixin)) { + seen!.add(mixin); + fn(mixin); + } + }); + } + pointer = pointer.parent; + } +} import { observerListenerMetaFor, ROOT, wrap } from '@ember/-internals/utils/lib/super'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; @@ -247,10 +302,10 @@ function mergeMixins( ); if (MIXINS.has(currentMixin)) { - if (meta.hasMixin(currentMixin)) { + if (metaHasMixin(meta, currentMixin)) { continue; } - meta.addMixin(currentMixin); + metaAddMixin(meta, currentMixin); let { properties, mixins } = currentMixin; @@ -592,7 +647,7 @@ export default class Mixin { return ret; } - meta.forEachMixins((currentMixin: Mixin) => { + metaForEachMixins(meta, (currentMixin: Mixin) => { // skip primitive mixins since these are always anonymous if (!currentMixin.properties) { ret.push(currentMixin); @@ -664,7 +719,7 @@ export default class Mixin { if (meta === null) { return false; } - return meta.hasMixin(this); + return metaHasMixin(meta, this); } /** @internal */ From d31f753561d244fd3a6758afada9c96057819e1d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:37:14 -0400 Subject: [PATCH 07/14] Tighten sideEffects list; remove refactor-context comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of polish from the user's review: 1. The `sideEffects` field in package.json was over-broad — listing whole directories (`**/manager/**`, `**/validator/**`, etc.) when only a handful of files in those trees actually have top-level side effects. Replaced the directory globs with the explicit list of files that contain top-level calls (registrations, opcode `APPEND_OPCODES.add(...)`, `setGlobalContext`, `_backburner.on`, etc.). Hello-world stays at 134.12 KB / 42.92 KB. 2. Removed comments in the refactored files that explained the refactor itself ("extracted from X for tree-shaking", "kept separate so Y", "back-compat re-export"). That kind of context belongs in the PR description and commit messages, not the source tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 45 ++++++++++--------- .../lib/component-managers/curly-symbols.ts | 8 ---- .../glimmer/lib/component-managers/curly.ts | 7 --- .../-internals/glimmer/lib/component.ts | 9 ---- .../glimmer/lib/register-curly-component.ts | 12 ----- .../@ember/-internals/glimmer/lib/renderer.ts | 9 ---- .../@ember/-internals/glimmer/lib/resolver.ts | 6 --- .../-internals/glimmer/lib/setup-registry.ts | 13 ------ .../-internals/glimmer/lib/utils/to-bool.ts | 5 --- packages/@ember/-internals/meta/lib/meta.ts | 9 ---- .../-internals/runtime/lib/mixins/-proxy.ts | 3 -- .../runtime/lib/mixins/content-for.ts | 9 +--- packages/@ember/instrumentation/index.ts | 6 --- .../lib/internal-instrument.ts | 13 +----- packages/@ember/object/mixin.ts | 6 --- .../runtime/lib/debug-render-tree-register.ts | 9 ---- .../@glimmer/runtime/lib/debug-render-tree.ts | 3 -- packages/@glimmer/runtime/lib/environment.ts | 5 --- .../@glimmer/runtime/lib/get-debug-name.ts | 5 --- 19 files changed, 26 insertions(+), 156 deletions(-) diff --git a/package.json b/package.json index b0f3f0ab89e..eaf6e2a92b6 100644 --- a/package.json +++ b/package.json @@ -18,28 +18,29 @@ }, "sideEffects": [ "./dist/dev/**", - "**/setup-registry*", - "**/register-routing-keywords*", - "**/register-curly-component*", - "**/debug-render-tree-register*", - "**/-internals/glimmer/lib/environment*", - "**/runtime/lib/environment*", - "**/-internals/runtime/lib/ext/rsvp*", - "**/runtime/lib/component/template-only*", - "**/runtime/lib/vm/low-level*", - "**/runtime/lib/compiled/opcodes/**", - "**/runtime/lib/helpers/**", - "**/runtime/lib/modifiers/**", - "**/-internals/glimmer/lib/components/**", - "**/-internals/views/lib/system/event_dispatcher*", - "**/runloop/index*", - "**/runloop/-private/**", - "**/manager/**", - "**/validator/**", - "**/global-context/**", - "**/destroyable/**", - "**/canary-features/**", - "**/-internals/environment/**" + "**/@ember/-internals/glimmer/lib/setup-registry.js", + "**/@ember/-internals/glimmer/lib/register-curly-component.js", + "**/@ember/-internals/glimmer/lib/syntax/register-routing-keywords.js", + "**/@ember/-internals/glimmer/lib/environment.js", + "**/@ember/-internals/glimmer/lib/renderer.js", + "**/@ember/-internals/glimmer/lib/helper.js", + "**/@ember/-internals/glimmer/lib/helpers/element.js", + "**/@ember/-internals/glimmer/lib/components/input.js", + "**/@ember/-internals/glimmer/lib/components/textarea.js", + "**/@ember/-internals/glimmer/lib/components/link-to.js", + "**/@ember/-internals/runtime/lib/ext/rsvp.js", + "**/@glimmer/runtime/lib/debug-render-tree-register.js", + "**/@glimmer/runtime/lib/component/template-only.js", + "**/@glimmer/runtime/lib/compiled/opcodes/expressions.js", + "**/@glimmer/runtime/lib/compiled/opcodes/vm.js", + "**/@glimmer/runtime/lib/compiled/opcodes/debugger.js", + "**/@glimmer/runtime/lib/compiled/opcodes/content.js", + "**/@glimmer/runtime/lib/compiled/opcodes/component.js", + "**/@glimmer/runtime/lib/compiled/opcodes/dom.js", + "**/@glimmer/runtime/lib/compiled/opcodes/lists.js", + "**/@glimmer/runtime/lib/vm/low-level.js", + "**/@glimmer/validator/index.js", + "**/@glimmer/validator/lib/validators.js" ], "homepage": "https://emberjs.com/", "bugs": { diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts index 94b29b90929..6df8d6280c1 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly-symbols.ts @@ -1,11 +1,3 @@ -// Symbols and the cheap brand-based `isCurlyManager` predicate, kept in -// their own module so consumers (e.g. the resolver) can identify the -// curly component manager without statically pulling in the full -// `./curly` module — that file carries the entire classic component -// lifecycle plus its transitive deps (~17 KB raw plus the @ember/object -// graph). With this split, `renderComponent`-only bundles never load -// `./curly` at all. - export const DIRTY_TAG: unique symbol = Symbol('DIRTY_TAG'); export const IS_DISPATCHING_ATTRS: unique symbol = Symbol('IS_DISPATCHING_ATTRS'); export const BOUNDS: unique symbol = Symbol('BOUNDS'); diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index bb581ac1472..c74f7543908 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -70,10 +70,6 @@ export function getComponentCapturedArgs( return COMPONENT_ARGS_MAP.get(component); } -// Re-exported from `./curly-symbols` to keep import sites unchanged. -// The actual symbols live there so consumers that only need the symbols -// (e.g. the renderer's resolver, classic-renderer's `getBounds`) don't -// have to load the full curly component manager. export { BOUNDS, DIRTY_TAG, IS_DISPATCHING_ATTRS } from './curly-symbols'; const EMBER_VIEW_REF = createPrimitiveRef('ember-view'); @@ -565,7 +561,4 @@ const CURLY_CAPABILITIES: InternalComponentCapabilities = { export const CURLY_COMPONENT_MANAGER = new CurlyComponentManager(); (CURLY_COMPONENT_MANAGER as unknown as Record)[CURLY_COMPONENT_BRAND] = true; -// Re-exported for back-compat — `isCurlyManager` and the symbols now live -// in `./curly-symbols` so that consumers who only need the brand check -// don't pull in the rest of this module. export { CURLY_COMPONENT_BRAND, isCurlyManager } from './curly-symbols'; diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index eff8895a7be..ffb00471f50 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -1680,13 +1680,4 @@ class Component } } -// `Component.reopenClass({ positionalParams: [] })` and the curly component -// manager registration that used to live here now happen in -// `./register-curly-component`, imported as a side effect by classic-app -// setup (`./setup-registry`). Splitting the side effect out lets bundlers -// tree-shake the classic `Component` class out of bundles that don't -// otherwise need it — e.g. the hello-world app that only uses -// `setComponentManager` / `capabilities` from `@ember/component` via -// `@glimmer/component`. - export default Component; diff --git a/packages/@ember/-internals/glimmer/lib/register-curly-component.ts b/packages/@ember/-internals/glimmer/lib/register-curly-component.ts index ee2e231e695..cec33796b74 100644 --- a/packages/@ember/-internals/glimmer/lib/register-curly-component.ts +++ b/packages/@ember/-internals/glimmer/lib/register-curly-component.ts @@ -1,15 +1,3 @@ -// Side-effect-only file. Registers the classic curly component manager -// for `Component` and seeds `positionalParams` on the base class. -// -// Kept separate from `./component` so that file's top-level evaluation -// has no side effects — bundlers can then tree-shake the classic -// `Component` class out of any bundle that doesn't otherwise need it -// (e.g. the hello-world that only pulls `setComponentManager` / -// `capabilities` from `@ember/component` via `@glimmer/component`). -// -// Classic apps opt back in to the registration via `./setup-registry`, -// which imports this module for its side effect. - import { setInternalComponentManager } from '@glimmer/manager/lib/internal/api'; import Component from './component'; diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts index 36ba0b72ab6..07409bcf277 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -34,12 +34,6 @@ import ResolverImpl from './resolver'; export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; -/** - * Common shape of root states tracked by `RendererState`. Both - * `ComponentRootState` (defined here) and `ClassicRootState` (defined in - * `./classic-renderer`) implement this so the renderer state can manage - * either kind without statically depending on classic-only code. - */ export interface RootState { readonly type: 'classic' | 'component'; readonly destroyed: boolean; @@ -670,7 +664,4 @@ export class BaseRenderer { } } -// Re-export classic-only types so existing import sites that did -// `import { Renderer, DynamicScope, View } from '.../renderer'` keep working. -// New code should import these directly from `./classic-renderer`. export type { DynamicScope, Renderer, View } from './classic-renderer'; diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 46f38862ce4..56daf95bad4 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -96,12 +96,6 @@ const BUILTIN_KEYWORD_HELPERS: Record = { '-in-el-null': inElementNullCheckHelper, }; -/** - * Register an additional built-in keyword helper (e.g. `{{outlet}}`, - * `{{mount}}`). Kept separate so the renderer doesn't statically depend on - * routing/engine infrastructure — callers that need those helpers must opt - * in by importing the registration module (`./syntax/register-routing-keywords`). - */ export function registerBuiltInKeywordHelper(name: string, helper: object): void { BUILTIN_KEYWORD_HELPERS[name] = helper; BUILTIN_HELPERS[name] = helper; diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 54a893326ba..29ba90ece01 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -10,19 +10,6 @@ import { Renderer } from './classic-renderer'; import OutletTemplate from './templates/outlet'; import RootTemplate from './templates/root'; import OutletView from './views/outlet'; -// Side-effect imports. Each keeps classic-only infrastructure off the -// `renderComponent`-only path: -// -// - `register-routing-keywords` registers `-mount` and `-outlet` as -// built-in keyword helpers (pulls in routing/engine). -// - `@glimmer/runtime/.../debug-render-tree-register` registers the -// `DebugRenderTreeImpl` factory so `delegate.enableDebugTooling: true` -// actually produces a render tree (used by Ember Inspector). Without -// this import, `env.debugRenderTree` stays `undefined`. -// - `register-curly-component` registers the classic component manager -// for the `Component` base class and seeds `positionalParams`. Without -// this import the classic `Component` class is dead code as far as -// `setComponentManager`/`capabilities` consumers are concerned. import './syntax/register-routing-keywords'; import '@glimmer/runtime/lib/debug-render-tree-register'; import './register-curly-component'; diff --git a/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts b/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts index 86cd9a6373a..ded35d037d6 100644 --- a/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts +++ b/packages/@ember/-internals/glimmer/lib/utils/to-bool.ts @@ -5,11 +5,6 @@ import { isEmberArray } from '@ember/array/-internals'; import { isProxy } from '@ember/-internals/utils/lib/is_proxy'; import { consumeTag } from '@glimmer/validator/lib/tracking'; -// Lightweight array predicate — covers native arrays plus anything branded -// via `setEmberArray` (which is what `EmberArray.init` does, so any -// instance of an EmberArray-mixed class is branded). Avoids the full -// `@ember/array/index` graph (Mixin, Enumerable, Observable, computed) -// just to test array-ness for `{{#if}}`. function isArrayLike(obj: unknown): obj is ArrayLike { return Array.isArray(obj) || isEmberArray(obj); } diff --git a/packages/@ember/-internals/meta/lib/meta.ts b/packages/@ember/-internals/meta/lib/meta.ts index 450c8a5a408..18f4f068ccc 100644 --- a/packages/@ember/-internals/meta/lib/meta.ts +++ b/packages/@ember/-internals/meta/lib/meta.ts @@ -267,15 +267,6 @@ export class Meta { return undefined; } - // The mixin-tracking helpers (`addMixin` / `hasMixin` / `forEachMixins`) - // moved to `@ember/object/mixin` as standalone functions - // (`metaAddMixin` / `metaHasMixin` / `metaForEachMixins`). They poke - // directly at the public `_mixins` / `parent` fields here. Splitting - // them out keeps the `Meta` class — which is reachable from the - // renderer via the property accessor + tag chain — free of references - // to the classic `Mixin` machinery, so bundles that don't actually use - // mixins can tree-shake `@ember/object/mixin` away. - /** @internal */ writeDescriptors(subkey: string, value: any) { assert( diff --git a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts index b88edf7b685..19adf728f97 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts @@ -19,9 +19,6 @@ import type { Tag } from '@glimmer/interfaces'; import { combine } from '@glimmer/validator/lib/validators'; import { tagFor, tagMetaFor } from '@glimmer/validator/lib/meta'; -// `contentFor` was extracted to `./content-for` so the `each-in` helper -// (reachable from the renderer) can use it without dragging in this -// file's `Mixin.create(...)` graph. Re-exported here for back-compat. import { contentFor } from './content-for'; export { contentFor }; diff --git a/packages/@ember/-internals/runtime/lib/mixins/content-for.ts b/packages/@ember/-internals/runtime/lib/mixins/content-for.ts index 0f3c45349c8..34f3b7f8723 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/content-for.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/content-for.ts @@ -4,15 +4,10 @@ import type { UpdatableTag } from '@glimmer/interfaces'; import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; import type ProxyMixin from './-proxy'; -// Extracted from `./-proxy` so that consumers (notably the `each-in` helper -// reachable from the renderer) don't have to drag in `@ember/object/mixin` -// and the `ProxyMixin = Mixin.create(...)` side effect — that's the entry -// point to the entire EmberObject/PrototypeMixin pyramid. - export function contentFor(proxy: ProxyMixin): T | null { let content = get(proxy, 'content'); - // SAFETY: matches the original cast in -proxy.ts; @glimmer/validator - // doesn't expose enough public types for an assertion here. + // SAFETY: @glimmer/validator doesn't expose enough public types for an + // assertion here. updateTag(tagForObject(proxy) as UpdatableTag, tagForObject(content)); return content; } diff --git a/packages/@ember/instrumentation/index.ts b/packages/@ember/instrumentation/index.ts index 6baea7827ce..6ba0db173a3 100644 --- a/packages/@ember/instrumentation/index.ts +++ b/packages/@ember/instrumentation/index.ts @@ -7,12 +7,6 @@ import { type Subscriber, } from './lib/internal-instrument'; -// Re-export with `export ... from` (rather than re-binding via local -// import + export) so the no-barrel-imports lint rule traces consumers -// through to `./lib/internal-instrument` and rewrites their imports to -// the deep path. That keeps the heavy `instrument` / `subscribe` / -// `unsubscribe` machinery defined below out of bundles that only need -// the hot path. export { _instrumentStart, flaggedInstrument, diff --git a/packages/@ember/instrumentation/lib/internal-instrument.ts b/packages/@ember/instrumentation/lib/internal-instrument.ts index b84b180184e..5c2a754ea38 100644 --- a/packages/@ember/instrumentation/lib/internal-instrument.ts +++ b/packages/@ember/instrumentation/lib/internal-instrument.ts @@ -1,12 +1,3 @@ -// Hot-path bits of `@ember/instrumentation`, factored out so internal -// callers (the resolver, the curly component manager, view states) can -// import `_instrumentStart` without dragging in the full subscribe / -// unsubscribe / instrument machinery — most of which is dead code unless -// something actually subscribes (e.g. Ember Inspector). -// -// `subscribers` and `cache` live here; `index.ts` imports and mutates -// them via `subscribe` / `unsubscribe` / `reset`. - import { ENV } from '@ember/-internals/environment/lib/env'; import { assert } from '@ember/debug'; @@ -56,9 +47,7 @@ const time = (): number => performance.now(); export const NOOP = (): void => {}; // `flaggedInstrument` historically wrapped a callback in conditionally- -// enabled instrumentation; today it's a thin pass-through. Lives here so -// hot-path callers (the views state machine) can deep-import it without -// pulling in the rest of `index.ts`. +// enabled instrumentation; today it's a thin pass-through. export function flaggedInstrument( _name: string, _payload: object, diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index 9f8bd298dda..91afbb3b912 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -7,12 +7,6 @@ import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; import { isDestroyed } from '@glimmer/destroyable'; import toString from '@ember/-internals/utils/lib/to-string'; -// Mixin-tracking helpers — moved out of `Meta` so the renderer-only -// path (which reaches `Meta` through the tag/property graph) doesn't -// pull a reference to `Mixin` into the bundle. They poke at `Meta`'s -// public `_mixins` / `parent` fields directly. - - function metaAddMixin(meta: Meta, mixin: any): void { assert( isDestroyed(meta.source as object) diff --git a/packages/@glimmer/runtime/lib/debug-render-tree-register.ts b/packages/@glimmer/runtime/lib/debug-render-tree-register.ts index 2125fe0aa9c..af8ab37555c 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree-register.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree-register.ts @@ -1,12 +1,3 @@ -// Side-effect-only module: registers `DebugRenderTreeImpl` as the factory -// used by `EnvironmentImpl` when `delegate.enableDebugTooling` is true. -// -// Importing this file pulls `./debug-render-tree` into the bundle. Apps -// that don't need render-tree introspection (anything not running the -// Ember Inspector) shouldn't import this module — `EnvironmentImpl`'s -// `debugRenderTree` will silently stay `undefined`, which is the same -// behavior you'd get with `enableDebugTooling: false`. - import DebugRenderTreeImpl from './debug-render-tree'; import { registerDebugRenderTreeFactory } from './environment'; diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index 51f73c82ef4..25b580c2740 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -197,7 +197,4 @@ export default class DebugRenderTreeImpl< } } -// `getDebugName` now lives in `./get-debug-name` so opcodes can pull it -// in without dragging in the full `DebugRenderTreeImpl` class. Re-export -// for back-compat with any external consumers. export { getDebugName } from './get-debug-name'; diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index 2bc3f5e30be..1648aa0b4b7 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -23,11 +23,6 @@ import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; import { DOMChangesImpl, DOMTreeConstruction } from './dom/helper'; import { isArgumentError } from './vm/arguments'; -// Lazy registration: the DebugRenderTree implementation registers itself -// here on import (`./debug-render-tree-register`). Apps that don't need -// render-tree introspection (anything not running the Ember Inspector) -// won't pull `./debug-render-tree` into the bundle, since this module no -// longer references it statically. type DebugRenderTreeFactory = () => DebugRenderTree; let debugRenderTreeFactory: DebugRenderTreeFactory | null = null; diff --git a/packages/@glimmer/runtime/lib/get-debug-name.ts b/packages/@glimmer/runtime/lib/get-debug-name.ts index 28bbc306be3..4fee4aac3a1 100644 --- a/packages/@glimmer/runtime/lib/get-debug-name.ts +++ b/packages/@glimmer/runtime/lib/get-debug-name.ts @@ -1,10 +1,5 @@ import type { ComponentDefinition } from '@glimmer/interfaces'; -// Tiny helper extracted from `./debug-render-tree` so opcodes that need a -// debug name don't have to import the whole `DebugRenderTreeImpl` class -// just to reach this 4-line function. Keeps `./debug-render-tree` out of -// bundles that aren't doing render-tree introspection (the class itself -// is only pulled in when `debug-render-tree-register` is imported). export function getDebugName( definition: ComponentDefinition, manager = definition.manager From 38beca220ddd0ba305868113132c5bb64de1bc1d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:51:25 -0400 Subject: [PATCH 08/14] Decouple VM debug symbols/names from opcodes.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `opcodes.ts` previously imported `@glimmer/debug` (DebugLogger, VmSnapshot, debugOp / describeOp, opcodeMetadata, frag, etc.) at the top level and assembled the per-opcode `debugBefore`/`debugAfter` hooks inline in `AppendOpcodes`'s constructor — gated by `LOCAL_DEBUG`, so dead in production, but the imports still pulled the heavy `@glimmer/debug` graph into the bundle. Same registration pattern as the DebugRenderTree split: opcodes.ts exposes `registerDebugOpcodeSetup(setup)`; the heavy hook implementation moved to `opcodes-debug-setup.ts`, which calls the registry on import. `externs(vm)` now also requires the hooks to be registered (returns `undefined` otherwise) so dev builds that don't opt in skip the debug path entirely instead of crashing on a non-null assertion. Production hello-world holds at 134.12 KB / 42.90 KB gzip (`LOCAL_DEBUG` already eliminated the hooks there); the analysis bundle drops the `@glimmer/debug` files entirely. Verified: lint clean (after `pnpm lint:fix`), type-checks pass, hello-world builds, classic v2-app-template `pnpm test` 1/1 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/lib/opcodes-debug-setup.ts | 115 ++++++++++++++ packages/@glimmer/runtime/lib/opcodes.ts | 141 +++--------------- 2 files changed, 135 insertions(+), 121 deletions(-) create mode 100644 packages/@glimmer/runtime/lib/opcodes-debug-setup.ts diff --git a/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts b/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts new file mode 100644 index 00000000000..6f6d75c7c3c --- /dev/null +++ b/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts @@ -0,0 +1,115 @@ +import type { DebugOp, SomeDisassembledOperand } from '@glimmer/debug/lib/debug'; +import { DebugLogger } from '@glimmer/debug/lib/render/logger'; +import { debugOp, describeOp, describeOpcode } from '@glimmer/debug/lib/debug'; +import { frag } from '@glimmer/debug/lib/render/fragment'; +import { opcodeMetadata } from '@glimmer/debug/lib/opcode-metadata'; +import { recordStackSize } from '@glimmer/debug/lib/stack-check'; +import { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; +import type { DebugVmSnapshot, Dict, Maybe, RuntimeOp } from '@glimmer/interfaces'; +import { unwrap } from '@glimmer/debug-util/lib/platform-utils'; +import { LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; +import { LOCAL_LOGGER } from '@glimmer/util'; +import { $pc, $ra, $s0, $s1, $sp, $t0, $t1, $v0 } from '@glimmer/vm/lib/registers'; + +import type { AppendOpcodes, DebugState } from './opcodes'; +import { registerDebugOpcodeSetup } from './opcodes'; + +registerDebugOpcodeSetup((opcodes: AppendOpcodes): void => { + opcodes.debugBefore = (debug: DebugVmSnapshot, opcode: RuntimeOp): DebugState => { + let opcodeSnapshot = { + type: opcode.type, + size: opcode.size, + isMachine: opcode.isMachine, + } as const; + + let snapshot = new VmSnapshot(opcodeSnapshot, debug); + let params: Maybe> = undefined; + let op: DebugOp | undefined = undefined; + let closeGroup: (() => void) | undefined; + + if (LOCAL_TRACE_LOGGING) { + const logger = DebugLogger.configured(); + + let pos = debug.registers[$pc] - opcode.size; + + op = debugOp(debug.context.program, opcode, debug.template); + + closeGroup = logger + .group(frag`${pos}. ${describeOp(opcode, debug.context.program, debug.template)}`) + .expanded(); + + let debugParams = []; + for (let [name, param] of Object.entries(op.params)) { + const value = param.value; + if (value !== null && (typeof value === 'object' || typeof value === 'function')) { + debugParams.push(name, '=', value); + } + } + LOCAL_LOGGER.debug(...debugParams); + } + + recordStackSize(debug.registers[$sp]); + return { + op, + closeGroup, + params, + opcode: opcodeSnapshot, + debug, + snapshot, + }; + }; + + opcodes.debugAfter = (postSnapshot: DebugVmSnapshot, pre: DebugState) => { + let post = new VmSnapshot(pre.opcode, postSnapshot); + let diff = pre.snapshot.diff(post); + let { + opcode: { type }, + } = pre; + + let sp = diff.registers[$sp]; + + let meta = opcodeMetadata(type); + let actualChange = sp.after - sp.before; + if ( + meta && + meta.check !== false && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + typeof meta.stackChange! === 'number' && + meta.stackChange !== actualChange + ) { + throw new Error( + `Error in ${pre.op?.name}:\n\n${pre.debug.registers[$pc]}. ${ + pre.op ? describeOpcode(pre.op.name, pre.params) : unwrap(opcodeMetadata(type)).name + }\n\nStack changed by ${actualChange}, expected ${meta.stackChange}` + ); + } + + if (LOCAL_TRACE_LOGGING) { + const logger = DebugLogger.configured(); + + logger.log(diff.registers[$pc].describe()); + logger.log(diff.registers[$ra].describe()); + logger.log(diff.registers[$s0].describe()); + logger.log(diff.registers[$s1].describe()); + logger.log(diff.registers[$t0].describe()); + logger.log(diff.registers[$t1].describe()); + logger.log(diff.registers[$v0].describe()); + logger.log(diff.stack.describe()); + logger.log(diff.destructors.describe()); + logger.log(diff.scope.describe()); + + if (diff.constructing.didChange || diff.blocks.change) { + const done = logger.group(`tree construction`).expanded(); + try { + logger.log(diff.constructing.describe()); + logger.log(diff.blocks.describe()); + logger.log(diff.cursors.describe()); + } finally { + done(); + } + } + + pre.closeGroup?.(); + } + }; +}); diff --git a/packages/@glimmer/runtime/lib/opcodes.ts b/packages/@glimmer/runtime/lib/opcodes.ts index 2a9228097ed..266f1bf81ef 100644 --- a/packages/@glimmer/runtime/lib/opcodes.ts +++ b/packages/@glimmer/runtime/lib/opcodes.ts @@ -2,7 +2,6 @@ import type { DebugOp, SomeDisassembledOperand } from '@glimmer/debug/lib/debug' import type { DebugVmSnapshot, Dict, - Maybe, Nullable, Optional, RuntimeOp, @@ -11,21 +10,21 @@ import type { VmOp, } from '@glimmer/interfaces'; import { VM_SYSCALL_SIZE } from '@glimmer/constants/lib/syscall-ops'; -import { DebugLogger } from '@glimmer/debug/lib/render/logger'; -import { debugOp, describeOp, describeOpcode } from '@glimmer/debug/lib/debug'; -import { frag } from '@glimmer/debug/lib/render/fragment'; -import { opcodeMetadata } from '@glimmer/debug/lib/opcode-metadata'; -import { recordStackSize } from '@glimmer/debug/lib/stack-check'; -import { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; +import type { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; import { dev, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import assert from '@glimmer/debug-util/lib/assert'; -import { LOCAL_DEBUG, LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; -import { LOCAL_LOGGER } from '@glimmer/util'; -import { $pc, $ra, $s0, $s1, $sp, $t0, $t1, $v0 } from '@glimmer/vm/lib/registers'; +import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import type { LowLevelVM, VM } from './vm'; import type { Externs } from './vm/low-level'; +type DebugOpcodeSetup = (opcodes: AppendOpcodes) => void; +let debugOpcodeSetup: DebugOpcodeSetup | null = null; + +export function registerDebugOpcodeSetup(setup: DebugOpcodeSetup): void { + debugOpcodeSetup = setup; +} + export interface OpcodeJSON { type: number | string; guid?: Nullable; @@ -69,104 +68,8 @@ export class AppendOpcodes { declare debugAfter?: (debug: DebugVmSnapshot, pre: DebugState) => void; constructor() { - if (LOCAL_DEBUG) { - this.debugBefore = (debug: DebugVmSnapshot, opcode: RuntimeOp): DebugState => { - let opcodeSnapshot = { - type: opcode.type, - size: opcode.size, - isMachine: opcode.isMachine, - } as const; - - let snapshot = new VmSnapshot(opcodeSnapshot, debug); - let params: Maybe> = undefined; - let op: DebugOp | undefined = undefined; - let closeGroup: (() => void) | undefined; - - if (LOCAL_TRACE_LOGGING) { - const logger = DebugLogger.configured(); - - let pos = debug.registers[$pc] - opcode.size; - - op = debugOp(debug.context.program, opcode, debug.template); - - closeGroup = logger - .group(frag`${pos}. ${describeOp(opcode, debug.context.program, debug.template)}`) - .expanded(); - - let debugParams = []; - for (let [name, param] of Object.entries(op.params)) { - const value = param.value; - if (value !== null && (typeof value === 'object' || typeof value === 'function')) { - debugParams.push(name, '=', value); - } - } - LOCAL_LOGGER.debug(...debugParams); - } - - recordStackSize(debug.registers[$sp]); - return { - op, - closeGroup, - params, - opcode: opcodeSnapshot, - debug, - snapshot, - }; - }; - - this.debugAfter = (postSnapshot: DebugVmSnapshot, pre: DebugState) => { - let post = new VmSnapshot(pre.opcode, postSnapshot); - let diff = pre.snapshot.diff(post); - let { - opcode: { type }, - } = pre; - - let sp = diff.registers[$sp]; - - let meta = opcodeMetadata(type); - let actualChange = sp.after - sp.before; - if ( - meta && - meta.check !== false && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - typeof meta.stackChange! === 'number' && - meta.stackChange !== actualChange - ) { - throw new Error( - `Error in ${pre.op?.name}:\n\n${pre.debug.registers[$pc]}. ${ - pre.op ? describeOpcode(pre.op.name, pre.params) : unwrap(opcodeMetadata(type)).name - }\n\nStack changed by ${actualChange}, expected ${meta.stackChange}` - ); - } - - if (LOCAL_TRACE_LOGGING) { - const logger = DebugLogger.configured(); - - logger.log(diff.registers[$pc].describe()); - logger.log(diff.registers[$ra].describe()); - logger.log(diff.registers[$s0].describe()); - logger.log(diff.registers[$s1].describe()); - logger.log(diff.registers[$t0].describe()); - logger.log(diff.registers[$t1].describe()); - logger.log(diff.registers[$v0].describe()); - logger.log(diff.stack.describe()); - logger.log(diff.destructors.describe()); - logger.log(diff.scope.describe()); - - if (diff.constructing.didChange || diff.blocks.change) { - const done = logger.group(`tree construction`).expanded(); - try { - logger.log(diff.constructing.describe()); - logger.log(diff.blocks.describe()); - logger.log(diff.cursors.describe()); - } finally { - done(); - } - } - - pre.closeGroup?.(); - } - }; + if (LOCAL_DEBUG && debugOpcodeSetup) { + debugOpcodeSetup(this); } } @@ -203,19 +106,15 @@ export class AppendOpcodes { } export function externs(vm: VM): Externs | undefined { - return LOCAL_DEBUG - ? { - debugBefore: (opcode: RuntimeOp): DebugState => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - return APPEND_OPCODES.debugBefore!(dev(vm.debug), opcode); - }, - - debugAfter: (state: DebugState): void => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - APPEND_OPCODES.debugAfter!(dev(vm.debug), state); - }, - } - : undefined; + if (!LOCAL_DEBUG || !APPEND_OPCODES.debugBefore || !APPEND_OPCODES.debugAfter) { + return undefined; + } + let debugBefore = APPEND_OPCODES.debugBefore; + let debugAfter = APPEND_OPCODES.debugAfter; + return { + debugBefore: (opcode: RuntimeOp): DebugState => debugBefore(dev(vm.debug), opcode), + debugAfter: (state: DebugState): void => debugAfter(dev(vm.debug), state), + }; } export const APPEND_OPCODES = new AppendOpcodes(); From 26900f5a5bf7b3dbbba6b281d52c04f0393794cf Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 21:55:52 -0400 Subject: [PATCH 09/14] prettier --write on the touched files `pnpm lint:fix` runs both `lint:eslint:fix` and `lint:format:fix`. I'd only been running the eslint half, so prettier formatting drift in five of my refactored files snuck through. CI's `lint:format` was the failure on the previous push. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/lib/classic-renderer.ts | 7 +------ packages/@ember/-internals/glimmer/lib/component.ts | 6 +----- packages/@ember/instrumentation/index.ts | 6 +----- packages/@ember/instrumentation/lib/internal-instrument.ts | 6 +----- packages/@ember/object/mixin.ts | 6 ++---- 5 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/classic-renderer.ts b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts index ccb1577b150..87486c421e7 100644 --- a/packages/@ember/-internals/glimmer/lib/classic-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/classic-renderer.ts @@ -40,12 +40,7 @@ import { createRootOutlet } from './component-managers/outlet'; import { RootComponentDefinition } from './component-managers/root'; import { makeRouteTemplate } from './component-managers/route-template'; import { unwrapTemplate } from './component-managers/unwrap-template'; -import { - BaseRenderer, - errorLoopTransaction, - type IBuilder, - type RootState, -} from './renderer'; +import { BaseRenderer, errorLoopTransaction, type IBuilder, type RootState } from './renderer'; import ResolverImpl from './resolver'; import type { OutletState } from './utils/outlet'; import OutletView from './views/outlet'; diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index ffb00471f50..2040dc62592 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -24,11 +24,7 @@ import type { DirtyableTag } from '@glimmer/interfaces'; import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators'; import type { SimpleElement } from '@simple-dom/interface'; import { getComponentCapturedArgs } from './component-managers/curly'; -import { - BOUNDS, - DIRTY_TAG, - IS_DISPATCHING_ATTRS, -} from './component-managers/curly-symbols'; +import { BOUNDS, DIRTY_TAG, IS_DISPATCHING_ATTRS } from './component-managers/curly-symbols'; import hasDOM from '@ember/-internals/browser-environment/lib/has-dom'; // Keep track of which component classes have already been processed for lazy event setup. diff --git a/packages/@ember/instrumentation/index.ts b/packages/@ember/instrumentation/index.ts index 6ba0db173a3..1fe451298f6 100644 --- a/packages/@ember/instrumentation/index.ts +++ b/packages/@ember/instrumentation/index.ts @@ -7,11 +7,7 @@ import { type Subscriber, } from './lib/internal-instrument'; -export { - _instrumentStart, - flaggedInstrument, - subscribers, -} from './lib/internal-instrument'; +export { _instrumentStart, flaggedInstrument, subscribers } from './lib/internal-instrument'; export type { Listener, Subscriber, StructuredProfilePayload } from './lib/internal-instrument'; export interface PayloadWithException { diff --git a/packages/@ember/instrumentation/lib/internal-instrument.ts b/packages/@ember/instrumentation/lib/internal-instrument.ts index 5c2a754ea38..10cab44910e 100644 --- a/packages/@ember/instrumentation/lib/internal-instrument.ts +++ b/packages/@ember/instrumentation/lib/internal-instrument.ts @@ -16,9 +16,8 @@ export interface StructuredProfilePayload { object: string | object; } - export const subscribers: Subscriber[] = []; - + export const cache: { [key: string]: Listener[] } = {}; export function resetCache(): void { @@ -27,9 +26,7 @@ export function resetCache(): void { } } - function populateListeners(name: string): Listener[] { - let listeners: Listener[] = []; for (let subscriber of subscribers) { @@ -91,7 +88,6 @@ export function _instrumentStart( console.time(timeName); } - let beforeValues: any[] = []; let timestamp = time(); for (let listener of listeners) { diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index 91afbb3b912..9cc0554602c 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -20,7 +20,6 @@ function metaAddMixin(meta: Meta, mixin: any): void { set.add(mixin); } - function metaHasMixin(meta: Meta, mixin: any): boolean { let pointer: Meta | null = meta; while (pointer !== null) { @@ -33,16 +32,15 @@ function metaHasMixin(meta: Meta, mixin: any): boolean { return false; } - function metaForEachMixins(meta: Meta, fn: (mixin: any) => void): void { let pointer: Meta | null = meta; - + let seen: Set | undefined; while (pointer !== null) { let set = pointer._mixins; if (set !== undefined) { seen = seen === undefined ? new Set() : seen; - + set.forEach((mixin: any) => { if (!seen!.has(mixin)) { seen!.add(mixin); From cbc57d75616548fcc8d935c85023c08b70c12da0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 22:07:06 -0400 Subject: [PATCH 10/14] Extract @ember/object's `action` decorator to its own sub-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `action` decorator lived inline in `@ember/object/index.ts`, which also imports `CoreObject` and `Observable` at module top — so any component that pulled `import { action } from '@ember/object'` (Input, Textarea, AbstractInput, LinkTo) dragged the full EmberObject / Observable / Mixin graph along with it. Move `action` (plus its `setupAction` helper, `BINDINGS_MAP`, and `hasProto`) to `@ember/object/action.ts`. `index.ts` re-exports it via `export { action } from './action'` so the no-barrel-imports lint autofix rewrites internal call sites to the deep path. `@ember/object/index.ts` itself loses its references to `isElementDescriptor` / `setClassicDecorator` / `ElementDescriptor` / `ExtendedMethodDecorator`, since those now live in `action.ts`. Hello-world: 134.12 KB / 42.94 KB → 133.42 KB / 42.69 KB gzip. Verified with `pnpm lint` (clean), `pnpm type-check:internals`, hello-world build, classic v2-app and v1 app smoke-test `pnpm test` (1/1 each). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + .../glimmer/lib/components/abstract-input.ts | 2 +- .../glimmer/lib/components/input.ts | 2 +- .../glimmer/lib/components/link-to.ts | 2 +- .../glimmer/lib/components/textarea.ts | 2 +- packages/@ember/object/action.ts | 120 ++++++++++++++++++ packages/@ember/object/index.ts | 120 +----------------- 7 files changed, 126 insertions(+), 123 deletions(-) create mode 100644 packages/@ember/object/action.ts diff --git a/package.json b/package.json index eaf6e2a92b6..fec74faadc8 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "@ember/modifier/index.js": "ember-source/@ember/modifier/index.js", "@ember/modifier/on.js": "ember-source/@ember/modifier/on.js", "@ember/object/-internals.js": "ember-source/@ember/object/-internals.js", + "@ember/object/action.js": "ember-source/@ember/object/action.js", "@ember/object/compat.js": "ember-source/@ember/object/compat.js", "@ember/object/computed.js": "ember-source/@ember/object/computed.js", "@ember/object/core.js": "ember-source/@ember/object/core.js", diff --git a/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts b/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts index 0c63b2793ac..5f84f710373 100644 --- a/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts +++ b/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts @@ -1,6 +1,6 @@ import { tracked } from '@ember/-internals/metal/lib/tracked'; import { assert } from '@ember/debug'; -import { action } from '@ember/object'; +import { action } from '@ember/object/action'; import type { Reference } from '@glimmer/reference/lib/reference'; import { isConstRef, diff --git a/packages/@ember/-internals/glimmer/lib/components/input.ts b/packages/@ember/-internals/glimmer/lib/components/input.ts index cd9898555dc..0bb706c7dce 100644 --- a/packages/@ember/-internals/glimmer/lib/components/input.ts +++ b/packages/@ember/-internals/glimmer/lib/components/input.ts @@ -4,7 +4,7 @@ import hasDOM from '@ember/-internals/browser-environment/lib/has-dom'; import { type Opaque } from '@ember/-internals/utility-types'; import { assert, warn } from '@ember/debug'; -import { action } from '@ember/object'; +import { action } from '@ember/object/action'; import { valueForRef } from '@glimmer/reference/lib/reference'; import { untrack } from '@glimmer/validator/lib/tracking'; import InputTemplate from '../templates/input'; diff --git a/packages/@ember/-internals/glimmer/lib/components/link-to.ts b/packages/@ember/-internals/glimmer/lib/components/link-to.ts index 105111f5ddd..3199f0e064b 100644 --- a/packages/@ember/-internals/glimmer/lib/components/link-to.ts +++ b/packages/@ember/-internals/glimmer/lib/components/link-to.ts @@ -6,7 +6,7 @@ import { assert, debugFreeze, warn } from '@ember/debug'; import { getEngineParent } from '@ember/engine/parent'; import type EngineInstance from '@ember/engine/instance'; import { flaggedInstrument } from '@ember/instrumentation/lib/internal-instrument'; -import { action } from '@ember/object'; +import { action } from '@ember/object/action'; import { service } from '@ember/service'; import { DEBUG } from '@glimmer/env'; import type { Maybe } from '@glimmer/interfaces'; diff --git a/packages/@ember/-internals/glimmer/lib/components/textarea.ts b/packages/@ember/-internals/glimmer/lib/components/textarea.ts index 3ab5f40c178..c50beee6abf 100644 --- a/packages/@ember/-internals/glimmer/lib/components/textarea.ts +++ b/packages/@ember/-internals/glimmer/lib/components/textarea.ts @@ -2,7 +2,7 @@ @module @ember/component */ import { type Opaque } from '@ember/-internals/utility-types'; -import { action } from '@ember/object'; +import { action } from '@ember/object/action'; import TextareaTemplate from '../templates/textarea'; import AbstractInput from './abstract-input'; import { type OpaqueInternalComponentConstructor, opaquify } from './internal'; diff --git a/packages/@ember/object/action.ts b/packages/@ember/object/action.ts new file mode 100644 index 00000000000..e514c005b81 --- /dev/null +++ b/packages/@ember/object/action.ts @@ -0,0 +1,120 @@ +import { assert } from '@ember/debug'; +import type { + ElementDescriptor, + ExtendedMethodDecorator, +} from '@ember/-internals/metal/lib/decorator'; +import { isElementDescriptor, setClassicDecorator } from '@ember/-internals/metal/lib/decorator'; + +const BINDINGS_MAP = new WeakMap(); + +interface HasProto { + constructor: { + proto(): void; + }; +} + +function hasProto(obj: unknown): obj is HasProto { + return ( + obj != null && + (obj as any).constructor !== undefined && + typeof ((obj as any).constructor as any).proto === 'function' + ); +} + +interface HasActions { + actions: Record; +} + +function setupAction( + target: Partial, + key: string | symbol, + actionFn: Function +): TypedPropertyDescriptor { + if (hasProto(target)) { + target.constructor.proto(); + } + + if (!Object.prototype.hasOwnProperty.call(target, 'actions')) { + let parentActions = target.actions; + // we need to assign because of the way mixins copy actions down when inheriting + target.actions = parentActions ? Object.assign({}, parentActions) : {}; + } + + assert("[BUG] Somehow the target doesn't have actions!", target.actions != null); + + target.actions[key] = actionFn; + + return { + get() { + let bindings = BINDINGS_MAP.get(this); + + if (bindings === undefined) { + bindings = new Map(); + BINDINGS_MAP.set(this, bindings); + } + + let fn = bindings.get(actionFn); + + if (fn === undefined) { + fn = actionFn.bind(this); + bindings.set(actionFn, fn); + } + + return fn; + }, + }; +} + +export function action( + target: ElementDescriptor[0], + key: ElementDescriptor[1], + desc: ElementDescriptor[2] +): PropertyDescriptor; +export function action(desc: PropertyDescriptor): ExtendedMethodDecorator; +export function action( + ...args: ElementDescriptor | [PropertyDescriptor] +): PropertyDescriptor | ExtendedMethodDecorator { + let actionFn: object | Function; + + if (!isElementDescriptor(args)) { + actionFn = args[0]; + + let decorator: ExtendedMethodDecorator = function ( + target, + key, + _desc, + _meta, + isClassicDecorator + ) { + assert( + 'The @action decorator may only be passed a method when used in classic classes. You should decorate methods directly in native classes', + isClassicDecorator + ); + + assert( + 'The action() decorator must be passed a method when used in classic classes', + typeof actionFn === 'function' + ); + + return setupAction(target, key, actionFn); + }; + + setClassicDecorator(decorator); + + return decorator; + } + + let [target, key, desc] = args; + + actionFn = desc?.value; + + assert( + 'The @action decorator must be applied to methods when used in native classes', + typeof actionFn === 'function' + ); + + // SAFETY: TS types are weird with decorators. This should work. + return setupAction(target, key, actionFn); +} + +setClassicDecorator(action as ExtendedMethodDecorator); diff --git a/packages/@ember/object/index.ts b/packages/@ember/object/index.ts index f664c95ef0b..11a3a745e36 100644 --- a/packages/@ember/object/index.ts +++ b/packages/@ember/object/index.ts @@ -1,10 +1,5 @@ import { assert } from '@ember/debug'; import { ENV } from '@ember/-internals/environment/lib/env'; -import type { - ElementDescriptor, - ExtendedMethodDecorator, -} from '@ember/-internals/metal/lib/decorator'; -import { isElementDescriptor, setClassicDecorator } from '@ember/-internals/metal/lib/decorator'; import expandProperties from '@ember/-internals/metal/lib/expand_properties'; import { getFactoryFor } from '@ember/-internals/container/lib/container'; import { setObservers } from '@ember/-internals/utils/lib/super'; @@ -109,120 +104,7 @@ export default EmberObject; @return {PropertyDecorator} property decorator instance */ -const BINDINGS_MAP = new WeakMap(); - -interface HasProto { - constructor: { - proto(): void; - }; -} - -function hasProto(obj: unknown): obj is HasProto { - return ( - obj != null && - (obj as any).constructor !== undefined && - typeof ((obj as any).constructor as any).proto === 'function' - ); -} - -interface HasActions { - actions: Record; -} - -function setupAction( - target: Partial, - key: string | symbol, - actionFn: Function -): TypedPropertyDescriptor { - if (hasProto(target)) { - target.constructor.proto(); - } - - if (!Object.prototype.hasOwnProperty.call(target, 'actions')) { - let parentActions = target.actions; - // we need to assign because of the way mixins copy actions down when inheriting - target.actions = parentActions ? Object.assign({}, parentActions) : {}; - } - - assert("[BUG] Somehow the target doesn't have actions!", target.actions != null); - - target.actions[key] = actionFn; - - return { - get() { - let bindings = BINDINGS_MAP.get(this); - - if (bindings === undefined) { - bindings = new Map(); - BINDINGS_MAP.set(this, bindings); - } - - let fn = bindings.get(actionFn); - - if (fn === undefined) { - fn = actionFn.bind(this); - bindings.set(actionFn, fn); - } - - return fn; - }, - }; -} - -export function action( - target: ElementDescriptor[0], - key: ElementDescriptor[1], - desc: ElementDescriptor[2] -): PropertyDescriptor; -export function action(desc: PropertyDescriptor): ExtendedMethodDecorator; -export function action( - ...args: ElementDescriptor | [PropertyDescriptor] -): PropertyDescriptor | ExtendedMethodDecorator { - let actionFn: object | Function; - - if (!isElementDescriptor(args)) { - actionFn = args[0]; - - let decorator: ExtendedMethodDecorator = function ( - target, - key, - _desc, - _meta, - isClassicDecorator - ) { - assert( - 'The @action decorator may only be passed a method when used in classic classes. You should decorate methods directly in native classes', - isClassicDecorator - ); - - assert( - 'The action() decorator must be passed a method when used in classic classes', - typeof actionFn === 'function' - ); - - return setupAction(target, key, actionFn); - }; - - setClassicDecorator(decorator); - - return decorator; - } - - let [target, key, desc] = args; - - actionFn = desc?.value; - - assert( - 'The @action decorator must be applied to methods when used in native classes', - typeof actionFn === 'function' - ); - - // SAFETY: TS types are weird with decorators. This should work. - return setupAction(target, key, actionFn); -} - -// SAFETY: TS types are weird with decorators. This should work. -setClassicDecorator(action as ExtendedMethodDecorator); +export { action } from './action'; // .......................................................... // OBSERVER HELPER From f6ec3c6999d0b5051b4e54f1e90d1f82408be4ea Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 22:27:42 -0400 Subject: [PATCH 11/14] Revert: drop the two zero-impact commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per a measurement pass, two commits had zero (or negative) effect on the hello-world prod bundle: - a0b1f09d7f (Move Meta mixin methods to standalone fns): bundle went from 134.17 KB → 134.19 KB (+0.02 KB). Mixin.create chain was already being tree-shaken in prod regardless. - 75761b841e (Decouple VM debug symbols/names from opcodes.ts): bundle held at 134.12 KB. LOCAL_DEBUG=false in dist/prod (and dist/dev) was already constant-folding the debug branches out, and vite was already tree-shaking the unused @glimmer/debug imports out of the smoke-test bundle. Both refactors were architecturally cleaner but pure no-ops at the bundle measurement that motivated this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/meta/lib/meta.ts | 39 +++++ packages/@ember/object/mixin.ts | 55 +------ .../runtime/lib/opcodes-debug-setup.ts | 115 -------------- packages/@glimmer/runtime/lib/opcodes.ts | 141 +++++++++++++++--- 4 files changed, 164 insertions(+), 186 deletions(-) delete mode 100644 packages/@glimmer/runtime/lib/opcodes-debug-setup.ts diff --git a/packages/@ember/-internals/meta/lib/meta.ts b/packages/@ember/-internals/meta/lib/meta.ts index 18f4f068ccc..781566d20bd 100644 --- a/packages/@ember/-internals/meta/lib/meta.ts +++ b/packages/@ember/-internals/meta/lib/meta.ts @@ -267,6 +267,45 @@ export class Meta { return undefined; } + /** @internal */ + addMixin(mixin: any) { + assert( + isDestroyed(this.source) + ? `Cannot add mixins of \`${toString(mixin)}\` on \`${toString( + this.source + )}\` call addMixin after it has been destroyed.` + : '', + !isDestroyed(this.source) + ); + let set = this._getOrCreateOwnSet('_mixins'); + set.add(mixin); + } + + /** @internal */ + hasMixin(mixin: any) { + return this._hasInInheritedSet('_mixins', mixin); + } + + /** @internal */ + forEachMixins(fn: Function) { + let pointer: Meta | null = this; + let seen: Set | undefined; + while (pointer !== null) { + let set = pointer._mixins; + if (set !== undefined) { + seen = seen === undefined ? new Set() : seen; + // TODO cleanup typing here + set.forEach((mixin: any) => { + if (!seen!.has(mixin)) { + seen!.add(mixin); + fn(mixin); + } + }); + } + pointer = pointer.parent; + } + } + /** @internal */ writeDescriptors(subkey: string, value: any) { assert( diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index 9cc0554602c..c76b57f7dbe 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -4,53 +4,6 @@ import { INIT_FACTORY } from '@ember/-internals/container/lib/container'; import type { Meta } from '@ember/-internals/meta/lib/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; -import { isDestroyed } from '@glimmer/destroyable'; -import toString from '@ember/-internals/utils/lib/to-string'; - -function metaAddMixin(meta: Meta, mixin: any): void { - assert( - isDestroyed(meta.source as object) - ? `Cannot add mixins of \`${toString(mixin)}\` on \`${toString( - meta.source - )}\` call addMixin after it has been destroyed.` - : '', - !isDestroyed(meta.source as object) - ); - let set = meta._mixins ?? (meta._mixins = new Set()); - set.add(mixin); -} - -function metaHasMixin(meta: Meta, mixin: any): boolean { - let pointer: Meta | null = meta; - while (pointer !== null) { - let set = pointer._mixins; - if (set !== undefined && set.has(mixin)) { - return true; - } - pointer = pointer.parent; - } - return false; -} - -function metaForEachMixins(meta: Meta, fn: (mixin: any) => void): void { - let pointer: Meta | null = meta; - - let seen: Set | undefined; - while (pointer !== null) { - let set = pointer._mixins; - if (set !== undefined) { - seen = seen === undefined ? new Set() : seen; - - set.forEach((mixin: any) => { - if (!seen!.has(mixin)) { - seen!.add(mixin); - fn(mixin); - } - }); - } - pointer = pointer.parent; - } -} import { observerListenerMetaFor, ROOT, wrap } from '@ember/-internals/utils/lib/super'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; @@ -294,10 +247,10 @@ function mergeMixins( ); if (MIXINS.has(currentMixin)) { - if (metaHasMixin(meta, currentMixin)) { + if (meta.hasMixin(currentMixin)) { continue; } - metaAddMixin(meta, currentMixin); + meta.addMixin(currentMixin); let { properties, mixins } = currentMixin; @@ -639,7 +592,7 @@ export default class Mixin { return ret; } - metaForEachMixins(meta, (currentMixin: Mixin) => { + meta.forEachMixins((currentMixin: Mixin) => { // skip primitive mixins since these are always anonymous if (!currentMixin.properties) { ret.push(currentMixin); @@ -711,7 +664,7 @@ export default class Mixin { if (meta === null) { return false; } - return metaHasMixin(meta, this); + return meta.hasMixin(this); } /** @internal */ diff --git a/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts b/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts deleted file mode 100644 index 6f6d75c7c3c..00000000000 --- a/packages/@glimmer/runtime/lib/opcodes-debug-setup.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { DebugOp, SomeDisassembledOperand } from '@glimmer/debug/lib/debug'; -import { DebugLogger } from '@glimmer/debug/lib/render/logger'; -import { debugOp, describeOp, describeOpcode } from '@glimmer/debug/lib/debug'; -import { frag } from '@glimmer/debug/lib/render/fragment'; -import { opcodeMetadata } from '@glimmer/debug/lib/opcode-metadata'; -import { recordStackSize } from '@glimmer/debug/lib/stack-check'; -import { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; -import type { DebugVmSnapshot, Dict, Maybe, RuntimeOp } from '@glimmer/interfaces'; -import { unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; -import { LOCAL_LOGGER } from '@glimmer/util'; -import { $pc, $ra, $s0, $s1, $sp, $t0, $t1, $v0 } from '@glimmer/vm/lib/registers'; - -import type { AppendOpcodes, DebugState } from './opcodes'; -import { registerDebugOpcodeSetup } from './opcodes'; - -registerDebugOpcodeSetup((opcodes: AppendOpcodes): void => { - opcodes.debugBefore = (debug: DebugVmSnapshot, opcode: RuntimeOp): DebugState => { - let opcodeSnapshot = { - type: opcode.type, - size: opcode.size, - isMachine: opcode.isMachine, - } as const; - - let snapshot = new VmSnapshot(opcodeSnapshot, debug); - let params: Maybe> = undefined; - let op: DebugOp | undefined = undefined; - let closeGroup: (() => void) | undefined; - - if (LOCAL_TRACE_LOGGING) { - const logger = DebugLogger.configured(); - - let pos = debug.registers[$pc] - opcode.size; - - op = debugOp(debug.context.program, opcode, debug.template); - - closeGroup = logger - .group(frag`${pos}. ${describeOp(opcode, debug.context.program, debug.template)}`) - .expanded(); - - let debugParams = []; - for (let [name, param] of Object.entries(op.params)) { - const value = param.value; - if (value !== null && (typeof value === 'object' || typeof value === 'function')) { - debugParams.push(name, '=', value); - } - } - LOCAL_LOGGER.debug(...debugParams); - } - - recordStackSize(debug.registers[$sp]); - return { - op, - closeGroup, - params, - opcode: opcodeSnapshot, - debug, - snapshot, - }; - }; - - opcodes.debugAfter = (postSnapshot: DebugVmSnapshot, pre: DebugState) => { - let post = new VmSnapshot(pre.opcode, postSnapshot); - let diff = pre.snapshot.diff(post); - let { - opcode: { type }, - } = pre; - - let sp = diff.registers[$sp]; - - let meta = opcodeMetadata(type); - let actualChange = sp.after - sp.before; - if ( - meta && - meta.check !== false && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - typeof meta.stackChange! === 'number' && - meta.stackChange !== actualChange - ) { - throw new Error( - `Error in ${pre.op?.name}:\n\n${pre.debug.registers[$pc]}. ${ - pre.op ? describeOpcode(pre.op.name, pre.params) : unwrap(opcodeMetadata(type)).name - }\n\nStack changed by ${actualChange}, expected ${meta.stackChange}` - ); - } - - if (LOCAL_TRACE_LOGGING) { - const logger = DebugLogger.configured(); - - logger.log(diff.registers[$pc].describe()); - logger.log(diff.registers[$ra].describe()); - logger.log(diff.registers[$s0].describe()); - logger.log(diff.registers[$s1].describe()); - logger.log(diff.registers[$t0].describe()); - logger.log(diff.registers[$t1].describe()); - logger.log(diff.registers[$v0].describe()); - logger.log(diff.stack.describe()); - logger.log(diff.destructors.describe()); - logger.log(diff.scope.describe()); - - if (diff.constructing.didChange || diff.blocks.change) { - const done = logger.group(`tree construction`).expanded(); - try { - logger.log(diff.constructing.describe()); - logger.log(diff.blocks.describe()); - logger.log(diff.cursors.describe()); - } finally { - done(); - } - } - - pre.closeGroup?.(); - } - }; -}); diff --git a/packages/@glimmer/runtime/lib/opcodes.ts b/packages/@glimmer/runtime/lib/opcodes.ts index 266f1bf81ef..2a9228097ed 100644 --- a/packages/@glimmer/runtime/lib/opcodes.ts +++ b/packages/@glimmer/runtime/lib/opcodes.ts @@ -2,6 +2,7 @@ import type { DebugOp, SomeDisassembledOperand } from '@glimmer/debug/lib/debug' import type { DebugVmSnapshot, Dict, + Maybe, Nullable, Optional, RuntimeOp, @@ -10,21 +11,21 @@ import type { VmOp, } from '@glimmer/interfaces'; import { VM_SYSCALL_SIZE } from '@glimmer/constants/lib/syscall-ops'; -import type { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; +import { DebugLogger } from '@glimmer/debug/lib/render/logger'; +import { debugOp, describeOp, describeOpcode } from '@glimmer/debug/lib/debug'; +import { frag } from '@glimmer/debug/lib/render/fragment'; +import { opcodeMetadata } from '@glimmer/debug/lib/opcode-metadata'; +import { recordStackSize } from '@glimmer/debug/lib/stack-check'; +import { VmSnapshot } from '@glimmer/debug/lib/vm/snapshot'; import { dev, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import assert from '@glimmer/debug-util/lib/assert'; -import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; +import { LOCAL_DEBUG, LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; +import { LOCAL_LOGGER } from '@glimmer/util'; +import { $pc, $ra, $s0, $s1, $sp, $t0, $t1, $v0 } from '@glimmer/vm/lib/registers'; import type { LowLevelVM, VM } from './vm'; import type { Externs } from './vm/low-level'; -type DebugOpcodeSetup = (opcodes: AppendOpcodes) => void; -let debugOpcodeSetup: DebugOpcodeSetup | null = null; - -export function registerDebugOpcodeSetup(setup: DebugOpcodeSetup): void { - debugOpcodeSetup = setup; -} - export interface OpcodeJSON { type: number | string; guid?: Nullable; @@ -68,8 +69,104 @@ export class AppendOpcodes { declare debugAfter?: (debug: DebugVmSnapshot, pre: DebugState) => void; constructor() { - if (LOCAL_DEBUG && debugOpcodeSetup) { - debugOpcodeSetup(this); + if (LOCAL_DEBUG) { + this.debugBefore = (debug: DebugVmSnapshot, opcode: RuntimeOp): DebugState => { + let opcodeSnapshot = { + type: opcode.type, + size: opcode.size, + isMachine: opcode.isMachine, + } as const; + + let snapshot = new VmSnapshot(opcodeSnapshot, debug); + let params: Maybe> = undefined; + let op: DebugOp | undefined = undefined; + let closeGroup: (() => void) | undefined; + + if (LOCAL_TRACE_LOGGING) { + const logger = DebugLogger.configured(); + + let pos = debug.registers[$pc] - opcode.size; + + op = debugOp(debug.context.program, opcode, debug.template); + + closeGroup = logger + .group(frag`${pos}. ${describeOp(opcode, debug.context.program, debug.template)}`) + .expanded(); + + let debugParams = []; + for (let [name, param] of Object.entries(op.params)) { + const value = param.value; + if (value !== null && (typeof value === 'object' || typeof value === 'function')) { + debugParams.push(name, '=', value); + } + } + LOCAL_LOGGER.debug(...debugParams); + } + + recordStackSize(debug.registers[$sp]); + return { + op, + closeGroup, + params, + opcode: opcodeSnapshot, + debug, + snapshot, + }; + }; + + this.debugAfter = (postSnapshot: DebugVmSnapshot, pre: DebugState) => { + let post = new VmSnapshot(pre.opcode, postSnapshot); + let diff = pre.snapshot.diff(post); + let { + opcode: { type }, + } = pre; + + let sp = diff.registers[$sp]; + + let meta = opcodeMetadata(type); + let actualChange = sp.after - sp.before; + if ( + meta && + meta.check !== false && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + typeof meta.stackChange! === 'number' && + meta.stackChange !== actualChange + ) { + throw new Error( + `Error in ${pre.op?.name}:\n\n${pre.debug.registers[$pc]}. ${ + pre.op ? describeOpcode(pre.op.name, pre.params) : unwrap(opcodeMetadata(type)).name + }\n\nStack changed by ${actualChange}, expected ${meta.stackChange}` + ); + } + + if (LOCAL_TRACE_LOGGING) { + const logger = DebugLogger.configured(); + + logger.log(diff.registers[$pc].describe()); + logger.log(diff.registers[$ra].describe()); + logger.log(diff.registers[$s0].describe()); + logger.log(diff.registers[$s1].describe()); + logger.log(diff.registers[$t0].describe()); + logger.log(diff.registers[$t1].describe()); + logger.log(diff.registers[$v0].describe()); + logger.log(diff.stack.describe()); + logger.log(diff.destructors.describe()); + logger.log(diff.scope.describe()); + + if (diff.constructing.didChange || diff.blocks.change) { + const done = logger.group(`tree construction`).expanded(); + try { + logger.log(diff.constructing.describe()); + logger.log(diff.blocks.describe()); + logger.log(diff.cursors.describe()); + } finally { + done(); + } + } + + pre.closeGroup?.(); + } + }; } } @@ -106,15 +203,19 @@ export class AppendOpcodes { } export function externs(vm: VM): Externs | undefined { - if (!LOCAL_DEBUG || !APPEND_OPCODES.debugBefore || !APPEND_OPCODES.debugAfter) { - return undefined; - } - let debugBefore = APPEND_OPCODES.debugBefore; - let debugAfter = APPEND_OPCODES.debugAfter; - return { - debugBefore: (opcode: RuntimeOp): DebugState => debugBefore(dev(vm.debug), opcode), - debugAfter: (state: DebugState): void => debugAfter(dev(vm.debug), state), - }; + return LOCAL_DEBUG + ? { + debugBefore: (opcode: RuntimeOp): DebugState => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + return APPEND_OPCODES.debugBefore!(dev(vm.debug), opcode); + }, + + debugAfter: (state: DebugState): void => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + APPEND_OPCODES.debugAfter!(dev(vm.debug), state); + }, + } + : undefined; } export const APPEND_OPCODES = new AppendOpcodes(); From 0cecaf87c334b68edfef975a93709845f4bed40e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 2 May 2026 23:27:31 -0400 Subject: [PATCH 12/14] Extract classic helper handler from resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazy-register the classic-helper detection (`isClassicHelper` + `CLASSIC_HELPER_MANAGER`) via a side-effect file imported from `setup-registry.ts`, the same pattern already used for routing keywords / curly components / debug-render-tree. Removes the static import of `./helper` from `resolver.ts`, which was pulling the classic Helper class chain (FrameworkObject → CoreObject → Mixin) into the renderer's path even when the app does not use any classic helpers. hello-world bundle: 131.27 KB → 114.92 KB raw (-16.35 KB), 42.06 KB → 36.43 KB gzip (-5.63 KB). classic v2-app builds unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../glimmer/lib/register-classic-helper.ts | 28 ++++++++++++++ .../@ember/-internals/glimmer/lib/resolver.ts | 38 ++++++++----------- .../-internals/glimmer/lib/setup-registry.ts | 1 + 3 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/register-classic-helper.ts diff --git a/packages/@ember/-internals/glimmer/lib/register-classic-helper.ts b/packages/@ember/-internals/glimmer/lib/register-classic-helper.ts new file mode 100644 index 00000000000..5018abe63ea --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/register-classic-helper.ts @@ -0,0 +1,28 @@ +import { DEBUG } from '@glimmer/env'; +import { setInternalHelperManager } from '@glimmer/manager/lib/internal/api'; + +import { CLASSIC_HELPER_MANAGER, isClassicHelper } from './helper'; +import { registerClassicHelperHandler } from './resolver'; + +const CLASSIC_HELPER_MANAGER_ASSOCIATED = new WeakSet(); + +registerClassicHelperHandler((definition, factory) => { + if (!isClassicHelper(definition)) return null; + + // For classic class based helpers, we need to pass the factoryFor result + // itself rather than the raw value (`factoryFor(...).class`). This is + // because injections are already bound in the factoryFor result, including + // type-based injections. + if (DEBUG) { + // In DEBUG we need to only set the associated value once, otherwise + // we'll trigger an assertion. + if (!CLASSIC_HELPER_MANAGER_ASSOCIATED.has(factory)) { + CLASSIC_HELPER_MANAGER_ASSOCIATED.add(factory); + setInternalHelperManager(CLASSIC_HELPER_MANAGER, factory); + } + } else { + setInternalHelperManager(CLASSIC_HELPER_MANAGER, factory); + } + + return factory; +}); diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 56daf95bad4..0ba9e0ac322 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -13,10 +13,7 @@ import type { } from '@glimmer/interfaces'; import type { Nullable } from '@ember/-internals/utility-types'; import { getComponentTemplate } from '@glimmer/manager/lib/public/template'; -import { - getInternalComponentManager, - setInternalHelperManager, -} from '@glimmer/manager/lib/internal/api'; +import { getInternalComponentManager } from '@glimmer/manager/lib/internal/api'; import { array } from '@glimmer/runtime/lib/helpers/array'; import { concat } from '@glimmer/runtime/lib/helpers/concat'; import { fn } from '@glimmer/runtime/lib/helpers/fn'; @@ -28,7 +25,6 @@ import { TEMPLATE_ONLY_COMPONENT_MANAGER, } from '@glimmer/runtime/lib/component/template-only'; import { isCurlyManager } from './component-managers/curly-symbols'; -import { CLASSIC_HELPER_MANAGER, isClassicHelper } from './helper'; import { default as disallowDynamicResolution } from './helpers/-disallow-dynamic-resolution'; import { default as inElementNullCheckHelper } from './helpers/-in-element-null-check'; import { default as normalizeClassHelper } from './helpers/-normalize-class'; @@ -134,7 +130,17 @@ const BUILTIN_MODIFIERS: Record = { on, }; -const CLASSIC_HELPER_MANAGER_ASSOCIATED = new WeakSet(); +type ClassicHelperHandler = ( + definition: object, + + factory: any +) => HelperDefinitionState | null; + +let classicHelperHandler: ClassicHelperHandler | null = null; + +export function registerClassicHelperHandler(handler: ClassicHelperHandler): void { + classicHelperHandler = handler; +} export default class ResolverImpl implements ClassicResolver { private componentDefinitionCache: Map = new Map(); @@ -166,23 +172,11 @@ export default class ResolverImpl implements ClassicResolver { return null; } - if (typeof definition === 'function' && isClassicHelper(definition)) { - // For classic class based helpers, we need to pass the factoryFor result itself rather - // than the raw value (`factoryFor(...).class`). This is because injections are already - // bound in the factoryFor result, including type-based injections - - if (DEBUG) { - // In DEBUG we need to only set the associated value once, otherwise - // we'll trigger an assertion - if (!CLASSIC_HELPER_MANAGER_ASSOCIATED.has(factory)) { - CLASSIC_HELPER_MANAGER_ASSOCIATED.add(factory); - setInternalHelperManager(CLASSIC_HELPER_MANAGER, factory); - } - } else { - setInternalHelperManager(CLASSIC_HELPER_MANAGER, factory); + if (typeof definition === 'function' && classicHelperHandler !== null) { + let result = classicHelperHandler(definition, factory); + if (result !== null) { + return result; } - - return factory; } return definition; diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 29ba90ece01..62783b9ce90 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -13,6 +13,7 @@ import OutletView from './views/outlet'; import './syntax/register-routing-keywords'; import '@glimmer/runtime/lib/debug-render-tree-register'; import './register-curly-component'; +import './register-classic-helper'; export function setupApplicationRegistry(registry: Registry): void { // because we are using injections we can't use instantiate false From 48af252598dea86d6b43c2cb1a51ea9d04baa8ac Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 3 May 2026 00:09:57 -0400 Subject: [PATCH 13/14] Decouple property_events / runloop / property_set from observer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer-only path was statically pulling in: - observer.ts (sync + async observer flush) - chain-tags.ts (transitively, for getChainTagsForKey) - events.ts (transitively, for addListener/sendEvent) - decorator.ts (for COMPUTED_SETTERS) …even though a Glimmer-only app never installs an observer or a classic computed setter. Three independent reach points were responsible: 1. property_events.ts -> observer (sync flushSyncObservers etc.) 2. runloop/index.ts -> observer (async flushAsyncObservers) 3. property_set.ts -> decorator (COMPUTED_SETTERS WeakSet) Replaced each direct import with a registration hook (`registerObserverFlushSync` / `registerObserverDeactivationHooks`, `registerAsyncObserverFlush`, `registerComputedSetterCheck`) and moved the wire-up to a top-level side effect in `observer.ts` and `decorator.ts` themselves. Anyone importing those modules (addObserver/removeObserver, @computed, etc.) gets the registration fire as a side effect; renderer-only paths skip it. Marked observer.ts and decorator.ts as side-effect files in the ember-source `sideEffects` list so the registration calls survive tree-shaking when the modules ARE loaded. hello-world bundle: 114.92 KB -> 109.03 KB raw (-5.89 KB), 36.43 KB -> 34.44 KB gzip (-1.99 KB). classic v2-app builds and tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 ++ .../@ember/-internals/metal/lib/decorator.ts | 3 ++ .../@ember/-internals/metal/lib/observer.ts | 7 +++- .../-internals/metal/lib/property_events.ts | 34 +++++++++++++------ .../-internals/metal/lib/property_set.ts | 17 ++++++++-- packages/@ember/runloop/index.ts | 17 ++++++++-- 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index fec74faadc8..03edceeb942 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "**/@ember/-internals/glimmer/lib/components/textarea.js", "**/@ember/-internals/glimmer/lib/components/link-to.js", "**/@ember/-internals/runtime/lib/ext/rsvp.js", + "**/@ember/-internals/metal/lib/observer.js", + "**/@ember/-internals/metal/lib/decorator.js", "**/@glimmer/runtime/lib/debug-render-tree-register.js", "**/@glimmer/runtime/lib/component/template-only.js", "**/@glimmer/runtime/lib/compiled/opcodes/expressions.js", diff --git a/packages/@ember/-internals/metal/lib/decorator.ts b/packages/@ember/-internals/metal/lib/decorator.ts index c5a305b2ce3..ee6e9229858 100644 --- a/packages/@ember/-internals/metal/lib/decorator.ts +++ b/packages/@ember/-internals/metal/lib/decorator.ts @@ -2,6 +2,7 @@ import type { Meta } from '@ember/-internals/meta/lib/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; +import { registerComputedSetterCheck } from './property_set'; export type DecoratorPropertyDescriptor = (PropertyDescriptor & { initializer?: any }) | undefined; @@ -109,6 +110,8 @@ function DESCRIPTOR_SETTER_FUNCTION( export const COMPUTED_SETTERS = new WeakSet(); +registerComputedSetterCheck((setter) => COMPUTED_SETTERS.has(setter)); + export function makeComputedDecorator( desc: ComputedDescriptor, DecoratorClass: { prototype: object } diff --git a/packages/@ember/-internals/metal/lib/observer.ts b/packages/@ember/-internals/metal/lib/observer.ts index 08c5148cd9c..2cb14d2d44e 100644 --- a/packages/@ember/-internals/metal/lib/observer.ts +++ b/packages/@ember/-internals/metal/lib/observer.ts @@ -1,6 +1,6 @@ import { ENV } from '@ember/-internals/environment/lib/env'; import { peekMeta } from '@ember/-internals/meta/lib/meta'; -import type { schedule } from '@ember/runloop'; +import { registerAsyncObserverFlush, type schedule } from '@ember/runloop'; import { registerDestructor } from '@glimmer/destroyable'; import type { Tag } from '@glimmer/interfaces'; import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; @@ -8,6 +8,7 @@ import { tagMetaFor } from '@glimmer/validator/lib/meta'; import { getChainTagsForKey } from './chain-tags'; import changeEvent from './change_event'; import { addListener, removeListener, sendEvent } from './events'; +import { registerObserverDeactivationHooks, registerObserverFlushSync } from './property_events'; interface ActiveObserver { tag: Tag; @@ -270,3 +271,7 @@ function destroyObservers(target: object) { if (SYNC_OBSERVERS.size > 0) SYNC_OBSERVERS.delete(target); if (ASYNC_OBSERVERS.size > 0) ASYNC_OBSERVERS.delete(target); } + +registerObserverFlushSync(flushSyncObservers); +registerObserverDeactivationHooks(suspendedObserverDeactivation, resumeObserverDeactivation); +registerAsyncObserverFlush(flushAsyncObservers); diff --git a/packages/@ember/-internals/metal/lib/property_events.ts b/packages/@ember/-internals/metal/lib/property_events.ts index b4420bc952b..6b501f86467 100644 --- a/packages/@ember/-internals/metal/lib/property_events.ts +++ b/packages/@ember/-internals/metal/lib/property_events.ts @@ -1,13 +1,21 @@ import type { Meta } from '@ember/-internals/meta/lib/meta'; import { peekMeta } from '@ember/-internals/meta/lib/meta'; import { assert } from '@ember/debug'; -import { - flushSyncObservers, - resumeObserverDeactivation, - suspendedObserverDeactivation, -} from './observer'; import { markObjectAsDirty } from './tags'; +let observerFlushSync: (() => void) | null = null; +let observerSuspendDeactivation: (() => void) | null = null; +let observerResumeDeactivation: (() => void) | null = null; + +export function registerObserverFlushSync(fn: () => void): void { + observerFlushSync = fn; +} + +export function registerObserverDeactivationHooks(suspend: () => void, resume: () => void): void { + observerSuspendDeactivation = suspend; + observerResumeDeactivation = resume; +} + /** @module ember @private @@ -61,8 +69,8 @@ function notifyPropertyChange( markObjectAsDirty(obj, keyName); - if (deferred <= 0) { - flushSyncObservers(); + if (deferred <= 0 && observerFlushSync !== null) { + observerFlushSync(); } if (PROPERTY_DID_CHANGE in obj) { @@ -87,7 +95,9 @@ function notifyPropertyChange( */ function beginPropertyChanges(): void { deferred++; - suspendedObserverDeactivation(); + if (observerSuspendDeactivation !== null) { + observerSuspendDeactivation(); + } } /** @@ -97,8 +107,12 @@ function beginPropertyChanges(): void { function endPropertyChanges(): void { deferred--; if (deferred <= 0) { - flushSyncObservers(); - resumeObserverDeactivation(); + if (observerFlushSync !== null) { + observerFlushSync(); + } + if (observerResumeDeactivation !== null) { + observerResumeDeactivation(); + } } } diff --git a/packages/@ember/-internals/metal/lib/property_set.ts b/packages/@ember/-internals/metal/lib/property_set.ts index 7502923e85c..56280e1b9f2 100644 --- a/packages/@ember/-internals/metal/lib/property_set.ts +++ b/packages/@ember/-internals/metal/lib/property_set.ts @@ -3,11 +3,19 @@ import { setWithMandatorySetter } from '@ember/-internals/utils/lib/mandatory-se import toString from '@ember/-internals/utils/lib/to-string'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; -import { COMPUTED_SETTERS } from './decorator'; import { isPath } from './path_cache'; import { notifyPropertyChange } from './property_events'; import { getPossibleMandatoryProxyValue, _getPath as getPath } from './property_get'; +let computedSetterCheck: ((setter: (this: object, value: unknown) => void) => boolean) | null = + null; + +export function registerComputedSetterCheck( + fn: (setter: (this: object, value: unknown) => void) => boolean +): void { + computedSetterCheck = fn; +} + interface ExtendedObject { isDestroyed?: boolean; setUnknownProperty?: (keyName: string, value: any) => any; @@ -70,7 +78,12 @@ export function set(obj: object, keyName: string, value: T, tolerant?: boolea export function _setProp(obj: object, keyName: string, value: any) { let descriptor = lookupDescriptor(obj, keyName); - if (descriptor !== null && COMPUTED_SETTERS.has(descriptor.set!)) { + if ( + descriptor !== null && + descriptor.set !== undefined && + computedSetterCheck !== null && + computedSetterCheck(descriptor.set) + ) { (obj as any)[keyName] = value; return value; } diff --git a/packages/@ember/runloop/index.ts b/packages/@ember/runloop/index.ts index db079ea25b2..0b7fb97aca6 100644 --- a/packages/@ember/runloop/index.ts +++ b/packages/@ember/runloop/index.ts @@ -1,9 +1,16 @@ import { assert } from '@ember/debug'; import { onErrorTarget } from '@ember/-internals/error-handling'; -import { flushAsyncObservers } from '@ember/-internals/metal/lib/observer'; import Backburner, { type Timer, type DeferredActionQueues } from 'backburner.js'; import type { AnyFn } from '@ember/-internals/utility-types'; +let asyncObserverFlush: ((schedule: AsyncObserverScheduler) => void) | null = null; + +type AsyncObserverScheduler = typeof schedule; + +export function registerAsyncObserverFlush(fn: (schedule: AsyncObserverScheduler) => void): void { + asyncObserverFlush = fn; +} + export type { Timer }; // Partial types from https://medium.com/codex/currying-in-typescript-ca5226c85b85 @@ -45,12 +52,16 @@ function onBegin(current: DeferredActionQueues) { function onEnd(_current: DeferredActionQueues, next: DeferredActionQueues) { currentRunLoop = next; - flushAsyncObservers(schedule); + if (asyncObserverFlush !== null) { + asyncObserverFlush(schedule); + } } function flush(queueName: string, next: () => void) { if (queueName === 'render' || queueName === _rsvpErrorQueue) { - flushAsyncObservers(schedule); + if (asyncObserverFlush !== null) { + asyncObserverFlush(schedule); + } } next(); From f4c440f6d73d5f3a42360d63e30fbbc3a3ff0526 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 8 May 2026 01:50:22 -0400 Subject: [PATCH 14/14] Update tree-shakability snapshot: new pure entry points Adds the new side-effect-free files introduced by this PR: - @ember/-internals/runtime/lib/mixins/content-for.js (extracted hot path) - @ember/instrumentation/lib/internal-instrument.js (extracted hot path) - @ember/object/action.js (decorator extracted from @ember/object) All three appear in the "shaken" snapshots (dev + prod), confirming they get fully tree-shaken when imported only for side effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/node-vitest/tree-shakability.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/node-vitest/tree-shakability.test.js b/tests/node-vitest/tree-shakability.test.js index d6220a4bc3d..ad916435d20 100644 --- a/tests/node-vitest/tree-shakability.test.js +++ b/tests/node-vitest/tree-shakability.test.js @@ -117,6 +117,7 @@ it('[dev] has expected tree-shakable entrypoints', async () => { "ember-source/@ember/-internals/runtime/lib/mixins/action_handler.js", "ember-source/@ember/-internals/runtime/lib/mixins/comparable.js", "ember-source/@ember/-internals/runtime/lib/mixins/container_proxy.js", + "ember-source/@ember/-internals/runtime/lib/mixins/content-for.js", "ember-source/@ember/-internals/runtime/lib/mixins/registry_proxy.js", "ember-source/@ember/-internals/runtime/lib/mixins/target_action_support.js", "ember-source/@ember/-internals/utils/index.js", @@ -149,9 +150,11 @@ it('[dev] has expected tree-shakable entrypoints', async () => { "ember-source/@ember/enumerable/mutable.js", "ember-source/@ember/helper/index.js", "ember-source/@ember/instrumentation/index.js", + "ember-source/@ember/instrumentation/lib/internal-instrument.js", "ember-source/@ember/modifier/index.js", "ember-source/@ember/modifier/on.js", "ember-source/@ember/object/-internals.js", + "ember-source/@ember/object/action.js", "ember-source/@ember/object/compat.js", "ember-source/@ember/object/computed.js", "ember-source/@ember/object/core.js", @@ -319,6 +322,7 @@ it('[prod] has expected tree-shakable entrypoints', async () => { "ember-source/@ember/-internals/runtime/lib/mixins/action_handler.js", "ember-source/@ember/-internals/runtime/lib/mixins/comparable.js", "ember-source/@ember/-internals/runtime/lib/mixins/container_proxy.js", + "ember-source/@ember/-internals/runtime/lib/mixins/content-for.js", "ember-source/@ember/-internals/runtime/lib/mixins/registry_proxy.js", "ember-source/@ember/-internals/runtime/lib/mixins/target_action_support.js", "ember-source/@ember/-internals/utils/index.js", @@ -350,9 +354,11 @@ it('[prod] has expected tree-shakable entrypoints', async () => { "ember-source/@ember/enumerable/mutable.js", "ember-source/@ember/helper/index.js", "ember-source/@ember/instrumentation/index.js", + "ember-source/@ember/instrumentation/lib/internal-instrument.js", "ember-source/@ember/modifier/index.js", "ember-source/@ember/modifier/on.js", "ember-source/@ember/object/-internals.js", + "ember-source/@ember/object/action.js", "ember-source/@ember/object/compat.js", "ember-source/@ember/object/computed.js", "ember-source/@ember/object/core.js",