From 7ee05f9581310bac3484d992597c5b7320698d1b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:28:15 -0400 Subject: [PATCH] RFC#999 - {{hash}} as keyword Add hash to the built-in keywords map so it no longer needs to be imported in strict-mode (gjs/gts) templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../template-compiler/lib/compile-options.ts | 3 +- .../lib/plugins/auto-import-builtins.ts | 13 ++ .../test/keywords/hash-runtime-test.ts | 139 ++++++++++++++++++ .../test/keywords/hash-test.ts | 113 ++++++++++++++ smoke-tests/scenarios/basic-test.ts | 34 +++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/hash-runtime-test.ts create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index c9db7a1777b..91207568ec9 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 { fn, hash } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -26,6 +26,7 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { fn, + hash, 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..0530ec25e66 100644 --- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -30,11 +30,17 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } + if (isHash(node, hasLocal)) { + rewriteKeyword(env, node, 'hash', '@ember/helper'); + } }, MustacheStatement(node: AST.MustacheStatement) { if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } + if (isHash(node, hasLocal)) { + rewriteKeyword(env, node, 'hash', '@ember/helper'); + } }, }, }; @@ -68,3 +74,10 @@ function isFn( ): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn'); } + +function isHash( + 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 === 'hash' && !hasLocal('hash'); +} diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/hash-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-runtime-test.ts new file mode 100644 index 00000000000..4037138a6dd --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-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 KeywordHashRuntime extends RenderTest { + static suiteName = 'keyword helper: hash (runtime)'; + + @test + 'explicit scope'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + 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.strictEqual(receivedData?.['greeting'], 'hello'); + } + + @test + 'implicit scope'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + 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.strictEqual(receivedData?.['greeting'], 'hello'); + } + + @test + 'MustacheStatement with explicit scope'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + 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.strictEqual(receivedData?.['greeting'], 'hello'); + } + + @test + 'no eval and no scope'(assert: Assert) { + let receivedData: Record | undefined; + + class Foo extends GlimmerishComponent { + static { + template( + '', + { + strictMode: true, + component: this, + } + ); + } + + capture = (data: Record) => { + receivedData = data; + assert.step('captured'); + }; + } + + this.renderComponent(Foo); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.strictEqual(receivedData?.['greeting'], 'hello'); + } +} + +jitSuite(KeywordHashRuntime); + +/** + * 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/hash-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts new file mode 100644 index 00000000000..acb95dde6f7 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts @@ -0,0 +1,113 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; +import { fn, hash } from '@ember/helper'; +import { on } from '@ember/modifier'; + +class KeywordHash extends RenderTest { + static suiteName = 'keyword helper: hash'; + + @test + 'it works'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + receivedData = data; + assert.step('captured'); + }; + + const compiled = template( + '', + { + strictMode: true, + scope: () => ({ + capture, + fn, + hash, + on, + }), + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.strictEqual(receivedData?.['greeting'], 'hello'); + assert.strictEqual(receivedData?.['farewell'], 'goodbye'); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + 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.strictEqual(receivedData?.['greeting'], 'hello'); + } + + @test + 'it works as a MustacheStatement'(assert: Assert) { + let receivedData: Record | undefined; + + let capture = (data: Record) => { + receivedData = data; + assert.step('captured'); + }; + + const Child = template('', { + strictMode: true, + scope: () => ({ on, fn, capture }), + }); + + const compiled = template('', { + strictMode: true, + scope: () => ({ + hash, + Child, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['captured']); + assert.strictEqual(receivedData?.['greeting'], 'hello'); + } +} + +jitSuite(KeywordHash); + +/** + * 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..c05404d324e 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -454,6 +454,40 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'hash-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 data = null; + setData = (d) => this.data = d; + + + } + + module('{{hash}} 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'); + }); + }); + `, }, }, });