diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts index 400979a4103..835cc8af9a1 100644 --- a/packages/@ember/helper/index.ts +++ b/packages/@ember/helper/index.ts @@ -10,6 +10,15 @@ import { concat as glimmerConcat, get as glimmerGet, fn as glimmerFn, + and as glimmerAnd, + or as glimmerOr, + not as glimmerNot, + eq as glimmerEq, + neq as glimmerNeq, + lt as glimmerLt, + lte as glimmerLte, + gt as glimmerGt, + gte as glimmerGte, } from '@glimmer/runtime'; import { uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer'; import { type Opaque } from '@ember/-internals/utility-types'; @@ -492,3 +501,120 @@ export const uniqueId = glimmerUniqueId; export type UniqueIdHelper = typeof uniqueId; /* eslint-enable @typescript-eslint/no-empty-object-type */ + +/** + * `{{and}}` returns the first falsy value or the last value if all are truthy. + * + * ```js + * import { and } from '@ember/helper'; + * + * + * ``` + */ +export const and = glimmerAnd; + +/** + * `{{or}}` returns the first truthy value or the last value if all are falsy. + * + * ```js + * import { or } from '@ember/helper'; + * + * + * ``` + */ +export const or = glimmerOr; + +/** + * `{{not}}` returns the boolean negation of its argument. + * + * ```js + * import { not } from '@ember/helper'; + * + * + * ``` + */ +export const not = glimmerNot; + +/** + * `{{eq}}` checks strict equality (`===`) between two values. + * + * ```js + * import { eq } from '@ember/helper'; + * + * + * ``` + */ +export const eq = glimmerEq; + +/** + * `{{neq}}` checks strict inequality (`!==`) between two values. + * + * ```js + * import { neq } from '@ember/helper'; + * + * + * ``` + */ +export const neq = glimmerNeq; + +/** + * `{{lt}}` checks if the first value is less than the second. + * + * ```js + * import { lt } from '@ember/helper'; + * + * + * ``` + */ +export const lt = glimmerLt; + +/** + * `{{lte}}` checks if the first value is less than or equal to the second. + * + * ```js + * import { lte } from '@ember/helper'; + * + * + * ``` + */ +export const lte = glimmerLte; + +/** + * `{{gt}}` checks if the first value is greater than the second. + * + * ```js + * import { gt } from '@ember/helper'; + * + * + * ``` + */ +export const gt = glimmerGt; + +/** + * `{{gte}}` checks if the first value is greater than or equal to the second. + * + * ```js + * import { gte } from '@ember/helper'; + * + * + * ``` + */ +export const gte = glimmerGte; diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index 00dbf842eb9..56f4c00ec02 100644 --- a/packages/@ember/template-compiler/lib/compile-options.ts +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -1,3 +1,5 @@ +import { on } from '@ember/modifier'; +import { fn, hash, array, and, or, not, eq, neq, lt, lte, gt, gte } from '@ember/helper'; import { assert } from '@ember/debug'; import { RESOLUTION_MODE_TRANSFORMS, @@ -14,11 +16,30 @@ function malformedComponentLookup(string: string) { return string.indexOf('::') === -1 && string.indexOf(':') > -1; } +const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; +export const keywords = { + on, + fn, + hash, + array, + and, + or, + not, + eq, + neq, + lt, + lte, + gt, + gte, +}; + +// Not worth adding a type +(globalThis as any)[RUNTIME_KEYWORDS_NAME] = keywords; + function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions { let moduleName = _options.moduleName; - let options: EmberPrecompileOptions & Partial = { - meta: {}, + let options = { isProduction: false, plugins: { ast: [] }, ..._options, @@ -35,8 +56,25 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO }, }; - if ('eval' in options) { - const localScopeEvaluator = options.eval as (value: string) => unknown; + options.meta ||= {}; + options.meta.emberRuntime ||= { + /** + * NOTE: when stepping through lexicalScope, or other callbacks here, + * we first detect the keywords as "not in scope", + * and that is what we want, so that we can import them. + */ + lookupKeyword(name: string): string { + assert( + `${name} is not a known keyword. Available keywords: ${Object.keys(keywords).join(', ')}`, + name in keywords + ); + + return `globalThis.${RUNTIME_KEYWORDS_NAME}.${name}`; + }, + }; + + if ('eval' in options && options.eval) { + const localScopeEvaluator = options.eval; const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)(); options.lexicalScope = (variable: string) => { @@ -57,9 +95,7 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO if ('scope' in options) { const scope = (options.scope as () => Record)(); - options.lexicalScope = (variable: string) => { - return variable in scope; - }; + options.lexicalScope = (variable: string) => variable in scope || variable in keywords; delete options.scope; } diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts new file mode 100644 index 00000000000..2e21e2abfa5 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -0,0 +1,83 @@ +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import type { EmberASTPluginEnvironment } from '../types'; +import { isPath, trackLocals } from './utils'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that makes importable keywords work + + @private + @class AutoImportBuiltins +*/ + +const MODIFIER_KEYWORDS: Record = { + on: '@ember/modifier', +}; + +const HELPER_KEYWORDS: Record = { + fn: '@ember/helper', + hash: '@ember/helper', + array: '@ember/helper', + and: '@ember/helper', + or: '@ember/helper', + not: '@ember/helper', + eq: '@ember/helper', + neq: '@ember/helper', + lt: '@ember/helper', + lte: '@ember/helper', + gt: '@ember/helper', + gte: '@ember/helper', +}; + +export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTPlugin { + let { hasLocal, visitor } = trackLocals(env); + + function rewrite( + node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression, + modulePath: string, + name: string + ) { + if (env.meta?.jsutils) { + (node.path as AST.PathExpression).original = env.meta.jsutils.bindImport( + modulePath, + name, + node, + { name } + ); + } else if (env.meta?.emberRuntime) { + (node.path as AST.PathExpression).original = env.meta.emberRuntime.lookupKeyword(name); + } + } + + return { + name: 'auto-import-built-ins', + + visitor: { + ...visitor, + ElementModifierStatement(node: AST.ElementModifierStatement) { + if (!isPath(node.path) || hasLocal(node.path.original)) return; + let modulePath = MODIFIER_KEYWORDS[node.path.original]; + if (modulePath) { + rewrite(node, modulePath, node.path.original); + } + }, + MustacheStatement(node: AST.MustacheStatement) { + if (!isPath(node.path) || hasLocal(node.path.original)) return; + let modulePath = HELPER_KEYWORDS[node.path.original]; + if (modulePath) { + rewrite(node, modulePath, node.path.original); + } + }, + SubExpression(node: AST.SubExpression) { + if (!isPath(node.path) || hasLocal(node.path.original)) return; + let modulePath = HELPER_KEYWORDS[node.path.original]; + if (modulePath) { + rewrite(node, modulePath, node.path.original); + } + }, + }, + }; +} diff --git a/packages/@ember/template-compiler/lib/plugins/index.ts b/packages/@ember/template-compiler/lib/plugins/index.ts index 8eea3b646bf..ace6b99f647 100644 --- a/packages/@ember/template-compiler/lib/plugins/index.ts +++ b/packages/@ember/template-compiler/lib/plugins/index.ts @@ -9,8 +9,10 @@ import TransformInElement from './transform-in-element'; import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings'; import TransformResolutions from './transform-resolutions'; import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet'; +import AutoImportBuiltins from './auto-import-builtins'; export const INTERNAL_PLUGINS = { + AutoImportBuiltins, AssertAgainstAttrs, AssertAgainstNamedOutlets, AssertInputHelperWithoutBlock, @@ -40,6 +42,7 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([ ]); export const STRICT_MODE_TRANSFORMS = Object.freeze([ + AutoImportBuiltins, TransformQuotedBindingsIntoJustBindings, AssertReservedNamedArguments, TransformActionSyntax, diff --git a/packages/@ember/template-compiler/lib/template.ts b/packages/@ember/template-compiler/lib/template.ts index bd2247eb386..ac6f1dee03b 100644 --- a/packages/@ember/template-compiler/lib/template.ts +++ b/packages/@ember/template-compiler/lib/template.ts @@ -3,7 +3,7 @@ import { precompile as glimmerPrecompile } from '@glimmer/compiler'; import type { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces'; import { setComponentTemplate } from '@glimmer/manager'; import { templateFactory } from '@glimmer/opcode-compiler'; -import compileOptions from './compile-options'; +import compileOptions, { keywords } from './compile-options'; import type { EmberPrecompileOptions } from './types'; type ComponentClass = abstract new (...args: any[]) => object; @@ -237,14 +237,16 @@ export function template( templateString: string, providedOptions?: BaseTemplateOptions | BaseClassTemplateOptions ): object { - const options: EmberPrecompileOptions = { strictMode: true, ...providedOptions }; - const evaluate = buildEvaluator(options); + const options = { strictMode: true, ...providedOptions }; + const evaluate = buildEvaluator(options); const normalizedOptions = compileOptions(options); const component = normalizedOptions.component ?? templateOnly(); const source = glimmerPrecompile(templateString, normalizedOptions); - const template = templateFactory(evaluate(`(${source})`) as SerializedTemplateWithLazyBlock); + const wire = evaluate(`(${source})`) as SerializedTemplateWithLazyBlock; + + const template = templateFactory(wire); setComponentTemplate(template, component); @@ -252,23 +254,31 @@ export function template( } const evaluator = (source: string) => { - return new Function(`return ${source}`)(); + return new Function(`return ${source}`)(); }; -function buildEvaluator(options: Partial | undefined) { - if (options === undefined) { - return evaluator; - } - +/** + * Builds the source wireformat JSON block + * + * @param options + * @returns + */ +function buildEvaluator(options: Partial) { if (options.eval) { return options.eval; } else { - const scope = options.scope?.(); + /** + * This is ran before the template is compiled, + * so we cannot use any information gathered during template compilation. + */ + let scope = options.scope?.(); if (!scope) { return evaluator; } + scope = Object.assign({}, keywords, scope); + return (source: string) => { let hasThis = Object.prototype.hasOwnProperty.call(scope, 'this'); let thisValue = hasThis ? (scope as { this?: unknown }).this : undefined; diff --git a/packages/@ember/template-compiler/lib/types.ts b/packages/@ember/template-compiler/lib/types.ts index 2242663eace..10309ac8ab9 100644 --- a/packages/@ember/template-compiler/lib/types.ts +++ b/packages/@ember/template-compiler/lib/types.ts @@ -1,4 +1,5 @@ import type { + ASTPluginBuilder, ASTPluginEnvironment, builders, PrecompileOptions, @@ -13,9 +14,7 @@ export type Builders = typeof builders; * typing. Here export the interface subclass with no modification. */ -export type PluginFunc = NonNullable< - NonNullable['ast'] ->[number]; +export type PluginFunc = ASTPluginBuilder; export type LexicalScope = NonNullable; @@ -23,11 +22,31 @@ interface Plugins { ast: PluginFunc[]; } -export interface EmberPrecompileOptions extends PrecompileOptions { +export interface EmberPrecompileOptions extends Omit { isProduction?: boolean; moduleName?: string; plugins?: Plugins; lexicalScope?: LexicalScope; + meta?: { + /** + * Exists for historical reasons, should not be in new code, as + * the module name does not correspond to anything meaningful at runtime. + */ + moduleName?: string | undefined; + + /** + * Not available at runtime + */ + jsutils?: { bindImport: (...args: unknown[]) => string }; + + /** + * Utils unique to the runtime compiler + */ + emberRuntime?: { + lookupKeyword(name: string): string; + }; + }; + /** * This supports template blocks defined in class bodies. * diff --git a/packages/@ember/template-compiler/package.json b/packages/@ember/template-compiler/package.json index 7a10006fbad..5f4fc58cfd7 100644 --- a/packages/@ember/template-compiler/package.json +++ b/packages/@ember/template-compiler/package.json @@ -12,6 +12,8 @@ "@ember/-internals": "workspace:*", "@ember/component": "workspace:*", "@ember/debug": "workspace:*", + "@ember/modifier": "workspace:*", + "@ember/helper": "workspace:*", "@glimmer/compiler": "workspace:*", "@glimmer/env": "workspace:*", "@glimmer/interfaces": "workspace:*", 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..adeb24a9a08 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts @@ -0,0 +1,84 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; +import { array } from '@ember/helper'; + +class KeywordArray extends RenderTest { + static suiteName = 'keyword helper: array'; + + @test + 'it works with explicit scope'(assert: Assert) { + let handleClick = (items: unknown[]) => { + assert.step(`count:${items.length}`); + }; + + const compiled = template( + '', + { + strictMode: true, + scope: () => ({ + handleClick, + array, + }), + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['count:3']); + } + + @test + 'it works as a keyword (no import needed)'(assert: Assert) { + let handleClick = (items: unknown[]) => { + assert.step(`count:${items.length}`); + }; + + const compiled = template( + '', + { + strictMode: true, + scope: () => ({ + handleClick, + }), + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['count:3']); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + let handleClick = (items: unknown[]) => { + assert.step(`count:${items.length}`); + }; + + hide(handleClick); + + const compiled = template( + '', + { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + } + ); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['count:3']); + } +} + +jitSuite(KeywordArray); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts new file mode 100644 index 00000000000..572a0f6bfca --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts @@ -0,0 +1,176 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordLt extends RenderTest { + static suiteName = 'keyword helper: lt'; + + @test + 'returns true when a < b'() { + let a = 1; + let b = 2; + + const compiled = template('{{if (lt a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false when a >= b'() { + let a = 2; + let b = 2; + + const compiled = template('{{if (lt a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + let a = 5; + let b = 10; + + hide(a, b); + + const compiled = template('{{if (lt a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } +} + +class KeywordLte extends RenderTest { + static suiteName = 'keyword helper: lte'; + + @test + 'returns true when a <= b'() { + let a = 2; + let b = 2; + + const compiled = template('{{if (lte a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false when a > b'() { + let a = 3; + let b = 2; + + const compiled = template('{{if (lte a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } +} + +class KeywordGt extends RenderTest { + static suiteName = 'keyword helper: gt'; + + @test + 'returns true when a > b'() { + let a = 3; + let b = 2; + + const compiled = template('{{if (gt a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false when a <= b'() { + let a = 2; + let b = 2; + + const compiled = template('{{if (gt a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } +} + +class KeywordGte extends RenderTest { + static suiteName = 'keyword helper: gte'; + + @test + 'returns true when a >= b'() { + let a = 2; + let b = 2; + + const compiled = template('{{if (gte a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false when a < b'() { + let a = 1; + let b = 2; + + const compiled = template('{{if (gte a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + let a = 10; + let b = 5; + + hide(a, b); + + const compiled = template('{{if (gte a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } +} + +jitSuite(KeywordLt); +jitSuite(KeywordLte); +jitSuite(KeywordGt); +jitSuite(KeywordGte); + +const hide = (...variables: unknown[]) => { + new Function(`return (${JSON.stringify(variables)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts new file mode 100644 index 00000000000..3ea3653989b --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts @@ -0,0 +1,98 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordEq extends RenderTest { + static suiteName = 'keyword helper: eq'; + + @test + 'it returns true for equal values'() { + const compiled = template('{{if (eq "a" "a") "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'it returns false for non-equal values'() { + const compiled = template('{{if (eq "a" "b") "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it uses strict equality'() { + let a = 1; + let b = '1'; + + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{if (eq "hello" "hello") "match" "no match"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('match'); + } +} + +class KeywordNeq extends RenderTest { + static suiteName = 'keyword helper: neq'; + + @test + 'it returns true for non-equal values'() { + const compiled = template('{{if (neq "a" "b") "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'it returns false for equal values'() { + const compiled = template('{{if (neq "a" "a") "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{if (neq "a" "b") "different" "same"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('different'); + } +} + +jitSuite(KeywordEq); +jitSuite(KeywordNeq); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts new file mode 100644 index 00000000000..e0190070769 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts @@ -0,0 +1,100 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; +import { setHelperManager, helperCapabilities } from '@glimmer/manager'; + +import { template } from '@ember/template-compiler/runtime'; +import { fn } from '@ember/helper'; + +class KeywordFn extends RenderTest { + static suiteName = 'keyword helper: fn'; + + @test + 'it works with explicit scope'(assert: Assert) { + let handleClick = (msg: string) => { + assert.step(msg); + }; + + const compiled = template('', { + strictMode: true, + scope: () => ({ + handleClick, + fn, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['hello']); + } + + @test + 'it works as a keyword (no import needed)'(assert: Assert) { + let handleClick = (msg: string) => { + assert.step(msg); + }; + + const compiled = template('', { + strictMode: true, + scope: () => ({ + handleClick, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['hello']); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + let handleClick = (msg: string) => { + assert.step(msg); + }; + + hide(handleClick); + + const compiled = template('', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['hello']); + } + + @test + 'can be shadowed'() { + let fn = setHelperManager( + () => ({ + capabilities: helperCapabilities('3.23', { hasValue: true }), + createHelper() { + return {}; + }, + getValue() { + return 'shadowed'; + }, + }), + {} + ); + + const compiled = template('{{fn "anything"}}', { + strictMode: true, + scope: () => ({ fn }), + }); + + this.renderComponent(compiled); + this.assertHTML('shadowed'); + } +} + +jitSuite(KeywordFn); + +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..a3f41fabb11 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts @@ -0,0 +1,47 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; +import { hash } from '@ember/helper'; + +class KeywordHash extends RenderTest { + static suiteName = 'keyword helper: hash'; + + @test + 'it works with explicit scope'() { + const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', { + strictMode: true, + scope: () => ({ + hash, + }), + }); + + this.renderComponent(compiled); + this.assertHTML('Ember'); + } + + @test + 'it works as a keyword (no import needed)'() { + const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('Ember'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('Ember'); + } +} + +jitSuite(KeywordHash); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts new file mode 100644 index 00000000000..3665d8aff5b --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts @@ -0,0 +1,136 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordAnd extends RenderTest { + static suiteName = 'keyword helper: and'; + + @test + 'returns last value when all truthy'() { + let a = 'first'; + let b = 'second'; + + const compiled = template('{{and a b}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('second'); + } + + @test + 'returns first falsy value'() { + let a = ''; + let b = 'second'; + + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{if (and true true) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } +} + +class KeywordOr extends RenderTest { + static suiteName = 'keyword helper: or'; + + @test + 'returns first truthy value'() { + let a = ''; + let b = 'second'; + + const compiled = template('{{or a b}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('second'); + } + + @test + 'returns last value when all falsy'() { + let a = ''; + let b = 0; + + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{if (or false true) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } +} + +class KeywordNot extends RenderTest { + static suiteName = 'keyword helper: not'; + + @test + 'negates truthy to false'() { + const compiled = template('{{if (not true) "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'negates falsy to true'() { + const compiled = template('{{if (not false) "yes" "no"}}', { + strictMode: true, + scope: () => ({}), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'it works with the runtime compiler'() { + const compiled = template('{{if (not false) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } +} + +jitSuite(KeywordAnd); +jitSuite(KeywordOr); +jitSuite(KeywordNot); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts new file mode 100644 index 00000000000..f4fc52c6405 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts @@ -0,0 +1,64 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordOn extends RenderTest { + static suiteName = 'keyword modifier: on (runtime)'; + + @test + 'explicit scope'(assert: Assert) { + let handleClick = () => { + assert.step('success'); + }; + + const compiled = template('', { + strictMode: true, + scope: () => ({ + handleClick, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['success']); + } + + @test + 'implicit scope'(assert: Assert) { + let handleClick = () => { + assert.step('success'); + }; + + hide(handleClick); + + const compiled = template('', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['success']); + } +} + +jitSuite(KeywordOn); + +/** + * 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/on-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/on-test.ts new file mode 100644 index 00000000000..c29cdd4f963 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/on-test.ts @@ -0,0 +1,99 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; +import { setModifierManager, modifierCapabilities } from '@glimmer/manager'; + +import { template } from '@ember/template-compiler/runtime'; +import { on } from '@ember/modifier'; + +class KeywordOn extends RenderTest { + static suiteName = 'keyword modifier: on'; + + /** + * We require the babel compiler to emit keywords, so this is actually no different than normal usage + * prior to RFC 997. + * + * We are required to have the compiler that emits this low-level format to detect if on is in scope and then + * _not_ add the `on` modifier from `@ember/modifier` import. + */ + @test + 'it works'(assert: Assert) { + let handleClick = () => { + assert.step('success'); + }; + + const compiled = template('', { + strictMode: true, + scope: () => ({ + handleClick, + on, + }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['success']); + } + + @test + 'it works with the runtime compiler'(assert: Assert) { + let handleClick = () => { + assert.step('success'); + }; + + hide(handleClick); + + const compiled = template('', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['success']); + } + + @test + 'can be shadowed'(assert: Assert) { + let on = setModifierManager(() => { + return { + capabilities: modifierCapabilities('3.22'), + createModifier() { + assert.step('shadowed:success'); + }, + installModifier() {}, + updateModifier() {}, + destroyModifier() {}, + }; + }, {}); + + const compiled = template('', { + strictMode: true, + scope: () => ({ on }), + }); + + this.renderComponent(compiled); + + castToBrowser(this.element, 'div').querySelector('button')!.click(); + assert.verifySteps(['shadowed:success']); + } +} + +jitSuite(KeywordOn); + +/** + * 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/package.json b/packages/@glimmer-workspace/integration-tests/test/package.json index 7fda1b061b7..3db79747f72 100644 --- a/packages/@glimmer-workspace/integration-tests/test/package.json +++ b/packages/@glimmer-workspace/integration-tests/test/package.json @@ -20,6 +20,9 @@ "@glimmer/syntax": "workspace:*", "@glimmer/util": "workspace:*", "@glimmer/validator": "workspace:*", - "@glimmer/wire-format": "workspace:*" + "@glimmer/wire-format": "workspace:*", + "@ember/helper": "workspace:*", + "@ember/modifier": "workspace:*", + "@ember/template-compiler": "workspace:*" } } diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index 9ec4eb2b603..57d2204d884 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -31,12 +31,21 @@ export { inTransaction, runtimeOptions, } from './lib/environment'; +export { and } from './lib/helpers/and'; export { array } from './lib/helpers/array'; export { concat } from './lib/helpers/concat'; +export { eq } from './lib/helpers/eq'; export { fn } from './lib/helpers/fn'; export { get } from './lib/helpers/get'; +export { gt } from './lib/helpers/gt'; +export { gte } from './lib/helpers/gte'; export { hash } from './lib/helpers/hash'; export { invokeHelper } from './lib/helpers/invoke'; +export { lt } from './lib/helpers/lt'; +export { lte } from './lib/helpers/lte'; +export { neq } from './lib/helpers/neq'; +export { not } from './lib/helpers/not'; +export { or } from './lib/helpers/or'; export { on } from './lib/modifiers/on'; export { renderComponent, renderMain, renderSync } from './lib/render'; export { DynamicScopeImpl, ScopeImpl } from './lib/scope'; diff --git a/packages/@glimmer/runtime/lib/helpers/and.ts b/packages/@glimmer/runtime/lib/helpers/and.ts new file mode 100644 index 00000000000..5426d975946 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/and.ts @@ -0,0 +1,35 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{and}}` helper to perform logical AND across multiple values. + + ```handlebars + {{if (and this.isActive this.isVerified) "Ready" "Not ready"}} + ``` + + Returns the first falsy value (short-circuits), or the last value if all are truthy. + + @method and + @param {Any} ...values + @return {Any} + @public +*/ +export const and = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let last: unknown; + for (let i = 0; i < positional.length; i++) { + let ref = positional[i]; + last = ref ? valueForRef(ref) : undefined; + if (!last) return last; + } + return last; + }, + null, + 'and' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/eq.ts b/packages/@glimmer/runtime/lib/helpers/eq.ts new file mode 100644 index 00000000000..b6e7e11a525 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/eq.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{eq}}` helper to check strict equality between two values. + + ```handlebars + {{if (eq this.status "active") "Yes" "No"}} + ``` + + Equivalent to `a === b` in JavaScript. + + @method eq + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const eq = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return a === b; + }, + null, + 'eq' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/gt.ts b/packages/@glimmer/runtime/lib/helpers/gt.ts new file mode 100644 index 00000000000..2e43b1289eb --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/gt.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{gt}}` helper to check if one value is greater than another. + + ```handlebars + {{if (gt this.count 10) "Over 10" "10 or under"}} + ``` + + Equivalent to `a > b` in JavaScript. + + @method gt + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const gt = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return (a as number) > (b as number); + }, + null, + 'gt' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/gte.ts b/packages/@glimmer/runtime/lib/helpers/gte.ts new file mode 100644 index 00000000000..e2442a90295 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/gte.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{gte}}` helper to check if one value is greater than or equal to another. + + ```handlebars + {{if (gte this.count 10) "10 or more" "Under 10"}} + ``` + + Equivalent to `a >= b` in JavaScript. + + @method gte + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const gte = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return (a as number) >= (b as number); + }, + null, + 'gte' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/lt.ts b/packages/@glimmer/runtime/lib/helpers/lt.ts new file mode 100644 index 00000000000..13fc23f44bb --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/lt.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{lt}}` helper to check if one value is less than another. + + ```handlebars + {{if (lt this.count 10) "Under 10" "10 or more"}} + ``` + + Equivalent to `a < b` in JavaScript. + + @method lt + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const lt = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return (a as number) < (b as number); + }, + null, + 'lt' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/lte.ts b/packages/@glimmer/runtime/lib/helpers/lte.ts new file mode 100644 index 00000000000..54592b38c91 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/lte.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{lte}}` helper to check if one value is less than or equal to another. + + ```handlebars + {{if (lte this.count 10) "10 or under" "Over 10"}} + ``` + + Equivalent to `a <= b` in JavaScript. + + @method lte + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const lte = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return (a as number) <= (b as number); + }, + null, + 'lte' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/neq.ts b/packages/@glimmer/runtime/lib/helpers/neq.ts new file mode 100644 index 00000000000..77036081fde --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/neq.ts @@ -0,0 +1,32 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{neq}}` helper to check strict inequality between two values. + + ```handlebars + {{if (neq this.status "active") "Not active" "Active"}} + ``` + + Equivalent to `a !== b` in JavaScript. + + @method neq + @param {Any} a + @param {Any} b + @return {Boolean} + @public +*/ +export const neq = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let a = positional[0] ? valueForRef(positional[0]) : undefined; + let b = positional[1] ? valueForRef(positional[1]) : undefined; + return a !== b; + }, + null, + 'neq' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/not.ts b/packages/@glimmer/runtime/lib/helpers/not.ts new file mode 100644 index 00000000000..0a421932fcc --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/not.ts @@ -0,0 +1,30 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{not}}` helper to negate a value using Handlebars truthiness. + + ```handlebars + {{if (not this.isActive) "Inactive" "Active"}} + ``` + + Returns `true` for falsy values, `false` for truthy values. + + @method not + @param {Any} value + @return {Boolean} + @public +*/ +export const not = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let value = positional[0] ? valueForRef(positional[0]) : undefined; + return !value; + }, + null, + 'not' + ); +}); diff --git a/packages/@glimmer/runtime/lib/helpers/or.ts b/packages/@glimmer/runtime/lib/helpers/or.ts new file mode 100644 index 00000000000..7804f0226ae --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/or.ts @@ -0,0 +1,35 @@ +import type { CapturedArguments } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; + +import { internalHelper } from './internal-helper'; + +/** + Use the `{{or}}` helper to perform logical OR across multiple values. + + ```handlebars + {{if (or this.isAdmin this.isModerator) "Has access" "No access"}} + ``` + + Returns the first truthy value (short-circuits), or the last value if all are falsy. + + @method or + @param {Any} ...values + @return {Any} + @public +*/ +export const or = internalHelper(({ positional }: CapturedArguments): Reference => { + return createComputeRef( + () => { + let last: unknown; + for (let i = 0; i < positional.length; i++) { + let ref = positional[i]; + last = ref ? valueForRef(ref) : undefined; + if (last) return last; + } + return last; + }, + null, + 'or' + ); +}); diff --git a/packages/@glimmer/syntax/lib/v2/normalize.ts b/packages/@glimmer/syntax/lib/v2/normalize.ts index 369f081975e..c858f3ce506 100644 --- a/packages/@glimmer/syntax/lib/v2/normalize.ts +++ b/packages/@glimmer/syntax/lib/v2/normalize.ts @@ -2,10 +2,7 @@ import type { PresentArray } from '@glimmer/interfaces'; import { asPresentArray, isPresentArray, localAssert } from '@glimmer/debug-util'; import { assign } from '@glimmer/util'; -import type { - PrecompileOptions, - PrecompileOptionsWithLexicalScope, -} from '../parser/tokenizer-event-handlers'; +import type { PrecompileOptions } from '../parser/tokenizer-event-handlers'; import type { SourceLocation } from '../source/location'; import type { Source } from '../source/source'; import type { SourceSpan } from '../source/span'; @@ -35,7 +32,7 @@ import { export function normalize( source: Source, - options: PrecompileOptionsWithLexicalScope = { lexicalScope: () => false } + options: PrecompileOptions & { lexicalScope?: (variable: string) => boolean } ): [ast: ASTv2.Template, locals: string[]] { let ast = preprocess(source, options); @@ -45,10 +42,12 @@ export function normalize( locals: ast.blockParams, keywords: options.keywords ?? [], }; + let localsSet = new Set(normalizeOptions.locals); + let lexicalScope = options.lexicalScope ?? ((name: string) => localsSet.has(name)); let top = SymbolTable.top(normalizeOptions.locals, normalizeOptions.keywords, { customizeComponentName: options.customizeComponentName ?? ((name) => name), - lexicalScope: options.lexicalScope, + lexicalScope, }); let block = new BlockContext(source, normalizeOptions, top); let normalizer = new StatementNormalizer(block); diff --git a/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts b/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts new file mode 100644 index 00000000000..b89c297c799 --- /dev/null +++ b/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts @@ -0,0 +1,4 @@ +import { INTERNAL_PLUGINS } from '@ember/template-compiler/-internal-primitives'; +import type { ASTPluginBuilder } from '@glimmer/syntax'; + +export default INTERNAL_PLUGINS.AutoImportBuiltins as ASTPluginBuilder; diff --git a/packages/ember-template-compiler/lib/plugins/index.ts b/packages/ember-template-compiler/lib/plugins/index.ts index ad24f63e151..a40843130ba 100644 --- a/packages/ember-template-compiler/lib/plugins/index.ts +++ b/packages/ember-template-compiler/lib/plugins/index.ts @@ -10,6 +10,7 @@ import TransformInElement from './transform-in-element'; import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings'; import TransformResolutions from './transform-resolutions'; import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet'; +import AutoImportBuiltins from './auto-import-builtins'; export const INTERNAL_PLUGINS: Record = { AssertAgainstAttrs, @@ -41,6 +42,7 @@ export const RESOLUTION_MODE_TRANSFORMS: readonly ASTPluginBuilder[] = Object.fr ]); export const STRICT_MODE_TRANSFORMS: readonly ASTPluginBuilder[] = Object.freeze([ + AutoImportBuiltins, TransformQuotedBindingsIntoJustBindings, AssertReservedNamedArguments, TransformActionSyntax, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df73016386..b926a44f131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1151,6 +1151,12 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/helper': + specifier: workspace:* + version: link:../helper + '@ember/modifier': + specifier: workspace:* + version: link:../modifier '@glimmer/compiler': specifier: workspace:* version: link:../../@glimmer/compiler @@ -1407,6 +1413,15 @@ importers: packages/@glimmer-workspace/integration-tests/test: dependencies: + '@ember/helper': + specifier: workspace:* + version: link:../../../@ember/helper + '@ember/modifier': + specifier: workspace:* + version: link:../../../@ember/modifier + '@ember/template-compiler': + specifier: workspace:* + version: link:../../../@ember/template-compiler '@glimmer-workspace/integration-tests': specifier: workspace:* version: link:.. @@ -2810,6 +2825,9 @@ importers: ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.29.0) + ember-modifier: + specifier: ^4.2.2 + version: 4.3.0(@babel/core@7.29.0) ember-page-title: specifier: ^8.2.3 version: 8.2.4(ember-source@) diff --git a/smoke-tests/app-template/package.json b/smoke-tests/app-template/package.json index 8600e5e8205..fc39cc59420 100644 --- a/smoke-tests/app-template/package.json +++ b/smoke-tests/app-template/package.json @@ -42,6 +42,7 @@ "ember-cli-terser": "^4.0.2", "ember-data": "~5.8.1", "ember-load-initializers": "^2.1.2", + "ember-modifier": "^4.2.2", "ember-page-title": "^8.2.3", "ember-qunit": "^8.0.2", "ember-resolver": "^11.0.1", diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index b58d060f79a..4a96f88e1a7 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -258,6 +258,166 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'on-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 message = 'hello'; + louder = () => this.message = this.message + '!'; + + + } + + module('{{on}} as keyword', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + await render(Demo); + assert.dom('button').hasText('hello'); + await click('button'); + assert.dom('button').hasText('hello!'); + }); + }); + `, + 'helper-keywords-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render } from '@ember/test-helpers'; + + import Component from '@glimmer/component'; + import { tracked } from '@glimmer/tracking'; + + class FnDemo extends Component { + greet = (name) => 'Hello, ' + name + '!'; + + + } + + class HashDemo extends Component { + + } + + class ArrayDemo extends Component { + + } + + class EqDemo extends Component { + @tracked status = 'active'; + + + } + + class LogicalDemo extends Component { + + } + + class ComparisonDemo extends Component { + + } + + module('helper keywords', function(hooks) { + setupRenderingTest(hooks); + + test('fn works as keyword', async function(assert) { + await render(FnDemo); + assert.dom('#fn-result').hasText('Hello, World!'); + }); + + test('hash works as keyword', async function(assert) { + await render(HashDemo); + assert.dom('#hash-result').hasText('Ember 6'); + }); + + test('array works as keyword', async function(assert) { + await render(ArrayDemo); + assert.dom('.array-item').exists({ count: 3 }); + }); + + test('eq works as keyword', async function(assert) { + await render(EqDemo); + assert.dom('#eq-result').hasText('yes'); + }); + + test('logical operators work as keywords', async function(assert) { + await render(LogicalDemo); + assert.dom('#and-result').hasText('yes'); + assert.dom('#or-result').hasText('yes'); + assert.dom('#not-result').hasText('yes'); + }); + + test('comparison operators work as keywords', async function(assert) { + await render(ComparisonDemo); + assert.dom('#lt-result').hasText('yes'); + assert.dom('#gt-result').hasText('yes'); + }); + }); + `, + 'on-as-keyword-but-its-shadowed-test.gjs': ` + import QUnit, { 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'; + import { modifier as eModifier } from 'ember-modifier'; + + module('{{on}} as keyword (but it is shadowed)', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + // shadows keyword! + const on = eModifier(() => { + assert.step('shadowed:on:create'); + }); + + class Demo extends Component { + @tracked message = 'hello'; + louder = () => this.message = this.message + '!'; + + + } + + await render(Demo); + assert.verifySteps(['shadowed:on:create']); + + assert.dom('button').hasText('hello'); + await click('button'); + assert.dom('button').hasText('hello', 'not changed because this on modifier does not add event listeners'); + + assert.verifySteps([]); + }); + }); + `, }, }, }); diff --git a/tests/docs/expected.js b/tests/docs/expected.js index 57caa8fb9a3..4fadc311856 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -172,6 +172,7 @@ module.exports = { 'domReady', 'each-in', 'each', + 'eq', 'eachComputedProperty', 'element', 'elementId', @@ -339,6 +340,7 @@ module.exports = { 'name', 'nearestOfType', 'nearestWithProperty', + 'neq', 'next', 'none', 'normalize',