diff --git a/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts b/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts
index 6aacd407591..0f9920c3d56 100644
--- a/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts
+++ b/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts
@@ -1440,7 +1440,166 @@ class BuiltInsStrictModeTest extends RenderTest {
}
}
+class AttributeStrictModeTest extends RenderTest {
+ static suiteName = 'strict mode: attribute position';
+
+ @test
+ 'Can use a helper in attribute position (without args)'() {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent({ works }, '
');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can use a helper in attribute interpolation (without args)'() {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent({ works }, '');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can use a plain function in attribute position (without args)'() {
+ const works = () => 'works';
+ const Foo = defineComponent({ works }, '');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can use a helper in attribute position on a component (without args)'() {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent({}, '');
+ const Bar = defineComponent({ works, Foo }, '');
+
+ this.renderComponent(Bar);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Helpers in attribute position update when their dependencies change'() {
+ const obj = trackedObj({ value: 'red' });
+ const color = defineSimpleHelper(() => obj['value']);
+ const Foo = defineComponent({ color }, '');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+
+ obj['value'] = 'blue';
+
+ this.rerender();
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Non-helper values in attribute position are used directly'() {
+ const value = 'works';
+ const Foo = defineComponent({ value }, '');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test({ skip: !DEBUG })
+ 'Component definitions in attribute position are an error'() {
+ const Foo = defineComponent({}, 'Hello, world!');
+ const Bar = defineComponent({ Foo }, '');
+
+ this.assert.throws(() => {
+ this.renderComponent(Bar);
+ }, /Attempted to use a component as the value of an attribute, but only values and helpers are valid in attribute position./u);
+ }
+
+ @test
+ 'Paths on lexical variables in attribute position are used as values'() {
+ const styles = { works: 'works' };
+ const Foo = defineComponent(
+ { styles },
+ ''
+ );
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can call a helper in attribute position (with args)'() {
+ const echo = defineSimpleHelper((value: string) => value);
+ const Foo = defineComponent(
+ { echo },
+ ''
+ );
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can call a helper in attribute position as a subexpression (without args)'() {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent(
+ { works },
+ ''
+ );
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Can use a helper in trusting attribute position (without args)'() {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent({ works }, '');
+
+ this.renderComponent(Foo);
+ this.assertHTML('');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'Helpers in argument position (without parens) are passed by value'(assert: Assert) {
+ const works = defineSimpleHelper(() => 'works');
+ const Foo = defineComponent({}, '{{this.receivedFunction}}', {
+ definition: class extends GlimmerishComponent {
+ get receivedFunction() {
+ assert.strictEqual(
+ typeof this.args['value'],
+ 'function',
+ 'the argument is a function, not an invoked result'
+ );
+ assert.strictEqual(
+ (this.args as { value: unknown })['value'],
+ works,
+ 'the helper itself was passed as the argument'
+ );
+ return String(typeof this.args['value'] === 'function');
+ }
+ },
+ });
+ const Bar = defineComponent({ works, Foo }, '');
+
+ this.renderComponent(Bar);
+ this.assertHTML('true');
+ this.assertStableRerender();
+ }
+}
+
jitSuite(GeneralStrictModeTest);
jitSuite(StaticStrictModeTest);
jitSuite(DynamicStrictModeTest);
jitSuite(BuiltInsStrictModeTest);
+jitSuite(AttributeStrictModeTest);
diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts
index f2ef561eda3..84b7348eda1 100644
--- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts
+++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/vm.ts
@@ -1,8 +1,10 @@
import type { CurriedType, NonSmallIntOperand, Nullable, WireFormat } from '@glimmer/interfaces';
+import { DEBUG } from '@glimmer/env';
import { encodeImmediate, isSmallInt } from '@glimmer/constants/lib/immediate';
import {
VM_BIND_DYNAMIC_SCOPE_OP,
VM_CAPTURE_ARGS_OP,
+ VM_CONSTANT_REFERENCE_OP,
VM_CURRY_OP,
VM_DUP_OP,
VM_DYNAMIC_HELPER_OP,
@@ -16,9 +18,11 @@ import {
} from '@glimmer/constants/lib/syscall-ops';
import { VM_POP_FRAME_OP, VM_PUSH_FRAME_OP } from '@glimmer/constants/lib/vm-ops';
import { $fp, $v0 } from '@glimmer/vm/lib/registers';
+import { opcodes as SexpOpcodes } from '@glimmer/wire-format/lib/opcodes';
import type { PushExpressionOp, PushStatementOp } from '../../syntax/compilers';
+import { HighLevelResolutionOpcodes } from '../opcodes';
import { isStrictMode, nonSmallIntOperand } from '../operands';
import { expr } from './expr';
import { SimpleArgs } from './shared';
@@ -105,6 +109,47 @@ export function CallDynamic(
}
}
+/**
+ * Compile an expression in attribute-value position (either an entire dynamic
+ * attribute value, or one part of an attribute interpolation).
+ *
+ * In strict mode, a bare curly referencing an in-scope (lexical) binding —
+ * e.g. `class={{foo}}` or `class="prefix {{foo}}"` — is invoked as a
+ * zero-argument helper if its value has an associated helper manager (which
+ * includes plain functions, per RFC 0756). Otherwise the value itself is
+ * used. This matches loose mode, where an attribute curly that resolves to a
+ * helper is always invoked.
+ */
+export function DynamicAttrValue(op: PushExpressionOp, expression: WireFormat.Expression): void {
+ if (
+ Array.isArray(expression) &&
+ expression[0] === SexpOpcodes.GetLexicalSymbol &&
+ expression.length === 2
+ ) {
+ op(HighLevelResolutionOpcodes.OptionalComponentOrHelper, expression, {
+ ifComponent: () => {
+ if (DEBUG) {
+ throw new Error(
+ 'Attempted to use a component as the value of an attribute, but only values and helpers are valid in attribute position.'
+ );
+ }
+
+ PushPrimitiveReference(op, undefined);
+ },
+
+ ifHelper: (handle: number) => {
+ Call(op, handle, null, null);
+ },
+
+ ifValue: (handle: number) => {
+ op(VM_CONSTANT_REFERENCE_OP, handle);
+ },
+ });
+ } else {
+ expr(op, expression);
+ }
+}
+
/**
* Evaluate statements in the context of new dynamic scope entries. Move entries from the
* stack into named entries in the dynamic scope, then evaluate the statements, then pop
diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts b/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts
index 1815ea34494..3e3f61ea2f3 100644
--- a/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts
+++ b/packages/@glimmer/opcode-compiler/lib/syntax/expressions.ts
@@ -23,7 +23,13 @@ import type { PushExpressionOp } from './compilers';
import { expr } from '../opcode-builder/helpers/expr';
import { isGetFreeHelper } from '../opcode-builder/helpers/resolution';
import { SimpleArgs } from '../opcode-builder/helpers/shared';
-import { Call, CallDynamic, Curry, PushPrimitiveReference } from '../opcode-builder/helpers/vm';
+import {
+ Call,
+ CallDynamic,
+ Curry,
+ DynamicAttrValue,
+ PushPrimitiveReference,
+} from '../opcode-builder/helpers/vm';
import { HighLevelResolutionOpcodes } from '../opcode-builder/opcodes';
import { Compilers } from './compilers';
@@ -31,7 +37,7 @@ export const EXPRESSIONS = new Compilers
EXPRESSIONS.add(SexpOpcodes.Concat, (op, [, parts]) => {
for (let part of parts) {
- expr(op, part);
+ DynamicAttrValue(op, part);
}
op(VM_CONCAT_OP, parts.length);
diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts
index cfafe8dd329..6f735730c0f 100644
--- a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts
+++ b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts
@@ -67,6 +67,7 @@ import { CompilePositional, SimpleArgs } from '../opcode-builder/helpers/shared'
import {
Call,
CallDynamic,
+ DynamicAttrValue,
DynamicScope,
PushPrimitiveReference,
} from '../opcode-builder/helpers/vm';
@@ -123,22 +124,22 @@ STATEMENTS.add(SexpOpcodes.StaticComponentAttr, (op, [, name, value, namespace])
});
STATEMENTS.add(SexpOpcodes.DynamicAttr, (op, [, name, value, namespace]) => {
- expr(op, value);
+ DynamicAttrValue(op, value);
op(VM_DYNAMIC_ATTR_OP, inflateAttrName(name), false, namespace ?? null);
});
STATEMENTS.add(SexpOpcodes.TrustingDynamicAttr, (op, [, name, value, namespace]) => {
- expr(op, value);
+ DynamicAttrValue(op, value);
op(VM_DYNAMIC_ATTR_OP, inflateAttrName(name), true, namespace ?? null);
});
STATEMENTS.add(SexpOpcodes.ComponentAttr, (op, [, name, value, namespace]) => {
- expr(op, value);
+ DynamicAttrValue(op, value);
op(VM_COMPONENT_ATTR_OP, inflateAttrName(name), false, namespace ?? null);
});
STATEMENTS.add(SexpOpcodes.TrustingComponentAttr, (op, [, name, value, namespace]) => {
- expr(op, value);
+ DynamicAttrValue(op, value);
op(VM_COMPONENT_ATTR_OP, inflateAttrName(name), true, namespace ?? null);
});