Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/@ember/template-compiler/lib/compile-options.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -25,6 +25,7 @@ function malformedComponentLookup(string: string) {
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';

export const keywords: Record<string, unknown> = {
array,
fn,
on,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
'<button {{on "click" (fn capture (array "hello" "goodbye"))}}>Click</button>',
{
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(
'<button {{on "click" (fn capture (array "hello" "goodbye"))}}>Click</button>',
{
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('<button {{on "click" (fn capture @items)}}>Click</button>', {
strictMode: true,
scope: () => ({ capture }),
});

const compiled = template('<Child @items={{array "hello" "goodbye"}} />', {
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(
'<button {{on "click" (fn this.capture (array "hello" "goodbye"))}}>Click</button>',
{
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)});`);
};
Original file line number Diff line number Diff line change
@@ -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(
'<button {{on "click" (fn capture (array "hello" "goodbye"))}}>Click</button>',
{
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(
'<button {{on "click" (fn capture (array "hello" "goodbye"))}}>Click</button>',
{
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('<button {{on "click" (fn capture @items)}}>Click</button>', {
strictMode: true,
scope: () => ({ on, fn, capture }),
});

const compiled = template('<Child @items={{array "hello" "goodbye"}} />', {
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)});`);
};
36 changes: 36 additions & 0 deletions smoke-tests/scenarios/basic-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

<template>
<button {{on 'click' (fn this.setItems (array "hello" "goodbye"))}}>
{{#if this.items}}
{{#each this.items as |item|}}
{{item}}
{{/each}}
{{else}}
click me
{{/if}}
</button>
</template>
}

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');
});
});
`,
},
},
});
Expand Down
Loading