diff --git a/packages/@ember/-internals/glimmer/lib/helpers/-disallow-dynamic-resolution.ts b/packages/@ember/-internals/glimmer/lib/helpers/-disallow-dynamic-resolution.ts new file mode 100644 index 00000000000..7d761c5c768 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/-disallow-dynamic-resolution.ts @@ -0,0 +1,57 @@ +/** +@module ember +*/ +import { assert } from '@ember/debug'; +import { CapturedArguments } from '@glimmer/interfaces'; +import { createComputeRef, valueForRef } from '@glimmer/reference'; +import { internalHelper } from './internal-helper'; + +export default internalHelper(({ positional, named }: CapturedArguments) => { + assert( + `[BUG] wrong number of positional arguments, expecting 1, got ${positional.length}`, + positional.length === 1 + ); + + let nameOrValueRef = positional[0]; + + assert(`[BUG] expecting \`type\` named argument`, 'type' in named); + assert(`[BUG] expecting \`loc\` named argument`, 'loc' in named); + assert(`[BUG] expecting \`original\` named argument`, 'original' in named); + + let typeRef = named.type; + let locRef = named.loc; + let originalRef = named.original; + + // Bug: why do these fail? + // assert('[BUG] expecting a string literal for the `type` argument', isConstRef(typeRef)); + // assert('[BUG] expecting a string literal for the `loc` argument', isConstRef(locRef)); + // assert('[BUG] expecting a string literal for the `original` argument', isConstRef(originalRef)); + + const type = valueForRef(typeRef); + const loc = valueForRef(locRef); + const original = valueForRef(originalRef); + + assert('[BUG] expecting a string literal for the `type` argument', typeof type === 'string'); + assert('[BUG] expecting a string literal for the `loc` argument', typeof loc === 'string'); + assert( + '[BUG] expecting a string literal for the `original` argument', + typeof original === 'string' + ); + + return createComputeRef(() => { + let nameOrValue = valueForRef(nameOrValueRef); + + assert( + `Passing a dynamic string to the \`(${type})\` keyword is disallowed. ` + + `(You specified \`(${type} ${original})\` and \`${original}\` evaluated into "${nameOrValue}".) ` + + `This ensures we can statically analyze the template and determine which ${type}s are used. ` + + `If the ${type} name is always the same, use a string literal instead, i.e. \`(${type} "${nameOrValue}")\`. ` + + `Otherwise, import the ${type}s into JavaScript and pass them to the ${type} keyword. ` + + 'See https://github.com/emberjs/rfcs/blob/master/text/0496-handlebars-strict-mode.md#4-no-dynamic-resolution for details. ' + + loc, + typeof nameOrValue !== 'string' + ); + + return nameOrValue; + }); +}); diff --git a/packages/@ember/-internals/glimmer/lib/helpers/-resolve.ts b/packages/@ember/-internals/glimmer/lib/helpers/-resolve.ts new file mode 100644 index 00000000000..23ed3da1a6a --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/-resolve.ts @@ -0,0 +1,38 @@ +/** +@module ember +*/ +import { Owner } from '@ember/-internals/owner'; +import { assert, runInDebug } from '@ember/debug'; +import { CapturedArguments } from '@glimmer/interfaces'; +import { createConstRef, isConstRef, valueForRef } from '@glimmer/reference'; +import { internalHelper } from './internal-helper'; + +export default internalHelper(({ positional }: CapturedArguments, owner: Owner | undefined) => { + // why is this allowed to be undefined in the first place? + assert('[BUG] missing owner', owner); + + assert( + `[BUG] wrong number of positional arguments, expecting 1, got ${positional.length}`, + positional.length === 1 + ); + + let fullNameRef = positional[0]; + + assert('[BUG] expecting a string literal as argument', isConstRef(fullNameRef)); + + const fullName = valueForRef(fullNameRef); + + assert('[BUG] expecting a string literal as argument', typeof fullName === 'string'); + assert('[BUG] expecting a valid full name', fullName.split(':').length === 2); + + runInDebug(() => { + let [type, name] = fullName.split(':'); + + assert( + `Attempted to invoke \`(-resolve "${fullName}")\`, but ${name} was not a valid ${type} name.`, + owner.hasRegistration(fullName) + ); + }); + + return createConstRef(owner.factoryFor(fullName)?.class, `(-resolve "${fullName}")`); +}); diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 8c87ec67c64..c20abe47c5f 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -42,8 +42,10 @@ import { isClassicHelper, SimpleHelper, } from './helper'; +import { default as disallowDynamicResolution } from './helpers/-disallow-dynamic-resolution'; import { default as inElementNullCheckHelper } from './helpers/-in-element-null-check'; import { default as normalizeClassHelper } from './helpers/-normalize-class'; +import { default as resolve } from './helpers/-resolve'; import { default as trackArray } from './helpers/-track-array'; import { default as action } from './helpers/action'; import { default as eachIn } from './helpers/each-in'; @@ -181,12 +183,27 @@ const BUILTIN_KEYWORD_HELPERS = { '-hash': hash, '-each-in': eachIn, '-normalize-class': normalizeClassHelper, + '-resolve': resolve, '-track-array': trackArray, '-mount': mountHelper, '-outlet': outletHelper, '-in-el-null': inElementNullCheckHelper, }; +if (DEBUG) { + BUILTIN_KEYWORD_HELPERS['-disallow-dynamic-resolution'] = disallowDynamicResolution; +} else { + // Bug: this may be a quirk of our test setup? + // In prod builds, this is a no-op helper and is unused in practice. We shouldn't need + // to add it at all, but the current test build doesn't produce a "prod compiler", so + // we ended up running the debug-build for the template compliler in prod tests. Once + // that is fixed, this can be removed. For now, this allows the test to work and does + // not really harm anything, since it's just a no-op pass-through helper and the bytes + // has to be included anyway. In the future, perhaps we can avoid the latter by using + // `import(...)`? + BUILTIN_KEYWORD_HELPERS['-disallow-dynamic-resolution'] = disallowDynamicResolution; +} + const BUILTIN_HELPERS = { ...BUILTIN_KEYWORD_HELPERS, array, diff --git a/packages/@ember/-internals/glimmer/tests/integration/custom-modifier-manager-test.js b/packages/@ember/-internals/glimmer/tests/integration/custom-modifier-manager-test.js index dd92643ce33..00940dc839f 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/custom-modifier-manager-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/custom-modifier-manager-test.js @@ -581,6 +581,54 @@ moduleFor( assert.equal(updateCount, 1); } + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Can resolve a modifier'() { + this.registerModifier( + 'replace', + defineSimpleModifier((element, [text]) => (element.innerHTML = text ?? 'Hello, world!')) + ); + + // BUG: this should work according to the RFC + // this.render( + // '[
Nope
][
Nope
]' + // ); + this.render( + '[
Nope
][
Nope
]' + ); + this.assertText('[Hello, world!][wow]'); + this.assertStableRerender(); + } + + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Cannot dynamically resolve a modifier'(assert) { + this.registerModifier( + 'replace', + defineSimpleModifier((element) => (element.innerHTML = 'Hello, world!')) + ); + + if (DEBUG) { + expectAssertion( + () => + this.render( + // BUG: this should work according to the RFC + // '
Nope
', + '
Nope
', + { name: 'replace' } + ), + /Passing a dynamic string to the `\(modifier\)` keyword is disallowed\./ + ); + } else { + // Bug: the VM should throw here, but doesn't? + // assert.throws(() => + // this.render( + // // BUG: this should work according to the RFC + // // '
Nope
', + // '
Nope
', + // { name: 'replace' } + // ) + // ); + assert.expect(0); + } + } + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Can be curried'() { let val = defineSimpleModifier((element, [text]) => (element.innerHTML = text)); diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/custom-helper-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/custom-helper-test.js index 08d55ef94b9..ba9cd80b5ca 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/helpers/custom-helper-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/custom-helper-test.js @@ -10,6 +10,7 @@ import { import { Helper, Component } from '@ember/-internals/glimmer'; import { set, tracked } from '@ember/-internals/metal'; import { backtrackingMessageFor } from '../../utils/debug-stack'; +import { assert } from 'qunit'; moduleFor( 'Helpers test: custom helpers', @@ -780,6 +781,27 @@ moduleFor( }, expectedMessage); } + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Can resolve a helper'() { + this.registerHelper('hello-world', ([text]) => text ?? 'Hello, world!'); + + this.render('[{{helper "hello-world"}}][{{helper (helper "hello-world") "wow"}}]'); + this.assertText('[Hello, world!][wow]'); + this.assertStableRerender(); + } + + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Cannot dynamically resolve a helper'(assert) { + this.registerHelper('hello-world', () => 'Hello, world!'); + + if (DEBUG) { + expectAssertion( + () => this.render('{{helper this.name}}', { name: 'hello-world' }), + /Passing a dynamic string to the `\(helper\)` keyword is disallowed\./ + ); + } else { + assert.throws(() => this.render('{{helper this.name}}', { name: 'hello-world' })); + } + } + '@feature(EMBER_DYNAMIC_HELPERS_AND_MODIFIERS) Can use a curried dynamic helper'() { let val = defineSimpleHelper((value) => value); diff --git a/packages/ember-template-compiler/lib/plugins/index.ts b/packages/ember-template-compiler/lib/plugins/index.ts index 4b8ac4fe831..2efa104176a 100644 --- a/packages/ember-template-compiler/lib/plugins/index.ts +++ b/packages/ember-template-compiler/lib/plugins/index.ts @@ -14,6 +14,7 @@ import TransformInElement from './transform-in-element'; import TransformLinkTo from './transform-link-to'; import TransformOldClassBindingSyntax from './transform-old-class-binding-syntax'; import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings'; +import TransformResolutions from './transform-resolutions'; import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet'; import { EMBER_DYNAMIC_HELPERS_AND_MODIFIERS, EMBER_NAMED_BLOCKS } from '@ember/canary-features'; @@ -38,7 +39,9 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze( DeprecateWith, SEND_ACTION ? DeprecateSendAction : null, !EMBER_NAMED_BLOCKS ? AssertAgainstNamedBlocks : null, - !EMBER_DYNAMIC_HELPERS_AND_MODIFIERS ? AssertAgainstDynamicHelpersModifiers : null, + EMBER_DYNAMIC_HELPERS_AND_MODIFIERS + ? TransformResolutions + : AssertAgainstDynamicHelpersModifiers, ].filter(notNull) ); diff --git a/packages/ember-template-compiler/lib/plugins/transform-resolutions.ts b/packages/ember-template-compiler/lib/plugins/transform-resolutions.ts new file mode 100644 index 00000000000..14f3849f1ce --- /dev/null +++ b/packages/ember-template-compiler/lib/plugins/transform-resolutions.ts @@ -0,0 +1,198 @@ +import { assert } from '@ember/debug'; +import { DEBUG } from '@glimmer/env'; +import { AST, ASTPlugin, print } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import { EmberASTPluginEnvironment } from '../types'; +import { isPath, isStringLiteral, trackLocals } from './utils'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that replaces all instances of + + ```handlebars + {{helper "..." ...}} + ``` + + with + + ```handlebars + {{helper (-resolve "helper:...") ...}} + ``` + + and + + ```handlebars + {{helper ... ...}} + ``` + + with + + ```handlebars + {{helper (-disallow-dynamic-resolution ...) ...}} + ``` + + and + + ```handlebars + {{modifier "..." ...}} + ``` + + with + + ```handlebars + {{modifier (-resolve "modifier:...") ...}} + ``` + and + + ```handlebars + {{modifier ... ...}} + ``` + + with + + ```handlebars + {{modifier (-disallow-dynamic-resolution ...) ...}} + ``` + + @private + @class TransformResolutions +*/ + +const TARGETS = Object.freeze(['helper', 'modifier']); + +export default function transformResolutions(env: EmberASTPluginEnvironment): ASTPlugin { + let { builders: b } = env.syntax; + let { moduleName } = env.meta; + let { hasLocal, node: tracker } = trackLocals(); + let seen: Set | undefined; + + return { + name: 'transform-resolutions', + + visitor: { + Template: { + enter() { + seen = new Set(); + }, + + exit() { + seen = undefined; + }, + }, + + Block: tracker, + + ElementNode: { + keys: { + children: tracker, + }, + }, + + MustacheStatement(node: AST.MustacheStatement): AST.Node | void { + assert('[BUG] seen set should be available', seen); + + if (seen.has(node)) { + return; + } + + if ( + isPath(node.path) && + !isLocalVariable(node.path, hasLocal) && + TARGETS.indexOf(node.path.original) !== -1 + ) { + let result = b.mustache( + node.path, + transformParams(b, node.params, node.path.original, moduleName, node.loc), + node.hash, + node.trusting, + node.loc, + node.strip + ); + + // Avoid double/infinite-processing + seen.add(result); + + return result; + } + }, + SubExpression(node: AST.SubExpression): AST.Node | void { + assert('[BUG] seen set should be available', seen); + + if (seen.has(node)) { + return; + } + + if ( + isPath(node.path) && + !isLocalVariable(node.path, hasLocal) && + TARGETS.indexOf(node.path.original) !== -1 + ) { + let result = b.sexpr( + node.path, + transformParams(b, node.params, node.path.original, moduleName, node.loc), + node.hash, + node.loc + ); + + // Avoid double/infinite-processing + seen.add(result); + + return result; + } + }, + }, + }; +} + +function isLocalVariable(node: AST.PathExpression, hasLocal: (k: string) => boolean): boolean { + return !node.this && node.parts.length === 1 && hasLocal(node.parts[0]); +} + +function transformParams( + b: EmberASTPluginEnvironment['syntax']['builders'], + params: AST.Expression[], + type: string, + moduleName: string, + loc: AST.SourceLocation | undefined +): AST.Expression[] { + let [first, ...rest] = params; + + assert( + `The ${type} keyword requires at least one positional arguments ${calculateLocationDisplay( + moduleName, + loc + )}`, + first + ); + + if (isStringLiteral(first)) { + return [ + b.sexpr( + b.path('-resolve', first.loc), + [b.string(`${type}:${first.value}`)], + undefined, + first.loc + ), + ...rest, + ]; + } else if (DEBUG) { + return [ + b.sexpr( + b.path('-disallow-dynamic-resolution', first.loc), + [first], + b.hash([ + b.pair('type', b.string(type), first.loc), + b.pair('loc', b.string(calculateLocationDisplay(moduleName, loc)), first.loc), + b.pair('original', b.string(print(first))), + ]), + first.loc + ), + ...rest, + ]; + } else { + return params; + } +} diff --git a/packages/ember-template-compiler/lib/plugins/utils.ts b/packages/ember-template-compiler/lib/plugins/utils.ts index fa310f00e31..a2e42b39ca0 100644 --- a/packages/ember-template-compiler/lib/plugins/utils.ts +++ b/packages/ember-template-compiler/lib/plugins/utils.ts @@ -8,18 +8,22 @@ export function isSubExpression(node: AST.Node): node is AST.SubExpression { return node.type === 'SubExpression'; } +export function isStringLiteral(node: AST.Expression): node is AST.StringLiteral { + return node.type === 'StringLiteral'; +} + export function trackLocals() { let locals = new Map(); let node = { - enter(node: AST.Program | AST.ElementNode) { + enter(node: AST.Program | AST.Block | AST.ElementNode) { for (let param of node.blockParams) { let value = locals.get(param) || 0; locals.set(param, value + 1); } }, - exit(node: AST.Program | AST.ElementNode) { + exit(node: AST.Program | AST.Block | AST.ElementNode) { for (let param of node.blockParams) { let value = locals.get(param) - 1;