diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index d9139b7b000..482e47ae18f 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -406,7 +406,7 @@ export { OutletState } from './lib/utils/outlet'; export { setComponentManager, setModifierManager, setHelperManager } from './lib/utils/managers'; export { capabilities } from './lib/component-managers/custom'; export { capabilities as modifierCapabilities } from './lib/modifiers/custom'; -export { helperCapabilities, HelperManager } from './lib/helpers/custom'; +export { helperCapabilities, HelperManager, invokeHelper } from './lib/helpers/custom'; export { isSerializationFirstNode } from './lib/utils/serialization-first-node-helpers'; export { setComponentTemplate, getComponentTemplate } from './lib/utils/component-template'; export { CapturedRenderNode } from './lib/utils/debug-render-tree'; diff --git a/packages/@ember/-internals/glimmer/lib/helper.ts b/packages/@ember/-internals/glimmer/lib/helper.ts index 4362b2f16c1..9b0d045244a 100644 --- a/packages/@ember/-internals/glimmer/lib/helper.ts +++ b/packages/@ember/-internals/glimmer/lib/helper.ts @@ -2,12 +2,13 @@ @module @ember/component */ -import { Factory } from '@ember/-internals/owner'; +import { Factory, Owner, setOwner } from '@ember/-internals/owner'; import { FrameworkObject } from '@ember/-internals/runtime'; import { getDebugName, symbol } from '@ember/-internals/utils'; import { join } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; import { Arguments, Dict } from '@glimmer/interfaces'; +import { _WeakSet as WeakSet } from '@glimmer/util'; import { consumeTag, createTag, @@ -38,6 +39,12 @@ export interface SimpleHelper { compute: HelperFunction; } +const CLASSIC_HELPER_MANAGERS = new WeakSet(); + +export function isClassicHelperManager(obj: object) { + return CLASSIC_HELPER_MANAGERS.has(obj); +} + /** Ember Helpers are functions that can compute values, and are used in templates. For example, this code calls a helper named `format-currency`: @@ -145,9 +152,21 @@ class ClassicHelperManager implements HelperManager { hasDestroyable: true, }); - createHelper(definition: ClassHelperFactory, args: Arguments) { + private ownerInjection: object; + + constructor(owner: Owner | undefined) { + CLASSIC_HELPER_MANAGERS.add(this); + let ownerInjection = {}; + setOwner(ownerInjection, owner!); + this.ownerInjection = ownerInjection; + } + + createHelper(definition: ClassHelperFactory | typeof Helper, args: Arguments) { + let instance = + definition.class === undefined ? definition.create(this.ownerInjection) : definition.create(); + return { - instance: definition.create(), + instance, args, }; } @@ -178,9 +197,7 @@ class ClassicHelperManager implements HelperManager { } } -export const CLASSIC_HELPER_MANAGER = new ClassicHelperManager(); - -setHelperManager(() => CLASSIC_HELPER_MANAGER, Helper); +setHelperManager((owner) => new ClassicHelperManager(owner), Helper); /////////// @@ -203,19 +220,21 @@ class SimpleClassicHelperManager implements HelperManager<() => unknown> { }); createHelper(definition: Wrapper, args: Arguments) { + let { compute } = definition; + if (DEBUG) { return () => { let ret; deprecateMutationsInTrackingTransaction!(() => { - ret = definition.compute.call(null, args.positional, args.named); + ret = compute.call(null, args.positional, args.named); }); return ret; }; } - return definition.compute.bind(null, args.positional, args.named); + return () => compute.call(null, args.positional, args.named); } getValue(fn: () => unknown) { diff --git a/packages/@ember/-internals/glimmer/lib/helpers/custom.ts b/packages/@ember/-internals/glimmer/lib/helpers/custom.ts index 912842630b6..bf1b1e17bd5 100644 --- a/packages/@ember/-internals/glimmer/lib/helpers/custom.ts +++ b/packages/@ember/-internals/glimmer/lib/helpers/custom.ts @@ -1,9 +1,20 @@ +import { getOwner } from '@ember/-internals/owner'; +import { getDebugName } from '@ember/-internals/utils'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import { Arguments, Helper as GlimmerHelper } from '@glimmer/interfaces'; import { createComputeRef, UNDEFINED_REFERENCE } from '@glimmer/reference'; +import { + associateDestroyableChild, + EMPTY_ARGS, + EMPTY_NAMED, + EMPTY_POSITIONAL, + isDestroyed, + isDestroying, +} from '@glimmer/runtime'; +import { Cache, createCache, getValue } from '@glimmer/validator'; import { argsProxyFor } from '../utils/args-proxy'; -import { buildCapabilities, InternalCapabilities } from '../utils/managers'; +import { buildCapabilities, getHelperManager, InternalCapabilities } from '../utils/managers'; export type HelperDefinition = object; @@ -63,12 +74,95 @@ function hasDestroyable(manager: HelperManager): manager is HelperManagerWithDes return manager.capabilities.hasDestroyable; } +let ARGS_CACHES = DEBUG ? new WeakMap>>() : undefined; + +function getArgs(proxy: SimpleArgsProxy): Partial { + return getValue(DEBUG ? ARGS_CACHES!.get(proxy)! : proxy.argsCache!)!; +} + +class SimpleArgsProxy { + argsCache?: Cache>; + + constructor( + context: object, + computeArgs: (context: object) => Partial = () => EMPTY_ARGS + ) { + let argsCache = createCache(() => computeArgs(context)); + + if (DEBUG) { + ARGS_CACHES!.set(this, argsCache); + Object.freeze(this); + } else { + this.argsCache = argsCache; + } + } + + get named() { + return getArgs(this).named || EMPTY_NAMED; + } + + get positional() { + return getArgs(this).positional || EMPTY_POSITIONAL; + } +} + +export function invokeHelper( + context: object, + definition: HelperDefinition, + computeArgs: (context: object) => Partial +): Cache { + assert( + `Expected a context object to be passed as the first parameter to invokeHelper, got ${context}`, + context !== null && typeof context === 'object' + ); + + const owner = getOwner(context); + const manager = getHelperManager(owner, definition)!; + + // TODO: figure out why assert isn't using the TS assert thing + assert( + `Expected a helper definition to be passed as the second parameter to invokeHelper, but no helper manager was found. The definition value that was passed was \`${getDebugName!( + definition + )}\`. Did you use setHelperManager to associate a helper manager with this value?`, + manager + ); + + let args = new SimpleArgsProxy(context, computeArgs); + let bucket = manager.createHelper(definition, args); + + let cache: Cache; + + if (hasValue(manager)) { + cache = createCache(() => { + assert( + `You attempted to get the value of a helper after the helper was destroyed, which is not allowed`, + !isDestroying(cache) && !isDestroyed(cache) + ); + + return manager.getValue(bucket); + }); + + associateDestroyableChild(context, cache); + } else { + throw new Error('TODO: unreachable, to be implemented with hasScheduledEffect'); + } + + if (hasDestroyable(manager)) { + let destroyable = manager.getDestroyable(bucket); + + associateDestroyableChild(cache, destroyable); + } + + return cache; +} + export default function customHelper( manager: HelperManager, definition: HelperDefinition ): GlimmerHelper { - return (args, vm) => { - const bucket = manager.createHelper(definition, argsProxyFor(args.capture(), 'helper')); + return (vmArgs, vm) => { + const args = argsProxyFor(vmArgs.capture(), 'helper'); + const bucket = manager.createHelper(definition, args); if (hasDestroyable(manager)) { vm.associateDestroyable(manager.getDestroyable(bucket)); diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 41ff7353bdb..ffb0b8505a7 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -26,9 +26,9 @@ import { InternalComponentDefinition, isInternalManager } from './component-mana import { TemplateOnlyComponentDefinition } from './component-managers/template-only'; import InternalComponent from './components/internal'; import { - CLASSIC_HELPER_MANAGER, HelperFactory, HelperInstance, + isClassicHelperManager, SIMPLE_CLASSIC_HELPER_MANAGER, SimpleHelper, } from './helper'; @@ -384,14 +384,14 @@ export default class RuntimeResolverImpl implements RuntimeResolver | InternalComponentManager> + ManagerFactory | InternalComponentManager> >(); const FROM_CAPABILITIES = DEBUG ? new _WeakSet() : undefined; -const MODIFIER_MANAGERS = new WeakMap>>(); +const MODIFIER_MANAGERS = new WeakMap< + object, + ManagerFactory> +>(); -const HELPER_MANAGERS = new WeakMap>>(); +const HELPER_MANAGERS = new WeakMap< + object, + ManagerFactory> +>(); -const MANAGER_INSTANCES: WeakMap> = new WeakMap(); +const OWNER_MANAGER_INSTANCES: WeakMap> = new WeakMap(); +const UNDEFINED_MANAGER_INSTANCES: WeakMap = new WeakMap(); -export type ManagerFactory = (owner: Owner) => D; +export type ManagerFactory = ( + owner: O +) => D; /////////// @@ -42,10 +51,10 @@ function setManager( return obj; } -function getManager( - map: WeakMap>, +function getManager( + map: WeakMap>, obj: object -): ManagerFactory | undefined { +): ManagerFactory | undefined { let pointer = obj; while (pointer !== undefined && pointer !== null) { const manager = map.get(pointer); @@ -61,21 +70,26 @@ function getManager( } function getManagerInstanceForOwner( - owner: Owner, - factory: ManagerFactory + owner: Owner | undefined, + factory: ManagerFactory ): D { - let managers = MANAGER_INSTANCES.get(owner); + let managers; - if (managers === undefined) { - managers = new WeakMap(); - MANAGER_INSTANCES.set(owner, managers); + if (owner === undefined) { + managers = UNDEFINED_MANAGER_INSTANCES; + } else { + managers = OWNER_MANAGER_INSTANCES.get(owner); + + if (managers === undefined) { + managers = new WeakMap(); + OWNER_MANAGER_INSTANCES.set(owner, managers); + } } let instance = managers.get(factory); if (instance === undefined) { - instance = factory(owner); - + instance = factory(owner!); managers.set(factory, instance!); } @@ -86,14 +100,14 @@ function getManagerInstanceForOwner( /////////// export function setModifierManager( - factory: ManagerFactory>, + factory: ManagerFactory>, definition: object ) { return setManager(MODIFIER_MANAGERS, factory, definition); } export function getModifierManager( - owner: Owner, + owner: Owner | undefined, definition: object ): ModifierManagerDelegate | undefined { const factory = getManager(MODIFIER_MANAGERS, definition); @@ -114,14 +128,14 @@ export function getModifierManager( } export function setHelperManager( - factory: ManagerFactory>, + factory: ManagerFactory>, definition: object ) { return setManager(HELPER_MANAGERS, factory, definition); } export function getHelperManager( - owner: Owner, + owner: Owner | undefined, definition: object ): HelperManager | undefined { const factory = getManager(HELPER_MANAGERS, definition); @@ -145,10 +159,10 @@ export function getHelperManager( export function setComponentManager( stringOrFunction: | string - | ManagerFactory | InternalComponentManager>, + | ManagerFactory | InternalComponentManager>, obj: object ) { - let factory: ManagerFactory | InternalComponentManager>; + let factory: ManagerFactory | InternalComponentManager>; if (COMPONENT_MANAGER_STRING_LOOKUP && typeof stringOrFunction === 'string') { deprecate( 'Passing the name of the component manager to "setupComponentManager" is deprecated. Please pass a function that produces an instance of the manager.', @@ -166,6 +180,7 @@ export function setComponentManager( }; } else { factory = stringOrFunction as ManagerFactory< + Owner, ComponentManagerDelegate | InternalComponentManager >; } @@ -174,10 +189,10 @@ export function setComponentManager( } export function getComponentManager( - owner: Owner, + owner: Owner | undefined, definition: object ): ComponentManagerDelegate | InternalComponentManager | undefined { - const factory = getManager | InternalComponentManager>( + const factory = getManager | InternalComponentManager>( COMPONENT_MANAGERS, definition ); diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/invoke-helper-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/invoke-helper-test.js new file mode 100644 index 00000000000..dc9c788f9d4 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/invoke-helper-test.js @@ -0,0 +1,618 @@ +import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers'; +import { + invokeHelper, + Helper, + helper, + Component as EmberComponent, + setHelperManager, + helperCapabilities, +} from '@ember/-internals/glimmer'; +import { tracked, set } from '@ember/-internals/metal'; +import { getOwner } from '@ember/-internals/owner'; +import { EMBER_GLIMMER_INVOKE_HELPER, EMBER_GLIMMER_HELPER_MANAGER } from '@ember/canary-features'; +import Service, { inject as service } from '@ember/service'; +import { DEBUG } from '@glimmer/env'; +import { getValue } from '@glimmer/validator'; +import { destroy, isDestroyed, registerDestructor } from '@glimmer/runtime'; + +if (EMBER_GLIMMER_INVOKE_HELPER) { + moduleFor( + 'Helpers test: invokeHelper', + class extends RenderingTestCase { + '@test it works with a component'() { + class PlusOneHelper extends Helper { + compute([num]) { + return num + 1; + } + } + + class PlusOne extends EmberComponent { + @tracked number; + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { + positional: [this.number], + }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + this.registerComponent('plus-one', { + template: `{{this.value}}`, + ComponentClass: PlusOne, + }); + + this.render(``, { + value: 4, + }); + + this.assertText('5'); + + runTask(() => this.rerender()); + + this.assertText('5'); + + runTask(() => set(this.context, 'value', 5)); + + this.assertText('6'); + } + + '@test it works with simple helpers'() { + let PlusOneHelper = helper(([num]) => num + 1); + + class PlusOne extends EmberComponent { + @tracked number; + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { + positional: [this.number], + }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + this.registerComponent('plus-one', { + template: `{{this.value}}`, + ComponentClass: PlusOne, + }); + + this.render(``, { + value: 4, + }); + + this.assertText('5'); + + runTask(() => this.rerender()); + + this.assertText('5'); + + runTask(() => set(this.context, 'value', 5)); + + this.assertText('6'); + } + + '@test services can be injected if there is an owner'() { + let numberService; + + this.registerService( + 'number', + class extends Service { + constructor() { + super(...arguments); + numberService = this; + } + + @tracked value = 4; + } + ); + + class PlusOneHelper extends Helper { + @service number; + + compute() { + return this.number.value + 1; + } + } + + class PlusOne extends EmberComponent { + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { + positional: [this.number], + }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + this.registerComponent('plus-one', { + template: `{{this.value}}`, + ComponentClass: PlusOne, + }); + + this.render(``); + + this.assertText('5'); + + runTask(() => this.rerender()); + + this.assertText('5'); + + runTask(() => (numberService.value = 5)); + + this.assertText('6'); + } + + '@test works if there is no owner'(assert) { + class PlusOneHelper extends Helper { + compute([num]) { + return num + 1; + } + } + + class PlusOne { + constructor(number) { + this.number = number; + } + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { positional: [this.number] }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(4); + + assert.notOk(getOwner(instance), 'no owner exists on the wrapper'); + assert.equal(instance.value, 5, 'helper works without an owner'); + } + + '@test tracking for arguments works for tracked properties'(assert) { + let count = 0; + + class PlusOneHelper extends Helper { + compute([num]) { + count++; + return num + 1; + } + } + + class PlusOne { + @tracked number; + + constructor(number) { + this.number = number; + } + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { positional: [this.number] }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(4); + + assert.equal(instance.value, 5, 'helper works'); + assert.equal(instance.value, 5, 'helper works'); + assert.equal(count, 1, 'helper only called once'); + + instance.number = 5; + + assert.equal(instance.value, 6, 'helper works'); + assert.equal(count, 2, 'helper called a second time'); + } + + '@test computeArgs only called when consumed values change'(assert) { + let count = 0; + + class PlusNHelper extends Helper { + compute([num], { n }) { + return num + n; + } + } + + class PlusN { + @tracked number; + @tracked n; + + constructor(number, n) { + this.number = number; + this.n = n; + } + + plusOne = invokeHelper(this, PlusNHelper, () => { + count++; + return { + positional: [this.number], + named: { + n: this.n, + }, + }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusN(4, 1); + + assert.equal(count, 0, 'computeArgs not called yet'); + + assert.equal(instance.value, 5, 'helper works'); + assert.equal(instance.value, 5, 'helper works'); + assert.equal(count, 1, 'computeArgs only called once'); + + instance.number = 5; + + assert.equal(instance.value, 6, 'helper works'); + assert.equal(instance.value, 6, 'helper works'); + assert.equal(count, 2, 'computeArgs called a second time'); + + instance.n = 5; + + assert.equal(instance.value, 10, 'helper works'); + assert.equal(instance.value, 10, 'helper works'); + assert.equal(count, 3, 'computeArgs called a third time'); + } + + '@test helper updates based on internal state changes'(assert) { + let count = 0; + let helper; + + class PlusOneHelper extends Helper { + @tracked number = 4; + + constructor() { + super(...arguments); + helper = this; + } + + compute() { + count++; + return this.number + 1; + } + } + + class PlusOne { + plusOne = invokeHelper(this, PlusOneHelper); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(); + + assert.equal(instance.value, 5, 'helper works'); + assert.equal(instance.value, 5, 'helper works'); + assert.equal(count, 1, 'helper only called once'); + + helper.number = 5; + + assert.equal(instance.value, 6, 'helper works'); + assert.equal(count, 2, 'helper called a second time'); + } + + '@test helper that with constant args is constant'(assert) { + let count = 0; + + class PlusOneHelper extends Helper { + compute([num]) { + count++; + return num + 1; + } + } + + class PlusOne { + number; + + constructor(number) { + this.number = number; + } + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { positional: [this.number] }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(4); + + assert.equal(instance.value, 5, 'helper works'); + assert.equal(instance.value, 5, 'helper works'); + assert.equal(count, 1, 'helper only called once'); + + instance.number = 5; + + assert.equal(instance.value, 5, 'helper works'); + assert.equal(count, 1, 'helper not called a second time'); + } + + '@test helper destroys correctly when context object is destroyed'(assert) { + let context = {}; + let instance; + + class TestHelper extends Helper { + constructor() { + super(...arguments); + instance = this; + } + } + + let cache = invokeHelper(context, TestHelper); + + registerDestructor(context, () => assert.step('context')); + registerDestructor(cache, () => assert.step('cache')); + registerDestructor(instance, () => assert.step('instance')); + + runTask(() => destroy(context)); + + assert.ok(isDestroyed(context), 'context destroyed'); + assert.ok(isDestroyed(cache), 'cache destroyed'); + assert.ok(isDestroyed(instance), 'instance destroyed'); + + assert.verifySteps(['instance', 'cache', 'context'], 'destructors ran in correct order'); + } + + '@test helper destroys correctly when helper cache is destroyed'(assert) { + let context = {}; + let instance; + + class TestHelper extends Helper { + constructor() { + super(...arguments); + instance = this; + } + } + + let cache = invokeHelper(context, TestHelper); + + registerDestructor(context, () => assert.step('context')); + registerDestructor(cache, () => assert.step('cache')); + registerDestructor(instance, () => assert.step('instance')); + + runTask(() => destroy(cache)); + + assert.notOk(isDestroyed(context), 'context NOT destroyed'); + assert.ok(isDestroyed(cache), 'cache destroyed'); + assert.ok(isDestroyed(instance), 'instance destroyed'); + + assert.verifySteps(['instance', 'cache'], 'destructors ran in correct order'); + } + + '@test simple helper destroys correctly when context object is destroyed'(assert) { + let context = {}; + + let TestHelper = helper(() => {}); + let cache = invokeHelper(context, TestHelper); + + registerDestructor(context, () => assert.step('context')); + registerDestructor(cache, () => assert.step('cache')); + + runTask(() => destroy(context)); + + assert.ok(isDestroyed(context), 'context destroyed'); + assert.ok(isDestroyed(cache), 'cache destroyed'); + + assert.verifySteps(['cache', 'context'], 'destructors ran in correct order'); + } + + '@test throws an error if value is accessed after it is destroyed'() { + expectAssertion(() => { + let helper = invokeHelper({}, class extends Helper {}); + + runTask(() => destroy(helper)); + + getValue(helper); + }, /You attempted to get the value of a helper after the helper was destroyed, which is not allowed/); + } + + '@test asserts if no context object is passed'() { + expectAssertion(() => { + invokeHelper(undefined, class extends Helper {}); + }, /Expected a context object to be passed as the first parameter to invokeHelper, got undefined/); + } + + '@test asserts if no manager exists for the helper definition'() { + expectAssertion(() => { + invokeHelper({}, class {}); + }, /Expected a helper definition to be passed as the second parameter to invokeHelper, but no helper manager was found. The definition value that was passed was `\(unknown function\)`. Did you use setHelperManager to associate a helper manager with this value?/); + } + } + ); +} + +if (EMBER_GLIMMER_HELPER_MANAGER && EMBER_GLIMMER_INVOKE_HELPER) { + class TestHelperManager { + capabilities = helperCapabilities('3.23', { + hasValue: true, + hasDestroyable: true, + }); + + createHelper(Helper, args) { + return new Helper(args); + } + + getValue(instance) { + return instance.value(); + } + + getDestroyable(instance) { + return instance; + } + } + + class TestHelper { + constructor(args) { + this.args = args; + + registerDestructor(this, () => this.willDestroy()); + } + + willDestroy() {} + } + + setHelperManager((owner) => new TestHelperManager(owner), TestHelper); + + moduleFor( + 'Helpers test: invokeHelper with custom helper managers', + class extends RenderingTestCase { + '@test it works with custom helper managers'() { + class PlusOneHelper extends TestHelper { + value() { + return this.args.positional[0] + 1; + } + } + + class PlusOne extends EmberComponent { + @tracked number; + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { + positional: [this.number], + }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + this.registerComponent('plus-one', { + template: `{{this.value}}`, + ComponentClass: PlusOne, + }); + + this.render(``, { + value: 4, + }); + + this.assertText('5'); + + runTask(() => this.rerender()); + + this.assertText('5'); + + runTask(() => set(this.context, 'value', 5)); + + this.assertText('6'); + } + + '@test helper that accesses no args is constant'(assert) { + let count = 0; + + class PlusOneHelper extends TestHelper { + value() { + count++; + return 123; + } + } + + class PlusOne { + @tracked number; + + constructor(number) { + this.number = number; + } + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { positional: [this.number] }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(4); + + assert.equal(instance.value, 123, 'helper works'); + assert.equal(instance.value, 123, 'helper works'); + assert.equal(count, 1, 'helper only called once'); + + instance.number = 5; + + assert.equal(instance.value, 123, 'helper works'); + assert.equal(count, 1, 'helper not called a second time'); + } + + '@test helper destroys correctly when context object is destroyed'(assert) { + let context = {}; + let instance; + + class MyTestHelper extends TestHelper { + constructor() { + super(...arguments); + instance = this; + } + } + + let cache = invokeHelper(context, MyTestHelper); + + registerDestructor(context, () => assert.step('context')); + registerDestructor(cache, () => assert.step('cache')); + registerDestructor(instance, () => assert.step('instance')); + + runTask(() => destroy(context)); + + assert.ok(isDestroyed(context), 'context destroyed'); + assert.ok(isDestroyed(cache), 'cache destroyed'); + assert.ok(isDestroyed(instance), 'instance destroyed'); + + assert.verifySteps(['instance', 'cache', 'context'], 'destructors ran in correct order'); + } + + '@test args are frozen in debug builds'(assert) { + if (!DEBUG) { + assert.expect(0); + } else { + class PlusOneHelper extends TestHelper { + value() { + this.args.foo = 123; + } + } + + class PlusOne { + number; + + constructor(number) { + this.number = number; + } + + plusOne = invokeHelper(this, PlusOneHelper, () => { + return { positional: [this.number] }; + }); + + get value() { + return getValue(this.plusOne); + } + } + + let instance = new PlusOne(4); + + assert.throws( + () => instance.value, + /TypeError: Cannot add property foo, object is not extensible/ + ); + } + } + } + ); +} diff --git a/packages/@ember/canary-features/index.ts b/packages/@ember/canary-features/index.ts index 595761d2948..2d93607ea89 100644 --- a/packages/@ember/canary-features/index.ts +++ b/packages/@ember/canary-features/index.ts @@ -22,6 +22,7 @@ export const DEFAULT_FEATURES = { EMBER_CACHE_API: true, EMBER_DESTROYABLES: true, EMBER_GLIMMER_HELPER_MANAGER: null, + EMBER_GLIMMER_INVOKE_HELPER: null, }; /** @@ -81,3 +82,4 @@ export const EMBER_GLIMMER_IN_ELEMENT = featureValue(FEATURES.EMBER_GLIMMER_IN_E export const EMBER_CACHE_API = featureValue(FEATURES.EMBER_CACHE_API); export const EMBER_DESTROYABLES = featureValue(FEATURES.EMBER_DESTROYABLES); export const EMBER_GLIMMER_HELPER_MANAGER = featureValue(FEATURES.EMBER_GLIMMER_HELPER_MANAGER); +export const EMBER_GLIMMER_INVOKE_HELPER = featureValue(FEATURES.EMBER_GLIMMER_INVOKE_HELPER); diff --git a/packages/ember/index.js b/packages/ember/index.js index f1c777877fe..5b1e1bc324d 100644 --- a/packages/ember/index.js +++ b/packages/ember/index.js @@ -11,6 +11,7 @@ import { FEATURES, isEnabled, EMBER_GLIMMER_HELPER_MANAGER, + EMBER_GLIMMER_INVOKE_HELPER, EMBER_GLIMMER_SET_COMPONENT_TEMPLATE, EMBER_CACHE_API, EMBER_DESTROYABLES, @@ -112,6 +113,7 @@ import { helper, helperCapabilities, htmlSafe, + invokeHelper, isHTMLSafe, LinkComponent, setTemplates, @@ -569,6 +571,9 @@ if (EMBER_GLIMMER_HELPER_MANAGER) { Ember._helperManagerCapabilities = helperCapabilities; Ember._setHelperManager = setHelperManager; } +if (EMBER_GLIMMER_INVOKE_HELPER) { + Ember._invokeHelper = invokeHelper; +} Ember._captureRenderTree = captureRenderTree; Ember.Handlebars = { template, diff --git a/packages/ember/tests/reexports_test.js b/packages/ember/tests/reexports_test.js index edd279a9af1..e7fe8069c6e 100644 --- a/packages/ember/tests/reexports_test.js +++ b/packages/ember/tests/reexports_test.js @@ -2,6 +2,7 @@ import Ember from '../index'; import { FEATURES, EMBER_GLIMMER_HELPER_MANAGER, + EMBER_GLIMMER_INVOKE_HELPER, EMBER_GLIMMER_SET_COMPONENT_TEMPLATE, } from '@ember/canary-features'; import { confirmExport } from 'internal-test-helpers'; @@ -236,6 +237,9 @@ let allExports = [ EMBER_GLIMMER_HELPER_MANAGER ? ['_helperManagerCapabilities', '@ember/-internals/glimmer', 'helperCapabilities'] : null, + EMBER_GLIMMER_INVOKE_HELPER + ? ['_invokeHelper', '@ember/-internals/glimmer', 'invokeHelper'] + : null, ['_captureRenderTree', '@ember/debug', 'captureRenderTree'], // @ember/-internals/runtime