From ff4dd178008253d81ff8f5db368f8c62b5aa0d79 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Sun, 27 Sep 2020 20:55:05 -0700 Subject: [PATCH] [FEAT] Implements Helper Managers Implements the helper manager feature specified in RFC 625. Highlights: 1. Generalizes the args proxy for use in both components and helpers. This does change the semantics slightly of `positional` arguments for component managers, notably it would make it so using computed properties with them would require a bit more work from custom component managers. The new semantics are more inline with the future of Ember, but if there many custom managers that use positional args we may want to reconsider this change. 2. Adds `getDebugName` to the interface for helper managers. This is an optional hook that is used for better logging purposes, and matches other internal APIs we've added recently. 3. `hasScheduledEffect` has not yet been implemented, and attempting to use it will cause an assertion to be thrown. Helper managers are not exposed with this PR, and the version passed to `helperCapabilities` is optimistic, but can be changed when we do expose them (along with an appropriate feature flag). --- packages/@ember/-internals/glimmer/index.ts | 8 +- .../glimmer/lib/component-managers/custom.ts | 126 +------- .../@ember/-internals/glimmer/lib/helper.ts | 107 ++++++- .../-internals/glimmer/lib/helpers/custom.ts | 86 ++++++ .../@ember/-internals/glimmer/lib/resolver.ts | 45 ++- .../glimmer/lib/utils/args-proxy.ts | 184 +++++++++++ .../-internals/glimmer/lib/utils/managers.ts | 26 +- .../glimmer/lib/utils/references.ts | 40 --- .../custom-component-manager-test.js | 8 +- .../helpers/helper-manager-test.js | 291 ++++++++++++++++++ .../lib/test-cases/rendering.js | 4 + 11 files changed, 720 insertions(+), 205 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/helpers/custom.ts create mode 100644 packages/@ember/-internals/glimmer/lib/utils/args-proxy.ts delete mode 100644 packages/@ember/-internals/glimmer/lib/utils/references.ts create mode 100644 packages/@ember/-internals/glimmer/tests/integration/helpers/helper-manager-test.js diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index 0a65246c8a2..d9139b7b000 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -403,14 +403,10 @@ export { default as AbstractComponentManager } from './lib/component-managers/ab export { INVOKE } from './lib/helpers/action'; export { default as OutletView } from './lib/views/outlet'; export { OutletState } from './lib/utils/outlet'; +export { setComponentManager, setModifierManager, setHelperManager } from './lib/utils/managers'; export { capabilities } from './lib/component-managers/custom'; -export { - setComponentManager, - getComponentManager, - setModifierManager, - getModifierManager, -} from './lib/utils/managers'; export { capabilities as modifierCapabilities } from './lib/modifiers/custom'; +export { helperCapabilities, HelperManager } 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/component-managers/custom.ts b/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts index a93f6ff52fd..ca30f1f4c78 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/custom.ts @@ -1,13 +1,8 @@ import { ENV } from '@ember/-internals/environment'; -import { CUSTOM_TAG_FOR } from '@ember/-internals/metal'; import { Factory } from '@ember/-internals/owner'; -import { HAS_NATIVE_PROXY } from '@ember/-internals/utils'; import { assert } from '@ember/debug'; -import { DEBUG } from '@glimmer/env'; import { Bounds, - CapturedArguments, - CapturedNamedArguments, ComponentCapabilities, ComponentDefinition, Destroyable, @@ -16,13 +11,13 @@ import { VMArguments, WithStaticLayout, } from '@glimmer/interfaces'; -import { createConstRef, Reference, valueForRef } from '@glimmer/reference'; -import { registerDestructor, reifyPositional } from '@glimmer/runtime'; +import { createConstRef, Reference } from '@glimmer/reference'; +import { registerDestructor } from '@glimmer/runtime'; import { unwrapTemplate } from '@glimmer/util'; -import { Tag, track } from '@glimmer/validator'; import { EmberVMEnvironment } from '../environment'; import RuntimeResolver from '../resolver'; import { OwnedTemplate } from '../template'; +import { argsProxyFor, TemplateArgs } from '../utils/args-proxy'; import AbstractComponentManager from './abstract'; const CAPABILITIES = { @@ -149,94 +144,6 @@ export interface ComponentArguments { named: Dict; } -function tagForNamedArg( - namedArgs: NamedArgs, - key: K -): Tag { - return track(() => valueForRef(namedArgs[key])); -} - -let namedArgsProxyFor: (namedArgs: CapturedNamedArguments, debugName?: string) => Args['named']; - -if (HAS_NATIVE_PROXY) { - namedArgsProxyFor = ( - namedArgs: NamedArgs, - debugName?: string - ) => { - let getTag = (key: keyof Args) => tagForNamedArg(namedArgs, key); - - let handler: ProxyHandler<{}> = { - get(_target, prop) { - let ref = namedArgs[prop as string]; - - if (ref !== undefined) { - return valueForRef(ref); - } else if (prop === CUSTOM_TAG_FOR) { - return getTag; - } - }, - - has(_target, prop) { - return namedArgs[prop as string] !== undefined; - }, - - ownKeys(_target) { - return Object.keys(namedArgs); - }, - - getOwnPropertyDescriptor(_target, prop) { - assert( - 'args proxies do not have real property descriptors, so you should never need to call getOwnPropertyDescriptor yourself. This code exists for enumerability, such as in for-in loops and Object.keys()', - namedArgs[prop as string] !== undefined - ); - - return { - enumerable: true, - configurable: true, - }; - }, - }; - - if (DEBUG) { - handler.set = function(_target, prop) { - assert( - `You attempted to set ${debugName}#${String( - prop - )} on a components arguments. Component arguments are immutable and cannot be updated directly, they always represent the values that are passed to your component. If you want to set default values, you should use a getter instead` - ); - - return false; - }; - } - - return new Proxy({}, handler); - }; -} else { - namedArgsProxyFor = (namedArgs: NamedArgs) => { - let getTag = (key: keyof Args) => tagForNamedArg(namedArgs, key); - - let proxy = {}; - - Object.defineProperty(proxy, CUSTOM_TAG_FOR, { - configurable: false, - enumerable: false, - value: getTag, - }); - - Object.keys(namedArgs).forEach(name => { - Object.defineProperty(proxy, name, { - enumerable: true, - configurable: true, - get() { - return valueForRef(namedArgs[name]); - }, - }); - }); - - return proxy; - }; -} - /** The CustomComponentManager allows addons to provide custom component implementations that integrate seamlessly into Ember. This is accomplished @@ -276,25 +183,20 @@ export default class CustomComponentManager create( env: EmberVMEnvironment, definition: CustomComponentDefinitionState, - args: VMArguments + vmArgs: VMArguments ): CustomComponentState { let { delegate } = definition; - let capturedArgs = args.capture(); - let { named, positional } = capturedArgs; - let namedArgsProxy = namedArgsProxyFor(named); + let args = argsProxyFor(vmArgs.capture(), 'component'); - let component = delegate.createComponent(definition.ComponentClass.class, { - named: namedArgsProxy, - positional: reifyPositional(positional), - }); + let component = delegate.createComponent(definition.ComponentClass.class, args); - let bucket = new CustomComponentState(delegate, component, capturedArgs, env, namedArgsProxy); + let bucket = new CustomComponentState(delegate, component, args, env); if (ENV._DEBUG_RENDER_TREE) { env.extra.debugRenderTree.create(bucket, { type: 'component', name: definition.name, - args: args.capture(), + args: vmArgs.capture(), instance: component, template: definition.template, }); @@ -317,12 +219,9 @@ export default class CustomComponentManager } if (hasUpdateHook(bucket.delegate)) { - let { delegate, component, args, namedArgsProxy } = bucket; + let { delegate, component, args } = bucket; - delegate.updateComponent(component, { - named: namedArgsProxy, - positional: reifyPositional(args.positional), - }); + delegate.updateComponent(component, args); } } @@ -383,9 +282,8 @@ export class CustomComponentState { constructor( public delegate: ManagerDelegate, public component: ComponentInstance, - public args: CapturedArguments, - public env: EmberVMEnvironment, - public namedArgsProxy: Args['named'] + public args: TemplateArgs, + public env: EmberVMEnvironment ) { if (hasDestructors(delegate)) { registerDestructor(this, () => delegate.destroyComponent(component)); diff --git a/packages/@ember/-internals/glimmer/lib/helper.ts b/packages/@ember/-internals/glimmer/lib/helper.ts index 4403bb613f0..fb3bb6ec982 100644 --- a/packages/@ember/-internals/glimmer/lib/helper.ts +++ b/packages/@ember/-internals/glimmer/lib/helper.ts @@ -4,10 +4,19 @@ import { Factory } from '@ember/-internals/owner'; import { FrameworkObject } from '@ember/-internals/runtime'; -import { symbol } from '@ember/-internals/utils'; +import { getDebugName, symbol } from '@ember/-internals/utils'; import { join } from '@ember/runloop'; +import { DEBUG } from '@glimmer/env'; import { Dict } from '@glimmer/interfaces'; -import { createTag, dirtyTag } from '@glimmer/validator'; +import { + consumeTag, + createTag, + deprecateMutationsInTrackingTransaction, + dirtyTag, +} from '@glimmer/validator'; +import { helperCapabilities, HelperManager } from './helpers/custom'; +import { TemplateArgs } from './utils/args-proxy'; +import { setHelperManager } from './utils/managers'; export const RECOMPUTE_TAG = symbol('RECOMPUTE_TAG'); @@ -30,18 +39,6 @@ export interface SimpleHelper { compute: HelperFunction; } -export function isHelperFactory( - helper: any | undefined | null -): helper is Factory> { - return ( - typeof helper === 'object' && helper !== null && helper.class && helper.class.isHelperFactory - ); -} - -export function isClassHelper(helper: SimpleHelper | HelperInstance): helper is HelperInstance { - return (helper as any).destroy !== undefined; -} - /** Ember Helpers are functions that can compute values, and are used in templates. For example, this code calls a helper named `format-currency`: @@ -138,6 +135,56 @@ let Helper = FrameworkObject.extend({ Helper.isHelperFactory = true; +interface ClassicHelperStateBucket { + instance: HelperInstance; + args: TemplateArgs; +} + +class ClassicHelperManager implements HelperManager { + capabilities = helperCapabilities('3.23', { + hasValue: true, + hasDestroyable: true, + }); + + createHelper(definition: ClassHelperFactory, args: TemplateArgs) { + return { + instance: definition.create(), + args, + }; + } + + getDestroyable({ instance }: ClassicHelperStateBucket) { + return instance; + } + + getValue({ instance, args }: ClassicHelperStateBucket) { + let ret; + let { positional, named } = args; + + if (DEBUG) { + deprecateMutationsInTrackingTransaction!(() => { + ret = instance.compute(positional, named); + }); + } else { + ret = instance.compute(positional, named); + } + + consumeTag(instance[RECOMPUTE_TAG]); + + return ret; + } + + getDebugName(definition: ClassHelperFactory) { + return getDebugName!(definition.class!['prototype']); + } +} + +export const CLASSIC_HELPER_MANAGER = new ClassicHelperManager(); + +setHelperManager(() => CLASSIC_HELPER_MANAGER, Helper); + +/////////// + class Wrapper implements HelperFactory { isHelperFactory: true = true; @@ -151,6 +198,38 @@ class Wrapper implements HelperFactory { } } +class SimpleClassicHelperManager implements HelperManager<() => unknown> { + capabilities = helperCapabilities('3.23', { + hasValue: true, + }); + + createHelper(definition: Wrapper, args: TemplateArgs) { + if (DEBUG) { + return () => { + let ret; + + deprecateMutationsInTrackingTransaction!(() => { + ret = definition.compute.call(null, args.positional, args.named); + }); + + return ret; + }; + } + + return definition.compute.bind(null, args.positional, args.named); + } + + getValue(fn: () => unknown) { + return fn(); + } + + getDebugName(definition: Wrapper) { + return getDebugName!(definition.compute); + } +} + +setHelperManager(() => new SimpleClassicHelperManager(), Wrapper.prototype); + /** In many cases it is not necessary to use the full `Helper` class. The `helper` method create pure-function helpers without instances. diff --git a/packages/@ember/-internals/glimmer/lib/helpers/custom.ts b/packages/@ember/-internals/glimmer/lib/helpers/custom.ts new file mode 100644 index 00000000000..91840b82ea7 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/helpers/custom.ts @@ -0,0 +1,86 @@ +import { assert } from '@ember/debug'; +import { DEBUG } from '@glimmer/env'; +import { Helper as GlimmerHelper } from '@glimmer/interfaces'; +import { createComputeRef, UNDEFINED_REFERENCE } from '@glimmer/reference'; +import { argsProxyFor, TemplateArgs } from '../utils/args-proxy'; + +export type HelperDefinition = object; + +export interface HelperCapabilities { + hasValue: boolean; + hasDestroyable: boolean; + hasScheduledEffect: boolean; +} + +export function helperCapabilities( + managerAPI: string, + options: Partial = {} +): HelperCapabilities { + assert('Invalid helper manager compatibility specified', managerAPI === '3.23'); + + assert( + 'You must pass either the `hasValue` OR the `hasScheduledEffect` capability when defining a helper manager. Passing neither, or both, is not permitted.', + (options.hasValue || options.hasScheduledEffect) && + !(options.hasValue && options.hasScheduledEffect) + ); + + assert( + 'The `hasScheduledEffect` capability has not yet been implemented for helper managers. Please pass `hasValue` instead', + !options.hasScheduledEffect + ); + + return { + hasValue: Boolean(options.hasValue), + hasDestroyable: Boolean(options.hasDestroyable), + hasScheduledEffect: Boolean(options.hasScheduledEffect), + }; +} + +export interface HelperManager { + capabilities: HelperCapabilities; + + createHelper(definition: HelperDefinition, args: TemplateArgs): HelperStateBucket; + + getDebugName?(definition: HelperDefinition): string; +} + +export interface HelperManagerWithValue + extends HelperManager { + getValue(bucket: HelperStateBucket): unknown; +} + +function hasValue(manager: HelperManager): manager is HelperManagerWithValue { + return manager.capabilities.hasValue; +} + +export interface HelperManagerWithDestroyable + extends HelperManager { + getDestroyable(bucket: HelperStateBucket): object; +} + +function hasDestroyable(manager: HelperManager): manager is HelperManagerWithDestroyable { + return manager.capabilities.hasDestroyable; +} + +export default function customHelper( + manager: HelperManager, + definition: HelperDefinition +): GlimmerHelper { + return (args, vm) => { + const bucket = manager.createHelper(definition, argsProxyFor(args.capture(), 'helper')); + + if (hasDestroyable(manager)) { + vm.associateDestroyable(manager.getDestroyable(bucket)); + } + + if (hasValue(manager)) { + return createComputeRef( + () => manager.getValue(bucket), + null, + DEBUG && manager.getDebugName && manager.getDebugName(definition) + ); + } else { + return UNDEFINED_REFERENCE; + } + }; +} diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index 7ae05061e22..aeb181fb6c0 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -8,7 +8,6 @@ import { assert, deprecate } from '@ember/debug'; import { PARTIALS } from '@ember/deprecated-features'; import EmberError from '@ember/error'; import { _instrumentStart } from '@ember/instrumentation'; -import { DEBUG } from '@glimmer/env'; import { ComponentDefinition, Helper, @@ -17,13 +16,13 @@ import { RuntimeResolver, } from '@glimmer/interfaces'; import { PartialDefinitionImpl } from '@glimmer/opcode-compiler'; -import { getDynamicVar, ModifierDefinition, registerDestructor } from '@glimmer/runtime'; +import { getDynamicVar, ModifierDefinition } from '@glimmer/runtime'; import { CurlyComponentDefinition } from './component-managers/curly'; import { CustomManagerDefinition } from './component-managers/custom'; import { InternalComponentDefinition, isInternalManager } from './component-managers/internal'; import { TemplateOnlyComponentDefinition } from './component-managers/template-only'; import InternalComponent from './components/internal'; -import { isClassHelper, isHelperFactory } from './helper'; +import { CLASSIC_HELPER_MANAGER, HelperFactory, HelperInstance, SimpleHelper } from './helper'; import { default as componentAssertionHelper } from './helpers/-assert-implicit-component-helper-argument'; import { default as inElementNullCheckHelper } from './helpers/-in-element-null-check'; import { default as normalizeClassHelper } from './helpers/-normalize-class'; @@ -31,6 +30,7 @@ import { default as trackArray } from './helpers/-track-array'; import { default as action } from './helpers/action'; import { default as array } from './helpers/array'; import { default as concat } from './helpers/concat'; +import customHelper from './helpers/custom'; import { default as eachIn } from './helpers/each-in'; import { default as fn } from './helpers/fn'; import { default as get } from './helpers/get'; @@ -48,8 +48,7 @@ import { mountHelper } from './syntax/mount'; import { outletHelper } from './syntax/outlet'; import { Factory as TemplateFactory, OwnedTemplate } from './template'; import { getComponentTemplate } from './utils/component-template'; -import { getComponentManager, getModifierManager } from './utils/managers'; -import { createHelperRef } from './utils/references'; +import { getComponentManager, getHelperManager, getModifierManager } from './utils/managers'; function instrumentationPayload(name: string) { return { object: `component:${name}` }; @@ -358,32 +357,26 @@ export default class RuntimeResolverImpl implements RuntimeResolver>( + `helper:${name}`, + options + ) || owner.factoryFor(`helper:${name}`); - if (!isHelperFactory(factory)) { + if (factory === undefined || factory.class === undefined) { return null; } - return (args, vm) => { - const helper = factory.create(); - - if (isClassHelper(helper)) { - let helperDestroyable = {}; - - // Do this so that `destroy` gets called correctly - registerDestructor(helperDestroyable, () => helper.destroy(), true); - vm.associateDestroyable(helperDestroyable); - } else if (DEBUG) { - // Bind to null in case someone accidentally passed an unbound function - // in, and attempts use `this` on it. - // - // TODO: Update buildUntouchableThis to be flexible enough to provide a - // nice error message here. - helper.compute = helper.compute.bind(null); - } + const definition = factory.class; + const manager = getHelperManager(owner, definition); - return createHelperRef(helper, args.capture()); - }; + if (manager === undefined) { + return null; + } + + // For classic class based helpers, we need to pass the factory itself rather + // than the definition. This is because injections are already bound in the + // factory, including type-based injections + return customHelper(manager, manager === CLASSIC_HELPER_MANAGER ? factory : definition); } private _lookupPartial(name: string, meta: OwnedTemplateMeta): PartialDefinition { diff --git a/packages/@ember/-internals/glimmer/lib/utils/args-proxy.ts b/packages/@ember/-internals/glimmer/lib/utils/args-proxy.ts new file mode 100644 index 00000000000..9f546ff6688 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/utils/args-proxy.ts @@ -0,0 +1,184 @@ +import { CUSTOM_TAG_FOR } from '@ember/-internals/metal'; +import { HAS_NATIVE_PROXY } from '@ember/-internals/utils'; +import { assert } from '@ember/debug'; +import { DEBUG } from '@glimmer/env'; +import { CapturedArguments, CapturedNamedArguments } from '@glimmer/interfaces'; +import { Reference, valueForRef } from '@glimmer/reference'; +import { Tag, track } from '@glimmer/validator'; + +export interface TemplateArgs< + Positional extends unknown[] = unknown[], + Named extends Record = Record +> { + named: Named; + positional: Positional; +} + +function convertToInt(prop: number | string | symbol): number | null { + if (typeof prop === 'symbol') return null; + + const num = Number(prop); + + if (isNaN(num)) return null; + + return num % 1 === 0 ? num : null; +} + +function tagForNamedArg( + namedArgs: NamedArgs, + key: K +): Tag { + return track(() => valueForRef(namedArgs[key])); +} + +export let argsProxyFor: ( + capturedArgs: CapturedArguments, + type: 'component' | 'helper' | 'modifier' +) => TemplateArgs; + +if (HAS_NATIVE_PROXY) { + argsProxyFor = (capturedArgs, type) => { + const { named, positional } = capturedArgs; + + let getTag = (key: keyof CapturedNamedArguments) => tagForNamedArg(named, key); + + const namedHandler: ProxyHandler<{}> = { + get(_target, prop) { + const ref = named[prop as string]; + + if (ref !== undefined) { + return valueForRef(ref); + } else if (prop === CUSTOM_TAG_FOR) { + return getTag; + } + }, + + has(_target, prop) { + return prop in named; + }, + + ownKeys(_target) { + return Object.keys(named); + }, + + isExtensible() { + return false; + }, + + getOwnPropertyDescriptor(_target, prop) { + assert( + 'args proxies do not have real property descriptors, so you should never need to call getOwnPropertyDescriptor yourself. This code exists for enumerability, such as in for-in loops and Object.keys()', + prop in named + ); + + return { + enumerable: true, + configurable: true, + }; + }, + }; + + const positionalHandler: ProxyHandler<[]> = { + get(target, prop) { + if (prop === 'length') { + return positional.length; + } + + const parsed = convertToInt(prop); + + if (parsed !== null && parsed < positional.length) { + return valueForRef(positional[parsed]); + } + + return (target as any)[prop]; + }, + + isExtensible() { + return false; + }, + + has(_target, prop) { + const parsed = convertToInt(prop); + + return parsed !== null && parsed < positional.length; + }, + }; + + const namedTarget = Object.create(null); + const positionalTarget: unknown[] = []; + + if (DEBUG) { + const setHandler = function(_target: unknown, prop: symbol | string | number): never { + throw new Error( + `You attempted to set ${String( + prop + )} on the arguments of a component, helper, or modifier. Arguments are immutable and cannot be updated directly, they always represent the values that is passed down. If you want to set default values, you should use a getter and local tracked state instead.` + ); + }; + + const forInDebugHandler = (): never => { + throw new Error( + `Object.keys() was called on the positional arguments array for a ${type}, which is not supported. This function is a low-level function that should not need to be called for positional argument arrays. You may be attempting to iterate over the array using for...in instead of for...of.` + ); + }; + + namedHandler.set = setHandler; + positionalHandler.set = setHandler; + positionalHandler.ownKeys = forInDebugHandler; + } + + return { + named: new Proxy(namedTarget, namedHandler), + positional: new Proxy(positionalTarget, positionalHandler), + }; + }; +} else { + argsProxyFor = (capturedArgs, _type) => { + const { named, positional } = capturedArgs; + + let getTag = (key: keyof CapturedNamedArguments) => tagForNamedArg(named, key); + + let namedProxy = {}; + + Object.defineProperty(namedProxy, CUSTOM_TAG_FOR, { + configurable: false, + enumerable: false, + value: getTag, + }); + + Object.keys(named).forEach(name => { + Object.defineProperty(namedProxy, name, { + enumerable: true, + configurable: true, + get() { + return valueForRef(named[name]); + }, + }); + }); + + let positionalProxy: unknown[] = []; + + positional.forEach((ref: Reference, index: number) => { + Object.defineProperty(positionalProxy, index, { + enumerable: true, + configurable: true, + get() { + return valueForRef(ref); + }, + }); + }); + + if (DEBUG) { + // Prevent mutations in development mode. This will not prevent the + // proxy from updating, but will prevent assigning new values or pushing + // for instance. + Object.freeze(namedProxy); + Object.freeze(positionalProxy); + } + + return { + named: namedProxy, + positional: positionalProxy, + }; + }; +} diff --git a/packages/@ember/-internals/glimmer/lib/utils/managers.ts b/packages/@ember/-internals/glimmer/lib/utils/managers.ts index b89ea86ae07..8dfaf55a952 100644 --- a/packages/@ember/-internals/glimmer/lib/utils/managers.ts +++ b/packages/@ember/-internals/glimmer/lib/utils/managers.ts @@ -3,12 +3,14 @@ import { deprecate } from '@ember/debug'; import { COMPONENT_MANAGER_STRING_LOOKUP } from '@ember/deprecated-features'; import { ManagerDelegate as ComponentManagerDelegate } from '../component-managers/custom'; import InternalComponentManager from '../component-managers/internal'; +import { HelperManager } from '../helpers/custom'; import { ModifierManagerDelegate } from '../modifiers/custom'; type ManagerDelegate = | ComponentManagerDelegate | InternalComponentManager - | ModifierManagerDelegate; + | ModifierManagerDelegate + | HelperManager; const COMPONENT_MANAGERS = new WeakMap< object, @@ -17,6 +19,8 @@ const COMPONENT_MANAGERS = new WeakMap< const MODIFIER_MANAGERS = new WeakMap>>(); +const HELPER_MANAGERS = new WeakMap>>(); + const MANAGER_INSTANCES: WeakMap> = new WeakMap(); export type ManagerFactory = (owner: Owner) => D; @@ -96,6 +100,26 @@ export function getModifierManager( return undefined; } +export function setHelperManager( + factory: ManagerFactory>, + definition: object +) { + return setManager(HELPER_MANAGERS, factory, definition); +} + +export function getHelperManager( + owner: Owner, + definition: object +): HelperManager | undefined { + const factory = getManager(HELPER_MANAGERS, definition); + + if (factory !== undefined) { + return getManagerInstanceForOwner(owner, factory); + } + + return undefined; +} + export function setComponentManager( stringOrFunction: | string diff --git a/packages/@ember/-internals/glimmer/lib/utils/references.ts b/packages/@ember/-internals/glimmer/lib/utils/references.ts deleted file mode 100644 index 8a9a7400f96..00000000000 --- a/packages/@ember/-internals/glimmer/lib/utils/references.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getDebugName } from '@ember/-internals/utils'; -import { debugFreeze } from '@ember/debug'; -import { DEBUG } from '@glimmer/env'; -import { CapturedArguments } from '@glimmer/interfaces'; -import { createComputeRef, Reference } from '@glimmer/reference'; -import { reifyArgs } from '@glimmer/runtime'; -import { consumeTag, deprecateMutationsInTrackingTransaction } from '@glimmer/validator'; -import { HelperInstance, isClassHelper, RECOMPUTE_TAG, SimpleHelper } from '../helper'; - -export function createHelperRef( - helper: SimpleHelper | HelperInstance, - args: CapturedArguments -): Reference { - return createComputeRef( - () => { - let { positional, named } = reifyArgs(args); - - let ret: T; - - if (DEBUG) { - debugFreeze(positional); - debugFreeze(named); - - deprecateMutationsInTrackingTransaction!(() => { - ret = helper.compute(positional, named); - }); - } else { - ret = helper.compute(positional, named); - } - - if (helper[RECOMPUTE_TAG]) { - consumeTag(helper[RECOMPUTE_TAG]); - } - - return ret!; - }, - null, - DEBUG && (isClassHelper(helper) ? getDebugName!(helper) : getDebugName!(helper.compute)) - ); -} diff --git a/packages/@ember/-internals/glimmer/tests/integration/custom-component-manager-test.js b/packages/@ember/-internals/glimmer/tests/integration/custom-component-manager-test.js index 0e7dbd68146..0a99945044c 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/custom-component-manager-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/custom-component-manager-test.js @@ -323,11 +323,11 @@ moduleFor( ['@test positional params are updated if they change']() { let ComponentClass = setComponentManager( createBasicManager, - EmberObject.extend({ - salutation: computed('args.positional', function() { + class extends EmberObject { + get salutation() { return this.args.positional[0] + ' ' + this.args.positional[1]; - }), - }) + } + } ); this.registerComponent('foo-bar', { diff --git a/packages/@ember/-internals/glimmer/tests/integration/helpers/helper-manager-test.js b/packages/@ember/-internals/glimmer/tests/integration/helpers/helper-manager-test.js new file mode 100644 index 00000000000..200b4180de4 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/helpers/helper-manager-test.js @@ -0,0 +1,291 @@ +import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers'; +import { + setHelperManager, + setModifierManager, + helperCapabilities, +} from '@ember/-internals/glimmer'; +import { tracked, set } from '@ember/-internals/metal'; +import { setOwner } from '@ember/-internals/owner'; +import Service, { inject as service } from '@ember/service'; +import { backtrackingMessageFor } from '../../utils/backtracking-rerender'; +import { registerDestructor } from '@glimmer/runtime'; + +class TestHelperManager { + capabilities = helperCapabilities('3.23', { + hasValue: true, + hasDestroyable: true, + }); + + constructor(owner) { + this.owner = owner; + } + + createHelper(Definition, args) { + return new Definition(this.owner, args); + } + + getValue(instance) { + return instance.value(); + } + + getDestroyable(instance) { + return instance; + } + + getDebugName(definition) { + return definition.name; + } +} + +class TestHelper { + constructor(owner, args) { + setOwner(this, owner); + this.args = args; + + registerDestructor(this, () => this.willDestroy()); + } + + willDestroy() {} +} + +setHelperManager(owner => new TestHelperManager(owner), TestHelper); + +moduleFor( + 'Helpers test: helper managers', + class extends RenderingTestCase { + ['@test it works']() { + this.registerCustomHelper( + 'hello', + class extends TestHelper { + value() { + return 'hello'; + } + } + ); + + this.render('{{hello}}'); + + this.assertText('hello'); + + runTask(() => this.rerender()); + + this.assertText('hello'); + } + + ['@test tracks changes to named arguments'](assert) { + let count = 0; + + this.registerCustomHelper( + 'hello', + class extends TestHelper { + value() { + count++; + return this.args.named.foo; + } + } + ); + + this.render('{{hello foo=foo}}', { + foo: 123, + }); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => this.rerender()); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => set(this.context, 'foo', 456)); + + assert.equal(count, 2, 'rendered twice'); + this.assertText('456'); + } + + ['@test tracks changes to positional arguments'](assert) { + let count = 0; + + this.registerCustomHelper( + 'hello', + class extends TestHelper { + value() { + count++; + return this.args.positional[0]; + } + } + ); + + this.render('{{hello foo}}', { + foo: 123, + }); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => this.rerender()); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => set(this.context, 'foo', 456)); + + assert.equal(count, 2, 'rendered twice'); + this.assertText('456'); + } + + ['@test tracks changes to tracked properties'](assert) { + let count = 0; + let instance; + + this.registerCustomHelper( + 'hello', + class extends TestHelper { + @tracked foo = 123; + + constructor(...args) { + super(...args); + instance = this; + } + + value() { + count++; + return this.foo; + } + } + ); + + this.render('{{hello}}'); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => this.rerender()); + + assert.equal(count, 1, 'rendered once'); + this.assertText('123'); + + runTask(() => (instance.foo = 456)); + + assert.equal(count, 2, 'rendered twice'); + this.assertText('456'); + } + + ['@test services can be injected']() { + this.registerService( + 'hello', + Service.extend({ + value: 'hello', + }) + ); + + this.registerCustomHelper( + 'hello', + class extends TestHelper { + @service hello; + + value() { + return this.hello.value; + } + } + ); + + this.render('{{hello}}'); + + this.assertText('hello'); + } + + ['@test destroyable is associated correctly'](assert) { + this.registerCustomHelper( + 'hello', + class extends TestHelper { + value() { + return 'hello'; + } + + willDestroy() { + assert.ok(true, 'destructor called'); + } + } + ); + + this.render('{{hello}}'); + + this.assertText('hello'); + } + + ['@test debug name is used for backtracking message']() { + this.registerCustomHelper( + 'hello', + class MyHelper extends TestHelper { + @tracked foo = 123; + + value() { + this.foo; + this.foo = 456; + } + } + ); + + let expectedMessage = backtrackingMessageFor('foo', 'MyHelper', { + renderTree: ['\\(result of a `MyHelper` helper\\)'], + }); + + expectAssertion(() => { + this.render('{{hello}}'); + }, expectedMessage); + } + + ['@test asserts against using both `hasValue` and `hasScheduledEffect`']() { + expectAssertion(() => { + helperCapabilities('3.23', { + hasValue: true, + hasScheduledEffect: true, + }); + }, /You must pass either the `hasValue` OR the `hasScheduledEffect` capability when defining a helper manager. Passing neither, or both, is not permitted./); + } + + ['@test asserts requiring either `hasValue` or `hasScheduledEffect`']() { + expectAssertion(() => { + helperCapabilities('3.23', {}); + }, /You must pass either the `hasValue` OR the `hasScheduledEffect` capability when defining a helper manager. Passing neither, or both, is not permitted./); + } + + ['@test asserts against using `hasScheduledEffect`']() { + expectAssertion(() => { + helperCapabilities('3.23', { + hasScheduledEffect: true, + }); + }, /The `hasScheduledEffect` capability has not yet been implemented for helper managers. Please pass `hasValue` instead/); + } + + ['@test asserts against using incorrect version for capabilities']() { + expectAssertion(() => { + helperCapabilities('aoeu', { + hasScheduledEffect: true, + }); + }, /Invalid helper manager compatibility specified/); + } + + ['@test helper manager and modifier manager can be associated with the same value']() { + setModifierManager(() => ({}), TestHelper); + + this.registerCustomHelper( + 'hello', + class extends TestHelper { + value() { + return 'hello'; + } + } + ); + + this.render('{{hello}}'); + + this.assertText('hello'); + + runTask(() => this.rerender()); + + this.assertText('hello'); + } + } +); diff --git a/packages/internal-test-helpers/lib/test-cases/rendering.js b/packages/internal-test-helpers/lib/test-cases/rendering.js index ab152b5e70c..2be75933b24 100644 --- a/packages/internal-test-helpers/lib/test-cases/rendering.js +++ b/packages/internal-test-helpers/lib/test-cases/rendering.js @@ -147,6 +147,10 @@ export default class RenderingTestCase extends AbstractTestCase { } } + registerCustomHelper(name, definition) { + this.owner.register(`helper:${name}`, definition); + } + registerPartial(name, template) { let owner = this.owner; if (typeof template === 'string') {