diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index c9db7a1777b..418327cbcad 100644 --- a/packages/@ember/template-compiler/lib/compile-options.ts +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -1,4 +1,4 @@ -import { fn } from '@ember/helper'; +import { array, fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -25,6 +25,7 @@ function malformedComponentLookup(string: string) { export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { + array, fn, on, }; diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts index 64503a3b5d9..dc76168f146 100644 --- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -27,11 +27,17 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP } }, SubExpression(node: AST.SubExpression) { + if (isArray(node, hasLocal)) { + rewriteKeyword(env, node, 'array', '@ember/helper'); + } if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } }, MustacheStatement(node: AST.MustacheStatement) { + if (isArray(node, hasLocal)) { + rewriteKeyword(env, node, 'array', '@ember/helper'); + } if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } @@ -62,6 +68,13 @@ function isOn( return isPath(node.path) && node.path.original === 'on' && !hasLocal('on'); } +function isArray( + node: AST.MustacheStatement | AST.SubExpression, + hasLocal: (k: string) => boolean +): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'array' && !hasLocal('array'); +} + function isFn( node: AST.MustacheStatement | AST.SubExpression, hasLocal: (k: string) => boolean diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/array-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/array-runtime-test.ts new file mode 100644 index 00000000000..17cc904c560 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/array-runtime-test.ts @@ -0,0 +1,139 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { + GlimmerishComponent, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordArrayRuntime extends RenderTest { + static suiteName = 'keyword helper: array (runtime)'; + + @test + 'explicit scope'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + const compiled = template( + '', + { + strictMode: true, + scope: () => ({ + capture, + }), + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } + + @test + 'implicit scope'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + hide(capture); + + const compiled = template( + '', + { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } + + @test + 'MustacheStatement with explicit scope'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + const Child = template('', { + strictMode: true, + scope: () => ({ capture }), + }); + + const compiled = template('', { + strictMode: true, + scope: () => ({ + Child, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } + + @test + 'no eval and no scope'(assert: Assert) { + let receivedData: unknown[] | undefined; + + class Foo extends GlimmerishComponent { + static { + template( + '', + { + strictMode: true, + component: this, + } + ); + } + + capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + } + + this.renderComponent(Foo); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } +} + +jitSuite(KeywordArrayRuntime); + +/** + * This function is used to hide a variable from the transpiler, so that it + * doesn't get removed as "unused". It does not actually do anything with the + * variable, it just makes it be part of an expression that the transpiler + * won't remove. + * + * It's a bit of a hack, but it's necessary for testing. + * + * @param variable The variable to hide. + */ +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts new file mode 100644 index 00000000000..618f90baff8 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts @@ -0,0 +1,112 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; +import { array, fn } from '@ember/helper'; +import { on } from '@ember/modifier'; + +class KeywordArray extends RenderTest { + static suiteName = 'keyword helper: array'; + + @test + 'it works'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + const compiled = template( + '', + { + strictMode: true, + scope: () => ({ + capture, + fn, + array, + on, + }), + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + hide(capture); + + const compiled = template( + '', + { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } + + @test + 'it works as a MustacheStatement'(assert: Assert) { + let receivedData: unknown[] | undefined; + + let capture = (data: unknown[]) => { + receivedData = data; + assert.step('captured'); + }; + + const Child = template('', { + strictMode: true, + scope: () => ({ on, fn, capture }), + }); + + const compiled = template('', { + strictMode: true, + scope: () => ({ + array, + Child, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.deepEqual(receivedData, ['hello', 'goodbye']); + } +} + +jitSuite(KeywordArray); + +/** + * This function is used to hide a variable from the transpiler, so that it + * doesn't get removed as "unused". It does not actually do anything with the + * variable, it just makes it be part of an expression that the transpiler + * won't remove. + * + * It's a bit of a hack, but it's necessary for testing. + * + * @param variable The variable to hide. + */ +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 069776a60cd..2dbf331d5dc 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -454,6 +454,42 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'array-as-keyword-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render, click } from '@ember/test-helpers'; + + import Component from '@glimmer/component'; + import { tracked } from '@glimmer/tracking'; + + class Demo extends Component { + @tracked items = null; + setItems = (arr) => this.items = arr; + + + } + + module('{{array}} as keyword', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + await render(Demo); + assert.dom('button').hasText('click me'); + await click('button'); + assert.dom('button').hasText('hello goodbye'); + }); + }); + `, }, }, });