Skip to content

Commit be16cb2

Browse files
wycatsNullVoxPopuli
authored andcommitted
RFC#1099 implementation of renderComponent
There's more in this commit than there should be, largely because this commit bundles a change to the plugins that fixes lexical scope bugs. I intend to separate those changes out into their own PR before attempting to merge this one. This PR also needs a flag for the new API. Finally this commit adds some new testing infrastructure to generalize the base render tests so it can be used with templates that are not registered into a container. This is useful more generally, and could be used in other places in the test suite in the future.
1 parent 2fe9ba3 commit be16cb2

File tree

15 files changed

+1057
-245
lines changed

15 files changed

+1057
-245
lines changed

packages/@ember/-internals/glimmer/lib/renderer.ts

Lines changed: 451 additions & 225 deletions
Large diffs are not rendered by default.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {
2+
InternalComponentManager,
3+
Nullable,
4+
ResolvedComponentDefinition,
5+
} from '@glimmer/interfaces';
6+
import { BUILTIN_HELPERS, BUILTIN_KEYWORD_HELPERS } from '../resolver';
7+
8+
///////////
9+
10+
/**
11+
* Resolution for non built ins is now handled by the vm as we are using strict mode
12+
*/
13+
export class StrictResolver {
14+
lookupHelper(name: string, _owner: object): Nullable<object> {
15+
return BUILTIN_HELPERS[name] ?? null;
16+
}
17+
18+
lookupBuiltInHelper(name: string): Nullable<object> {
19+
return BUILTIN_KEYWORD_HELPERS[name] ?? null;
20+
}
21+
22+
lookupModifier(_name: string, _owner: object): Nullable<object> {
23+
return null;
24+
}
25+
26+
lookupComponent(
27+
_name: string,
28+
_owner: object
29+
): Nullable<
30+
ResolvedComponentDefinition<object, unknown, InternalComponentManager<unknown, object>>
31+
> {
32+
return null;
33+
}
34+
35+
lookupBuiltInModifier(_name: string): Nullable<object> {
36+
return null;
37+
}
38+
}

packages/@ember/-internals/glimmer/lib/resolver.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function lookupComponentPair(owner: InternalOwner, name: string): Nullable<Looku
8787
}
8888
}
8989

90-
const BUILTIN_KEYWORD_HELPERS: Record<string, object> = {
90+
export const BUILTIN_KEYWORD_HELPERS: Record<string, object> = {
9191
mut,
9292
readonly,
9393
unbound,
@@ -101,7 +101,7 @@ const BUILTIN_KEYWORD_HELPERS: Record<string, object> = {
101101
'-in-el-null': inElementNullCheckHelper,
102102
};
103103

104-
const BUILTIN_HELPERS: Record<string, object> = {
104+
export const BUILTIN_HELPERS: Record<string, object> = {
105105
...BUILTIN_KEYWORD_HELPERS,
106106
array,
107107
concat,
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import {
2+
AbstractStrictTestCase,
3+
assertClassicComponentElement,
4+
assertHTML,
5+
buildOwner,
6+
clickElement,
7+
defComponent,
8+
defineComponent,
9+
defineSimpleHelper,
10+
defineSimpleModifier,
11+
moduleFor,
12+
type ClassicComponentShape,
13+
} from 'internal-test-helpers';
14+
15+
import { Input, Textarea } from '@ember/component';
16+
import { array, concat, fn, get, hash, on } from '@glimmer/runtime';
17+
import GlimmerishComponent from '../../utils/glimmerish-component';
18+
19+
import { run } from '@ember/runloop';
20+
import { associateDestroyableChild } from '@glimmer/destroyable';
21+
import type { RenderResult } from '@glimmer/interfaces';
22+
import { renderComponent } from '../../../lib/renderer';
23+
24+
class RenderComponentTestCase extends AbstractStrictTestCase {
25+
component: RenderResult | undefined;
26+
owner: object;
27+
28+
constructor(assert: QUnit['assert']) {
29+
super(assert);
30+
31+
this.owner = buildOwner({});
32+
associateDestroyableChild(this, this.owner);
33+
}
34+
35+
get element() {
36+
return document.querySelector('#qunit-fixture')!;
37+
}
38+
39+
renderComponent(
40+
component: object,
41+
options: { expect: string } | { classic: ClassicComponentShape }
42+
) {
43+
let { owner } = this;
44+
45+
run(() => {
46+
this.component = renderComponent(component, {
47+
owner,
48+
env: { document: document, isInteractive: true, hasDOM: true },
49+
into: this.element,
50+
});
51+
if (this.component) {
52+
associateDestroyableChild(this, this.component);
53+
}
54+
});
55+
56+
if ('expect' in options) {
57+
assertHTML(options.expect);
58+
} else {
59+
assertClassicComponentElement(options.classic);
60+
}
61+
62+
this.assertStableRerender();
63+
}
64+
}
65+
66+
moduleFor(
67+
'Strict Mode - renderComponent',
68+
class extends RenderComponentTestCase {
69+
'@test Can use a component in scope'() {
70+
let Foo = defComponent('Hello, world!');
71+
let Root = defComponent('<Foo/>', { scope: { Foo } });
72+
73+
this.renderComponent(Root, { expect: 'Hello, world!' });
74+
}
75+
76+
'@test Can use a custom helper in scope (in append position)'() {
77+
let foo = defineSimpleHelper(() => 'Hello, world!');
78+
let Root = defComponent('{{foo}}', { scope: { foo } });
79+
80+
this.renderComponent(Root, { expect: 'Hello, world!' });
81+
}
82+
83+
'@test Can use a custom modifier in scope'() {
84+
let foo = defineSimpleModifier((element) => (element.innerHTML = 'Hello, world!'));
85+
let Root = defComponent('<div {{foo}}></div>', { scope: { foo } });
86+
87+
this.renderComponent(Root, { expect: '<div>Hello, world!</div>' });
88+
}
89+
90+
'@test Can shadow keywords'() {
91+
let ifComponent = defineComponent({}, 'Hello, world!');
92+
let Bar = defComponent('{{#if}}{{/if}}', { scope: { if: ifComponent } });
93+
94+
this.renderComponent(Bar, { expect: 'Hello, world!' });
95+
}
96+
97+
'@test Can use constant values in ambiguous helper/component position'() {
98+
let value = 'Hello, world!';
99+
100+
let Root = defComponent('{{value}}', { scope: { value } });
101+
102+
this.renderComponent(Root, { expect: 'Hello, world!' });
103+
}
104+
105+
'@test Can use inline if and unless in strict mode templates'() {
106+
let Root = defComponent('{{if true "foo" "bar"}}{{unless true "foo" "bar"}}');
107+
108+
this.renderComponent(Root, { expect: 'foobar' });
109+
}
110+
111+
'@test Can use a dynamic component definition'() {
112+
let Foo = defComponent('Hello, world!');
113+
let Root = defComponent('<this.Foo/>', {
114+
component: class extends GlimmerishComponent {
115+
Foo = Foo;
116+
},
117+
});
118+
119+
this.renderComponent(Root, { expect: 'Hello, world!' });
120+
}
121+
122+
'@test Can use a dynamic component definition (curly)'() {
123+
let Foo = defComponent('Hello, world!');
124+
let Root = defComponent('{{this.Foo}}', {
125+
component: class extends GlimmerishComponent {
126+
Foo = Foo;
127+
},
128+
});
129+
130+
this.renderComponent(Root, { expect: 'Hello, world!' });
131+
}
132+
133+
'@test Can use a dynamic helper definition'() {
134+
let foo = defineSimpleHelper(() => 'Hello, world!');
135+
let Root = defComponent('{{this.foo}}', {
136+
component: class extends GlimmerishComponent {
137+
foo = foo;
138+
},
139+
});
140+
141+
this.renderComponent(Root, { expect: 'Hello, world!' });
142+
}
143+
144+
'@test Can use a curried dynamic helper'() {
145+
let foo = defineSimpleHelper((value) => value);
146+
let Foo = defComponent('{{@value}}');
147+
let Root = defComponent('<Foo @value={{helper foo "Hello, world!"}}/>', {
148+
scope: { Foo, foo },
149+
});
150+
151+
this.renderComponent(Root, { expect: 'Hello, world!' });
152+
}
153+
154+
'@test Can use a curried dynamic modifier'() {
155+
let foo = defineSimpleModifier((element, [text]) => (element.innerHTML = text));
156+
let Foo = defComponent('<div {{@value}}></div>');
157+
let Root = defComponent('<Foo @value={{modifier foo "Hello, world!"}}/>', {
158+
scope: { Foo, foo },
159+
});
160+
161+
this.renderComponent(Root, { expect: '<div>Hello, world!</div>' });
162+
}
163+
}
164+
);
165+
166+
moduleFor(
167+
'Strict Mode - renderComponent - built ins',
168+
class extends RenderComponentTestCase {
169+
'@test Can use Input'() {
170+
let Root = defComponent('<Input/>', { scope: { Input } });
171+
172+
this.renderComponent(Root, {
173+
classic: {
174+
tagName: 'input',
175+
attrs: {
176+
type: 'text',
177+
class: 'ember-text-field ember-view',
178+
},
179+
},
180+
});
181+
}
182+
183+
'@test Can use Textarea'() {
184+
let Root = defComponent('<Textarea/>', { scope: { Textarea } });
185+
186+
this.renderComponent(Root, {
187+
classic: {
188+
tagName: 'textarea',
189+
attrs: {
190+
class: 'ember-text-area ember-view',
191+
},
192+
},
193+
});
194+
}
195+
196+
'@test Can use hash'() {
197+
let Root = defComponent(
198+
'{{#let (hash value="Hello, world!") as |hash|}}{{hash.value}}{{/let}}',
199+
{ scope: { hash } }
200+
);
201+
202+
this.renderComponent(Root, { expect: 'Hello, world!' });
203+
}
204+
205+
'@test Can use array'() {
206+
let Root = defComponent('{{#each (array "Hello, world!") as |value|}}{{value}}{{/each}}', {
207+
scope: { array },
208+
});
209+
210+
this.renderComponent(Root, { expect: 'Hello, world!' });
211+
}
212+
213+
'@test Can use concat'() {
214+
let Root = defComponent('{{(concat "Hello" ", " "world!")}}', { scope: { concat } });
215+
216+
this.renderComponent(Root, { expect: 'Hello, world!' });
217+
}
218+
219+
'@test Can use get'() {
220+
let Root = defComponent(
221+
'{{#let (hash value="Hello, world!") as |hash|}}{{(get hash "value")}}{{/let}}',
222+
{ scope: { hash, get } }
223+
);
224+
225+
this.renderComponent(Root, { expect: 'Hello, world!' });
226+
}
227+
228+
'@test Can use on and fn'(assert: Assert) {
229+
let handleClick = (value: unknown) => {
230+
assert.step('handleClick');
231+
assert.equal(value, 123);
232+
};
233+
234+
let Root = defComponent('<button {{on "click" (fn handleClick 123)}}>Click</button>', {
235+
scope: { on, fn, handleClick },
236+
});
237+
238+
this.renderComponent(Root, { expect: '<button>Click</button>' });
239+
240+
clickElement('button');
241+
242+
assert.verifySteps(['handleClick']);
243+
}
244+
245+
// Ember currently uses AST plugins to implement certain features that
246+
// glimmer-vm does not natively provide, such as {{#each-in}}, {{outlet}}
247+
// {{mount}} and some features in {{#in-element}}. These rewrites the AST
248+
// and insert private keywords e.g. `{{#each (-each-in)}}`. These tests
249+
// ensures we have _some_ basic coverage for those features in strict mode.
250+
//
251+
// Ultimately, our test coverage for strict mode is quite inadequate. This
252+
// is particularly important as we expect more apps to start adopting the
253+
// feature. Ideally we would run our entire/most of our test suite against
254+
// both strict and resolution modes, and these things would be implicitly
255+
// covered elsewhere, but until then, these coverage are essential.
256+
257+
'@test Can use each-in'() {
258+
let obj = {
259+
foo: 'FOO',
260+
bar: 'BAR',
261+
};
262+
263+
let Root = defComponent('{{#each-in obj as |k v|}}[{{k}}:{{v}}]{{/each-in}}', {
264+
scope: { obj },
265+
});
266+
267+
this.renderComponent(Root, { expect: '[foo:FOO][bar:BAR]' });
268+
}
269+
270+
'@test Can use in-element'() {
271+
let getElement = (id: string) => document.getElementById(id);
272+
273+
let Foo = defComponent(
274+
'{{#in-element (getElement "in-element-test")}}before{{/in-element}}after',
275+
{ scope: { getElement } }
276+
);
277+
let Root = defComponent('[<div id="in-element-test" />][<Foo/>]', { scope: { Foo } });
278+
279+
this.renderComponent(Root, {
280+
expect: '[<div id="in-element-test">before</div>][<!---->after]',
281+
});
282+
}
283+
}
284+
);

packages/@ember/template-compiler/lib/plugins/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export function isPath(node: AST.Node): node is AST.PathExpression {
55
return node.type === 'PathExpression';
66
}
77

8+
export function isSubExpression(node: AST.Node): node is AST.SubExpression {
9+
return node.type === 'SubExpression';
10+
}
11+
812
export function isStringLiteral(node: AST.Expression): node is AST.StringLiteral {
913
return node.type === 'StringLiteral';
1014
}

packages/ember-template-compiler/lib/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ASTPluginEnvironment,
44
builders,
55
PrecompileOptions,
6+
PrecompileOptionsWithLexicalScope,
67
} from '@glimmer/syntax';
78

89
export type Builders = typeof builders;
@@ -19,11 +20,13 @@ interface Plugins {
1920
ast: PluginFunc[];
2021
}
2122

23+
export type LexicalScope = NonNullable<PrecompileOptionsWithLexicalScope['lexicalScope']>;
24+
2225
export interface EmberPrecompileOptions extends PrecompileOptions {
2326
isProduction?: boolean;
2427
moduleName?: string;
2528
plugins?: Plugins;
26-
lexicalScope?: (name: string) => boolean;
29+
lexicalScope?: LexicalScope;
2730
}
2831

2932
export type EmberASTPluginEnvironment = ASTPluginEnvironment & EmberPrecompileOptions;

0 commit comments

Comments
 (0)