Skip to content

Commit 5830f2e

Browse files
NullVoxPopuliclaude
andcommitted
Shrink hello-world bundle: split classic Renderer + lazy routing keywords
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) <[email protected]>
1 parent c00601d commit 5830f2e

6 files changed

Lines changed: 441 additions & 381 deletions

File tree

packages/@ember/-internals/glimmer/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -467,13 +467,8 @@ export {
467467
htmlSafe,
468468
isHTMLSafe,
469469
} from './lib/utils/string';
470-
export {
471-
Renderer,
472-
_resetRenderers,
473-
renderSettled,
474-
renderComponent,
475-
type View,
476-
} from './lib/renderer';
470+
export { _resetRenderers, renderSettled, renderComponent } from './lib/renderer';
471+
export { Renderer, type View } from './lib/classic-renderer';
477472
export {
478473
getTemplate,
479474
setTemplate,
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
import { privatize as P } from '@ember/-internals/container/lib/registry';
2+
import type { InternalOwner } from '@ember/-internals/owner';
3+
import { getOwner } from '@ember/-internals/owner';
4+
import type { Nullable } from '@ember/-internals/utility-types';
5+
import { guidFor } from '@ember/-internals/utils/lib/guid';
6+
import { getViewElement, getViewId } from '@ember/-internals/views/lib/system/utils';
7+
import { assert } from '@ember/debug';
8+
import {
9+
associateDestroyableChild,
10+
destroy,
11+
isDestroyed,
12+
isDestroying,
13+
} from '@glimmer/destroyable';
14+
import type {
15+
Bounds,
16+
CurriedComponent,
17+
DynamicScope as GlimmerDynamicScope,
18+
Environment,
19+
EvaluationContext,
20+
RenderResult as GlimmerRenderResult,
21+
Template,
22+
TemplateFactory,
23+
} from '@glimmer/interfaces';
24+
import type { Reference } from '@glimmer/reference/lib/reference';
25+
import { createConstRef, UNDEFINED_REFERENCE, valueForRef } from '@glimmer/reference/lib/reference';
26+
import type { CurriedValue } from '@glimmer/runtime/lib/curried-value';
27+
import { curry } from '@glimmer/runtime/lib/curried-value';
28+
import { createCapturedArgs, EMPTY_POSITIONAL } from '@glimmer/runtime/lib/vm/arguments';
29+
import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder';
30+
import { inTransaction } from '@glimmer/runtime/lib/environment';
31+
import { renderMain } from '@glimmer/runtime/lib/render';
32+
import { dict } from '@glimmer/util/lib/collections';
33+
import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/interface';
34+
35+
import { hasDOM } from '../../browser-environment';
36+
import type Component from './component';
37+
import type ClassicComponent from './component';
38+
import { BOUNDS } from './component-managers/curly';
39+
import { createRootOutlet } from './component-managers/outlet';
40+
import { RootComponentDefinition } from './component-managers/root';
41+
import { makeRouteTemplate } from './component-managers/route-template';
42+
import { unwrapTemplate } from './component-managers/unwrap-template';
43+
import {
44+
BaseRenderer,
45+
errorLoopTransaction,
46+
type IBuilder,
47+
type RootState,
48+
} from './renderer';
49+
import ResolverImpl from './resolver';
50+
import type { OutletState } from './utils/outlet';
51+
import OutletView from './views/outlet';
52+
53+
export interface View {
54+
parentView: Nullable<View>;
55+
renderer: Renderer;
56+
tagName: string | null;
57+
elementId: string | null;
58+
isDestroying: boolean;
59+
isDestroyed: boolean;
60+
[BOUNDS]: Bounds | null;
61+
}
62+
63+
export class DynamicScope implements GlimmerDynamicScope {
64+
constructor(
65+
public view: View | null,
66+
public outletState: Reference<OutletState | undefined>
67+
) {}
68+
69+
child() {
70+
return new DynamicScope(this.view, this.outletState);
71+
}
72+
73+
get(key: 'outletState'): Reference<OutletState | undefined> {
74+
assert(
75+
`Using \`-get-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`,
76+
key === 'outletState'
77+
);
78+
return this.outletState;
79+
}
80+
81+
set(key: 'outletState', value: Reference<OutletState | undefined>) {
82+
assert(
83+
`Using \`-with-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`,
84+
key === 'outletState'
85+
);
86+
this.outletState = value;
87+
return value;
88+
}
89+
}
90+
91+
class ClassicRootState implements RootState {
92+
readonly type = 'classic';
93+
public id: string;
94+
public result: GlimmerRenderResult | undefined;
95+
public destroyed: boolean;
96+
public render: () => void;
97+
readonly env: Environment;
98+
99+
constructor(
100+
public root: Component | OutletView,
101+
context: EvaluationContext,
102+
owner: object,
103+
template: Template,
104+
self: Reference<unknown>,
105+
parentElement: SimpleElement,
106+
dynamicScope: DynamicScope,
107+
builder: IBuilder
108+
) {
109+
assert(
110+
`You cannot render \`${valueForRef(self)}\` without a template.`,
111+
template !== undefined
112+
);
113+
114+
this.id = root instanceof OutletView ? guidFor(root) : getViewId(root);
115+
this.result = undefined;
116+
this.destroyed = false;
117+
this.env = context.env;
118+
119+
this.render = errorLoopTransaction(() => {
120+
let layout = unwrapTemplate(template).asLayout();
121+
122+
let iterator = renderMain(
123+
context,
124+
owner,
125+
self,
126+
builder(context.env, { element: parentElement, nextSibling: null }),
127+
layout,
128+
dynamicScope
129+
);
130+
131+
let result = (this.result = iterator.sync());
132+
133+
associateDestroyableChild(this, result);
134+
135+
this.render = errorLoopTransaction(() => {
136+
if (isDestroying(result) || isDestroyed(result)) return;
137+
138+
return result.rerender({
139+
alwaysRevalidate: false,
140+
});
141+
});
142+
});
143+
}
144+
145+
isFor(possibleRoot: unknown): boolean {
146+
return this.root === possibleRoot;
147+
}
148+
149+
destroy() {
150+
let { result, env } = this;
151+
152+
this.destroyed = true;
153+
154+
this.root = null as any;
155+
this.result = undefined;
156+
this.render = undefined as any;
157+
158+
if (result !== undefined) {
159+
/*
160+
Handles these scenarios:
161+
162+
* When roots are removed during standard rendering process, a transaction exists already
163+
`.begin()` / `.commit()` are not needed.
164+
* When roots are being destroyed manually (`component.append(); component.destroy() case), no
165+
transaction exists already.
166+
* When roots are being destroyed during `Renderer#destroy`, no transaction exists
167+
168+
*/
169+
170+
inTransaction(env, () => destroy(result!));
171+
}
172+
}
173+
}
174+
175+
interface ViewRegistry {
176+
[viewId: string]: unknown;
177+
}
178+
179+
export class Renderer extends BaseRenderer {
180+
static override strict(
181+
owner: object,
182+
document: SimpleDocument | Document,
183+
options: { isInteractive: boolean; hasDOM?: boolean }
184+
): BaseRenderer {
185+
return new BaseRenderer(
186+
owner,
187+
{ hasDOM: hasDOM, ...options },
188+
document as SimpleDocument,
189+
new ResolverImpl(),
190+
clientBuilder
191+
);
192+
}
193+
194+
private _rootTemplate: Template;
195+
private _viewRegistry: ViewRegistry;
196+
197+
static create(props: { _viewRegistry: any }): Renderer {
198+
let { _viewRegistry } = props;
199+
let owner = getOwner(props);
200+
assert('Renderer is unexpectedly missing an owner', owner);
201+
let document = owner.lookup('service:-document') as SimpleDocument;
202+
let env = owner.lookup('-environment:main') as {
203+
isInteractive: boolean;
204+
hasDOM: boolean;
205+
};
206+
let rootTemplate = owner.lookup(P`template:-root`) as TemplateFactory;
207+
let builder = owner.lookup('service:-dom-builder') as IBuilder;
208+
return new this(owner, document, env, rootTemplate, _viewRegistry, builder);
209+
}
210+
211+
constructor(
212+
owner: InternalOwner,
213+
document: SimpleDocument,
214+
env: { isInteractive: boolean; hasDOM: boolean },
215+
rootTemplate: TemplateFactory,
216+
viewRegistry: ViewRegistry,
217+
builder = clientBuilder,
218+
resolver = new ResolverImpl()
219+
) {
220+
super(owner, env, document, resolver, builder);
221+
this._rootTemplate = rootTemplate(owner);
222+
this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main');
223+
}
224+
225+
// renderer HOOKS
226+
227+
appendOutletView(view: OutletView, target: SimpleElement): void {
228+
// TODO: This bypasses the {{outlet}} syntax so logically duplicates
229+
// some of the set up code. Since this is all internal (or is it?),
230+
// we can refactor this to do something more direct/less convoluted
231+
// and with less setup, but get it working first
232+
let outlet = createRootOutlet(view);
233+
let { name, /* controller, */ template } = view.state;
234+
235+
let named = dict<Reference>();
236+
237+
named['Component'] = createConstRef(
238+
makeRouteTemplate(view.owner, name, template as Template),
239+
'@Component'
240+
);
241+
242+
// TODO: is this guaranteed to be undefined? It seems to be the
243+
// case in the `OutletView` class. Investigate how much that class
244+
// exists as an internal implementation detail only, or if it was
245+
// used outside of core. As far as I can tell, test-helpers uses
246+
// it but only for `setOutletState`.
247+
// named['controller'] = createConstRef(controller, '@controller');
248+
// Update: at least according to the debug render tree tests, we
249+
// appear to always expect this to be undefined. Not a definitive
250+
// source by any means, but is useful evidence
251+
named['controller'] = UNDEFINED_REFERENCE;
252+
named['model'] = UNDEFINED_REFERENCE;
253+
254+
let args = createCapturedArgs(named, EMPTY_POSITIONAL);
255+
256+
this._appendDefinition(
257+
view,
258+
curry(0 as CurriedComponent, outlet, view.owner, args, true),
259+
target
260+
);
261+
}
262+
263+
appendTo(view: ClassicComponent, target: SimpleElement): void {
264+
let definition = new RootComponentDefinition(view);
265+
this._appendDefinition(
266+
view,
267+
curry(0 as CurriedComponent, definition, this.state.owner, null, true),
268+
target
269+
);
270+
}
271+
272+
_appendDefinition(
273+
root: OutletView | ClassicComponent,
274+
definition: CurriedValue,
275+
target: SimpleElement
276+
): void {
277+
let self = createConstRef(definition, 'this');
278+
let dynamicScope = new DynamicScope(null, UNDEFINED_REFERENCE);
279+
let rootState = new ClassicRootState(
280+
root,
281+
this.state.context,
282+
this.state.owner,
283+
this._rootTemplate,
284+
self,
285+
target,
286+
dynamicScope,
287+
this.state.builder
288+
);
289+
this.state.renderRoot(rootState, this);
290+
}
291+
292+
cleanupRootFor(component: ClassicComponent): void {
293+
// no need to cleanup roots if we have already been destroyed
294+
if (isDestroyed(this)) {
295+
return;
296+
}
297+
298+
let roots = this.state.roots;
299+
300+
// traverse in reverse so we can remove items
301+
// without mucking up the index
302+
let i = roots.length;
303+
while (i--) {
304+
let root = roots[i];
305+
assert('has root', root);
306+
if (root.type === 'classic' && (root as ClassicRootState).isFor(component)) {
307+
root.destroy();
308+
roots.splice(i, 1);
309+
}
310+
}
311+
}
312+
313+
remove(view: ClassicComponent): void {
314+
view._transitionTo('destroying');
315+
316+
this.cleanupRootFor(view);
317+
318+
if (this.state.isInteractive) {
319+
view.trigger('didDestroyElement');
320+
}
321+
}
322+
323+
get _roots() {
324+
return this.state.debug.roots;
325+
}
326+
327+
get _inRenderTransaction() {
328+
return this.state.debug.inRenderTransaction;
329+
}
330+
331+
get _isInteractive() {
332+
return this.state.debug.isInteractive;
333+
}
334+
335+
get _context() {
336+
return this.state.context;
337+
}
338+
339+
register(view: any): void {
340+
let id = getViewId(view);
341+
assert(
342+
'Attempted to register a view with an id already in use: ' + id,
343+
!this._viewRegistry[id]
344+
);
345+
this._viewRegistry[id] = view;
346+
}
347+
348+
unregister(view: any): void {
349+
delete this._viewRegistry[getViewId(view)];
350+
}
351+
352+
getElement(component: View): Nullable<Element> {
353+
if (this._isInteractive) {
354+
return getViewElement(component);
355+
} else {
356+
throw new Error(
357+
'Accessing `this.element` is not allowed in non-interactive environments (such as FastBoot).'
358+
);
359+
}
360+
}
361+
362+
getBounds(component: View): {
363+
parentElement: SimpleElement;
364+
firstNode: SimpleNode;
365+
lastNode: SimpleNode;
366+
} {
367+
let bounds: Bounds | null = component[BOUNDS];
368+
369+
assert('object passed to getBounds must have the BOUNDS symbol as a property', bounds);
370+
371+
let parentElement = bounds.parentElement();
372+
let firstNode = bounds.firstNode();
373+
let lastNode = bounds.lastNode();
374+
375+
return { parentElement, firstNode, lastNode };
376+
}
377+
}

0 commit comments

Comments
 (0)