diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/implicit-explicit.spec.js b/packages/@lwc/babel-plugin-component/src/__tests__/implicit-explicit.spec.js index 68816d909b..dcdb4137e3 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/implicit-explicit.spec.js +++ b/packages/@lwc/babel-plugin-component/src/__tests__/implicit-explicit.spec.js @@ -129,6 +129,12 @@ describe('Implicit mode', () => { params: {}, static: { id: 1 + }, + hasParams: false, + config: function($cmp) { + return { + id: 1 + }; } } } diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/observed-fields.spec.js b/packages/@lwc/babel-plugin-component/src/__tests__/observed-fields.spec.js index 37eddbac0d..7f7a933acd 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/observed-fields.spec.js +++ b/packages/@lwc/babel-plugin-component/src/__tests__/observed-fields.spec.js @@ -60,7 +60,11 @@ describe('observed fields', () => { publicMethods: ["someMethod"], wire: { wiredProp: { - adapter: createElement + adapter: createElement, + hasParams: false, + config: function($cmp) { + return {}; + } } }, track: { @@ -114,7 +118,11 @@ describe('observed fields', () => { }, wire: { function: { - adapter: createElement + adapter: createElement, + hasParams: false, + config: function($cmp) { + return {}; + } } }, track: { @@ -303,7 +311,11 @@ describe('observed fields', () => { publicMethods: ["someMethod"], wire: { wiredProp: { - adapter: createElement + adapter: createElement, + hasParams: false, + config: function($cmp) { + return {}; + } } }, track: { diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/wire-decorator.spec.js b/packages/@lwc/babel-plugin-component/src/__tests__/wire-decorator.spec.js index 70229e0ec3..3bb382471b 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/wire-decorator.spec.js +++ b/packages/@lwc/babel-plugin-component/src/__tests__/wire-decorator.spec.js @@ -40,6 +40,181 @@ describe('Transform property', () => { }, static: { key2: ["fixed", "array"] + }, + hasParams: true, + config: function($cmp) { + return { + key2: ["fixed", "array"], + key1: $cmp.prop1 + }; + } + } + } + }); + + export default _registerComponent(Test, { + tmpl: _tmpl + }); +`, + }, + } + ); + + pluginTest( + 'transforms named imports from static imports', + ` + import { wire } from 'lwc'; + import importedValue from "ns/module"; + import { getFoo } from 'data-service'; + export default class Test { + @wire(getFoo, { key1: importedValue }) + wiredProp; + } + `, + { + output: { + code: ` + import { registerDecorators as _registerDecorators } from "lwc"; + import _tmpl from "./test.html"; + import { registerComponent as _registerComponent } from "lwc"; + import importedValue from "ns/module"; + import { getFoo } from "data-service"; + + class Test { + constructor() { + this.wiredProp = void 0; + } + } + + _registerDecorators(Test, { + wire: { + wiredProp: { + adapter: getFoo, + params: {}, + static: { + key1: importedValue + }, + hasParams: false, + config: function($cmp) { + return { + key1: importedValue + }; + } + } + } + }); + + export default _registerComponent(Test, { + tmpl: _tmpl + }); +`, + }, + } + ); + + pluginTest( + 'transforms parameters with 2 levels deep (foo.bar)', + ` + import { wire } from 'lwc'; + import { getFoo } from 'data-service'; + export default class Test { + @wire(getFoo, { key1: "$prop1.prop2", key2: ["fixed", 'array'], key3: "$p1.p2" }) + wiredProp; + } + `, + { + output: { + code: ` + import { registerDecorators as _registerDecorators } from "lwc"; + import _tmpl from "./test.html"; + import { registerComponent as _registerComponent } from "lwc"; + import { getFoo } from "data-service"; + + class Test { + constructor() { + this.wiredProp = void 0; + } + } + + _registerDecorators(Test, { + wire: { + wiredProp: { + adapter: getFoo, + params: { + key1: "prop1.prop2", + key3: "p1.p2" + }, + static: { + key2: ["fixed", "array"] + }, + hasParams: true, + config: function($cmp) { + let v1 = $cmp.prop1; + let v2 = $cmp.p1; + return { + key2: ["fixed", "array"], + key1: v1 != null ? v1.prop2 : undefined, + key3: v2 != null ? v2.p2 : undefined + }; + } + } + } + }); + + export default _registerComponent(Test, { + tmpl: _tmpl + }); +`, + }, + } + ); + + pluginTest( + 'transforms parameters with multiple levels deep', + ` + import { wire } from 'lwc'; + import { getFoo } from 'data-service'; + export default class Test { + @wire(getFoo, { key1: "$prop1.prop2.prop3.prop4", key2: ["fixed", 'array']}) + wiredProp; + } + `, + { + output: { + code: ` + import { registerDecorators as _registerDecorators } from "lwc"; + import _tmpl from "./test.html"; + import { registerComponent as _registerComponent } from "lwc"; + import { getFoo } from "data-service"; + + class Test { + constructor() { + this.wiredProp = void 0; + } + } + + _registerDecorators(Test, { + wire: { + wiredProp: { + adapter: getFoo, + params: { + key1: "prop1.prop2.prop3.prop4" + }, + static: { + key2: ["fixed", "array"] + }, + hasParams: true, + config: function($cmp) { + let v1 = $cmp.prop1; + return { + key2: ["fixed", "array"], + key1: + v1 != null && + (v1 = v1.prop2) != null && + (v1 = v1.prop3) != null + ? v1.prop4 + : undefined + }; } } } @@ -88,6 +263,15 @@ describe('Transform property', () => { static: { key3: "fixed", key4: ["fixed", "array"] + }, + hasParams: true, + config: function($cmp) { + return { + key3: "fixed", + key4: ["fixed", "array"], + key1: $cmp.prop, + key2: $cmp.prop + }; } } } @@ -171,7 +355,11 @@ describe('Transform property', () => { wiredProp: { adapter: getFoo, params: {}, - static: {} + static: {}, + hasParams: false, + config: function($cmp) { + return {}; + } } } }); @@ -185,7 +373,7 @@ describe('Transform property', () => { ); pluginTest( - 'decorator accepts a member epxression', + 'decorator accepts a member expression', ` import { wire } from 'lwc'; import { Foo } from 'data-service'; @@ -212,7 +400,11 @@ describe('Transform property', () => { wiredProp: { adapter: Foo.Bar, params: {}, - static: {} + static: {}, + hasParams: false, + config: function($cmp) { + return {}; + } } } }); @@ -253,7 +445,11 @@ describe('Transform property', () => { wiredProp: { adapter: Foo.Bar, params: {}, - static: {} + static: {}, + hasParams: false, + config: function($cmp) { + return {}; + } } } }); @@ -314,7 +510,11 @@ describe('Transform property', () => { _registerDecorators(Test, { wire: { wiredProp: { - adapter: getFoo + adapter: getFoo, + hasParams: false, + config: function($cmp) { + return {}; + } } } }); @@ -395,6 +595,114 @@ describe('Transform property', () => { } ); + pluginTest( + "config function should use bracket notation for param when it's definition has invalid identifier as segment", + ` + import { api, wire } from 'lwc'; + import { getFoo } from 'data-service'; + export default class Test { + @wire(getFoo, { key1: "$prop1.a b", key2: "$p1.p2" }) + wiredProp; + } + `, + { + output: { + code: ` + import { registerDecorators as _registerDecorators } from "lwc"; + import _tmpl from "./test.html"; + import { registerComponent as _registerComponent } from "lwc"; + import { getFoo } from "data-service"; + + class Test { + constructor() { + this.wiredProp = void 0; + } + } + + _registerDecorators(Test, { + wire: { + wiredProp: { + adapter: getFoo, + params: { + key1: "prop1.a b", + key2: "p1.p2" + }, + static: {}, + hasParams: true, + config: function($cmp) { + let v1 = $cmp["prop1"]; + let v2 = $cmp.p1; + return { + key1: v1 != null ? v1["a b"] : undefined, + key2: v2 != null ? v2.p2 : undefined + }; + } + } + } + }); + + export default _registerComponent(Test, { + tmpl: _tmpl + }); +`, + }, + } + ); + + pluginTest( + 'config function should use bracket notation when param definition has empty segment', + ` + import { api, wire } from 'lwc'; + import { getFoo } from 'data-service'; + export default class Test { + @wire(getFoo, { key1: "$prop1..prop2", key2: ["fixed", 'array']}) + wiredProp; + } + `, + { + output: { + code: ` + import { registerDecorators as _registerDecorators } from "lwc"; + import _tmpl from "./test.html"; + import { registerComponent as _registerComponent } from "lwc"; + import { getFoo } from "data-service"; + + class Test { + constructor() { + this.wiredProp = void 0; + } + } + + _registerDecorators(Test, { + wire: { + wiredProp: { + adapter: getFoo, + params: { + key1: "prop1..prop2" + }, + static: { + key2: ["fixed", "array"] + }, + hasParams: true, + config: function($cmp) { + let v1 = $cmp["prop1"]; + return { + key2: ["fixed", "array"], + key1: v1 != null && (v1 = v1[""]) != null ? v1["prop2"] : undefined + }; + } + } + } + }); + + export default _registerComponent(Test, { + tmpl: _tmpl + }); +`, + }, + } + ); + pluginTest( 'throws when wired property is combined with @track', ` @@ -479,6 +787,13 @@ describe('Transform property', () => { }, static: { key2: ["fixed"] + }, + hasParams: true, + config: function($cmp) { + return { + key2: ["fixed"], + key1: $cmp.prop1 + }; } }, wired2: { @@ -488,6 +803,13 @@ describe('Transform property', () => { }, static: { key2: ["array"] + }, + hasParams: true, + config: function($cmp) { + return { + key2: ["array"], + key1: $cmp.prop1 + }; } } } @@ -535,7 +857,14 @@ describe('Transform method', () => { static: { key2: ["fixed"] }, - method: 1 + method: 1, + hasParams: true, + config: function($cmp) { + return { + key2: ["fixed"], + key1: $cmp.prop1 + }; + } } } }); diff --git a/packages/@lwc/babel-plugin-component/src/constants.js b/packages/@lwc/babel-plugin-component/src/constants.js index bad385ef8a..9c28e96e55 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.js +++ b/packages/@lwc/babel-plugin-component/src/constants.js @@ -40,6 +40,7 @@ const LWC_SUPPORTED_APIS = new Set([ 'getComponentDef', 'getComponentConstructor', 'isComponentConstructor', + 'createContextProvider', 'readonly', 'register', 'setFeatureFlagForTest', diff --git a/packages/@lwc/babel-plugin-component/src/decorators/wire/transform.js b/packages/@lwc/babel-plugin-component/src/decorators/wire/transform.js index 0d9e3a6d3b..680e5104e5 100644 --- a/packages/@lwc/babel-plugin-component/src/decorators/wire/transform.js +++ b/packages/@lwc/babel-plugin-component/src/decorators/wire/transform.js @@ -9,6 +9,7 @@ const { staticClassProperty, markAsLWCNode } = require('../../utils'); const { LWC_COMPONENT_PROPERTIES } = require('../../constants'); const WIRE_PARAM_PREFIX = '$'; +const WIRE_CONFIG_ARG_NAME = '$cmp'; function isObservedProperty(configProperty) { const propertyValue = configProperty.get('value'); @@ -37,6 +38,110 @@ function getWiredParams(t, wireConfig) { }); } +function getGeneratedConfig(t, wiredValue) { + let counter = 0; + const configBlockBody = []; + const configProps = []; + const generateParameterConfigValue = memberExprPaths => { + // Note: When memberExprPaths ($foo.bar) has an invalid identifier (eg: foo..bar, foo.bar[3]) + // it should (ideally) resolve in a compilation error during validation phase. + // This is not possible due that platform components may have a param definition which is invalid + // but passes compilation, and throwing at compile time would break such components. + // In such cases where the param does not have proper notation, the config generated will use the bracket + // notation to match the current behavior (that most likely end up resolving that param as undefined). + const isInvalidMemberExpr = memberExprPaths.some( + maybeIdentifier => + !(t.isValidES3Identifier(maybeIdentifier) && maybeIdentifier.length > 0) + ); + const memberExprPropertyGen = !isInvalidMemberExpr ? t.identifier : t.StringLiteral; + + if (memberExprPaths.length === 1) { + return { + configValueExpression: t.memberExpression( + t.identifier(WIRE_CONFIG_ARG_NAME), + memberExprPropertyGen(memberExprPaths[0]) + ), + }; + } + + const varName = 'v' + ++counter; + const varDeclaration = t.variableDeclaration('let', [ + t.variableDeclarator( + t.identifier(varName), + t.memberExpression( + t.identifier(WIRE_CONFIG_ARG_NAME), + memberExprPropertyGen(memberExprPaths[0]), + isInvalidMemberExpr + ) + ), + ]); + + // Results in: v != null && ... (v = v.i) != null && ... (v = v.(n-1)) != null + let conditionTest = t.binaryExpression('!=', t.identifier(varName), t.nullLiteral()); + + for (let i = 1, n = memberExprPaths.length; i < n - 1; i++) { + const nextPropValue = t.assignmentExpression( + '=', + t.identifier(varName), + t.memberExpression( + t.identifier(varName), + memberExprPropertyGen(memberExprPaths[i]), + isInvalidMemberExpr + ) + ); + + conditionTest = t.logicalExpression( + '&&', + conditionTest, + t.binaryExpression('!=', nextPropValue, t.nullLiteral()) + ); + } + + // conditionTest ? v.n : undefined + const configValueExpression = t.conditionalExpression( + conditionTest, + t.memberExpression( + t.identifier(varName), + memberExprPropertyGen(memberExprPaths[memberExprPaths.length - 1]), + isInvalidMemberExpr + ), + t.identifier('undefined') + ); + + return { + varDeclaration, + configValueExpression, + }; + }; + + if (wiredValue.static) { + Array.prototype.push.apply(configProps, wiredValue.static); + } + + if (wiredValue.params) { + wiredValue.params.forEach(param => { + const memberExprPaths = param.value.value.split('.'); + const paramConfigValue = generateParameterConfigValue(memberExprPaths); + + configProps.push(t.objectProperty(param.key, paramConfigValue.configValueExpression)); + + if (paramConfigValue.varDeclaration) { + configBlockBody.push(paramConfigValue.varDeclaration); + } + }); + } + + configBlockBody.push(t.returnStatement(t.objectExpression(configProps))); + + const fnExpression = t.functionExpression( + null, + [t.identifier(WIRE_CONFIG_ARG_NAME)], + t.blockStatement(configBlockBody) + ); + + return t.objectProperty(t.identifier('config'), fnExpression); +} + function buildWireConfigValue(t, wiredValues) { return t.objectExpression( wiredValues.map(wiredValue => { @@ -63,6 +168,14 @@ function buildWireConfigValue(t, wiredValues) { wireConfig.push(t.objectProperty(t.identifier('method'), t.numericLiteral(1))); } + wireConfig.push( + t.objectProperty( + t.identifier('hasParams'), + t.booleanLiteral(!!wiredValue.params && wiredValue.params.length > 0) + ) + ); + wireConfig.push(getGeneratedConfig(t, wiredValue)); + return t.objectProperty( t.identifier(wiredValue.propertyName), t.objectExpression(wireConfig) diff --git a/packages/@lwc/engine/src/framework/__tests__/error-boundary.spec.ts b/packages/@lwc/engine/src/framework/__tests__/error-boundary.spec.ts index 7ff0a5d3c6..705c64b2b9 100644 --- a/packages/@lwc/engine/src/framework/__tests__/error-boundary.spec.ts +++ b/packages/@lwc/engine/src/framework/__tests__/error-boundary.spec.ts @@ -979,7 +979,7 @@ describe('error boundary component', () => { } } registerDecorators(PreErrorChildContent, { - publicProps: { foo: { config: 1 } }, + publicProps: { foo: { config: 3 } }, }); const baseTmpl = compileTemplate( ` diff --git a/packages/@lwc/engine/src/framework/base-lightning-element.ts b/packages/@lwc/engine/src/framework/base-lightning-element.ts index 297a476878..09525f9110 100644 --- a/packages/@lwc/engine/src/framework/base-lightning-element.ts +++ b/packages/@lwc/engine/src/framework/base-lightning-element.ts @@ -13,19 +13,16 @@ * shape of a component. It is also used internally to apply extra optimizations. */ import { - ArrayReduce, assert, create, defineProperties, freeze, - getOwnPropertyNames, - isFalse, isFunction, isNull, - isObject, seal, defineProperty, isUndefined, + isObject, } from '@lwc/shared'; import { HTMLElementOriginalDescriptors } from './html-properties'; import { @@ -36,7 +33,7 @@ import { } from './component'; import { vmBeingConstructed, isBeingConstructed, isInvokingRender } from './invoker'; import { associateVM, getAssociatedVM, VM } from './vm'; -import { valueObserved, valueMutated } from '../libs/mutation-tracker'; +import { componentValueMutated, componentValueObserved } from './mutation-tracker'; import { dispatchEvent } from '../env/dom'; import { patchComponentWithRestrictions, @@ -93,7 +90,7 @@ function createBridgeToElementDescriptor( } return; } - valueObserved(this, propName); + componentValueObserved(vm, propName); return get.call(vm.elm); }, set(this: ComponentInterface, newValue: any) { @@ -122,10 +119,8 @@ function createBridgeToElementDescriptor( if (newValue !== vm.cmpProps[propName]) { vm.cmpProps[propName] = newValue; - if (isFalse(vm.isDirty)) { - // perf optimization to skip this step if not in the DOM - valueMutated(this, propName); - } + + componentValueMutated(vm, propName); } return set.call(vm.elm, newValue); }, @@ -535,19 +530,15 @@ BaseLightningElementConstructor.prototype = { }, }; -const baseDescriptors = ArrayReduce.call( - getOwnPropertyNames(HTMLElementOriginalDescriptors), - (descriptors, propName) => { - (descriptors as PropertyDescriptorMap)[propName] = createBridgeToElementDescriptor( - propName, - HTMLElementOriginalDescriptors[propName] - ); - return descriptors; - }, - create(null) -) as PropertyDescriptorMap; +export const lightningBasedDescriptors: PropertyDescriptorMap = create(null); +for (const propName in HTMLElementOriginalDescriptors) { + lightningBasedDescriptors[propName] = createBridgeToElementDescriptor( + propName, + HTMLElementOriginalDescriptors[propName] + ); +} -defineProperties(BaseLightningElementConstructor.prototype, baseDescriptors); +defineProperties(BaseLightningElementConstructor.prototype, lightningBasedDescriptors); const ComponentConstructorAsCustomElementConstructorMap = new Map< ComponentConstructor, diff --git a/packages/@lwc/engine/src/framework/component.ts b/packages/@lwc/engine/src/framework/component.ts index eb66671d2e..8a13ece6ee 100644 --- a/packages/@lwc/engine/src/framework/component.ts +++ b/packages/@lwc/engine/src/framework/component.ts @@ -11,7 +11,6 @@ import { isInvokingRender, invokeEventListener, } from './invoker'; -import { invokeServiceHook, Services } from './services'; import { VM, UninitializedVM, scheduleRehydration } from './vm'; import { VNodes } from '../3rdparty/snabbdom/types'; import { ReactiveObserver } from '../libs/mutation-tracker'; @@ -67,31 +66,8 @@ export function createComponent(uninitializedVm: UninitializedVM, Ctor: Componen } } -export function linkComponent(vm: VM) { - const { - def: { wire }, - } = vm; - - if (!isUndefined(wire)) { - const { wiring } = Services; - if (wiring) { - invokeServiceHook(vm, wiring); - } - } -} - export function getTemplateReactiveObserver(vm: VM): ReactiveObserver { return new ReactiveObserver(() => { - if (process.env.NODE_ENV !== 'production') { - assert.invariant( - !isInvokingRender, - `Mutating property is not allowed during the rendering life-cycle of ${getVMBeingRendered()}.` - ); - assert.invariant( - !isUpdatingTemplate, - `Mutating property is not allowed while updating template of ${getVMBeingRendered()}.` - ); - } const { isDirty } = vm; if (isFalse(isDirty)) { markComponentAsDirty(vm); diff --git a/packages/@lwc/engine/src/framework/context-provider.ts b/packages/@lwc/engine/src/framework/context-provider.ts new file mode 100644 index 0000000000..d16c71736d --- /dev/null +++ b/packages/@lwc/engine/src/framework/context-provider.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { isUndefined, ArrayIndexOf } from '@lwc/shared'; +import { guid } from './utils'; +import { WireAdapterConstructor, ContextValue, getAdapterToken, setAdapterToken } from './wiring'; + +type WireContextDisconnectCallback = () => void; +type WireContextInternalProtocolCallback = ( + newContext: ContextValue, + disconnectCallback: WireContextDisconnectCallback +) => void; +interface ContextConsumer { + provide(newContext: ContextValue): void; +} + +interface WireContextEvent extends CustomEvent {} + +interface ContextProviderOptions { + consumerConnectedCallback: (consumer: ContextConsumer) => void; + consumerDisconnectedCallback?: (consumer: ContextConsumer) => void; +} + +// this is lwc internal implementation +export function createContextProvider(adapter: WireAdapterConstructor) { + let adapterContextToken = getAdapterToken(adapter); + if (!isUndefined(adapterContextToken)) { + throw new Error(`Adapter already have a context provider.`); + } + adapterContextToken = guid(); + setAdapterToken(adapter, adapterContextToken); + const providers: EventTarget[] = []; + + return (elm: EventTarget, options: ContextProviderOptions) => { + if (ArrayIndexOf.call(providers, elm) !== -1) { + throw new Error(`Adapter was already installed on ${elm}.`); + } + providers.push(elm); + + const { consumerConnectedCallback, consumerDisconnectedCallback } = options; + elm.addEventListener( + adapterContextToken as string, + ((evt: WireContextEvent) => { + const { detail } = evt; + const consumer: ContextConsumer = { + provide(newContext) { + detail(newContext, disconnectCallback); + }, + }; + const disconnectCallback = () => { + if (!isUndefined(consumerDisconnectedCallback)) { + consumerDisconnectedCallback(consumer); + } + }; + consumerConnectedCallback(consumer); + evt.stopImmediatePropagation(); + }) as EventListener + ); + }; +} diff --git a/packages/@lwc/engine/src/framework/decorators/__tests__/wire.spec.ts b/packages/@lwc/engine/src/framework/decorators/__tests__/wire.spec.ts index 9e6703508b..81c22708d8 100644 --- a/packages/@lwc/engine/src/framework/decorators/__tests__/wire.spec.ts +++ b/packages/@lwc/engine/src/framework/decorators/__tests__/wire.spec.ts @@ -4,233 +4,10 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { compileTemplate } from 'test-utils'; import { createElement, LightningElement, registerDecorators } from '../../main'; import wire from '../wire'; -const emptyTemplate = compileTemplate(``); - describe('wire.ts', () => { - describe('integration', () => { - it('should support setting a wired property in constructor', () => { - expect.assertions(3); - - const o = { x: 1 }; - class MyComponent extends LightningElement { - constructor() { - super(); - expect('foo' in this).toBe(true); - this.foo = o; - - expect(this.foo).toEqual(o); - expect(this.foo).not.toBe(o); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - }); - - it('should support wired properties', () => { - expect.assertions(2); - - const o = { x: 1 }; - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - expect(this.foo).toEqual(o); - expect(this.foo).not.toBe(o); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - elm.injectFoo(o); - }); - - it('should make wired properties reactive', () => { - let counter = 0; - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - } - constructor() { - super(); - this.foo = { x: 1 }; - } - render() { - counter++; - this.foo.x; - return emptyTemplate; - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - elm.injectFoo({ x: 2 }); - - return Promise.resolve().then(() => { - expect(counter).toBe(2); - }); - }); - - it('should make properties of a wired object property reactive', () => { - let counter = 0; - class MyComponent extends LightningElement { - injectFooDotX(x) { - this.foo.x = x; - } - constructor() { - super(); - this.foo = { x: 1 }; - } - render() { - counter++; - this.foo.x; - return emptyTemplate; - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFooDotX'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - elm.injectFooDotX(2); - - return Promise.resolve().then(() => { - expect(counter).toBe(2); - }); - }); - - it('should not proxify primitive value', function() { - expect.assertions(1); - - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - expect(this.foo).toBe(1); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - elm.injectFoo(1); - }); - - it('should proxify plain arrays', function() { - expect.assertions(2); - - const a = []; - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - expect(this.foo).toEqual(a); - expect(this.foo).not.toBe(a); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - elm.injectFoo(a); - }); - - it('should not proxify exotic objects', function() { - expect.assertions(1); - - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - expect(this.foo).toBe(d); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - - const d = new Date(); - elm.injectFoo(d); - }); - - it('should not proxify non-observable object', function() { - expect.assertions(1); - - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - expect(this.foo).toBe(o); - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - - const o = Object.create({}); - elm.injectFoo(o); - }); - - it('should not throw an error if wire is observable object', function() { - class MyComponent extends LightningElement { - injectFoo(v) { - this.foo = v; - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - publicMethods: ['injectFoo'], - }); - const elm = createElement('x-foo', { is: MyComponent }); - document.body.appendChild(elm); - expect(() => { - elm.injectFoo({}); - }).not.toThrow(); - }); - - it('should throw a wire property is mutated during rendering', function() { - class MyComponent extends LightningElement { - render() { - this.foo = 1; - return emptyTemplate; - } - } - registerDecorators(MyComponent, { - wire: { foo: {} }, - }); - const elm = createElement('x-foo', { is: MyComponent }); - expect(() => { - document.body.appendChild(elm); - }).toThrow(); - }); - }); - describe('@wire misuse', () => { it('should throw when invoking wire without adapter', () => { class MyComponent extends LightningElement { @@ -243,5 +20,17 @@ describe('wire.ts', () => { createElement('x-foo', { is: MyComponent }); }).toThrow('@wire(adapter, config?) may only be used as a decorator.'); }); + + it('should throw if wire adapter is not truthy', () => { + class MyComponent extends LightningElement {} + + expect(() => { + registerDecorators(MyComponent, { + wire: { + foo: {}, + }, + }); + }).toThrow('Assert Violation: @wire on field "foo": adapter id must be truthy.'); + }); }); }); diff --git a/packages/@lwc/engine/src/framework/decorators/api.ts b/packages/@lwc/engine/src/framework/decorators/api.ts index 14fbd13c5e..a9b478db62 100644 --- a/packages/@lwc/engine/src/framework/decorators/api.ts +++ b/packages/@lwc/engine/src/framework/decorators/api.ts @@ -5,13 +5,16 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import features from '@lwc/features'; -import { assert, isFalse, isFunction, isObject, isTrue, isUndefined, toString } from '@lwc/shared'; +import { assert, isFalse, isFunction, isTrue, isUndefined, toString } from '@lwc/shared'; import { logError } from '../../shared/logger'; import { isInvokingRender, isBeingConstructed } from '../invoker'; -import { valueObserved, valueMutated, ReactiveObserver } from '../../libs/mutation-tracker'; -import { ComponentInterface, ComponentConstructor } from '../component'; +import { + componentValueObserved, + componentValueMutated, + ReactiveObserver, +} from '../mutation-tracker'; +import { ComponentInterface } from '../component'; import { getAssociatedVM, rerenderVM, VM } from '../vm'; -import { getDecoratorsRegisteredMeta } from './register'; import { addCallbackToNextTick } from '../utils'; import { isUpdatingTemplate, getVMBeingRendered } from '../template'; @@ -20,51 +23,15 @@ import { isUpdatingTemplate, getVMBeingRendered } from '../template'; * LWC Components. This function implements the internals of this * decorator. */ -export default function api( - target: ComponentConstructor, - propName: PropertyKey, - descriptor: PropertyDescriptor | undefined -): PropertyDescriptor { +export default function api(target: any, propertyKey: string, descriptor: PropertyDescriptor): void; +export default function api() { if (process.env.NODE_ENV !== 'production') { - if (arguments.length !== 3) { - assert.fail(`@api decorator can only be used as a decorator function.`); - } - } - if (process.env.NODE_ENV !== 'production') { - assert.invariant( - !descriptor || isFunction(descriptor.get) || isFunction(descriptor.set), - `Invalid property ${toString( - propName - )} definition in ${target}, it cannot be a prototype definition if it is a public property. Instead use the constructor to define it.` - ); - if (isObject(descriptor) && isFunction(descriptor.set)) { - assert.isTrue( - isObject(descriptor) && isFunction(descriptor.get), - `Missing getter for property ${toString( - propName - )} decorated with @api in ${target}. You cannot have a setter without the corresponding getter.` - ); - } - } - const meta = getDecoratorsRegisteredMeta(target); - // initializing getters and setters for each public prop on the target prototype - if (isObject(descriptor) && (isFunction(descriptor.get) || isFunction(descriptor.set))) { - // if it is configured as an accessor it must have a descriptor - // @ts-ignore it must always be set before calling this method - meta.props[propName].config = isFunction(descriptor.set) ? 3 : 1; - return createPublicAccessorDescriptor(target, propName, descriptor); - } else { - // @ts-ignore it must always be set before calling this method - meta.props[propName].config = 0; - return createPublicPropertyDescriptor(target, propName, descriptor); + assert.fail(`@api decorator can only be used as a decorator function.`); } + throw new Error(); } -function createPublicPropertyDescriptor( - proto: ComponentConstructor, - key: PropertyKey, - descriptor: PropertyDescriptor | undefined -): PropertyDescriptor { +export function createPublicPropertyDescriptor(key: string): PropertyDescriptor { return { get(this: ComponentInterface): any { const vm = getAssociatedVM(this); @@ -80,7 +47,7 @@ function createPublicPropertyDescriptor( } return; } - valueObserved(this, key); + componentValueObserved(vm, key); return vm.cmpProps[key]; }, set(this: ComponentInterface, newValue: any) { @@ -102,13 +69,10 @@ function createPublicPropertyDescriptor( } vm.cmpProps[key] = newValue; - // avoid notification of observability if the instance is already dirty - if (isFalse(vm.isDirty)) { - // perf optimization to skip this step if the component is dirty already. - valueMutated(this, key); - } + componentValueMutated(vm, key); }, - enumerable: isUndefined(descriptor) ? true : descriptor.enumerable, + enumerable: true, + configurable: true, }; } @@ -148,23 +112,19 @@ class AccessorReactiveObserver extends ReactiveObserver { } } -function createPublicAccessorDescriptor( - Ctor: ComponentConstructor, +export function createPublicAccessorDescriptor( key: PropertyKey, descriptor: PropertyDescriptor ): PropertyDescriptor { - const { get, set, enumerable } = descriptor; + const { get, set, enumerable, configurable } = descriptor; if (!isFunction(get)) { if (process.env.NODE_ENV !== 'production') { - assert.fail( - `Invalid attempt to create public property descriptor ${toString( - key - )} in ${Ctor}. It is missing the getter declaration with @api get ${toString( - key - )}() {} syntax.` + assert.invariant( + isFunction(get), + `Invalid compiler output for public accessor ${toString(key)} decorated with @api` ); } - throw new TypeError(); + throw new Error(); } return { get(this: ComponentInterface): any { @@ -217,5 +177,6 @@ function createPublicAccessorDescriptor( } }, enumerable, + configurable, }; } diff --git a/packages/@lwc/engine/src/framework/decorators/decorate.ts b/packages/@lwc/engine/src/framework/decorators/decorate.ts deleted file mode 100644 index 7e3ceceff3..0000000000 --- a/packages/@lwc/engine/src/framework/decorators/decorate.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import { - defineProperty, - getOwnPropertyDescriptor, - getOwnPropertyNames, - isFunction, - isUndefined, -} from '@lwc/shared'; - -export type DecoratorFunction = ( - Ctor: any, - key: PropertyKey, - descriptor: PropertyDescriptor | undefined -) => PropertyDescriptor; -export type DecoratorMap = Record; - -/** - * EXPERIMENTAL: This function allows for the registration of "services" in - * LWC by exposing hooks into the component life-cycle. This API is subject - * to change or being removed. - */ -export default function decorate(Ctor: any, decorators: DecoratorMap): any { - // intentionally comparing decorators with null and undefined - if (!isFunction(Ctor) || decorators == null) { - throw new TypeError(); - } - const props = getOwnPropertyNames(decorators); - // intentionally allowing decoration of classes only for now - const target = Ctor.prototype; - for (let i = 0, len = props.length; i < len; i += 1) { - const propName = props[i]; - const decorator = decorators[propName]; - if (!isFunction(decorator)) { - throw new TypeError(); - } - const originalDescriptor = getOwnPropertyDescriptor(target, propName); - const descriptor = decorator(Ctor, propName, originalDescriptor); - if (!isUndefined(descriptor)) { - defineProperty(target, propName, descriptor); - } - } - return Ctor; // chaining -} diff --git a/packages/@lwc/engine/src/framework/decorators/register.ts b/packages/@lwc/engine/src/framework/decorators/register.ts index d42de095d1..3c6b59e7ec 100644 --- a/packages/@lwc/engine/src/framework/decorators/register.ts +++ b/packages/@lwc/engine/src/framework/decorators/register.ts @@ -4,163 +4,305 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { assert, assign, create, getOwnPropertyNames, isFunction, isUndefined } from '@lwc/shared'; +import { + assert, + create, + isFunction, + isUndefined, + forEach, + defineProperty, + getOwnPropertyDescriptor, + toString, + isFalse, +} from '@lwc/shared'; import { ComponentConstructor } from '../component'; -import wireDecorator from './wire'; -import trackDecorator from './track'; -import apiDecorator from './api'; +import { internalWireFieldDecorator } from './wire'; +import { internalTrackDecorator } from './track'; +import { createPublicPropertyDescriptor, createPublicAccessorDescriptor } from './api'; +import { + WireAdapterConstructor, + storeWiredMethodMeta, + storeWiredFieldMeta, + ConfigCallback, +} from '../wiring'; import { EmptyObject } from '../utils'; -import { getAttrNameFromPropName } from '../attributes'; -import decorate, { DecoratorMap } from './decorate'; +import { createObservedFieldPropertyDescriptor } from '../observed-fields'; -export interface PropDef { - config: number; +// data produced by compiler +type WireCompilerMeta = Record; +type TrackCompilerMeta = Record; +type MethodCompilerMeta = string[]; +type PropCompilerMeta = Record; +export enum PropType { + Field = 0, + Set = 1, + Get = 2, + GetSet = 3, +} + +interface PropCompilerDef { + config: PropType; // 0 m type: string; // TODO [#1301]: make this an enum - attr: string; } -export interface WireDef { +interface WireCompilerDef { method?: number; - [key: string]: any; + adapter: WireAdapterConstructor; + config: ConfigCallback; + hasParams: boolean; } -export interface PropsDef { - [key: string]: PropDef; +interface RegisterDecoratorMeta { + readonly publicMethods?: MethodCompilerMeta; + readonly publicProps?: PropCompilerMeta; + readonly track?: TrackCompilerMeta; + readonly wire?: WireCompilerMeta; + readonly fields?: string[]; } -export interface TrackDef { - [key: string]: 1; + +function validateObservedField( + Ctor: ComponentConstructor, + fieldName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if (!isUndefined(descriptor)) { + assert.fail(`Compiler Error: Invalid field ${fieldName} declaration.`); + } + } } -type PublicMethod = (...args: any[]) => any; -export interface MethodDef { - [key: string]: PublicMethod; + +function validateFieldDecoratedWithTrack( + Ctor: ComponentConstructor, + fieldName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if (!isUndefined(descriptor)) { + assert.fail(`Compiler Error: Invalid @track ${fieldName} declaration.`); + } + } } -export interface WireHash { - [key: string]: WireDef; + +function validateFieldDecoratedWithWire( + Ctor: ComponentConstructor, + fieldName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if (!isUndefined(descriptor)) { + assert.fail(`Compiler Error: Invalid @wire(...) ${fieldName} field declaration.`); + } + } } -export interface RegisterDecoratorMeta { - readonly publicMethods?: string[]; - readonly publicProps?: PropsDef; - readonly track?: TrackDef; - readonly wire?: WireHash; - readonly fields?: string[]; +function validateMethodDecoratedWithWire( + Ctor: ComponentConstructor, + methodName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if ( + isUndefined(descriptor) || + !isFunction(descriptor.value) || + isFalse(descriptor.writable) + ) { + assert.fail(`Compiler Error: Invalid @wire(...) ${methodName} method declaration.`); + } + } } -export interface DecoratorMeta { - wire: WireHash | undefined; - track: TrackDef; - props: PropsDef; - methods: MethodDef; - fields?: string[]; +function validateFieldDecoratedWithApi( + Ctor: ComponentConstructor, + fieldName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if (!isUndefined(descriptor)) { + assert.fail(`Compiler Error: Invalid @api ${fieldName} field declaration.`); + } + } } -const signedDecoratorToMetaMap: Map = new Map(); +function validateAccessorDecoratedWithApi( + Ctor: ComponentConstructor, + fieldName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if (isUndefined(descriptor)) { + assert.fail(`Compiler Error: Invalid @api get ${fieldName} accessor declaration.`); + } else if (isFunction(descriptor.set)) { + assert.isTrue( + isFunction(descriptor.get), + `Compiler Error: Missing getter for property ${toString( + fieldName + )} decorated with @api in ${Ctor}. You cannot have a setter without the corresponding getter.` + ); + } else if (!isFunction(descriptor.get)) { + assert.fail(`Compiler Error: Missing @api get ${fieldName} accessor declaration.`); + } + } +} + +function validateMethodDecoratedWithApi( + Ctor: ComponentConstructor, + methodName: string, + descriptor: PropertyDescriptor | undefined +) { + if (process.env.NODE_ENV !== 'production') { + if ( + isUndefined(descriptor) || + !isFunction(descriptor.value) || + isFalse(descriptor.writable) + ) { + assert.fail(`Compiler Error: Invalid @api ${methodName} method declaration.`); + } + } +} /** * INTERNAL: This function can only be invoked by compiled code. The compiler - * will prevent this function from being imported by userland code. + * will prevent this function from being imported by user-land code. */ export function registerDecorators( Ctor: ComponentConstructor, meta: RegisterDecoratorMeta ): ComponentConstructor { - const decoratorMap: DecoratorMap = create(null); - const props = getPublicPropertiesHash(Ctor, meta.publicProps); - const methods = getPublicMethodsHash(Ctor, meta.publicMethods); - const wire = getWireHash(Ctor, meta.wire); - const track = getTrackHash(Ctor, meta.track); - const fields = meta.fields; - signedDecoratorToMetaMap.set(Ctor, { - props, - methods, - wire, - track, - fields, - }); - for (const propName in props) { - decoratorMap[propName] = apiDecorator; - } - if (wire) { - for (const propName in wire) { - const wireDef = wire[propName]; - if (wireDef.method) { - // for decorated methods we need to do nothing - continue; + const proto = Ctor.prototype; + const { publicProps, publicMethods, wire, track, fields } = meta; + const apiMethods: PropertyDescriptorMap = create(null); + const apiFields: PropertyDescriptorMap = create(null); + const wiredMethods: PropertyDescriptorMap = create(null); + const wiredFields: PropertyDescriptorMap = create(null); + const observedFields: PropertyDescriptorMap = create(null); + const apiFieldsConfig: Record = create(null); + let descriptor: PropertyDescriptor | undefined; + if (!isUndefined(publicProps)) { + for (const fieldName in publicProps) { + const propConfig = publicProps[fieldName]; + apiFieldsConfig[fieldName] = propConfig.config; + + descriptor = getOwnPropertyDescriptor(proto, fieldName); + if (propConfig.config > 0) { + // accessor declaration + if (process.env.NODE_ENV !== 'production') { + validateAccessorDecoratedWithApi(Ctor, fieldName, descriptor); + } + if (isUndefined(descriptor)) { + throw new Error(); + } + descriptor = createPublicAccessorDescriptor(fieldName, descriptor); + } else { + // field declaration + if (process.env.NODE_ENV !== 'production') { + validateFieldDecoratedWithApi(Ctor, fieldName, descriptor); + } + descriptor = createPublicPropertyDescriptor(fieldName); } - decoratorMap[propName] = wireDecorator(wireDef.adapter, wireDef.params); + apiFields[fieldName] = descriptor; + defineProperty(proto, fieldName, descriptor); } } - if (track) { - for (const propName in track) { - decoratorMap[propName] = trackDecorator; + if (!isUndefined(publicMethods)) { + forEach.call(publicMethods, methodName => { + descriptor = getOwnPropertyDescriptor(proto, methodName); + if (process.env.NODE_ENV !== 'production') { + validateMethodDecoratedWithApi(Ctor, methodName, descriptor); + } + if (isUndefined(descriptor)) { + throw new Error(); + } + apiMethods[methodName] = descriptor; + }); + } + if (!isUndefined(wire)) { + for (const fieldOrMethodName in wire) { + const { adapter, method, config: configCallback, hasParams } = wire[fieldOrMethodName]; + descriptor = getOwnPropertyDescriptor(proto, fieldOrMethodName); + if (method === 1) { + if (process.env.NODE_ENV !== 'production') { + assert.isTrue( + adapter, + `@wire on method "${fieldOrMethodName}": adapter id must be truthy.` + ); + validateMethodDecoratedWithWire(Ctor, fieldOrMethodName, descriptor); + } + if (isUndefined(descriptor)) { + throw new Error(); + } + wiredMethods[fieldOrMethodName] = descriptor; + storeWiredMethodMeta(descriptor, adapter, configCallback, hasParams); + } else { + if (process.env.NODE_ENV !== 'production') { + assert.isTrue( + adapter, + `@wire on field "${fieldOrMethodName}": adapter id must be truthy.` + ); + validateFieldDecoratedWithWire(Ctor, fieldOrMethodName, descriptor); + } + descriptor = internalWireFieldDecorator(fieldOrMethodName); + wiredFields[fieldOrMethodName] = descriptor; + storeWiredFieldMeta(descriptor, adapter, configCallback, hasParams); + defineProperty(proto, fieldOrMethodName, descriptor); + } } } - decorate(Ctor, decoratorMap); - return Ctor; -} -export function getDecoratorsRegisteredMeta(Ctor: ComponentConstructor): DecoratorMeta | undefined { - return signedDecoratorToMetaMap.get(Ctor); -} - -function getTrackHash(target: ComponentConstructor, track: TrackDef | undefined): TrackDef { - if (isUndefined(track) || getOwnPropertyNames(track).length === 0) { - return EmptyObject; + if (!isUndefined(track)) { + for (const fieldName in track) { + descriptor = getOwnPropertyDescriptor(proto, fieldName); + if (process.env.NODE_ENV !== 'production') { + validateFieldDecoratedWithTrack(Ctor, fieldName, descriptor); + } + descriptor = internalTrackDecorator(fieldName); + defineProperty(proto, fieldName, descriptor); + } } - - // TODO [#1302]: check that anything in `track` is correctly defined in the prototype - return assign(create(null), track); + if (!isUndefined(fields)) { + for (let i = 0, n = fields.length; i < n; i++) { + const fieldName = fields[i]; + descriptor = getOwnPropertyDescriptor(proto, fieldName); + if (process.env.NODE_ENV !== 'production') { + validateObservedField(Ctor, fieldName, descriptor); + } + observedFields[fieldName] = createObservedFieldPropertyDescriptor(fieldName); + } + } + setDecoratorsMeta(Ctor, { + apiMethods, + apiFields, + apiFieldsConfig, + wiredMethods, + wiredFields, + observedFields, + }); + return Ctor; } -function getWireHash( - target: ComponentConstructor, - wire: WireHash | undefined -): WireHash | undefined { - if (isUndefined(wire) || getOwnPropertyNames(wire).length === 0) { - return; - } +const signedDecoratorToMetaMap: Map = new Map(); - // TODO [#1302]: check that anything in `wire` is correctly defined in the prototype - return assign(create(null), wire); +interface DecoratorMeta { + readonly apiMethods: PropertyDescriptorMap; + readonly apiFields: PropertyDescriptorMap; + readonly apiFieldsConfig: Record; + readonly wiredMethods: PropertyDescriptorMap; + readonly wiredFields: PropertyDescriptorMap; + readonly observedFields: PropertyDescriptorMap; } -function getPublicPropertiesHash( - target: ComponentConstructor, - props: PropsDef | undefined -): PropsDef { - if (isUndefined(props) || getOwnPropertyNames(props).length === 0) { - return EmptyObject; - } - return getOwnPropertyNames(props).reduce((propsHash: PropsDef, propName: string): PropsDef => { - const attr = getAttrNameFromPropName(propName); - propsHash[propName] = assign( - { - config: 0, - type: 'any', - attr, - }, - props[propName] - ); - return propsHash; - }, create(null)); +function setDecoratorsMeta(Ctor: ComponentConstructor, meta: DecoratorMeta) { + signedDecoratorToMetaMap.set(Ctor, meta); } -function getPublicMethodsHash( - target: ComponentConstructor, - publicMethods: string[] | undefined -): MethodDef { - if (isUndefined(publicMethods) || publicMethods.length === 0) { - return EmptyObject; - } - return publicMethods.reduce((methodsHash: MethodDef, methodName: string): MethodDef => { - const method = (target.prototype as any)[methodName]; - - if (process.env.NODE_ENV !== 'production') { - assert.isTrue( - isFunction(method), - `Component "${target.name}" should have a method \`${methodName}\` instead of ${method}.` - ); - } +const defaultMeta: DecoratorMeta = { + apiMethods: EmptyObject, + apiFields: EmptyObject, + apiFieldsConfig: EmptyObject, + wiredMethods: EmptyObject, + wiredFields: EmptyObject, + observedFields: EmptyObject, +}; - methodsHash[methodName] = method; - return methodsHash; - }, create(null)); +export function getDecoratorsMeta(Ctor: ComponentConstructor): DecoratorMeta { + const meta = signedDecoratorToMetaMap.get(Ctor); + return isUndefined(meta) ? defaultMeta : meta; } diff --git a/packages/@lwc/engine/src/framework/decorators/track.ts b/packages/@lwc/engine/src/framework/decorators/track.ts index fcc6fdf94e..0612e9a86b 100644 --- a/packages/@lwc/engine/src/framework/decorators/track.ts +++ b/packages/@lwc/engine/src/framework/decorators/track.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { assert, isFalse, isUndefined, toString } from '@lwc/shared'; -import { valueObserved, valueMutated } from '../../libs/mutation-tracker'; +import { assert, toString } from '@lwc/shared'; +import { componentValueObserved, componentValueMutated } from '../mutation-tracker'; import { isInvokingRender } from '../invoker'; import { getAssociatedVM } from '../vm'; import { reactiveMembrane } from '../membrane'; @@ -13,57 +13,33 @@ import { ComponentInterface } from '../component'; import { isUpdatingTemplate, getVMBeingRendered } from '../template'; /** - * @track decorator to mark fields as reactive in - * LWC Components. This function implements the internals of this - * decorator. + * @track decorator function to mark field value as reactive in + * LWC Components. This function can also be invoked directly + * with any value to obtain the trackable version of the value. */ export default function track( target: any, - prop: PropertyKey, - descriptor?: PropertyDescriptor -): any { + propertyKey: string, + descriptor: PropertyDescriptor +): any; +export default function track(target: any): any { if (arguments.length === 1) { return reactiveMembrane.getProxy(target); } if (process.env.NODE_ENV !== 'production') { - if (arguments.length !== 3) { - assert.fail( - `@track decorator can only be used with one argument to return a trackable object, or as a decorator function.` - ); - } - if (!isUndefined(descriptor)) { - const { get, set, configurable, writable } = descriptor; - assert.isTrue( - !get && !set, - `Compiler Error: A @track decorator can only be applied to a public field.` - ); - assert.isTrue( - configurable !== false, - `Compiler Error: A @track decorator can only be applied to a configurable property.` - ); - assert.isTrue( - writable !== false, - `Compiler Error: A @track decorator can only be applied to a writable property.` - ); - } + assert.fail( + `@track decorator can only be used with one argument to return a trackable object, or as a decorator function.` + ); } - return createTrackedPropertyDescriptor( - target, - prop, - isUndefined(descriptor) ? true : descriptor.enumerable === true - ); + throw new Error(); } -export function createTrackedPropertyDescriptor( - Ctor: any, - key: PropertyKey, - enumerable: boolean -): PropertyDescriptor { +export function internalTrackDecorator(key: string): PropertyDescriptor { return { get(this: ComponentInterface): any { const vm = getAssociatedVM(this); - valueObserved(this, key); - return vm.cmpTrack[key]; + componentValueObserved(vm, key); + return vm.cmpFields[key]; }, set(this: ComponentInterface, newValue: any) { const vm = getAssociatedVM(this); @@ -83,15 +59,13 @@ export function createTrackedPropertyDescriptor( ); } const reactiveOrAnyValue = reactiveMembrane.getProxy(newValue); - if (reactiveOrAnyValue !== vm.cmpTrack[key]) { - vm.cmpTrack[key] = reactiveOrAnyValue; - if (isFalse(vm.isDirty)) { - // perf optimization to skip this step if the track property is on a component that is already dirty - valueMutated(this, key); - } + if (reactiveOrAnyValue !== vm.cmpFields[key]) { + vm.cmpFields[key] = reactiveOrAnyValue; + + componentValueMutated(vm, key); } }, - enumerable, + enumerable: true, configurable: true, }; } diff --git a/packages/@lwc/engine/src/framework/decorators/wire.ts b/packages/@lwc/engine/src/framework/decorators/wire.ts index 4e4c52ef1e..cec802ddb8 100644 --- a/packages/@lwc/engine/src/framework/decorators/wire.ts +++ b/packages/@lwc/engine/src/framework/decorators/wire.ts @@ -4,53 +4,44 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { assert, isObject, isUndefined } from '@lwc/shared'; -import { createTrackedPropertyDescriptor } from './track'; -import { DecoratorFunction } from './decorate'; -import { ComponentConstructor } from '../component'; - -function wireDecorator( - target: ComponentConstructor, - prop: PropertyKey, - descriptor: PropertyDescriptor | undefined -): PropertyDescriptor | any { - if (process.env.NODE_ENV !== 'production') { - if (!isUndefined(descriptor)) { - const { get, set, configurable, writable } = descriptor; - assert.isTrue( - !get && !set, - `Compiler Error: A @wire decorator can only be applied to a public field.` - ); - assert.isTrue( - configurable !== false, - `Compiler Error: A @wire decorator can only be applied to a configurable property.` - ); - assert.isTrue( - writable !== false, - `Compiler Error: A @wire decorator can only be applied to a writable property.` - ); - } - } - return createTrackedPropertyDescriptor( - target, - prop, - isObject(descriptor) ? descriptor.enumerable === true : true - ); -} +import { assert } from '@lwc/shared'; +import { ComponentInterface } from '../component'; +import { componentValueObserved } from '../mutation-tracker'; +import { getAssociatedVM } from '../vm'; +import { WireAdapterConstructor } from '../wiring'; /** * @wire decorator to wire fields and methods to a wire adapter in * LWC Components. This function implements the internals of this * decorator. */ -export default function wire(_adapter: any, _config: any): DecoratorFunction { - const len = arguments.length; - if (len > 0 && len < 3) { - return wireDecorator; - } else { - if (process.env.NODE_ENV !== 'production') { - assert.fail('@wire(adapter, config?) may only be used as a decorator.'); - } - throw new TypeError(); +export default function wire( + _adapter: WireAdapterConstructor, + _config?: Record +): PropertyDecorator | MethodDecorator { + if (process.env.NODE_ENV !== 'production') { + assert.fail('@wire(adapter, config?) may only be used as a decorator.'); } + throw new Error(); +} + +export function internalWireFieldDecorator(key: string): PropertyDescriptor { + return { + get(this: ComponentInterface): any { + const vm = getAssociatedVM(this); + componentValueObserved(vm, key); + return vm.cmpFields[key]; + }, + set(this: ComponentInterface, value: any) { + const vm = getAssociatedVM(this); + /** + * intentionally ignoring the reactivity here since this is just + * letting the author to do the wrong thing, but it will keep our + * system to be backward compatible. + */ + vm.cmpFields[key] = value; + }, + enumerable: true, + configurable: true, + }; } diff --git a/packages/@lwc/engine/src/framework/def.ts b/packages/@lwc/engine/src/framework/def.ts index b5450bec7a..0b2a2af818 100644 --- a/packages/@lwc/engine/src/framework/def.ts +++ b/packages/@lwc/engine/src/framework/def.ts @@ -14,60 +14,56 @@ */ import { - ArrayReduce, assert, assign, create, defineProperties, freeze, - getOwnPropertyNames, getPrototypeOf, isFunction, isNull, isUndefined, setPrototypeOf, + keys, } from '@lwc/shared'; import { getAttrNameFromPropName } from './attributes'; +import { EmptyObject } from './utils'; import { ComponentConstructor, ErrorCallback, ComponentMeta, getComponentRegisteredMeta, } from './component'; -import { createObservedFieldsDescriptorMap } from './observed-fields'; import { Template } from './template'; -import { HTMLElementOriginalDescriptors } from './html-properties'; -import { BaseLightningElement } from './base-lightning-element'; +import { BaseLightningElement, lightningBasedDescriptors } from './base-lightning-element'; +import { PropType, getDecoratorsMeta } from './decorators/register'; +import { defaultEmptyTemplate } from './secure-template'; + import { BaseBridgeElement, HTMLBridgeElementFactory, HTMLElementConstructor, } from './base-bridge-element'; -import { - getDecoratorsRegisteredMeta, - DecoratorMeta, - PropsDef, - WireHash, - MethodDef, - TrackDef, -} from './decorators/register'; -import { defaultEmptyTemplate } from './secure-template'; import { getAssociatedVMIfPresent } from './vm'; import { isCircularModuleDependency, resolveCircularModuleDependency, } from '../shared/circular-module-dependencies'; -export interface ComponentDef extends DecoratorMeta { +export interface ComponentDef { name: string; + wire: PropertyDescriptorMap | undefined; + props: PropertyDescriptorMap; + propsConfig: Record; + methods: PropertyDescriptorMap; template: Template; ctor: ComponentConstructor; bridge: HTMLElementConstructor; connectedCallback?: () => void; disconnectedCallback?: () => void; renderedCallback?: () => void; - render: () => Template; errorCallback?: ErrorCallback; + render: () => Template; } const CtorToDefMap: WeakMap = new WeakMap(); @@ -116,19 +112,15 @@ function createComponentDef( const { name } = meta; let { template } = meta; - const decoratorsMeta = getDecoratorsRegisteredMeta(Ctor); - let props: PropsDef = {}; - let methods: MethodDef = {}; - let wire: WireHash | undefined; - let track: TrackDef = {}; - let fields: string[] | undefined; - if (!isUndefined(decoratorsMeta)) { - props = decoratorsMeta.props; - methods = decoratorsMeta.methods; - wire = decoratorsMeta.wire; - track = decoratorsMeta.track; - fields = decoratorsMeta.fields; - } + const decoratorsMeta = getDecoratorsMeta(Ctor); + const { + apiFields, + apiFieldsConfig, + apiMethods, + wiredFields, + wiredMethods, + observedFields, + } = decoratorsMeta; const proto = Ctor.prototype; let { @@ -139,45 +131,37 @@ function createComponentDef( render, } = proto; const superProto = getCtorProto(Ctor, subclassComponentName); - const superDef = - superProto !== BaseLightningElement - ? getComponentDef(superProto, subclassComponentName) - : null; + const superDef: ComponentDef = + (superProto as any) !== BaseLightningElement + ? getComponentInternalDef(superProto, subclassComponentName) + : lightingElementDef; const SuperBridge = isNull(superDef) ? BaseBridgeElement : superDef.bridge; - const bridge = HTMLBridgeElementFactory( - SuperBridge, - getOwnPropertyNames(props), - getOwnPropertyNames(methods) + const bridge = HTMLBridgeElementFactory(SuperBridge, keys(apiFields), keys(apiMethods)); + const props: PropertyDescriptorMap = assign(create(null), superDef.props, apiFields); + const propsConfig = assign(create(null), superDef.propsConfig, apiFieldsConfig); + const methods: PropertyDescriptorMap = assign(create(null), superDef.methods, apiMethods); + const wire: PropertyDescriptorMap = assign( + create(null), + superDef.wire, + wiredFields, + wiredMethods ); - if (!isNull(superDef)) { - props = assign(create(null), superDef.props, props); - methods = assign(create(null), superDef.methods, methods); - wire = superDef.wire || wire ? assign(create(null), superDef.wire, wire) : undefined; - track = assign(create(null), superDef.track, track); - connectedCallback = connectedCallback || superDef.connectedCallback; - disconnectedCallback = disconnectedCallback || superDef.disconnectedCallback; - renderedCallback = renderedCallback || superDef.renderedCallback; - errorCallback = errorCallback || superDef.errorCallback; - render = render || superDef.render; - template = template || superDef.template; - } - props = assign(create(null), HTML_PROPS, props); + connectedCallback = connectedCallback || superDef.connectedCallback; + disconnectedCallback = disconnectedCallback || superDef.disconnectedCallback; + renderedCallback = renderedCallback || superDef.renderedCallback; + errorCallback = errorCallback || superDef.errorCallback; + render = render || superDef.render; + template = template || superDef.template; - if (!isUndefined(fields)) { - defineProperties(proto, createObservedFieldsDescriptorMap(fields)); - } - - if (isUndefined(template)) { - // default template - template = defaultEmptyTemplate; - } + // installing observed fields into the prototype. + defineProperties(proto, observedFields); const def: ComponentDef = { ctor: Ctor, name, wire, - track, props, + propsConfig, methods, bridge, template, @@ -234,11 +218,7 @@ export function isComponentConstructor(ctor: unknown): ctor is ComponentConstruc return false; } -/** - * EXPERIMENTAL: This function allows for the collection of internal component metadata. This API is - * subject to change or being removed. - */ -export function getComponentDef(Ctor: unknown, name?: string): ComponentDef { +export function getComponentInternalDef(Ctor: unknown, name?: string): ComponentDef { let def = CtorToDefMap.get(Ctor); if (isUndefined(def)) { @@ -292,16 +272,60 @@ export function setElementProto(elm: Element, def: ComponentDef) { setPrototypeOf(elm, def.bridge.prototype); } -const HTML_PROPS = ArrayReduce.call( - getOwnPropertyNames(HTMLElementOriginalDescriptors), - (props, propName) => { - const attrName = getAttrNameFromPropName(propName); - (props as PropsDef)[propName] = { - config: 3, - type: 'any', - attr: attrName, +const lightingElementDef: ComponentDef = { + ctor: BaseLightningElement, + name: BaseLightningElement.name, + props: lightningBasedDescriptors, + propsConfig: EmptyObject, + methods: EmptyObject, + wire: EmptyObject, + bridge: BaseBridgeElement, + template: defaultEmptyTemplate, + render: BaseLightningElement.prototype.render, +}; + +interface PropDef { + config: number; + type: string; + attr: string; +} +type PublicMethod = (...args: any[]) => any; +interface PublicComponentDef { + name: string; + props: Record; + methods: Record; + ctor: ComponentConstructor; +} + +/** + * EXPERIMENTAL: This function allows for the collection of internal component metadata. This API is + * subject to change or being removed. + */ +export function getComponentDef(Ctor: any, subclassComponentName?: string): PublicComponentDef { + const def = getComponentInternalDef(Ctor, subclassComponentName); + // From the internal def object, we need to extract the info that is useful + // for some external services, e.g.: Locker Service, usually, all they care + // is about the shape of the constructor, the internals of it are not relevant + // because they don't have a way to mess with that. + const { ctor, name, props, propsConfig, methods } = def; + const publicProps: Record = {}; + for (const key in props) { + // avoid leaking the reference to the public props descriptors + publicProps[key] = { + config: propsConfig[key] || 0, // a property by default + type: 'any', // no type inference for public services + attr: getAttrNameFromPropName(key), }; - return props; - }, - create(null) -) as PropsDef; + } + const publicMethods: Record = {}; + for (const key in methods) { + // avoid leaking the reference to the public method descriptors + publicMethods[key] = methods[key].value as (...args: any[]) => any; + } + return { + ctor, + name, + props: publicProps, + methods: publicMethods, + }; +} diff --git a/packages/@lwc/engine/src/framework/hooks.ts b/packages/@lwc/engine/src/framework/hooks.ts index 3564755e38..2023195feb 100644 --- a/packages/@lwc/engine/src/framework/hooks.ts +++ b/packages/@lwc/engine/src/framework/hooks.ts @@ -26,7 +26,7 @@ import modStaticClassName from './modules/static-class-attr'; import modStaticStyle from './modules/static-style-attr'; import { updateDynamicChildren, updateStaticChildren } from '../3rdparty/snabbdom/snabbdom'; import { patchElementWithRestrictions, unlockDomMutation, lockDomMutation } from './restrictions'; -import { getComponentDef, setElementProto } from './def'; +import { getComponentInternalDef, setElementProto } from './def'; const noop = () => void 0; @@ -186,7 +186,7 @@ export function createViewModelHook(vnode: VCustomElement) { return; } const { mode, ctor, owner } = vnode; - const def = getComponentDef(ctor); + const def = getComponentInternalDef(ctor); setElementProto(elm, def); if (isTrue(useSyntheticShadow)) { const { shadowAttribute } = owner.context; diff --git a/packages/@lwc/engine/src/framework/main.ts b/packages/@lwc/engine/src/framework/main.ts index 179b69e01e..79be67a6e5 100644 --- a/packages/@lwc/engine/src/framework/main.ts +++ b/packages/@lwc/engine/src/framework/main.ts @@ -11,6 +11,7 @@ import '../polyfills/aria-properties/main'; // TODO [#1296]: Revisit these exports and figure out a better separation export { createElement } from './upgrade'; +export { createContextProvider } from './context-provider'; export { getComponentDef, isComponentConstructor, getComponentConstructor } from './def'; export { BaseLightningElement as LightningElement } from './base-lightning-element'; export { register } from './services'; @@ -22,9 +23,8 @@ export { isNodeFromTemplate } from './vm'; export { default as api } from './decorators/api'; export { default as track } from './decorators/track'; -export { default as readonly } from './decorators/readonly'; export { default as wire } from './decorators/wire'; -export { default as decorate } from './decorators/decorate'; +export { readonly } from './readonly'; export { deprecatedBuildCustomElementConstructor as buildCustomElementConstructor } from './wc'; export { setFeatureFlag, setFeatureFlagForTest } from '@lwc/features'; diff --git a/packages/@lwc/engine/src/framework/membrane.ts b/packages/@lwc/engine/src/framework/membrane.ts index dbdc1a244a..53ed51aa99 100644 --- a/packages/@lwc/engine/src/framework/membrane.ts +++ b/packages/@lwc/engine/src/framework/membrane.ts @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import ObservableMembrane from 'observable-membrane'; -import { valueObserved, valueMutated } from '../libs/mutation-tracker'; +import { valueObserved, valueMutated } from './mutation-tracker'; function valueDistortion(value: any) { return value; diff --git a/packages/@lwc/engine/src/framework/mutation-tracker.ts b/packages/@lwc/engine/src/framework/mutation-tracker.ts new file mode 100644 index 0000000000..2cb6457927 --- /dev/null +++ b/packages/@lwc/engine/src/framework/mutation-tracker.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { valueMutated, valueObserved } from '../libs/mutation-tracker'; +import { VM } from './vm'; + +export function componentValueMutated(vm: VM, key: PropertyKey) { + valueMutated(vm.component, key); +} + +export function componentValueObserved(vm: VM, key: PropertyKey) { + valueObserved(vm.component, key); +} + +export * from '../libs/mutation-tracker'; diff --git a/packages/@lwc/engine/src/framework/observed-fields.ts b/packages/@lwc/engine/src/framework/observed-fields.ts index 87d2ba8ee5..baac137ab6 100644 --- a/packages/@lwc/engine/src/framework/observed-fields.ts +++ b/packages/@lwc/engine/src/framework/observed-fields.ts @@ -4,37 +4,24 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { ArrayReduce, isFalse } from '@lwc/shared'; import { ComponentInterface } from './component'; import { getAssociatedVM } from './vm'; -import { valueMutated, valueObserved } from '../libs/mutation-tracker'; +import { componentValueMutated, componentValueObserved } from './mutation-tracker'; -export function createObservedFieldsDescriptorMap(fields: PropertyKey[]): PropertyDescriptorMap { - return ArrayReduce.call( - fields, - (acc, field) => { - (acc as PropertyDescriptorMap)[field] = createObservedFieldPropertyDescriptor(field); - - return acc; - }, - {} - ) as PropertyDescriptorMap; -} - -function createObservedFieldPropertyDescriptor(key: PropertyKey): PropertyDescriptor { +export function createObservedFieldPropertyDescriptor(key: string): PropertyDescriptor { return { get(this: ComponentInterface): any { const vm = getAssociatedVM(this); - valueObserved(this, key); - return vm.cmpTrack[key]; + componentValueObserved(vm, key); + return vm.cmpFields[key]; }, set(this: ComponentInterface, newValue: any) { const vm = getAssociatedVM(this); - if (newValue !== vm.cmpTrack[key]) { - vm.cmpTrack[key] = newValue; - if (isFalse(vm.isDirty)) { - valueMutated(this, key); - } + + if (newValue !== vm.cmpFields[key]) { + vm.cmpFields[key] = newValue; + + componentValueMutated(vm, key); } }, enumerable: true, diff --git a/packages/@lwc/engine/src/framework/decorators/readonly.ts b/packages/@lwc/engine/src/framework/readonly.ts similarity index 89% rename from packages/@lwc/engine/src/framework/decorators/readonly.ts rename to packages/@lwc/engine/src/framework/readonly.ts index 37b366ffe9..8cbe99d652 100644 --- a/packages/@lwc/engine/src/framework/decorators/readonly.ts +++ b/packages/@lwc/engine/src/framework/readonly.ts @@ -5,14 +5,14 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { assert } from '@lwc/shared'; -import { reactiveMembrane } from '../membrane'; +import { reactiveMembrane } from './membrane'; /** * EXPERIMENTAL: This function allows you to create a reactive readonly * membrane around any object value. This API is subject to change or * being removed. */ -export default function readonly(obj: any): any { +export function readonly(obj: any): any { if (process.env.NODE_ENV !== 'production') { // TODO [#1292]: Remove the readonly decorator if (arguments.length !== 1) { diff --git a/packages/@lwc/engine/src/framework/services.ts b/packages/@lwc/engine/src/framework/services.ts index 540b5aef8f..cc325bf568 100644 --- a/packages/@lwc/engine/src/framework/services.ts +++ b/packages/@lwc/engine/src/framework/services.ts @@ -18,20 +18,18 @@ type ServiceCallback = ( context: Context ) => void; interface ServiceDef { - wiring?: ServiceCallback; connected?: ServiceCallback; disconnected?: ServiceCallback; rendered?: ServiceCallback; } export const Services: { - wiring?: ServiceCallback[]; connected?: ServiceCallback[]; disconnected?: ServiceCallback[]; rendered?: ServiceCallback[]; } = create(null); -const hooks: Array = ['wiring', 'rendered', 'connected', 'disconnected']; +const hooks: Array = ['rendered', 'connected', 'disconnected']; /** * EXPERIMENTAL: This function allows for the registration of "services" diff --git a/packages/@lwc/engine/src/framework/upgrade.ts b/packages/@lwc/engine/src/framework/upgrade.ts index 549073149e..d64ff3d0d2 100644 --- a/packages/@lwc/engine/src/framework/upgrade.ts +++ b/packages/@lwc/engine/src/framework/upgrade.ts @@ -27,7 +27,7 @@ import { disconnectedRootElement, } from './vm'; import { ComponentConstructor } from './component'; -import { getComponentDef, setElementProto } from './def'; +import { getComponentInternalDef, setElementProto } from './def'; type NodeSlotCallback = (element: Node) => {}; @@ -112,7 +112,7 @@ export function createElement( return element; } - const def = getComponentDef(Ctor); + const def = getComponentInternalDef(Ctor); setElementProto(element, def); createVM(element, def.ctor, { diff --git a/packages/@lwc/engine/src/framework/utils.ts b/packages/@lwc/engine/src/framework/utils.ts index fb9811a6e7..4b6573c419 100644 --- a/packages/@lwc/engine/src/framework/utils.ts +++ b/packages/@lwc/engine/src/framework/utils.ts @@ -44,3 +44,13 @@ export function addCallbackToNextTick(callback: Callback) { } export const useSyntheticShadow = hasOwnProperty.call(Element.prototype, '$shadowToken$'); + +export function guid(): string { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); +} diff --git a/packages/@lwc/engine/src/framework/vm.ts b/packages/@lwc/engine/src/framework/vm.ts index 63179a624c..17e619eed1 100644 --- a/packages/@lwc/engine/src/framework/vm.ts +++ b/packages/@lwc/engine/src/framework/vm.ts @@ -20,11 +20,11 @@ import { createHiddenField, getHiddenField, setHiddenField, + getOwnPropertyNames, } from '@lwc/shared'; -import { getComponentDef } from './def'; +import { getComponentInternalDef } from './def'; import { createComponent, - linkComponent, renderComponent, ComponentConstructor, markComponentAsDirty, @@ -48,9 +48,10 @@ import { } from './performance-timing'; import { updateDynamicChildren, updateStaticChildren } from '../3rdparty/snabbdom/snabbdom'; import { hasDynamicChildren } from './hooks'; -import { ReactiveObserver } from '../libs/mutation-tracker'; +import { ReactiveObserver } from './mutation-tracker'; import { LightningElement } from './base-lightning-element'; import { getErrorComponentStack } from '../shared/format'; +import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring'; export interface SlotSet { [key: string]: VNodes; @@ -81,9 +82,9 @@ export interface UninitializedVM { /** Adopted Children List */ aChildren: VNodes; velements: VCustomElement[]; - cmpProps: any; + cmpProps: Record; cmpSlots: SlotSet; - cmpTrack: any; + cmpFields: Record; callHook: ( cmp: ComponentInterface | undefined, fn: (...args: any[]) => any, @@ -214,7 +215,7 @@ export function createVM( `VM creation requires a DOM element instead of ${elm}.` ); } - const def = getComponentDef(Ctor); + const def = getComponentInternalDef(Ctor); const { isRoot, mode, owner } = options; idx += 1; const uninitializedVm: UninitializedVM = { @@ -232,7 +233,7 @@ export function createVM( data: EmptyObject, context: create(null), cmpProps: create(null), - cmpTrack: create(null), + cmpFields: create(null), cmpSlots: useSyntheticShadow ? create(null) : undefined, callHook, setHook, @@ -259,7 +260,10 @@ export function createVM( // link component to the wire service const initializedVm = uninitializedVm as VM; - linkComponent(initializedVm); + // initializing the wire decorator per instance only when really needed + if (hasWireAdapters(initializedVm)) { + installWireAdapters(initializedVm); + } return initializedVm; } @@ -404,6 +408,9 @@ export function runConnectedCallback(vm: VM) { if (connected) { invokeServiceHook(vm, connected); } + if (hasWireAdapters(vm)) { + connectWireAdapters(vm); + } const { connectedCallback } = vm.def; if (!isUndefined(connectedCallback)) { if (process.env.NODE_ENV !== 'production') { @@ -418,6 +425,10 @@ export function runConnectedCallback(vm: VM) { } } +function hasWireAdapters(vm: VM): boolean { + return getOwnPropertyNames(vm.def.wire).length > 0; +} + function runDisconnectedCallback(vm: VM) { if (process.env.NODE_ENV !== 'production') { assert.isTrue(vm.state !== VMState.disconnected, `${vm} must be inserted.`); @@ -435,6 +446,9 @@ function runDisconnectedCallback(vm: VM) { if (disconnected) { invokeServiceHook(vm, disconnected); } + if (hasWireAdapters(vm)) { + disconnectWireAdapters(vm); + } const { disconnectedCallback } = vm.def; if (!isUndefined(disconnectedCallback)) { if (process.env.NODE_ENV !== 'production') { diff --git a/packages/@lwc/engine/src/framework/wc.ts b/packages/@lwc/engine/src/framework/wc.ts index 0dc7e4ad3b..1765892acf 100644 --- a/packages/@lwc/engine/src/framework/wc.ts +++ b/packages/@lwc/engine/src/framework/wc.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { ArrayMap, getOwnPropertyNames, isUndefined } from '@lwc/shared'; +import { isUndefined, keys } from '@lwc/shared'; import { ComponentConstructor } from './component'; import { createVM, connectRootElement, disconnectedRootElement } from './vm'; -import { getComponentDef } from './def'; -import { getPropNameFromAttrName, isAttributeLocked } from './attributes'; +import { getAttrNameFromPropName, isAttributeLocked } from './attributes'; +import { getComponentInternalDef } from './def'; import { HTMLElementConstructor } from './base-bridge-element'; /** @@ -41,8 +41,14 @@ export function deprecatedBuildCustomElementConstructor( } export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLElementConstructor { - const { props, bridge: BaseElement } = getComponentDef(Ctor); + const { props, bridge: BaseElement } = getComponentInternalDef(Ctor); + // generating the hash table for attributes to avoid duplicate fields + // and facilitate validation and false positives in case of inheritance. + const attributeToPropMap: Record = {}; + for (const propName in props) { + attributeToPropMap[getAttrNameFromPropName(propName)] = propName; + } return class extends BaseElement { constructor() { super(); @@ -63,8 +69,8 @@ export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLE // ignoring similar values for better perf return; } - const propName = getPropNameFromAttrName(attrName); - if (isUndefined(props[propName])) { + const propName = attributeToPropMap[attrName]; + if (isUndefined(propName)) { // ignoring unknown attributes return; } @@ -82,9 +88,6 @@ export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLE } // collecting all attribute names from all public props to apply // the reflection from attributes to props via attributeChangedCallback. - static observedAttributes = ArrayMap.call( - getOwnPropertyNames(props), - propName => props[propName].attr - ); + static observedAttributes = keys(attributeToPropMap); }; } diff --git a/packages/@lwc/engine/src/framework/wiring.ts b/packages/@lwc/engine/src/framework/wiring.ts new file mode 100644 index 0000000000..6deb03c536 --- /dev/null +++ b/packages/@lwc/engine/src/framework/wiring.ts @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2018, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { assert, isUndefined, ArrayPush, getOwnPropertyNames, defineProperty } from '@lwc/shared'; +import { ComponentInterface } from './component'; +import { componentValueMutated, ReactiveObserver } from './mutation-tracker'; +import { VM, runWithBoundaryProtection } from './vm'; +import { invokeComponentCallback } from './invoker'; +import { dispatchEvent } from '../env/dom'; + +const DeprecatedWiredElementHost = '$$DeprecatedWiredElementHostKey$$'; + +const WireMetaMap: Map = new Map(); +function noop(): void {} + +function createFieldDataCallback(vm: VM, name: string) { + const { cmpFields } = vm; + return (value: any) => { + if (value !== vm.cmpFields[name]) { + // storing the value in the underlying storage + cmpFields[name] = value; + + componentValueMutated(vm, name); + } + }; +} + +function createMethodDataCallback(vm: VM, method: (data: any) => any) { + return (value: any) => { + // dispatching new value into the wired method + invokeComponentCallback(vm, method, [value]); + }; +} + +function createConfigWatcher( + vm: VM, + wireDef: WireDef, + callbackWhenConfigIsReady: (newConfig: ConfigValue) => void +) { + const { component } = vm; + const { configCallback } = wireDef; + let hasPendingConfig: boolean = false; + // creating the reactive observer for reactive params when needed + const ro = new ReactiveObserver(() => { + if (hasPendingConfig === false) { + hasPendingConfig = true; + // collect new config in the micro-task + Promise.resolve().then(() => { + hasPendingConfig = false; + // resetting current reactive params + ro.reset(); + // dispatching a new config due to a change in the configuration + callback(); + }); + } + }); + const callback = () => { + let config: ConfigValue; + ro.observe(() => (config = configCallback(component))); + // eslint-disable-next-line lwc-internal/no-invalid-todo + // TODO: dev-mode validation of config based on the adapter.configSchema + // @ts-ignore it is assigned in the observe() callback + callbackWhenConfigIsReady(config); + }; + return callback; +} + +function createContextWatcher( + vm: VM, + wireDef: WireDef, + callbackWhenContextIsReady: (newContext: ContextValue) => void +) { + const { adapter } = wireDef; + const adapterContextToken = getAdapterToken(adapter); + if (isUndefined(adapterContextToken)) { + return; // no provider found, nothing to be done + } + const { + elm, + context: { wiredConnecting, wiredDisconnecting }, + } = vm; + // waiting for the component to be connected to formally request the context via the token + ArrayPush.call(wiredConnecting, () => { + // This event is responsible for connecting the host element with another + // element in the composed path that is providing contextual data. The provider + // must be listening for a special dom event with the name corresponding to the value of + // `adapterContextToken`, which will remain secret and internal to this file only to + // guarantee that the linkage can be forged. + const internalDomEvent = new CustomEvent(adapterContextToken, { + bubbles: true, + composed: true, + detail(newContext: ContextValue, disconnectCallback: () => void) { + // adds this callback into the disconnect bucket so it gets disconnected from parent + // the the element hosting the wire is disconnected + ArrayPush.call(wiredDisconnecting, disconnectCallback); + // eslint-disable-next-line lwc-internal/no-invalid-todo + // TODO: dev-mode validation of config based on the adapter.contextSchema + callbackWhenContextIsReady(newContext); + }, + }); + dispatchEvent.call(elm, internalDomEvent); + }); +} + +function createConnector(vm: VM, name: string, wireDef: WireDef): WireAdapter { + const { method, adapter, configCallback, hasParams } = wireDef; + const { component } = vm; + const dataCallback = isUndefined(method) + ? createFieldDataCallback(vm, name) + : createMethodDataCallback(vm, method); + let context: ContextValue | undefined; + let connector: WireAdapter; + + // Workaround to pass the component element associated to this wire adapter instance. + defineProperty(dataCallback, DeprecatedWiredElementHost, { + value: vm.elm, + }); + + runWithBoundaryProtection( + vm, + vm, + noop, + () => { + // job + connector = new adapter(dataCallback); + }, + noop + ); + const updateConnectorConfig = (config: ConfigValue) => { + // every time the config is recomputed due to tracking, + // this callback will be invoked with the new computed config + runWithBoundaryProtection( + vm, + vm, + noop, + () => { + // job + connector.update(config, context); + }, + noop + ); + }; + + // Computes the current wire config and calls the update method on the wire adapter. + // This initial implementation may change depending on the specific wire instance, if it has params, we will need + // to observe changes in the next tick. + let computeConfigAndUpdate = () => { + updateConnectorConfig(configCallback(component)); + }; + + if (hasParams) { + // This wire has dynamic parameters: we wait for the component instance is created and its values set + // in order to call the update(config) method. + Promise.resolve().then(() => { + computeConfigAndUpdate = createConfigWatcher(vm, wireDef, updateConnectorConfig); + + computeConfigAndUpdate(); + }); + } else { + computeConfigAndUpdate(); + } + + // if the adapter needs contextualization, we need to watch for new context and push it alongside the config + if (!isUndefined(adapter.contextSchema)) { + createContextWatcher(vm, wireDef, (newContext: ContextValue) => { + // every time the context is pushed into this component, + // this callback will be invoked with the new computed context + if (context !== newContext) { + context = newContext; + // Note: when new context arrives, the config will be recomputed and pushed along side the new + // context, this is to preserve the identity characteristics, config should not have identity + // (ever), while context can have identity + computeConfigAndUpdate(); + } + }); + } + // @ts-ignore the boundary protection executes sync, connector is always defined + return connector; +} + +type DataCallback = (value: any) => void; +type ConfigValue = Record; + +interface WireAdapter { + update(config: ConfigValue, context?: ContextValue): void; + connect(): void; + disconnect(): void; +} + +type WireAdapterSchemaValue = 'optional' | 'required'; + +interface WireDef { + method?: (data: any) => void; + adapter: WireAdapterConstructor; + hasParams: boolean; + configCallback: ConfigCallback; +} + +interface WireMethodDef extends WireDef { + method: (data: any) => void; +} + +interface WireFieldDef extends WireDef { + method?: undefined; +} + +const AdapterToTokenMap: Map = new Map(); + +export function getAdapterToken(adapter: WireAdapterConstructor): string | undefined { + return AdapterToTokenMap.get(adapter); +} + +export function setAdapterToken(adapter: WireAdapterConstructor, token: string) { + AdapterToTokenMap.set(adapter, token); +} + +export type ContextValue = Record; +export type ConfigCallback = (component: ComponentInterface) => ConfigValue; +export interface WireAdapterConstructor { + new (callback: DataCallback): WireAdapter; + configSchema?: Record; + contextSchema?: Record; +} + +export function storeWiredMethodMeta( + descriptor: PropertyDescriptor, + adapter: WireAdapterConstructor, + configCallback: ConfigCallback, + hasParams: boolean +) { + // support for callable adapters + if ((adapter as any).adapter) { + adapter = (adapter as any).adapter; + } + const method = descriptor.value; + const def: WireMethodDef = { + adapter, + method, + configCallback, + hasParams, + }; + WireMetaMap.set(descriptor, def); +} + +export function storeWiredFieldMeta( + descriptor: PropertyDescriptor, + adapter: WireAdapterConstructor, + configCallback: ConfigCallback, + hasParams: boolean +) { + // support for callable adapters + if ((adapter as any).adapter) { + adapter = (adapter as any).adapter; + } + const def: WireFieldDef = { + adapter, + configCallback, + hasParams, + }; + WireMetaMap.set(descriptor, def); +} + +export function installWireAdapters(vm: VM) { + const { + def: { wire }, + } = vm; + if (getOwnPropertyNames(wire).length === 0) { + if (process.env.NODE_ENV !== 'production') { + assert.fail( + `Internal Error: wire adapters should only be installed in instances with at least one wire declaration.` + ); + } + } else { + const connect = (vm.context.wiredConnecting = []); + const disconnect = (vm.context.wiredDisconnecting = []); + for (const fieldNameOrMethod in wire) { + const descriptor = wire[fieldNameOrMethod]; + const wireDef = WireMetaMap.get(descriptor); + if (process.env.NODE_ENV !== 'production') { + assert.invariant(wireDef, `Internal Error: invalid wire definition found.`); + } + if (!isUndefined(wireDef)) { + const adapterInstance = createConnector(vm, fieldNameOrMethod, wireDef); + ArrayPush.call(connect, () => adapterInstance.connect()); + ArrayPush.call(disconnect, () => adapterInstance.disconnect()); + } + } + } +} + +export function connectWireAdapters(vm: VM) { + const { + context: { wiredConnecting }, + } = vm; + if (isUndefined(wiredConnecting)) { + if (process.env.NODE_ENV !== 'production') { + assert.fail( + `Internal Error: wire adapters must be installed in instances with at least one wire declaration.` + ); + } + } + for (let i = 0, len = wiredConnecting.length; i < len; i += 1) { + wiredConnecting[i](); + } +} + +export function disconnectWireAdapters(vm: VM) { + const { + context: { wiredDisconnecting }, + } = vm; + if (isUndefined(wiredDisconnecting)) { + if (process.env.NODE_ENV !== 'production') { + assert.fail( + `Internal Error: wire adapters must be installed in instances with at least one wire declaration.` + ); + } + } + runWithBoundaryProtection( + vm, + vm, + noop, + () => { + // job + for (let i = 0, len = wiredDisconnecting.length; i < len; i += 1) { + wiredDisconnecting[i](); + } + }, + noop + ); +} diff --git a/packages/@lwc/shared/src/language.ts b/packages/@lwc/shared/src/language.ts index 76d480fbb5..5483a5d2e4 100644 --- a/packages/@lwc/shared/src/language.ts +++ b/packages/@lwc/shared/src/language.ts @@ -24,7 +24,6 @@ const { isArray } = Array; const { filter: ArrayFilter, find: ArrayFind, - forEach, indexOf: ArrayIndexOf, join: ArrayJoin, map: ArrayMap, @@ -34,6 +33,7 @@ const { slice: ArraySlice, splice: ArraySplice, unshift: ArrayUnshift, + forEach, } = Array.prototype; const { diff --git a/packages/@lwc/wire-service/src/__tests__/index.spec.ts b/packages/@lwc/wire-service/src/__tests__/index.spec.ts index 65486731e3..3e13607df2 100644 --- a/packages/@lwc/wire-service/src/__tests__/index.spec.ts +++ b/packages/@lwc/wire-service/src/__tests__/index.spec.ts @@ -4,288 +4,256 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { registerWireService, register } from '../index'; - -import { Element, ElementDef } from '../engine'; - -import { Context, ConfigContext } from '../wiring'; -import { CONTEXT_ID, CONTEXT_CONNECTED, CONTEXT_DISCONNECTED, CONTEXT_UPDATED } from '../constants'; - -describe('wire service', () => { - describe('registers the service with engine', () => { - it('uses wiring hook', () => { - const mockEngineRegister = jest.fn(); - registerWireService(mockEngineRegister); - expect(mockEngineRegister).toHaveBeenCalledWith( - expect.objectContaining({ - wiring: expect.any(Function), - }) - ); - }); - it('uses connected hook', () => { - const mockEngineRegister = jest.fn(); - registerWireService(mockEngineRegister); - expect(mockEngineRegister).toHaveBeenCalledWith( - expect.objectContaining({ - connected: expect.any(Function), - }) - ); - }); - it('uses disconnected hook', () => { - const mockEngineRegister = jest.fn(); - registerWireService(mockEngineRegister); - expect(mockEngineRegister).toHaveBeenCalledWith( - expect.objectContaining({ - disconnected: expect.any(Function), - }) - ); +import { register, WireEventTarget, ValueChangedEvent } from '../index'; + +describe('WireEventTarget from register', () => { + describe('connected', () => { + it('should invoke connected listeners', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + const adapter = new adapterId.adapter(() => {}); + + const listener1 = jest.fn(); + const listener2 = jest.fn(); + wireEventTarget!.addEventListener('connect', listener1); + wireEventTarget!.addEventListener('connect', listener2); + + adapter.connect(); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); }); }); - describe('wiring process', () => { - it('invokes adapter factory once per wire', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ - }; - const adapterFactory = jest.fn(); + + describe('disconnected', () => { + it('should invoke disconnected listeners', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + register(adapterId, adapterFactory); - const mockDef: ElementDef = { - wire: { - targetFunction: { - adapter: adapterId, - method: 1, - }, - targetProperty: { - adapter: adapterId, - }, - }, - }; + const adapter = new adapterId.adapter(dataCallback); - wireService.wiring({} as Element, {}, mockDef, {} as Context); - expect(adapterFactory).toHaveBeenCalledTimes(2); - }); - it('throws when adapter id is not truthy', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const mockDef: ElementDef = { - wire: { - target: { - adapter: undefined, - method: 1, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": adapter id must be truthy'); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + wireEventTarget!.addEventListener('disconnect', listener1); + wireEventTarget!.addEventListener('disconnect', listener2); + + adapter.disconnect(); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); }); - it('throws when adapter factory is not found', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const mockDef: ElementDef = { - wire: { - target: { - adapter: () => { - /**/ - }, - method: 1, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": unknown adapter id: '); + }); + + describe('config', () => { + it('should invoke config listeners', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + const adapter = new adapterId.adapter(dataCallback); + + const listener1 = jest.fn(); + const listener2 = jest.fn(); + wireEventTarget!.addEventListener('config', listener1); + wireEventTarget!.addEventListener('config', listener2); + + const config = {}; + adapter.update(config); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1.mock.calls[0][0]).toBe(config); + + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener2.mock.calls[0][0]).toBe(config); }); - it('throws when dot-notation reactive parameter refers to non-@wire target', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ - }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - target: { - adapter: adapterId, - params: { p1: 'x.y' }, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError( - '@wire on "target": dot-notation reactive parameter "x.y" must refer to a @wire property' - ); + + it('should immediately fires CONFIG when there is a config ready in the adapter instance', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + const adapter = new adapterId.adapter(jest.fn()); + const expectedConfig = {}; + + adapter.update(expectedConfig); + + const listener = jest.fn(); + wireEventTarget!.addEventListener('config', listener); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toBe(expectedConfig); }); - it('throws when dot-notation reactive parameter refers to @wired method', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ - }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - x: { - adapter: adapterId, - method: 1, - }, - target: { - adapter: adapterId, - params: { p1: 'x.y' }, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError( - '@wire on "target": dot-notation reactive parameter "x.y" must refer to a @wire property' - ); + + it('should enqueue listener to be called when config is ready', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + const adapter = new adapterId.adapter(jest.fn()); + const listener = jest.fn(); + wireEventTarget!.addEventListener('config', listener); + + expect(listener).not.toBeCalled(); + + const expectedConfig = {}; + adapter.update(expectedConfig); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toBe(expectedConfig); }); - it('throws when reactive parameter refers to own wire target', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ - }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - target: { - adapter: adapterId, - params: { p1: 'target' }, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": reactive parameter "target" must not refer to self'); + }); + + describe('dispatchEvent', () => { + it('should invoke data callback when dispatchEvent', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + new adapterId.adapter(dataCallback); + + const expected = 'changed value'; + wireEventTarget!.dispatchEvent(new ValueChangedEvent(expected)); + + expect(dataCallback).toHaveBeenCalledTimes(1); + expect(dataCallback.mock.calls[0][0]).toBe(expected); }); - it('throws when reactive parameter is empty', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ + + it('should dispatchEvent in wiredComponent when dispatching event with type wirecontextevent', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const wiredElementMock = { + dispatchEvent: jest.fn(), }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - target: { - adapter: adapterId, - params: { p1: '' }, - }, + dataCallback.$$DeprecatedWiredElementHostKey$$ = wiredElementMock; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + new adapterId.adapter(dataCallback); + + const wireContextEventInLowercase = new CustomEvent('wirecontextevent', { + detail: { + foo: 'bar', }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": reactive parameters must not be empty'); - }); - it('throws when reactive parameter contains empty segment', () => { - let wireService; - registerWireService(svc => { - wireService = svc; }); - const adapterId = () => { - /**/ - }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - target: { - adapter: adapterId, - params: { p1: 'a..b' }, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": reactive parameters must not be empty'); + + wireEventTarget!.dispatchEvent(wireContextEventInLowercase); + + expect(wiredElementMock.dispatchEvent).toHaveBeenCalledTimes(1); + expect(wiredElementMock.dispatchEvent.mock.calls[0][0]).toBe( + wireContextEventInLowercase + ); }); - it('throws when reactive parameter ends with empty segment', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const adapterId = () => { - /**/ - }; - register(adapterId, adapterId); - const mockDef: ElementDef = { - wire: { - target: { - adapter: adapterId, - params: { p1: 'a.b.' }, - }, - }, - }; - expect(() => - wireService.wiring({} as Element, {}, mockDef, {} as Context) - ).toThrowError('@wire on "target": reactive parameters must not be empty'); + + it('should throw on non-ValueChangedEvent', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + new adapterId.adapter(dataCallback); + + expect(() => { + const testEvent = 'test' as any; + wireEventTarget.dispatchEvent(testEvent); + }).toThrowError('Invalid event type undefined.'); + + expect(() => { + const testEvent = new CustomEvent('test') as any; + wireEventTarget.dispatchEvent(testEvent as ValueChangedEvent); + }).toThrowError('Invalid event type test.'); }); }); - describe('connected handling', () => { - const def: ElementDef = { - wire: { - target: { adapter: true }, - }, - }; - it('invokes connected listeners', () => { - let wireService; - registerWireService(svc => { - wireService = svc; - }); - const listener = jest.fn(); - const context: Context = { - [CONTEXT_ID]: { - [CONTEXT_CONNECTED]: [listener], - [CONTEXT_DISCONNECTED]: [], - [CONTEXT_UPDATED]: {} as ConfigContext, - }, - }; - wireService.connected({} as Element, {}, def, context); - expect(listener).toHaveBeenCalledTimes(1); + describe('addEventListener', () => { + it('should throw when adding unknown event listener type', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const dataCallback = jest.fn(); + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + new adapterId.adapter(dataCallback); + + expect(() => { + wireEventTarget!.addEventListener('invalidEventType', () => {}); + }).toThrow('Invalid event type invalidEventType.'); }); }); - describe('disconnected handling', () => { - const def: ElementDef = { - wire: { - target: { adapter: true }, - }, - }; - it('invokes connected listeners', () => { - let wireService; - registerWireService(svc => { - wireService = svc; + + describe('removeEventListener', () => { + ['connect', 'disconnect', 'config'].forEach(eventType => { + it(`should remove listener from the queue for ${eventType} event`, () => { + const eventToAdapterMethod = { + connect: 'connect', + disconnect: 'disconnect', + config: 'update', + }; + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + const adapter = new adapterId.adapter(() => {}); + + const listener = jest.fn(); + wireEventTarget.addEventListener(eventType, listener); + adapter[eventToAdapterMethod[eventType]](); + + expect(listener).toHaveBeenCalledTimes(1); + + wireEventTarget.removeEventListener(eventType, listener); + adapter[eventToAdapterMethod[eventType]](); + expect(listener).toHaveBeenCalledTimes(1); }); - const listener = jest.fn(); - const context: Context = { - [CONTEXT_ID]: { - [CONTEXT_CONNECTED]: [], - [CONTEXT_DISCONNECTED]: [listener], - [CONTEXT_UPDATED]: {} as ConfigContext, - }, - }; + }); - wireService.disconnected({} as Element, {}, def, context); - expect(listener).toHaveBeenCalledTimes(1); + it('should throw when event type is not supported', () => { + const adapterId = {}; + let wireEventTarget: WireEventTarget; + const adapterFactory = (wireEvtTarget: WireEventTarget) => + (wireEventTarget = wireEvtTarget); + + register(adapterId, adapterFactory); + new adapterId.adapter(() => {}); + + expect(() => { + const testEvent = 'test' as any; + wireEventTarget.removeEventListener(testEvent, jest.fn()); + }).toThrowError('Invalid event type test.'); }); }); + + it('should invoke adapter factory once per wire', () => { + const adapterId = {}; + const dataCallback = jest.fn(); + const adapterFactory = jest.fn(); + + register(adapterId, adapterFactory); + new adapterId.adapter(dataCallback); + new adapterId.adapter(dataCallback); + + expect(adapterFactory).toHaveBeenCalledTimes(2); + }); }); describe('register', () => { @@ -295,16 +263,46 @@ describe('register', () => { function adapterFactory() {} register(adapterId, adapterFactory); }); - it('accepts symbol as adapter id', () => { - const adapterId = Symbol(); - function adapterFactory() {} - register(adapterId, adapterFactory); - }); + it('throws when adapter id is not truthy', () => { function adapterFactory() {} - expect(() => register(undefined, adapterFactory)).toThrowError('adapter id must be truthy'); + expect(() => register(undefined, adapterFactory)).toThrowError( + 'adapter id must be extensible' + ); }); + it('throws when adapter factory is not a function', () => { expect(() => register({}, {} as any)).toThrowError('adapter factory must be a callable'); }); + + it('should throw when adapter id is already associated to an adapter factory', () => { + const adapterId = { adapter: {} }; + expect(() => register(adapterId, () => {})).toThrowError( + 'adapter id is already associated to an adapter factory' + ); + }); + + it('should freeze adapter property', () => { + const adapterId = {}; + function adapterFactory() {} + register(adapterId, adapterFactory); + + expect(() => { + adapterId.adapter = 'modified'; + }).toThrow("Cannot assign to read only property 'adapter'"); + }); + + it('should freeze adapter class', () => { + const adapterId = {}; + function adapterFactory() {} + register(adapterId, adapterFactory); + + expect(() => { + adapterId.adapter.prototype.update = 'modified'; + }).toThrow('Cannot add property update, object is not extensible'); + + expect(() => { + adapterId.adapter.update = 'modified'; + }).toThrow('Cannot add property update, object is not extensible'); + }); }); diff --git a/packages/@lwc/wire-service/src/__tests__/property-trap.spec.ts b/packages/@lwc/wire-service/src/__tests__/property-trap.spec.ts deleted file mode 100644 index c50a6894be..0000000000 --- a/packages/@lwc/wire-service/src/__tests__/property-trap.spec.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import { installTrap, findDescriptor, getReactiveParameterValue, updated } from '../property-trap'; -import { ConfigContext, ReactiveParameter } from '../wiring'; - -describe('findDescriptor', () => { - it('detects circular prototype chains', () => { - function A() { - /**/ - } - function B() { - /**/ - } - B.prototype = Object.create(A.prototype); - A.prototype = Object.create(B.prototype); - const actual = findDescriptor(B, 'target'); - expect(actual).toBe(null); - }); - - it('finds descriptor on super with prototype setting', () => { - function A() { - /**/ - } - A.prototype.target = 'target'; - function B() { - /**/ - } - B.prototype = Object.create(A.prototype); - expect(findDescriptor(B, 'target')).toBe(null); - expect(findDescriptor(new B(), 'target')).not.toBe(null); - }); - - it('finds descriptor on super with classes', () => { - class A { - target: any; - constructor() { - this.target = 'target'; - } - } - class B extends A {} - expect(findDescriptor(B, 'target')).toBe(null); - expect(findDescriptor(new B(), 'target')).not.toBe(null); - }); -}); - -describe('installTrap', () => { - const context: ConfigContext = { - listeners: { - prop1: [], - }, - values: { - prop1: '', - }, - }; - const reactiveParametersGroupByHead: Array = [ - { - reference: 'prop1', - head: 'prop1', - }, - ]; - - it('defaults to original value when setter installed', () => { - class Target { - prop1 = 'initial'; - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - expect(cmp.prop1).toBe('initial'); - }); - - it('updates original property when installed setter invoked', () => { - const expected = 'expected'; - class Target { - prop1; - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - cmp.prop1 = expected; - expect(cmp.prop1).toBe(expected); - }); - - it('installs setter on cmp for property', () => { - class Target { - set prop1(value) { - /**/ - } - } - const original = Object.getOwnPropertyDescriptor(Target.prototype, 'prop1'); - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - const descriptor = Object.getOwnPropertyDescriptor(cmp, 'prop1'); - expect(descriptor!.set).not.toBe(original!.set); - }); - - it('invokes original setter when installed setter invoked', () => { - const setter = jest.fn(); - const expected = 'expected'; - class Target { - set prop1(value) { - setter(value); - } - get prop1() { - return ''; - } - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - cmp.prop1 = expected; - expect(setter).toHaveBeenCalledTimes(1); - expect(setter).toHaveBeenCalledWith(expected); - }); - - it('installs setter on cmp only for reactiveParameter.root', () => { - const dotNotationReactiveParameters: Array = [ - { - reference: 'prop1.x.y', - head: 'prop1', - }, - ]; - class Target { - set prop1(value) { - /**/ - } - } - const original = Object.getOwnPropertyDescriptor(Target.prototype, 'prop1'); - const cmp = new Target(); - installTrap(cmp, 'prop1', dotNotationReactiveParameters, context); - const descriptor = Object.getOwnPropertyDescriptor(cmp, 'prop1'); - expect(descriptor!.set).not.toBe(original!.set); - expect(Object.getOwnPropertyDescriptor(Target.prototype, 'prop1.x.y')).toBeUndefined(); - }); -}); - -describe('invokeConfigListeners', () => { - const reactiveParametersGroupByHead: Array = [ - { - reference: 'prop1', - head: 'prop1', - }, - ]; - - it('invokes listener with reactive parameter default value', () => { - const expected = 'expected'; - const listener = jest.fn(); - const context: ConfigContext = { - listeners: { - prop1: [{ listener, reactives: { param1: 'prop1' } }], - }, - values: { - // initial state is empty - }, - }; - class Target { - prop1 = expected; - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - updated(cmp, reactiveParametersGroupByHead, context); - return Promise.resolve().then(() => { - expect(listener).toHaveBeenCalledTimes(1); - expect(listener.mock.calls[0][0]).toEqual({ param1: expected }); - }); - }); - - it('invokes listener with new value once', () => { - const expected = 'expected'; - const listener = jest.fn(); - const context: ConfigContext = { - listeners: { - prop1: [{ listener, reactives: { param1: 'prop1' } }], - }, - values: {}, - }; - class Target { - prop1; - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - updated(cmp, reactiveParametersGroupByHead, context); - cmp.prop1 = expected; - return Promise.resolve().then(() => { - expect(listener).toHaveBeenCalledTimes(1); - expect(listener.mock.calls[0][0]).toEqual({ param1: expected }); - }); - }); - - it('does not invoke listener if param value is unchanged', () => { - const expected = 'expected'; - const listener = jest.fn(); - const context: ConfigContext = { - listeners: { - prop1: [{ listener, reactives: { param1: 'prop1' } }], - }, - values: { - prop1: 'expected', - }, - }; - class Target { - prop1; - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - cmp.prop1 = expected; - return Promise.resolve().then(() => { - expect(listener).toHaveBeenCalledTimes(0); - }); - }); - - it('invokes listener with getter value', () => { - const expected = 'expected'; - const listener = jest.fn(); - const context: ConfigContext = { - listeners: { - prop1: [{ listener, reactives: { param1: 'prop1' } }], - }, - values: { - prop1: '', - }, - }; - class Target { - set prop1(value) { - /**/ - } - get prop1() { - return expected; - } - } - const cmp = new Target(); - installTrap(cmp, 'prop1', reactiveParametersGroupByHead, context); - cmp.prop1 = 'unexpected'; - return Promise.resolve().then(() => { - expect(listener).toHaveBeenCalledTimes(1); - expect(listener.mock.calls[0][0]).toEqual({ param1: expected }); - }); - }); -}); - -describe('getReactiveParameterValue', () => { - it('returns leaf in object graph', () => { - const expected = 'expected'; - const reactiveParameter: ReactiveParameter = { - reference: 'a.b.c.d', - head: 'a', - tail: ['b', 'c', 'd'], - }; - class Target { - a = { b: { c: { d: expected } } }; - } - expect(getReactiveParameterValue(new Target(), reactiveParameter)).toBe(expected); - }); - - it('returns tree in object graph', () => { - const expected = { e: { f: 'expected' } }; - const reactiveParameter: ReactiveParameter = { - reference: 'a.b.c.d', - head: 'a', - tail: ['b', 'c', 'd'], - }; - class Target { - a = { b: { c: { d: expected } } }; - } - expect(getReactiveParameterValue(new Target(), reactiveParameter)).toBe(expected); - }); - - it('returns undefined if root is undefined', () => { - const reactiveParameter: ReactiveParameter = { - reference: 'a.b.c.d', - head: 'a', - tail: ['b', 'c', 'd'], - }; - class Target { - // a does not exist - } - expect(getReactiveParameterValue(new Target(), reactiveParameter)).toBeUndefined(); - }); - - it('returns undefined if a segment is undefined', () => { - const reactiveParameter: ReactiveParameter = { - reference: 'a.b.c.d', - head: 'a', - tail: ['b', 'c', 'd'], - }; - class Target { - a = { b: undefined }; - } - expect(getReactiveParameterValue(new Target(), reactiveParameter)).toBeUndefined(); - }); - - it('returns undefined if a segment is not found', () => { - const reactiveParameter: ReactiveParameter = { - reference: 'a.b.c.d', - head: 'a', - tail: ['b', 'c', 'd'], - }; - class Target { - a = { b: {} }; - } - expect(getReactiveParameterValue(new Target(), reactiveParameter)).toBeUndefined(); - }); -}); diff --git a/packages/@lwc/wire-service/src/__tests__/wiring.spec.ts b/packages/@lwc/wire-service/src/__tests__/wiring.spec.ts deleted file mode 100644 index f22ec12bb0..0000000000 --- a/packages/@lwc/wire-service/src/__tests__/wiring.spec.ts +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import * as target from '../wiring'; -import { ValueChangedEvent } from '../value-changed-event'; -import { LinkContextEvent } from '../link-context-event'; -import { - CONTEXT_ID, - CONTEXT_CONNECTED, - CONNECT, - CONTEXT_DISCONNECTED, - DISCONNECT, - CONTEXT_UPDATED, - CONFIG, -} from '../constants'; -import { ElementDef, WireDef } from '../engine'; -import * as dependency from '../property-trap'; - -describe('WireEventTarget', () => { - describe('addEventListener', () => { - describe('connect event', () => { - it('throws on duplicate listener', () => { - function dupeListener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_CONNECTED] = [dupeListener]; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - expect(() => { - wireEventTarget.addEventListener(CONNECT, dupeListener); - }).toThrowError('must not call addEventListener("connect") with the same listener'); - }); - - it('adds listener to the queue', () => { - function listener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_CONNECTED] = []; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - wireEventTarget.addEventListener(CONNECT, listener); - const actual = mockContext[CONTEXT_ID][CONTEXT_CONNECTED]; - expect(actual).toHaveLength(1); - expect(actual[0]).toBe(listener); - }); - }); - - describe('disconnect event', () => { - it('throws on duplicate listener', () => { - function dupeListener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_DISCONNECTED] = [dupeListener]; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - expect(() => { - wireEventTarget.addEventListener(DISCONNECT, dupeListener); - }).toThrowError( - 'must not call addEventListener("disconnect") with the same listener' - ); - }); - - it('adds listener to the queue', () => { - function listener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_DISCONNECTED] = []; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - wireEventTarget.addEventListener(DISCONNECT, listener); - const actual = mockContext[CONTEXT_ID][CONTEXT_DISCONNECTED]; - expect(actual).toHaveLength(1); - expect(actual[0]).toBe(listener); - }); - }); - - describe('config event', () => { - it('immediately fires when no static or dynamic parameters', () => { - const listener = jest.fn(); - const mockWireDef: WireDef = { - adapter: {}, - }; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, listener); - expect(listener).toHaveBeenCalledTimes(1); - }); - it('immediately fires when config is statics only', () => { - const listener = jest.fn(); - const mockWireDef: WireDef = { - adapter: {}, - params: {}, - static: { - test: ['fixed', 'array'], - }, - }; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, listener); - expect(listener).toHaveBeenCalledTimes(1); - }); - it('does not install traps or enqueue for default values when no dynamic parameters', () => { - const mockWireDef: WireDef = { - adapter: {}, - params: {}, - static: { - test: ['fixed', 'array'], - }, - }; - const { installTrap, updated } = dependency; - (dependency as any).installTrap = jest.fn(); - (dependency as any).updated = jest.fn(); - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalledTimes(0); - expect(dependency.updated).toHaveBeenCalledTimes(0); - (dependency as any).installTrap = installTrap; - (dependency as any).updated = updated; - }); - it('enqueues for default values of dynamic parameters', () => { - const wireContext = Object.create(null); - wireContext[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = wireContext; - const mockWireDef: WireDef = { - adapter: {}, - params: { - key: 'prop', - }, - }; - const { updated } = dependency; - (dependency as any).updated = jest.fn(); - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.updated).toHaveBeenCalledTimes(1); - (dependency as any).updated = updated; - }); - it('creates one trap per property for multiple listeners', () => { - const wireContext = Object.create(null); - wireContext[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = wireContext; - const mockWireDef: WireDef = { - adapter: {}, - params: { - key: 'prop', - }, - }; - const { installTrap } = dependency; - (dependency as any).installTrap = jest.fn(); - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalled(); - const wireEventTarget1 = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test1' - ); - wireEventTarget1.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalledTimes(1); - (dependency as any).installTrap = installTrap; - }); - it('creates one trap for root property for multiple listeners to dot-notation parameters', () => { - const wireContext = Object.create(null); - wireContext[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = wireContext; - const mockWireDef: WireDef = { - adapter: {}, - params: { - key: 'a.b.c.d', - other: 'a.x.y', - }, - }; - const { installTrap } = dependency; - (dependency as any).installTrap = jest.fn(); - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalledTimes(1); - const wireEventTarget1 = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test1' - ); - wireEventTarget1.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalledTimes(1); - (dependency as any).installTrap = installTrap; - }); - it('creates one trap per property per component', () => { - const wireContext1 = Object.create(null); - wireContext1[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - const mockContext1 = Object.create(null); - mockContext1[CONTEXT_ID] = wireContext1; - - const wireContext2 = Object.create(null); - wireContext2[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - const mockContext2 = Object.create(null); - mockContext2[CONTEXT_ID] = wireContext2; - - const mockWireDef: WireDef = { - adapter: {}, - params: { - key: 'prop', - }, - }; - - const { installTrap } = dependency; - (dependency as any).installTrap = jest.fn(); - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext1, - mockWireDef, - 'test' - ); - wireEventTarget.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalled(); - const wireEventTarget1 = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext2, - mockWireDef, - 'test' - ); - wireEventTarget1.addEventListener(CONFIG, () => { - /**/ - }); - expect(dependency.installTrap).toHaveBeenCalledTimes(2); - (dependency as any).installTrap = installTrap; - }); - }); - - it('throws when event type is not supported', () => { - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - {} as WireDef, - 'test' - ); - expect(() => { - wireEventTarget.addEventListener('test', () => { - /**/ - }); - }).toThrowError('unsupported event type test'); - }); - }); - - describe('removeEventListener', () => { - it('removes listener from the queue for connect event', () => { - function listener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_CONNECTED] = [listener]; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - wireEventTarget.removeEventListener(CONNECT, listener); - expect(mockContext[CONTEXT_ID][CONTEXT_CONNECTED]).toHaveLength(0); - }); - it('removes listener from the queue for disconnect event', () => { - function listener() { - /**/ - } - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_DISCONNECTED] = [listener]; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - {} as WireDef, - 'test' - ); - wireEventTarget.removeEventListener(DISCONNECT, listener); - expect(mockContext[CONTEXT_ID][CONTEXT_DISCONNECTED]).toHaveLength(0); - }); - it('removes listenerMetadata from the queue for config event for non-dot-notation reactive parameter', () => { - function listener() { - /**/ - } - const mockConfigListenerMetadata = { listener }; - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_UPDATED] = { - listeners: { prop: [mockConfigListenerMetadata] }, - }; - const mockWireDef: WireDef = { - adapter: {}, - params: { - test: 'prop', - }, - }; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test' - ); - wireEventTarget.removeEventListener(CONFIG, listener); - expect(mockContext[CONTEXT_ID][CONTEXT_UPDATED].listeners.prop).toHaveLength(0); - }); - it('removes listenerMetadata from the queue for config event for dot-notation reactive parameter', () => { - function listener() { - /**/ - } - const mockConfigListenerMetadata = { listener }; - const mockContext = Object.create(null); - mockContext[CONTEXT_ID] = Object.create(null); - mockContext[CONTEXT_ID][CONTEXT_UPDATED] = { - listeners: { x: [mockConfigListenerMetadata] }, - }; - const mockWireDef: WireDef = { - adapter: {}, - params: { - test: 'x.y.z', - }, - }; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - mockContext, - mockWireDef, - 'test' - ); - wireEventTarget.removeEventListener(CONFIG, listener); - expect(mockContext[CONTEXT_ID][CONTEXT_UPDATED].listeners.x).toHaveLength(0); - }); - it('throws when event type is not supported', () => { - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - {} as WireDef, - 'test' - ); - expect(() => { - wireEventTarget.removeEventListener('test', () => { - /**/ - }); - }).toThrowError('unsupported event type test'); - }); - }); - - describe('dispatchEvent', () => { - it('updates wired property when ValueChangeEvent received', () => { - const mockCmp = { - test: undefined, - }; - const wireEventTarget = new target.WireEventTarget( - mockCmp as any, - {} as ElementDef, - {} as target.Context, - {} as WireDef, - 'test' - ); - wireEventTarget.dispatchEvent(new ValueChangedEvent('value')); - expect(mockCmp.test).toBe('value'); - }); - it('invokes wired method when ValueChangedEvent received', () => { - let actual; - const mockCmp = { - test: value => { - actual = value; - }, - }; - const wireEventTarget = new target.WireEventTarget( - mockCmp as any, - {} as ElementDef, - {} as target.Context, - { method: 1 } as WireDef, - 'test' - ); - wireEventTarget.dispatchEvent(new ValueChangedEvent('value')); - expect(actual).toBe('value'); - }); - it('invokes dispatch method on element when LinkContextEvent received', () => { - expect.assertions(5); - function callback(data, disconnect) { - expect(data).toBe(1); - expect(disconnect).toBe(2); - } - const mockCmp = { - dispatchEvent(evt) { - expect(evt.type).toBe('foo'); - expect(typeof evt.detail).toBe('function'); - expect(evt.detail).not.toBe(callback); // avoid side-channeling by not leaking the original callback - evt.detail(1, 2); - }, - }; - const wireEventTarget = new target.WireEventTarget( - mockCmp as any, - {} as ElementDef, - {} as target.Context, - { method: 1 } as WireDef, - 'test' - ); - wireEventTarget.dispatchEvent(new LinkContextEvent('foo', callback)); - }); - it('invokes dispatch method on element when wirecontextevent received', () => { - expect.assertions(1); - const event = { type: 'wirecontextevent' }; - const mockCmp = { - dispatchEvent(evt) { - expect(evt.type).toBe('wirecontextevent'); - }, - }; - const wireEventTarget = new target.WireEventTarget( - mockCmp as any, - {} as ElementDef, - {} as target.Context, - { method: 1 } as WireDef, - 'test' - ); - wireEventTarget.dispatchEvent(event as Event); - }); - it('throws on non-ValueChangedEvent', () => { - const test = {}; - test.toString = () => 'test'; - const wireEventTarget = new target.WireEventTarget( - {} as EventTarget, - {} as ElementDef, - {} as target.Context, - {} as WireDef, - 'test' - ); - expect(() => { - wireEventTarget.dispatchEvent(test as ValueChangedEvent); - }).toThrowError('Invalid event test.'); - }); - }); -}); diff --git a/packages/@lwc/wire-service/src/constants.ts b/packages/@lwc/wire-service/src/constants.ts deleted file mode 100644 index 9ddbb8d3e5..0000000000 --- a/packages/@lwc/wire-service/src/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -// key in engine service context for wire service context -export const CONTEXT_ID = '@wire'; -// key in wire service context for updated listener metadata -export const CONTEXT_UPDATED = 'updated'; -// key in wire service context for connected listener metadata -export const CONTEXT_CONNECTED = 'connected'; -// key in wire service context for disconnected listener metadata -export const CONTEXT_DISCONNECTED = 'disconnected'; - -// wire event target life cycle connectedCallback hook event type -export const CONNECT = 'connect'; -// wire event target life cycle disconnectedCallback hook event type -export const DISCONNECT = 'disconnect'; -// wire event target life cycle config changed hook event type -export const CONFIG = 'config'; diff --git a/packages/@lwc/wire-service/src/engine.ts b/packages/@lwc/wire-service/src/engine.ts deleted file mode 100644 index 9116f69e9e..0000000000 --- a/packages/@lwc/wire-service/src/engine.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -// subtypes from lwc -export interface WireDef { - params?: { - [key: string]: string; - }; - static?: { - [key: string]: any; - }; - adapter: any; - method?: 1; -} -export interface ElementDef { - // wire is optional on ElementDef but the lwc guarantees it before invoking wiring service hook - wire: { - [key: string]: WireDef; - }; -} diff --git a/packages/@lwc/wire-service/src/index.ts b/packages/@lwc/wire-service/src/index.ts index 04ce41b9fe..8f6ee07c0b 100644 --- a/packages/@lwc/wire-service/src/index.ts +++ b/packages/@lwc/wire-service/src/index.ts @@ -4,176 +4,195 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ +import { ValueChangedEvent } from './value-changed-event'; + +const { freeze, defineProperty, isExtensible } = Object; + +// This value needs to be in sync with wiring.ts from @lwc/engine +const DeprecatedWiredElementHost = '$$DeprecatedWiredElementHostKey$$'; + /** - * The @wire service. - * - * Provides data binding between wire adapters and LWC components decorated with @wire. - * Register wire adapters with `register(adapterId: any, adapterFactory: WireAdapterFactory)`. + * Registers a wire adapter factory for Lightning Platform. + * @deprecated */ +export function register( + adapterId: any, + adapterEventTargetCallback: (eventTarget: WireEventTarget) => void +) { + if (adapterId == null || !isExtensible(adapterId)) { + throw new TypeError('adapter id must be extensible'); + } + if (typeof adapterEventTargetCallback !== 'function') { + throw new TypeError('adapter factory must be a callable'); + } + if ('adapter' in adapterId) { + throw new TypeError('adapter id is already associated to an adapter factory'); + } -import { assert } from '@lwc/shared'; -import { CONTEXT_ID, CONTEXT_CONNECTED, CONTEXT_DISCONNECTED, CONTEXT_UPDATED } from './constants'; -import { ElementDef } from './engine'; -import { - WireEventTargetListener, - Context, - WireContext, - WireEventTarget as WireServiceTargetConstructor, -} from './wiring'; -import { ValueChangedEvent } from './value-changed-event'; -import { LinkContextEvent } from './link-context-event'; + const AdapterClass = class extends WireAdapter { + constructor(dataCallback: dataCallback) { + super(dataCallback); + adapterEventTargetCallback(this.eventTarget); + } + }; -interface Service { - wiring(cmp: EventTarget, data: object, def: ElementDef, context: Context): void; - connected(cmp: EventTarget, data: object, def: ElementDef, context: Context): void; - disconnected(cmp: EventTarget, data: object, def: ElementDef, context: Context): void; + freeze(AdapterClass); + freeze(AdapterClass.prototype); + + defineProperty(adapterId, 'adapter', { + writable: false, + configurable: false, + value: AdapterClass, + }); } -export interface WireEventTarget { - dispatchEvent(evt: ValueChangedEvent): boolean; - addEventListener(type: string, listener: WireEventTargetListener): void; - removeEventListener(type: string, listener: WireEventTargetListener): void; +/** + * Registers the wire service. noop + * @deprecated + */ +export function registerWireService() {} + +const { forEach, splice: ArraySplice, indexOf: ArrayIndexOf } = Array.prototype; + +// wire event target life cycle connectedCallback hook event type +const CONNECT = 'connect'; +// wire event target life cycle disconnectedCallback hook event type +const DISCONNECT = 'disconnect'; +// wire event target life cycle config changed hook event type +const CONFIG = 'config'; + +type NoArgumentListener = () => void; +interface ConfigListenerArgument { + [key: string]: any; } +type ConfigListener = (config: ConfigListenerArgument) => void; -export type WireAdapterFactory = (eventTarget: WireEventTarget) => void; +type WireEventTargetListener = NoArgumentListener | ConfigListener; -// wire adapters: wire adapter id => adapter ctor -const adapterFactories: Map = new Map(); +export interface WireEventTarget { + addEventListener: (type: string, listener: WireEventTargetListener) => void; + removeEventListener: (type: string, listener: WireEventTargetListener) => void; + dispatchEvent: (evt: ValueChangedEvent) => boolean; +} -/** - * Invokes the specified callbacks. - * @param listeners functions to call - */ -function invokeListener(listeners: WireEventTargetListener[]): void { - for (let i = 0, len = listeners.length; i < len; ++i) { - listeners[i].call(undefined); +function removeListener(listeners: WireEventTargetListener[], toRemove: WireEventTargetListener) { + const idx = ArrayIndexOf.call(listeners, toRemove); + if (idx > -1) { + ArraySplice.call(listeners, idx, 1); } } -/** - * The wire service. - * - * This service is registered with the engine's service API. It connects service - * callbacks to wire adapter lifecycle events. - */ -const wireService: Service = { - wiring: (cmp, data, def, context) => { - const wireContext: WireContext = (context[CONTEXT_ID] = Object.create(null)); - wireContext[CONTEXT_CONNECTED] = []; - wireContext[CONTEXT_DISCONNECTED] = []; - wireContext[CONTEXT_UPDATED] = { listeners: {}, values: {} }; - - // engine guarantees invocation only if def.wire is defined - const wireStaticDef = def.wire; - const wireTargets = Object.keys(wireStaticDef); - for (let i = 0, len = wireTargets.length; i < len; i++) { - const wireTarget = wireTargets[i]; - const wireDef = wireStaticDef[wireTarget]; - const adapterFactory = adapterFactories.get(wireDef.adapter); - - if (process.env.NODE_ENV !== 'production') { - assert.isTrue( - wireDef.adapter, - `@wire on "${wireTarget}": adapter id must be truthy` - ); - assert.isTrue( - adapterFactory, - `@wire on "${wireTarget}": unknown adapter id: ${String(wireDef.adapter)}` - ); - - // enforce restrictions of reactive parameters - if (wireDef.params) { - Object.keys(wireDef.params).forEach(param => { - const prop = wireDef.params![param]; - const segments = prop.split('.'); - segments.forEach(segment => { - assert.isTrue( - segment.length > 0, - `@wire on "${wireTarget}": reactive parameters must not be empty` - ); - }); - assert.isTrue( - segments[0] !== wireTarget, - `@wire on "${wireTarget}": reactive parameter "${segments[0]}" must not refer to self` - ); - // restriction for dot-notation reactive parameters - if (segments.length > 1) { - // @wire emits a stream of immutable values. an emit sets the target property; it does not mutate a previously emitted value. - // restricting dot-notation reactive parameters to reference other @wire targets makes trapping the 'head' of the parameter - // sufficient to observe the value change. - assert.isTrue( - wireTargets.includes(segments[0]) && - wireStaticDef[segments[0]].method !== 1, - `@wire on "${wireTarget}": dot-notation reactive parameter "${prop}" must refer to a @wire property` - ); +interface dataCallback { + (value: any): void; + [DeprecatedWiredElementHost]: any; +} +export interface WireAdapterConstructor { + new (callback: dataCallback): WireAdapter; +} + +export class WireAdapter { + private callback: dataCallback; + private readonly wiredElementHost: EventTarget; + + private connecting: NoArgumentListener[] = []; + private disconnecting: NoArgumentListener[] = []; + private configuring: ConfigListener[] = []; + + /** + * Attaching a config listener. + * + * The old behavior for attaching a config listener depended on these 3 cases: + * 1- The wire instance does have any arguments. + * 2- The wire instance have only static arguments. + * 3- The wire instance have at least one dynamic argument. + * + * In case 1 and 2, the listener should be called immediately. + * In case 3, the listener needs to wait for the value of the dynamic argument to be updated by the engine. + * + * In order to match the above logic, we need to save the last config available: + * if is undefined, the engine hasn't set it yet, we treat it as case 3. Note: the current logic does not make a distinction between dynamic and static config. + * if is defined, it means that for the component instance, and this adapter instance, the currentConfig is the proper one + * and the listener will be called immediately. + * + */ + private currentConfig?: ConfigListenerArgument; + + constructor(callback: dataCallback) { + this.callback = callback; + this.wiredElementHost = callback[DeprecatedWiredElementHost]; + this.eventTarget = { + addEventListener: (type: string, listener: WireEventTargetListener): void => { + switch (type) { + case CONNECT: { + this.connecting.push(listener as NoArgumentListener); + break; + } + case DISCONNECT: { + this.disconnecting.push(listener as NoArgumentListener); + break; + } + case CONFIG: { + this.configuring.push(listener as ConfigListener); + + if (this.currentConfig !== undefined) { + (listener as ConfigListener).call(undefined, this.currentConfig); } - }); + break; + } + default: + throw new Error(`Invalid event type ${type}.`); } - } - - if (adapterFactory) { - const wireEventTarget = new WireServiceTargetConstructor( - cmp, - def, - context, - wireDef, - wireTarget - ); - - adapterFactory({ - dispatchEvent: wireEventTarget.dispatchEvent.bind(wireEventTarget), - addEventListener: wireEventTarget.addEventListener.bind(wireEventTarget), - removeEventListener: wireEventTarget.removeEventListener.bind(wireEventTarget), - }); - } - } - }, - - connected: (cmp, data, def, context) => { - let listeners: WireEventTargetListener[]; - if (process.env.NODE_ENV !== 'production') { - assert.isTrue( - !def.wire || context[CONTEXT_ID], - 'wire service was not initialized prior to component creation: "connected" service hook invoked without necessary context' - ); - } - if (!def.wire || !(listeners = context[CONTEXT_ID][CONTEXT_CONNECTED])) { - return; - } - invokeListener(listeners); - }, - - disconnected: (cmp, data, def, context) => { - let listeners: WireEventTargetListener[]; - if (process.env.NODE_ENV !== 'production') { - assert.isTrue( - !def.wire || context[CONTEXT_ID], - 'wire service was not initialized prior to component creation: "disconnected" service hook invoked without necessary context' - ); - } - if (!def.wire || !(listeners = context[CONTEXT_ID][CONTEXT_DISCONNECTED])) { - return; - } - invokeListener(listeners); - }, -}; + }, + removeEventListener: (type: string, listener: WireEventTargetListener): void => { + switch (type) { + case CONNECT: { + removeListener(this.connecting, listener); + break; + } + case DISCONNECT: { + removeListener(this.disconnecting, listener); + break; + } + case CONFIG: { + removeListener(this.configuring, listener); + break; + } + default: + throw new Error(`Invalid event type ${type}.`); + } + }, + dispatchEvent: (evt: ValueChangedEvent | Event): boolean => { + if (evt instanceof ValueChangedEvent) { + const value = evt.value; + this.callback(value); + } else if (evt.type === 'wirecontextevent') { + // TODO [#1357]: remove this branch + return this.wiredElementHost.dispatchEvent(evt); + } else { + throw new Error(`Invalid event type ${(evt as any).type}.`); + } + return false; // canceling signal since we don't want this to propagate + }, + }; + } -/** - * Registers the wire service. - */ -export function registerWireService(registerService: (service: Service) => void): void { - registerService(wireService); -} + protected eventTarget: WireEventTarget; -/** - * Registers a wire adapter. - */ -export function register(adapterId: any, adapterFactory: WireAdapterFactory): void { - if (process.env.NODE_ENV !== 'production') { - assert.isTrue(adapterId, 'adapter id must be truthy'); - assert.isTrue(typeof adapterFactory === 'function', 'adapter factory must be a callable'); + update(config: Record) { + this.currentConfig = config; + forEach.call(this.configuring, listener => { + listener.call(undefined, config); + }); + } + + connect() { + forEach.call(this.connecting, listener => listener.call(undefined)); + } + + disconnect() { + forEach.call(this.disconnecting, listener => listener.call(undefined)); } - adapterFactories.set(adapterId, adapterFactory); } -export { ValueChangedEvent, LinkContextEvent }; +// re-exporting event constructors +export { ValueChangedEvent }; diff --git a/packages/@lwc/wire-service/src/link-context-event.ts b/packages/@lwc/wire-service/src/link-context-event.ts deleted file mode 100644 index 3196513b08..0000000000 --- a/packages/@lwc/wire-service/src/link-context-event.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -const LinkContextEventType = 'LinkContextEvent'; - -/** - * Event fired by wire adapters to link to a context provider - */ -export class LinkContextEvent { - type: string; - uid: string; - callback: (...args: any[]) => void; - constructor(uid: string, callback: (...args: any[]) => void) { - this.type = LinkContextEventType; - this.uid = uid; - this.callback = callback; - } -} diff --git a/packages/@lwc/wire-service/src/property-trap.ts b/packages/@lwc/wire-service/src/property-trap.ts deleted file mode 100644 index 62f575ed38..0000000000 --- a/packages/@lwc/wire-service/src/property-trap.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -/* - * Detects property changes by installing setter/getter overrides on the component - * instance. - */ - -import { ConfigListenerMetadata, ConfigContext, ReactiveParameter } from './wiring'; - -/** - * Invokes the provided change listeners with the resolved component properties. - * @param configListenerMetadatas List of config listener metadata (config listeners and their context) - * @param paramValues Values for all wire adapter config params - */ -function invokeConfigListeners( - configListenerMetadatas: Set, - paramValues: any -) { - configListenerMetadatas.forEach(metadata => { - const { listener, statics, reactives } = metadata; - - const reactiveValues = Object.create(null); - if (reactives) { - const keys = Object.keys(reactives); - for (let j = 0, jlen = keys.length; j < jlen; j++) { - const key = keys[j]; - const value = paramValues[reactives[key]]; - reactiveValues[key] = value; - } - } - - // TODO [#1634]: consider read-only membrane to enforce invariant of immutable config - const config = Object.assign({}, statics, reactiveValues); - listener.call(undefined, config); - }); -} - -/** - * Marks a reactive parameter as having changed. - * @param cmp The component - * @param reactiveParameters Reactive parameters that has changed - * @param configContext The service context - */ -export function updated( - cmp: EventTarget, - reactiveParameters: Array, - configContext: ConfigContext -) { - if (!configContext.mutated) { - configContext.mutated = new Set(reactiveParameters); - // collect all prop changes via a microtask - Promise.resolve().then(updatedFuture.bind(undefined, cmp, configContext)); - } else { - for (let i = 0, n = reactiveParameters.length; i < n; i++) { - configContext.mutated.add(reactiveParameters[i]); - } - } -} - -function updatedFuture(cmp: EventTarget, configContext: ConfigContext) { - const uniqueListeners = new Set(); - - // configContext.mutated must be set prior to invoking this function - const mutated = configContext.mutated as Set; - delete configContext.mutated; - - mutated.forEach(reactiveParameter => { - const value = getReactiveParameterValue(cmp, reactiveParameter); - if (configContext.values[reactiveParameter.reference] === value) { - return; - } - configContext.values[reactiveParameter.reference] = value; - - const listeners = configContext.listeners[reactiveParameter.head]; - for (let i = 0, len = listeners.length; i < len; i++) { - uniqueListeners.add(listeners[i]); - } - }); - - invokeConfigListeners(uniqueListeners, configContext.values); -} - -/** - * Gets the value of an @wire reactive parameter. - * @param cmp The component - * @param reactiveParameter The parameter to get - */ -export function getReactiveParameterValue( - cmp: EventTarget, - reactiveParameter: ReactiveParameter -): any { - let value = (cmp as any)[reactiveParameter.head]; - if (!reactiveParameter.tail) { - return value; - } - - const segments = reactiveParameter.tail; - for (let i = 0, len = segments.length; i < len && value != null; i++) { - const segment = segments[i]; - if (typeof value !== 'object' || !(segment in value)) { - return undefined; - } - value = value[segment]; - } - return value; -} - -/** - * Installs setter override to trap changes to a property, triggering the config listeners. - * @param cmp The component - * @param reactiveParametersHead The common head of the reactiveParameters - * @param reactiveParameters Reactive parameters with the same head, that defines the property to monitor - * @param configContext The service context - */ -export function installTrap( - cmp: EventTarget, - reactiveParametersHead: string, - reactiveParameters: Array, - configContext: ConfigContext -) { - const callback = updated.bind(undefined, cmp, reactiveParameters, configContext); - const newDescriptor = getOverrideDescriptor(cmp, reactiveParametersHead, callback); - Object.defineProperty(cmp, reactiveParametersHead, newDescriptor); -} - -/** - * Finds the descriptor of the named property on the prototype chain - * @param target The target instance/constructor function - * @param propName Name of property to find - * @param protoSet Prototypes searched (to avoid circular prototype chains) - */ -export function findDescriptor( - target: any, - propName: PropertyKey, - protoSet?: any[] -): PropertyDescriptor | null { - protoSet = protoSet || []; - if (!target || protoSet.indexOf(target) > -1) { - return null; // null, undefined, or circular prototype definition - } - const descriptor = Object.getOwnPropertyDescriptor(target, propName); - if (descriptor) { - return descriptor; - } - const proto = Object.getPrototypeOf(target); - if (!proto) { - return null; - } - protoSet.push(target); - return findDescriptor(proto, propName, protoSet); -} - -/** - * Gets a property descriptor that monitors the provided property for changes - * @param cmp The component - * @param prop The name of the property to be monitored - * @param callback A function to invoke when the prop's value changes - * @return A property descriptor - */ -function getOverrideDescriptor(cmp: EventTarget, prop: string, callback: () => void) { - const descriptor = findDescriptor(cmp, prop); - let enumerable; - let get; - let set; - // This does not cover the override of existing descriptors at the instance level - // and that's ok because eventually we will not need to do any of these :) - if (descriptor === null || (descriptor.get === undefined && descriptor.set === undefined)) { - let value = (cmp as any)[prop]; - enumerable = true; - get = function() { - return value; - }; - set = function(newValue: any) { - value = newValue; - callback(); - }; - } else { - const { set: originalSet, get: originalGet } = descriptor; - enumerable = descriptor.enumerable; - set = function(newValue: any) { - if (originalSet) { - originalSet.call(cmp, newValue); - } - callback(); - }; - get = function() { - return originalGet ? originalGet.call(cmp) : undefined; - }; - } - return { - set, - get, - enumerable, - configurable: true, - }; -} diff --git a/packages/@lwc/wire-service/src/wiring.ts b/packages/@lwc/wire-service/src/wiring.ts deleted file mode 100644 index 17e5c7f50f..0000000000 --- a/packages/@lwc/wire-service/src/wiring.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright (c) 2018, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ -import { assert, isUndefined } from '@lwc/shared'; -import { - CONTEXT_ID, - CONTEXT_CONNECTED, - CONTEXT_DISCONNECTED, - CONTEXT_UPDATED, - CONNECT, - DISCONNECT, - CONFIG, -} from './constants'; -import { ElementDef, WireDef } from './engine'; -import { installTrap, updated } from './property-trap'; -import { ValueChangedEvent } from './value-changed-event'; -import { LinkContextEvent } from './link-context-event'; - -export type WireEventTargetListener = (config?: any) => void; - -// a reactive parameter (WireDef.params.key) may be a dot-notation string to traverse into another @wire's target -export interface ReactiveParameter { - reference: string; // the complete parameter (aka original WireDef.params.key) - head: string; // head of the parameter - tail?: string[]; // remaining tail of the parameter, present if it's dot-notation -} - -export interface ConfigListenerMetadata { - listener: WireEventTargetListener; - statics?: { - [key: string]: any; - }; - reactives?: { - [key: string]: string; - }; -} -export interface ConfigContext { - // map of reactive parameters to list of config listeners - // when a reactive parameter changes it's a O(1) lookup to the list of config listeners to notify - listeners: { - [key: string]: ConfigListenerMetadata[]; - }; - // map of param values - values: { - [key: string]: any; - }; - // mutated reactive parameters (debounced then cleared) - mutated?: Set; -} - -export interface WireContext { - [CONTEXT_CONNECTED]: WireEventTargetListener[]; - [CONTEXT_DISCONNECTED]: WireEventTargetListener[]; - [CONTEXT_UPDATED]: ConfigContext; -} - -export interface Context { - [CONTEXT_ID]: WireContext; -} - -function removeListener(listeners: WireEventTargetListener[], toRemove: WireEventTargetListener) { - const idx = listeners.indexOf(toRemove); - if (idx > -1) { - listeners.splice(idx, 1); - } -} - -function removeConfigListener( - configListenerMetadatas: ConfigListenerMetadata[], - toRemove: WireEventTargetListener -) { - for (let i = 0, len = configListenerMetadatas.length; i < len; i++) { - if (configListenerMetadatas[i].listener === toRemove) { - configListenerMetadatas.splice(i, 1); - return; - } - } -} - -function buildReactiveParameter(reference: string): ReactiveParameter { - if (!reference.includes('.')) { - return { - reference, - head: reference, - }; - } - const segments = reference.split('.'); - return { - reference, - head: segments.shift() as string, - tail: segments, - }; -} - -export class WireEventTarget { - _cmp: EventTarget; - _def: ElementDef; - _context: Context; - _wireDef: WireDef; - _wireTarget: string; - - constructor( - cmp: EventTarget, - def: ElementDef, - context: Context, - wireDef: WireDef, - wireTarget: string - ) { - this._cmp = cmp; - this._def = def; - this._context = context; - this._wireDef = wireDef; - this._wireTarget = wireTarget; - } - - addEventListener(type: string, listener: WireEventTargetListener): void { - switch (type) { - case CONNECT: { - const connectedListeners = this._context[CONTEXT_ID][CONTEXT_CONNECTED]; - if (process.env.NODE_ENV !== 'production') { - assert.isFalse( - connectedListeners.includes(listener), - 'must not call addEventListener("connect") with the same listener' - ); - } - connectedListeners.push(listener); - break; - } - - case DISCONNECT: { - const disconnectedListeners = this._context[CONTEXT_ID][CONTEXT_DISCONNECTED]; - if (process.env.NODE_ENV !== 'production') { - assert.isFalse( - disconnectedListeners.includes(listener), - 'must not call addEventListener("disconnect") with the same listener' - ); - } - disconnectedListeners.push(listener); - break; - } - - case CONFIG: { - const reactives = this._wireDef.params; - const statics = this._wireDef.static; - let reactiveKeys: string[]; - - // no reactive parameters. fire config once with static parameters (if present). - if (!reactives || (reactiveKeys = Object.keys(reactives)).length === 0) { - const config = statics || Object.create(null); - listener.call(undefined, config); - return; - } - - const configListenerMetadata: ConfigListenerMetadata = { - listener, - statics, - reactives, - }; - - // setup listeners for all reactive parameters - const configContext = this._context[CONTEXT_ID][CONTEXT_UPDATED]; - const reactiveParametersGroupByHead: Record> = {}; - - reactiveKeys.forEach(key => { - const reactiveParameter = buildReactiveParameter(reactives[key]); - const reactiveParameterHead = reactiveParameter.head; - let configListenerMetadatas = configContext.listeners[reactiveParameterHead]; - - let reactiveParametersWithSameHead = - reactiveParametersGroupByHead[reactiveParameterHead]; - - if (isUndefined(reactiveParametersWithSameHead)) { - reactiveParametersWithSameHead = []; - reactiveParametersGroupByHead[ - reactiveParameterHead - ] = reactiveParametersWithSameHead; - } - - reactiveParametersWithSameHead.push(reactiveParameter); - - if (!configListenerMetadatas) { - configListenerMetadatas = [configListenerMetadata]; - configContext.listeners[reactiveParameterHead] = configListenerMetadatas; - installTrap( - this._cmp, - reactiveParameterHead, - reactiveParametersWithSameHead, - configContext - ); - } else { - configListenerMetadatas.push(configListenerMetadata); - } - }); - - // enqueue to pickup default values - Object.keys(reactiveParametersGroupByHead).forEach(head => { - updated(this._cmp, reactiveParametersGroupByHead[head], configContext); - }); - - break; - } - - default: - throw new Error(`unsupported event type ${type}`); - } - } - - removeEventListener(type: string, listener: WireEventTargetListener): void { - switch (type) { - case CONNECT: { - const connectedListeners = this._context[CONTEXT_ID][CONTEXT_CONNECTED]; - removeListener(connectedListeners, listener); - break; - } - - case DISCONNECT: { - const disconnectedListeners = this._context[CONTEXT_ID][CONTEXT_DISCONNECTED]; - removeListener(disconnectedListeners, listener); - break; - } - - case CONFIG: { - const paramToConfigListenerMetadata = this._context[CONTEXT_ID][CONTEXT_UPDATED] - .listeners; - const reactives = this._wireDef.params; - if (reactives) { - Object.keys(reactives).forEach(key => { - const reactiveParameter = buildReactiveParameter(reactives[key]); - const configListenerMetadatas = - paramToConfigListenerMetadata[reactiveParameter.head]; - if (configListenerMetadatas) { - removeConfigListener(configListenerMetadatas, listener); - } - }); - } - break; - } - - default: - throw new Error(`unsupported event type ${type}`); - } - } - - dispatchEvent(evt: ValueChangedEvent | LinkContextEvent | Event): boolean { - if (evt instanceof ValueChangedEvent) { - const value = evt.value; - if (this._wireDef.method) { - (this._cmp as any)[this._wireTarget](value); - } else { - (this._cmp as any)[this._wireTarget] = value; - } - return false; // canceling signal since we don't want this to propagate - } else if (evt instanceof LinkContextEvent) { - const { uid, callback } = evt; - // This event is responsible for connecting the host element with another - // element in the composed path that is providing contextual data. The provider - // must be listening for a special dom event with the name corresponding to `uid`, - // which must remain secret, to guarantee that the linkage is only possible via - // the corresponding wire adapter. - const internalDomEvent = new CustomEvent(uid, { - bubbles: true, - composed: true, - // avoid leaking the callback function directly to prevent a side channel - // during the linking phase to the context provider. - detail(...args: any[]) { - callback(...args); - }, - }); - this._cmp.dispatchEvent(internalDomEvent); - return false; // canceling signal since we don't want this to propagate - } else if (evt.type === 'wirecontextevent') { - // TODO [#1357]: remove this branch - return this._cmp.dispatchEvent(evt); - } else { - throw new Error(`Invalid event ${evt}.`); - } - } -} diff --git a/packages/integration-karma/test/api/getComponentDef/index.spec.js b/packages/integration-karma/test/api/getComponentDef/index.spec.js index 2003c24872..496d574a56 100644 --- a/packages/integration-karma/test/api/getComponentDef/index.spec.js +++ b/packages/integration-karma/test/api/getComponentDef/index.spec.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { LightningElement, api, getComponentDef } from 'lwc'; import PublicProperties from 'x/publicProperties'; @@ -6,13 +7,6 @@ import PublicMethods from 'x/publicMethods'; import PublicPropertiesInheritance from 'x/publicPropertiesInheritance'; import PublicMethodsInheritance from 'x/publicMethodsInheritance'; -import WireProperties from 'x/wireProperties'; -import WireMethods from 'x/wireMethods'; -import WirePropertiesInheritance from 'x/wirePropertiesInheritance'; -import WireMethodsInheritance from 'x/wireMethodsInheritance'; - -import wireAdapter from 'x/wireAdapter'; - function testInvalidComponentConstructor(name, ctor) { it(`should throw for ${name}`, () => { expect(() => getComponentDef(ctor)).toThrowError( @@ -21,6 +15,36 @@ function testInvalidComponentConstructor(name, ctor) { }); } +beforeAll(function() { + const getNormalizedFunctionAsString = fn => fn.toString().replace(/(\s|\n)/g, ''); + + jasmine.addMatchers({ + toEqualWireSettings: function() { + return { + compare: function(actual, expected) { + Object.keys(actual).forEach(currentKey => { + const normalizedActual = Object.assign({}, actual[currentKey], { + config: getNormalizedFunctionAsString(actual[currentKey].config), + }); + + const normalizedExpected = Object.assign({}, expected[currentKey], { + config: getNormalizedFunctionAsString( + expected[currentKey].config || function() {} + ), + }); + + expect(normalizedActual).toEqual(normalizedExpected); + }); + + return { + pass: true, + }; + }, + }; + }, + }); +}); + testInvalidComponentConstructor('null', null); testInvalidComponentConstructor('undefined', undefined); testInvalidComponentConstructor('String', 'component'); @@ -145,7 +169,7 @@ describe('@api', () => { expect(props).toEqual( jasmine.objectContaining({ parentProp: { - config: 0, + config: 3, type: 'any', attr: 'parent-prop', }, @@ -173,118 +197,6 @@ describe('@api', () => { }); }); -describe('@wire', () => { - it('should return the wired properties in wire object', () => { - const { wire } = getComponentDef(WireProperties); - expect(wire).toEqual({ - foo: { - adapter: wireAdapter, - }, - bar: { - adapter: wireAdapter, - static: { - a: true, - }, - params: {}, - }, - baz: { - adapter: wireAdapter, - static: { - b: true, - }, - params: { - c: 'foo', - }, - }, - }); - }); - - it('should return the wired methods in the wire object with a method flag', () => { - const { wire } = getComponentDef(WireMethods); - expect(wire).toEqual({ - foo: { - adapter: wireAdapter, - method: 1, - }, - bar: { - adapter: wireAdapter, - static: { - a: true, - }, - params: {}, - method: 1, - }, - baz: { - adapter: wireAdapter, - static: { - b: true, - }, - params: { - c: 'foo', - }, - method: 1, - }, - }); - }); - - it('should inherit wire properties from the base class', () => { - const { wire } = getComponentDef(WirePropertiesInheritance); - expect(wire).toEqual({ - parentProp: { - adapter: wireAdapter, - static: { - parent: true, - }, - params: {}, - }, - overriddenInChild: { - adapter: wireAdapter, - static: { - child: true, - }, - params: {}, - }, - childProp: { - adapter: wireAdapter, - static: { - child: true, - }, - params: {}, - }, - }); - }); - - it('should inherit the wire methods from the case class', () => { - const { wire } = getComponentDef(WireMethodsInheritance); - expect(wire).toEqual({ - parentMethod: { - adapter: wireAdapter, - static: { - parent: true, - }, - params: {}, - method: 1, - }, - overriddenInChild: { - adapter: wireAdapter, - static: { - child: true, - }, - params: {}, - method: 1, - }, - childMethod: { - adapter: wireAdapter, - static: { - child: true, - }, - params: {}, - method: 1, - }, - }); - }); -}); - describe('circular dependencies', () => { // Emulates an AMD module with circular dependency. function circularDependency(klass) { diff --git a/packages/integration-karma/test/api/getComponentDef/x/publicPropertiesInheritance/base.js b/packages/integration-karma/test/api/getComponentDef/x/publicPropertiesInheritance/base.js index 8ea2934e57..151d34981b 100644 --- a/packages/integration-karma/test/api/getComponentDef/x/publicPropertiesInheritance/base.js +++ b/packages/integration-karma/test/api/getComponentDef/x/publicPropertiesInheritance/base.js @@ -1,6 +1,10 @@ import { LightningElement, api } from 'lwc'; export default class Base extends LightningElement { - @api parentProp; + @api get parentProp() { + return undefined; + } + set parentProp(v) {} + @api overriddenInChild; } diff --git a/packages/integration-karma/test/api/getComponentDef/x/wireAdapter/wireAdapter.js b/packages/integration-karma/test/api/getComponentDef/x/wireAdapter/wireAdapter.js deleted file mode 100644 index f2672e14de..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wireAdapter/wireAdapter.js +++ /dev/null @@ -1 +0,0 @@ -export default function wireAdapter() {} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wireMethods/wireMethods.js b/packages/integration-karma/test/api/getComponentDef/x/wireMethods/wireMethods.js deleted file mode 100644 index 04d60e9f76..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wireMethods/wireMethods.js +++ /dev/null @@ -1,8 +0,0 @@ -import { LightningElement, wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -export default class WireMethods extends LightningElement { - @wire(wireAdapter) foo() {} - @wire(wireAdapter, { a: true }) bar() {} - @wire(wireAdapter, { b: true, c: '$foo' }) baz() {} -} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/base.js b/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/base.js deleted file mode 100644 index 9d6c243dbf..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/base.js +++ /dev/null @@ -1,7 +0,0 @@ -import { LightningElement, wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -export default class Base extends LightningElement { - @wire(wireAdapter, { parent: true }) parentMethod() {} - @wire(wireAdapter, { parent: true }) overriddenInChild() {} -} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/wireMethodsInheritance.js b/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/wireMethodsInheritance.js deleted file mode 100644 index 9fc83603c0..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wireMethodsInheritance/wireMethodsInheritance.js +++ /dev/null @@ -1,9 +0,0 @@ -import { wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -import Base from './base'; - -export default class WireMethodsInheritance extends Base { - @wire(wireAdapter, { child: true }) childMethod() {} - @wire(wireAdapter, { child: true }) overriddenInChild() {} -} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wireProperties/wireProperties.js b/packages/integration-karma/test/api/getComponentDef/x/wireProperties/wireProperties.js deleted file mode 100644 index 98c24dac7e..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wireProperties/wireProperties.js +++ /dev/null @@ -1,8 +0,0 @@ -import { LightningElement, wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -export default class WireProperties extends LightningElement { - @wire(wireAdapter) foo; - @wire(wireAdapter, { a: true }) bar; - @wire(wireAdapter, { b: true, c: '$foo' }) baz; -} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/base.js b/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/base.js deleted file mode 100644 index efe484f59e..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/base.js +++ /dev/null @@ -1,7 +0,0 @@ -import { LightningElement, wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -export default class Base extends LightningElement { - @wire(wireAdapter, { parent: true }) parentProp; - @wire(wireAdapter, { parent: true }) overriddenInChild; -} diff --git a/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/wirePropertiesInheritance.js b/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/wirePropertiesInheritance.js deleted file mode 100644 index 39f6d4946d..0000000000 --- a/packages/integration-karma/test/api/getComponentDef/x/wirePropertiesInheritance/wirePropertiesInheritance.js +++ /dev/null @@ -1,9 +0,0 @@ -import { wire } from 'lwc'; -import wireAdapter from 'x/wireAdapter'; - -import Base from './base'; - -export default class WirePropertiesInheritance extends Base { - @wire(wireAdapter, { child: true }) childProp; - @wire(wireAdapter, { child: true }) overriddenInChild; -} diff --git a/packages/integration-karma/test/context/advanced-context.spec.js b/packages/integration-karma/test/context/advanced-context.spec.js index ac883638ec..2e5e6ec655 100644 --- a/packages/integration-karma/test/context/advanced-context.spec.js +++ b/packages/integration-karma/test/context/advanced-context.spec.js @@ -1,6 +1,4 @@ -import { createElement, register } from 'lwc'; -import { registerWireService } from 'wire-service'; -registerWireService(register); +import { createElement } from 'lwc'; import { installCustomContext, getValueForIdentity } from 'x/advancedProvider'; import Consumer from 'x/advancedConsumer'; import { setValueForIdentity } from './x/advancedProvider/advancedProvider'; diff --git a/packages/integration-karma/test/context/simple-context.spec.js b/packages/integration-karma/test/context/simple-context.spec.js index f3f08cb008..b1b630cb29 100644 --- a/packages/integration-karma/test/context/simple-context.spec.js +++ b/packages/integration-karma/test/context/simple-context.spec.js @@ -1,6 +1,4 @@ -import { createElement, register } from 'lwc'; -import { registerWireService } from 'wire-service'; -registerWireService(register); +import { createElement } from 'lwc'; import { installCustomContext, setCustomContext } from 'x/simpleProvider'; import Consumer from 'x/simpleConsumer'; @@ -29,4 +27,21 @@ describe('Simple Custom Context Provider', () => { div.appendChild(elm); expect(elm.shadowRoot.textContent).toBe('pending'); }); + it('should use closest context when installed in a hierarchy of targets', function() { + const div = document.createElement('div'); + div.innerHTML = '
'; + const elm = createElement('x-consumer', { is: Consumer }); + const elm2 = createElement('x-consumer', { is: Consumer }); + const childTarget = div.querySelector('.child-ctx'); + + document.body.appendChild(div); + installCustomContext(div); + installCustomContext(childTarget); + setCustomContext(div, 'parent'); + setCustomContext(childTarget, 'child'); + div.appendChild(elm); + childTarget.appendChild(elm2); + expect(elm.shadowRoot.textContent).toBe('parent'); + expect(elm2.shadowRoot.textContent).toBe('child'); + }); }); diff --git a/packages/integration-karma/test/context/x/advancedConsumer/advancedConsumer.js b/packages/integration-karma/test/context/x/advancedConsumer/advancedConsumer.js index 57c244f94a..f5e30e7cc8 100644 --- a/packages/integration-karma/test/context/x/advancedConsumer/advancedConsumer.js +++ b/packages/integration-karma/test/context/x/advancedConsumer/advancedConsumer.js @@ -1,8 +1,8 @@ import { LightningElement, wire, api } from 'lwc'; -import { Provider } from 'x/advancedProvider'; +import { WireAdapter } from 'x/advancedProvider'; export default class ConsumerElement extends LightningElement { - @wire(Provider) context; + @wire(WireAdapter) context; @api getIdentity() { return this.context; diff --git a/packages/integration-karma/test/context/x/advancedProvider/advancedProvider.js b/packages/integration-karma/test/context/x/advancedProvider/advancedProvider.js index 5cbd8608a1..849fa6d20e 100644 --- a/packages/integration-karma/test/context/x/advancedProvider/advancedProvider.js +++ b/packages/integration-karma/test/context/x/advancedProvider/advancedProvider.js @@ -6,50 +6,43 @@ * This provide is sharing the same value with every child, and the * identity of the consumer is not tracked. */ - -import { register, ValueChangedEvent, LinkContextEvent } from 'wire-service'; - -const { addEventListener } = Document.prototype; +import { createContextProvider } from 'lwc'; const IdentityMetaMap = new WeakMap(); -const UniqueEventName = `advanced_context_event_${guid()}`; -const Provider = Symbol('SimpleContextProvider'); +const ConsumerMetaMap = new WeakMap(); -function guid() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -} +const { hasOwnProperty } = Object.prototype; -register(Provider, eventTarget => { - let unsubscribeCallback; +export class WireAdapter { + // no provider was found, in which case the default + // context should be set. + contextValue = null; - function callback(data, unsubscribe) { - eventTarget.dispatchEvent(new ValueChangedEvent(data)); - unsubscribeCallback = unsubscribe; + constructor(dataCallback) { + this._dataCallback = dataCallback; + // Note: you might also use a global identity in constructors + this._dataCallback(this.contextValue); } - - eventTarget.addEventListener('connect', () => { - const event = new LinkContextEvent(UniqueEventName, callback); - eventTarget.dispatchEvent(event); - if (unsubscribeCallback === undefined) { - // no provider was found, in which case the default - // context should be set. - const defaultContext = null; - // Note: you might decide to use a global identity instead - eventTarget.dispatchEvent(new ValueChangedEvent(defaultContext)); + update(_config, context) { + if (context) { + // we only care about the context, no config is expected or used + if (!hasOwnProperty.call(context, 'value')) { + throw new Error(`Invalid context provided`); + } + this.contextValue = context.value; + this._dataCallback(this.contextValue); } - }); - - eventTarget.addEventListener('disconnect', () => { - if (unsubscribeCallback !== undefined) { - unsubscribeCallback(); - unsubscribeCallback = undefined; // resetting it to support reinsertion - } - }); -}); + } + connect() { + // noop + } + disconnect() { + // noop + } + static contextSchema = { value: 'required' /* could be 'optional' */ }; +} -function createNewConsumerMeta(provider, callback) { +function createNewConsumerMeta(consumer) { // identity must be an object that can't be proxified otherwise we // loose the identity when tracking the value. const identity = Object.freeze(_ => { @@ -60,42 +53,44 @@ function createNewConsumerMeta(provider, callback) { // this object is what we can get to via the weak map by using the identity as a key const meta = { identity, - callback, - provider, + consumer, value, }; // storing identity into the map IdentityMetaMap.set(identity, meta); + ConsumerMetaMap.set(consumer, meta); return meta; } -function disconnectConsumer(eventTarget, consumerMeta) { - const meta = IdentityMetaMap.get(consumerMeta.identity); - if (meta !== undefined) { - // take care of disconnecting everything for this consumer - // ... - // then remove the identity from the map - IdentityMetaMap.delete(consumerMeta.identity); - } else { - throw new TypeError(`Invalid context operation in ${eventTarget}.`); +function decommissionConsumer(consumer) { + const meta = ConsumerMetaMap.get(consumer); + if (meta === undefined) { + // this should never happen unless you decommission consumers + // manually without waiting for the disconnect to occur. + throw new TypeError(`Invalid context operation.`); } + // take care of disconnecting everything for this consumer + // ... + // then remove the identity and consumer from maps + IdentityMetaMap.delete(meta.identity); + ConsumerMetaMap.delete(consumer.identity); } -function setupNewContextProvider(eventTarget) { - addEventListener.call(eventTarget, UniqueEventName, event => { - // this event must have a full stop when it is intercepted by a provider - event.stopImmediatePropagation(); - // the new child provides a callback as a communication channel - const { detail: callback } = event; - // create consumer metadata as soon as it is connected - const consumerMeta = createNewConsumerMeta(eventTarget, callback); - // emit the identity value and provide disconnect callback - callback(consumerMeta.identity, () => disconnectConsumer(eventTarget, consumerMeta)); - }); -} +const contextualizer = createContextProvider(WireAdapter); -export function installCustomContext(elm) { - setupNewContextProvider(elm); +export function installCustomContext(target) { + // Note: the identity of the consumer is already bound to the target. + contextualizer(target, { + consumerConnectedCallback(consumer) { + // create consumer metadata as soon as it is connected + const consumerMeta = createNewConsumerMeta(target, consumer); + // emit the identity value + consumer.provide({ value: consumerMeta.identity }); + }, + consumerDisconnectedCallback(consumer) { + decommissionConsumer(consumer); + }, + }); } export function setValueForIdentity(identity, value) { @@ -111,5 +106,3 @@ export function getValueForIdentity(identity) { const meta = IdentityMetaMap.get(identity); return meta.value; } - -export { Provider }; diff --git a/packages/integration-karma/test/context/x/simpleConsumer/simpleConsumer.js b/packages/integration-karma/test/context/x/simpleConsumer/simpleConsumer.js index 4541f65eed..93b1bacc70 100644 --- a/packages/integration-karma/test/context/x/simpleConsumer/simpleConsumer.js +++ b/packages/integration-karma/test/context/x/simpleConsumer/simpleConsumer.js @@ -1,6 +1,6 @@ import { LightningElement, wire } from 'lwc'; -import { Provider } from 'x/simpleProvider'; +import { WireAdapter } from 'x/simpleProvider'; export default class ConsumerElement extends LightningElement { - @wire(Provider) context; + @wire(WireAdapter) context; } diff --git a/packages/integration-karma/test/context/x/simpleProvider/simpleProvider.js b/packages/integration-karma/test/context/x/simpleProvider/simpleProvider.js index 785bb1472c..5bfe75e4b3 100644 --- a/packages/integration-karma/test/context/x/simpleProvider/simpleProvider.js +++ b/packages/integration-karma/test/context/x/simpleProvider/simpleProvider.js @@ -6,21 +6,10 @@ * This provide is sharing the same value with every child, and the * identity of the consumer is not tracked. */ - -// Per Context Component Instance, track the current context data -import { register, ValueChangedEvent, LinkContextEvent } from 'wire-service'; - -const { addEventListener } = Document.prototype; +import { createContextProvider } from 'lwc'; const ContextValueMap = new WeakMap(); -const UniqueEventName = `simple_context_event_${guid()}`; -const Provider = Symbol('SimpleContextProvider'); - -function guid() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -} +const { hasOwnProperty } = Object.prototype; function getDefaultContext() { return 'missing'; @@ -37,39 +26,39 @@ function createContextPayload(value) { return value; } -register(Provider, eventTarget => { - let unsubscribeCallback; - - function callback(value, unsubscribe) { - eventTarget.dispatchEvent(new ValueChangedEvent(createContextPayload(value))); - unsubscribeCallback = unsubscribe; +export class WireAdapter { + contextValue = getDefaultContext(); + constructor(dataCallback) { + this._dataCallback = dataCallback; + // provides the default wired value based on the default context value + this._dataCallback(createContextPayload(this.contextValue)); } - - eventTarget.addEventListener('connect', () => { - const event = new LinkContextEvent(UniqueEventName, callback); - eventTarget.dispatchEvent(event); - if (unsubscribeCallback === undefined) { - // no provider was found, in which case the default - // context should be set. - const defaultContext = getDefaultContext(); - eventTarget.dispatchEvent(new ValueChangedEvent(createContextPayload(defaultContext))); + update(_config, context) { + if (context) { + // we only care about the context, no config is expected or used + if (!hasOwnProperty.call(context, 'value')) { + throw new Error(`Invalid context provided`); + } + this.contextValue = context.value; + this._dataCallback(createContextPayload(this.contextValue)); } - }); - - eventTarget.addEventListener('disconnect', () => { - if (unsubscribeCallback !== undefined) { - unsubscribeCallback(); - unsubscribeCallback = undefined; // resetting it to support reinsertion - } - }); -}); + } + connect() { + // noop + } + disconnect() { + // noop + } + static configSchema = {}; + static contextSchema = { value: 'required' /* could be 'optional' */ }; +} function getContextData(eventTarget) { let contextData = ContextValueMap.get(eventTarget); if (contextData === undefined) { - // collection of consumers' callbacks and default context value per provider instance + // collection of consumers and default context value per provider instance contextData = { - listeners: [], + consumers: [], value: getInitialContext(), // initial value for an installed provider }; ContextValueMap.set(eventTarget, contextData); @@ -77,48 +66,33 @@ function getContextData(eventTarget) { return contextData; } -function disconnectConsumer(eventTarget, contextData, callback) { - const i = contextData.listeners.indexOf(callback); - if (i >= 0) { - contextData.listeners.splice(i, 1); - } else { - throw new TypeError(`Invalid context operation in ${eventTarget}.`); - } -} - -function setupNewContextProvider(eventTarget) { - let contextData; // lazy initialization - addEventListener.call(eventTarget, UniqueEventName, event => { - // this event must have a full stop when it is intercepted by a provider - event.stopImmediatePropagation(); - // the new child provides a callback as a communication channel - const { detail: callback } = event; - // once the first consumer gets connected, then we create the contextData object - if (contextData === undefined) { - contextData = getContextData(eventTarget); - } - // registering the new callback - contextData.listeners.push(callback); - // emit the current value and provide disconnect callback - callback(contextData.value, () => disconnectConsumer(eventTarget, contextData, callback)); +const contextualizer = createContextProvider(WireAdapter); + +export function installCustomContext(target) { + contextualizer(target, { + consumerConnectedCallback(consumer) { + // once the first consumer gets connected, then we create the contextData object + const contextData = getContextData(target); + // registering the new consumer + contextData.consumers.push(consumer); + // push the current value + consumer.provide({ value: contextData.value }); + }, + consumerDisconnectedCallback(consumer) { + const contextData = getContextData(target); + const i = contextData.consumers.indexOf(consumer); + if (i >= 0) { + contextData.consumers.splice(i, 1); + } else { + throw new TypeError(`Invalid context operation in ${target}.`); + } + }, }); } -function emitNewContextValue(eventTarget, newValue) { - const contextData = getContextData(eventTarget); +export function setCustomContext(target, newValue) { + const contextData = getContextData(target); // in this example, all consumers get the same context value contextData.value = newValue; - contextData.listeners.forEach(callback => - callback(newValue, () => disconnectConsumer(eventTarget, contextData, callback)) - ); + contextData.consumers.forEach(consumer => consumer.provide({ value: newValue })); } - -export function installCustomContext(node) { - setupNewContextProvider(node); -} - -export function setCustomContext(node, newValue) { - emitNewContextValue(node, newValue); -} - -export { Provider }; diff --git a/packages/integration-karma/test/wire/property-trap/index.spec.js b/packages/integration-karma/test/wire/property-trap/index.spec.js new file mode 100644 index 0000000000..bfc6a57527 --- /dev/null +++ b/packages/integration-karma/test/wire/property-trap/index.spec.js @@ -0,0 +1,175 @@ +import { createElement } from 'lwc'; + +import EchoAdapterConsumer from 'x/echoAdapterConsumer'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +describe('wire adapter update', () => { + it('should invoke listener with reactive parameter default value', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.recordId).toBe('default value'); + }); + }); + + xit('should invoke listener with reactive parameter is an expando and change', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setExpandoValue('expando modified value'); + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.expando).toBe('expando modified value'); + }); + }); + + it('should not invoke update when parameter value is unchanged', () => { + const spy = []; + EchoWireAdapter.setSpy(spy); + const wireKey = { b: { c: { d: 'a.b.c.d value' } } }; + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + + document.body.appendChild(elm); + elm.setWireKeyParameter(wireKey); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(spy.length).toBe(1); + expect(actualWiredValues.data.recordId).toBe('default value'); + elm.setWireKeyParameter(wireKey); + + return Promise.resolve().then(() => { + expect(spy.length).toBe(1); + }); + }); + }); + + it('should invoke listener with getter value', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.getterValue).toBe('getterValue'); + }); + }); + + it('should react invoke listener with getter value when dependant value is updated', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setMutatedGetterValue(' mutated'); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.getterValue).toBe('getterValue mutated'); + }); + }); + + // all currently failing + describe('reactivity when vm.isDirty === true', () => { + it('should call update with value set before connected (using observed fields)', () => { + const wireKey = { b: { c: { d: 'expected' } } }; + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + elm.setWireKeyParameter(wireKey); + + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe('expected'); + }); + }); + + it('should call update when value set in setter (using @track) that makes dirty the vm', () => { + const wireKey = { b: { c: { d: 'a.b.c.d value' } } }; + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + + document.body.appendChild(elm); + elm.setTrackedPropAndWireKeyParameter(wireKey); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe('a.b.c.d value'); + }); + }); + + it('should call update when value set in setter (using @api) that makes dirty the vm', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + // done before connected, so we ensure the component is dirty. + elm.recordId = 'modified Value'; + + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.recordId).toBe('modified Value'); + }); + }); + }); +}); + +describe('reactive parameter', () => { + it('should return value when multiple levels deep', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setWireKeyParameter({ b: { c: { d: 'a.b.c.d value' } } }); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe('a.b.c.d value'); + }); + }); + + it('should return object value', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + const expected = { e: { f: 'expected' } }; + elm.setWireKeyParameter({ b: { c: { d: expected } } }); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe(expected); + }); + }); + + it('should return undefined when root is undefined', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setWireKeyParameter(undefined); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe(undefined); + }); + }); + + it('should return undefined when part of the value is not defined', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setWireKeyParameter({ b: undefined }); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe(undefined); + }); + }); + + it('should return undefined when a segment is not found', () => { + const elm = createElement('x-echo-adapter-consumer', { is: EchoAdapterConsumer }); + document.body.appendChild(elm); + + elm.setWireKeyParameter({ b: { fooNotC: 'a.b.c.d value' } }); + + return Promise.resolve().then(() => { + const actualWiredValues = elm.getWiredProp(); + expect(actualWiredValues.data.keyVal).toBe(undefined); + }); + }); +}); diff --git a/packages/integration-karma/test/wire/property-trap/x/echoAdapter/echoAdapter.js b/packages/integration-karma/test/wire/property-trap/x/echoAdapter/echoAdapter.js new file mode 100644 index 0000000000..38e8fdce25 --- /dev/null +++ b/packages/integration-karma/test/wire/property-trap/x/echoAdapter/echoAdapter.js @@ -0,0 +1,29 @@ +let adapterSpy; + +export class EchoWireAdapter { + callback; + + static setSpy(spy) { + adapterSpy = spy; + } + + constructor(callback) { + this.callback = callback; + } + + update(config) { + // it passes as value the config + this.log('update', arguments); + this.callback({ data: config, error: undefined }); + } + + connect() {} + + disconnect() {} + + log(method, args) { + if (adapterSpy) { + adapterSpy.push({ method, args }); + } + } +} diff --git a/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.html b/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.html new file mode 100644 index 0000000000..b1e80b33a8 --- /dev/null +++ b/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.js b/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.js new file mode 100644 index 0000000000..26300c61f4 --- /dev/null +++ b/packages/integration-karma/test/wire/property-trap/x/echoAdapterConsumer/echoAdapterConsumer.js @@ -0,0 +1,48 @@ +import { LightningElement, wire, api, track } from 'lwc'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +export default class EchoAdapterConsumer extends LightningElement { + @api recordId = 'default value'; + @wire(EchoWireAdapter, { + recordId: '$recordId', + keyVal: '$a.b.c.d', + getterValue: '$getterValue', + expandoValue: '$expando', + }) + wiredProp; + + @track trackedProp; + + a = {}; + mutatedGetterValue = ''; + + @api + getWiredProp() { + return this.wiredProp; + } + + @api + setWireKeyParameter(newValue) { + this.a = newValue; + } + + @api + setTrackedPropAndWireKeyParameter(newValue) { + this.trackedProp = Math.random(); + this.a = newValue; + } + + @api + setMutatedGetterValue(newValue) { + this.mutatedGetterValue = newValue; + } + + @api + setExpandoValue(newValue) { + this.expando = newValue; + } + + get getterValue() { + return 'getterValue' + this.mutatedGetterValue; + } +} diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/index.spec.js b/packages/integration-karma/test/wire/wirecontextevent-legacy/index.spec.js new file mode 100644 index 0000000000..c534986247 --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/index.spec.js @@ -0,0 +1,18 @@ +import { createElement } from 'lwc'; + +import WireContextProvider from 'x/wireContextProvider'; + +describe('wirecontextevent', () => { + it('should dispatchEvent on custom element when adapter dispatch an event of type wirecontextevent on the wireEventTarget', () => { + const elm = createElement('x-wirecontext-provider', { is: WireContextProvider }); + elm.context = 'test value'; + + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + const consumer = elm.shadowRoot.querySelector('.consumer'); + + expect(consumer.shadowRoot.textContent).toBe('test value'); + }); + }); +}); diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextAdapter/wireContextAdapter.js b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextAdapter/wireContextAdapter.js new file mode 100644 index 0000000000..b4a4bf776d --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextAdapter/wireContextAdapter.js @@ -0,0 +1,20 @@ +import { register, ValueChangedEvent } from 'wire-service'; + +export const wireContextAdapter = () => {}; + +register(wireContextAdapter, wiredEventTarget => { + wiredEventTarget.addEventListener('connect', () => { + const srEvent = new CustomEvent('wirecontextevent', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + callback: contextValue => { + wiredEventTarget.dispatchEvent(new ValueChangedEvent(contextValue)); + }, + }, + }); + + wiredEventTarget.dispatchEvent(srEvent); + }); +}); diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.html b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.html new file mode 100644 index 0000000000..772a25ab50 --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.js b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.js new file mode 100644 index 0000000000..fdc660a46d --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextConsumer/wireContextConsumer.js @@ -0,0 +1,6 @@ +import { LightningElement, wire } from 'lwc'; +import { wireContextAdapter } from 'x/wireContextAdapter'; + +export default class WireContextConsumer extends LightningElement { + @wire(wireContextAdapter) contextValue; +} diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.html b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.html new file mode 100644 index 0000000000..81c0740295 --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.js b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.js new file mode 100644 index 0000000000..cac37bd9ea --- /dev/null +++ b/packages/integration-karma/test/wire/wirecontextevent-legacy/x/wireContextProvider/wireContextProvider.js @@ -0,0 +1,9 @@ +import { LightningElement, api } from 'lwc'; + +export default class WireContextAdapter extends LightningElement { + @api context; + + provideContextValue(contextEvent) { + contextEvent.detail.callback(this.context); + } +} diff --git a/packages/integration-karma/test/wire/wiring/index.spec.js b/packages/integration-karma/test/wire/wiring/index.spec.js new file mode 100644 index 0000000000..b48b1a1f3e --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/index.spec.js @@ -0,0 +1,270 @@ +import { createElement } from 'lwc'; + +import AdapterConsumer from 'x/adapterConsumer'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +import BroadcastConsumer from 'x/broadcastConsumer'; +import { BroadcastAdapter } from 'x/broadcastAdapter'; + +import InheritedMethods from 'x/inheritedMethods'; + +const ComponentClass = AdapterConsumer; +const AdapterId = EchoWireAdapter; + +function filterCalls(echoAdapterSpy, methodType) { + return echoAdapterSpy.filter(call => call.method === methodType); +} + +describe('wiring', () => { + describe('component lifecycle and wire adapter', () => { + it('should call a connect when component is connected', () => { + const spy = []; + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + AdapterId.setSpy(spy); + document.body.appendChild(elm); + + expect(filterCalls(spy, 'connect').length).toBe(1); + }); + + it('should call a disconnect when component is disconnected', () => { + const spy = []; + AdapterId.setSpy(spy); + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + + document.body.appendChild(elm); + document.body.removeChild(elm); + expect(filterCalls(spy, 'disconnect').length).toBe(1); + }); + + it('should call a connect and disconnect when component is connected, disconnected twice', () => { + const spy = []; + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + AdapterId.setSpy(spy); + + document.body.appendChild(elm); + document.body.removeChild(elm); + document.body.appendChild(elm); + document.body.removeChild(elm); + + const connectCalls = filterCalls(spy, 'connect'); + const disconnectCalls = filterCalls(spy, 'disconnect'); + + expect(connectCalls.length).toBe(2); + expect(disconnectCalls.length).toBe(2); + }); + }); + + describe('update method on wire adapter', () => { + it('should be called in same tick when component with wire no dynamic params is created', () => { + const spy = []; + AdapterId.setSpy(spy); + expect(spy.length).toBe(0); + + createElement('x-echo-adapter-consumer', { is: InheritedMethods }); + + expect(spy.length).toBe(3); + expect(spy[0].method).toBe('update'); // parentMethod + expect(spy[1].method).toBe('update'); // childMethod + expect(spy[2].method).toBe('update'); // overriddenInChild + }); + + it('should be called next tick when component with wire that has dynamic params is created', () => { + const spy = []; + AdapterId.setSpy(spy); + expect(spy.length).toBe(0); + + createElement('x-echo-adapter-consumer', { is: ComponentClass }); + + return Promise.resolve().then(() => { + expect(spy.length).toBe(1); + expect(spy[0].method).toBe('update'); + }); + }); + + it('should call update only once when the component is created and a wire dynamic param is modified in the same tick', () => { + const spy = []; + AdapterId.setSpy(spy); + expect(spy.length).toBe(0); + + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + elm.setDynamicParamSource(1); + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + // in the old wire protocol, there is only one call because + // on the same tick the config was modified + const updateCalls = filterCalls(spy, 'update'); + expect(updateCalls.length).toBe(1); + }); + }); + + it('should be called only once during multiple renders when the wire config does not change', () => { + const spy = []; + AdapterId.setSpy(spy); + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + document.body.appendChild(elm); + + return Promise.resolve() + .then(() => { + expect(filterCalls(spy, 'update').length).toBe(1); + elm.forceRerender(); + + return Promise.resolve(); + }) + .then(() => { + expect(filterCalls(spy, 'update').length).toBe(1); + }); + }); + + it('should be called when the wire parameters change its value.', () => { + const spy = []; + AdapterId.setSpy(spy); + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + document.body.appendChild(elm); + + return Promise.resolve() + .then(() => { + expect(filterCalls(spy, 'update').length).toBe(1); + elm.setDynamicParamSource('simpleParam modified'); + + return Promise.resolve(); + }) + .then(() => { + expect(filterCalls(spy, 'update').length).toBe(2); + const wireResult = elm.getWiredProp(); + + expect(wireResult.simpleParam).toBe('simpleParam modified'); + }); + }); + + it('should be called for common parameter when shared among wires', () => { + const spy = []; + AdapterId.setSpy(spy); + const elm = createElement('x-bc-consumer', { is: BroadcastConsumer }); + document.body.appendChild(elm); + + return Promise.resolve().then(() => { + expect(filterCalls(spy, 'update').length).toBe(2); + elm.setCommonParameter('modified'); + + return Promise.resolve().then(() => { + expect(filterCalls(spy, 'update').length).toBe(4); + const wireResult1 = elm.getEchoWiredProp1(); + const wireResult2 = elm.getEchoWiredProp2(); + + expect(wireResult1.id).toBe('echoWire1'); + expect(wireResult1.common).toBe('modified'); + expect(wireResult2.id).toBe('echoWire2'); + expect(wireResult2.common).toBe('modified'); + }); + }); + }); + + it('should not update when setting parameter with same value', () => { + const spy = []; + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + document.body.appendChild(elm); + + AdapterId.setSpy(spy); + const expected = 'expected value'; + elm.setDynamicParamSource(expected); + + return Promise.resolve() + .then(() => { + expect(spy.length).toBe(1); // update,connected + const wireResult = elm.getWiredProp(); + expect(wireResult.simpleParam).toBe(expected); + + elm.setDynamicParamSource(expected); + + return Promise.resolve(); + }) + .then(() => { + expect(spy.length).toBe(1); // update,connected + }); + }); + + it('should trigger component rerender when field is updated', done => { + const elm = createElement('x-echo-adapter-consumer', { is: ComponentClass }); + document.body.appendChild(elm); + + return Promise.resolve() + .then(() => Promise.resolve()) // In this tick, the config is injected. + .then(() => { + // Now the component have re-rendered. + const staticValue = elm.shadowRoot.querySelector('.static'); + const dynamicValue = elm.shadowRoot.querySelector('.dynamic'); + + expect(staticValue.textContent).toBe('1,2,3'); + expect(dynamicValue.textContent).toBe(''); + + elm.setDynamicParamSource('modified value'); + + setTimeout(() => { + const staticValue = elm.shadowRoot.querySelector('.static'); + const dynamicValue = elm.shadowRoot.querySelector('.dynamic'); + + expect(staticValue.textContent).toBe('1,2,3'); + expect(dynamicValue.textContent).toBe('modified value'); + + done(); + }, 5); + }); + }); + }); +}); + +describe('wired fields', () => { + it('should rerender component when adapter pushes data', () => { + BroadcastAdapter.clearInstances(); + const elm = createElement('x-bc-consumer', { is: BroadcastConsumer }); + document.body.appendChild(elm); + BroadcastAdapter.broadcastData('expected value'); + + return Promise.resolve() + .then(() => { + const staticValue = elm.shadowRoot.querySelector('span'); + expect(staticValue.textContent).toBe('expected value'); + BroadcastAdapter.broadcastData('modified value'); + + return Promise.resolve(); + }) + .then(() => { + const staticValue = elm.shadowRoot.querySelector('span'); + expect(staticValue.textContent).toBe('modified value'); + }); + }); +}); + +describe('wired methods', () => { + it('should call component method when wired to a method', () => { + BroadcastAdapter.clearInstances(); + const elm = createElement('x-bc-consumer', { is: BroadcastConsumer }); + document.body.appendChild(elm); + BroadcastAdapter.broadcastData('expected value'); + + return Promise.resolve().then(() => { + const actual = elm.getWiredMethodArgument(); + expect(actual).toBe('expected value'); + }); + }); + + it('should support method override', () => { + const spy = []; + EchoWireAdapter.setSpy(spy); + const elm = createElement('x-inherited-methods', { is: InheritedMethods }); + document.body.appendChild(elm); + + // No need to wait for next tick, the wire only has static config. + const calls = filterCalls(spy, 'update'); + const getCallByName = name => { + return calls.filter(call => name === call.args[0].name)[0]; + }; + + expect(calls.length).toBe(3); + + expect(getCallByName('overriddenInChild').args[0].child).toBe(true); + expect(getCallByName('childMethod').args[0].child).toBe(true); + expect(getCallByName('parentMethod').args[0].parent).toBe(true); + }); +}); diff --git a/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.html b/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.html new file mode 100644 index 0000000000..8e4d12a862 --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.js b/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.js new file mode 100644 index 0000000000..01da3de996 --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/adapterConsumer/adapterConsumer.js @@ -0,0 +1,31 @@ +import { LightningElement, wire, api } from 'lwc'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +export default class AdapterConsumer extends LightningElement { + renderId; + simpleParam; + @wire(EchoWireAdapter, { simpleParam: '$simpleParam', staticParam: [1, 2, 3] }) wireConnected; + + @api + getWiredProp() { + return this.wireConnected; + } + + @api + setDynamicParamSource(newValue) { + this.simpleParam = newValue; + } + + @api + forceRerender() { + this.renderId = Math.random() + Math.random(); + } + + get staticParamValue() { + return this.wireConnected.staticParam && this.wireConnected.staticParam.join(','); + } + + get simpleParamValue() { + return this.wireConnected.simpleParam; + } +} diff --git a/packages/integration-karma/test/wire/wiring/x/base/base.js b/packages/integration-karma/test/wire/wiring/x/base/base.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/integration-karma/test/wire/wiring/x/broadcastAdapter/broadcastAdapter.js b/packages/integration-karma/test/wire/wiring/x/broadcastAdapter/broadcastAdapter.js new file mode 100644 index 0000000000..dceb831f88 --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/broadcastAdapter/broadcastAdapter.js @@ -0,0 +1,26 @@ +let instances = []; + +export class BroadcastAdapter { + callback; + + static clearInstances() { + instances = []; + } + + static broadcastData(data) { + instances.forEach(instance => { + instance.callback(data); + }); + } + + constructor(callback) { + this.callback = callback; + instances.push(this); + } + + update() {} + + connect() {} + + disconnect() {} +} diff --git a/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.html b/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.html new file mode 100644 index 0000000000..90deceaeaf --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.js b/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.js new file mode 100644 index 0000000000..a213bc490d --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/broadcastConsumer/broadcastConsumer.js @@ -0,0 +1,57 @@ +import { LightningElement, wire, api } from 'lwc'; +import { BroadcastAdapter } from 'x/broadcastAdapter'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +export default class BroadcastConsumer extends LightningElement { + @wire(BroadcastAdapter) wiredProp; + + @wire(BroadcastAdapter) + setWirePropertyInMethod(data) { + this.methodArgument = data; + } + + commonParameter; + + @wire(EchoWireAdapter, { id: 'echoWire1', common: '$commonParameter' }) echoWiredProp1; + @wire(EchoWireAdapter, { id: 'echoWire2', common: '$commonParameter' }) echoWiredProp2; + + @api + getEchoWiredProp1() { + return this.echoWiredProp1; + } + + @api + getEchoWiredProp2() { + return this.echoWiredProp2; + } + + @api + setCommonParameter(value) { + this.commonParameter = value; + } + + @api + getWiredProp() { + return this.wiredProp; + } + + @api + getWiredMethodArgument() { + return this.methodArgument; + } + + @api + setWiredPropData(newValue) { + this.wiredProp.data = newValue; + } + + get WiredPropValue() { + const propValue = this.wiredProp || ''; + + if (propValue.data) { + return propValue.data; + } + + return propValue; + } +} diff --git a/packages/integration-karma/test/wire/wiring/x/echoAdapter/echoAdapter.js b/packages/integration-karma/test/wire/wiring/x/echoAdapter/echoAdapter.js new file mode 100644 index 0000000000..d95c13bf36 --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/echoAdapter/echoAdapter.js @@ -0,0 +1,34 @@ +let adapterSpy; + +export class EchoWireAdapter { + callback; + + static setSpy(spy) { + adapterSpy = spy; + } + + constructor(callback) { + this.callback = callback; + callback({}); + } + + update(config) { + // it passes as value the config + this.log('update', arguments); + this.callback(config); + } + + connect() { + this.log('connect', arguments); + } + + disconnect() { + this.log('disconnect', arguments); + } + + log(method, args) { + if (adapterSpy) { + adapterSpy.push({ method, args }); + } + } +} diff --git a/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.html b/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.html new file mode 100644 index 0000000000..6beff5199f --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.html @@ -0,0 +1,2 @@ + diff --git a/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.js b/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.js new file mode 100644 index 0000000000..b80b191427 --- /dev/null +++ b/packages/integration-karma/test/wire/wiring/x/inheritedMethods/inheritedMethods.js @@ -0,0 +1,12 @@ +import { LightningElement, wire } from 'lwc'; +import { EchoWireAdapter } from 'x/echoAdapter'; + +class Base extends LightningElement { + @wire(EchoWireAdapter, { name: 'parentMethod', parent: true }) parentMethod() {} + @wire(EchoWireAdapter, { name: 'overriddenInChild', parent: true }) overriddenInChild() {} +} + +export default class InheritedMethods extends Base { + @wire(EchoWireAdapter, { name: 'childMethod', child: true }) childMethod() {} + @wire(EchoWireAdapter, { name: 'overriddenInChild', child: true }) overriddenInChild() {} +} diff --git a/packages/integration-tests/scripts/build.js b/packages/integration-tests/scripts/build.js index caa726fc15..be23d123b3 100644 --- a/packages/integration-tests/scripts/build.js +++ b/packages/integration-tests/scripts/build.js @@ -39,6 +39,7 @@ const wireServicePath = getModulePath( isProd ? 'prod' : 'dev' ); const todoPath = path.join(require.resolve('../src/shared/todo.js')); +const todoContent = fs.readFileSync(todoPath).toString(); const testSufix = '.test.js'; const testPrefix = 'test-'; @@ -86,6 +87,8 @@ function entryPointResolverPlugin() { resolveId(id) { if (id.includes(testSufix)) { return id; + } else if (id === 'todo') { + return 'todo.js'; } }, load(id) { @@ -94,6 +97,8 @@ function entryPointResolverPlugin() { return testBundle.startsWith('wired-') ? getTodoApp(testBundle) : templates.app(testBundle); + } else if (id === 'todo.js') { + return todoContent; } }, }; @@ -106,7 +111,6 @@ const globalModules = { 'compat-polyfills/polyfills': 'window', lwc: 'LWC', 'wire-service': 'WireService', - todo: 'Todo', }; function createRollupInputConfig() { diff --git a/packages/integration-tests/src/shared/templates.js b/packages/integration-tests/src/shared/templates.js index 74ef187c1a..287d1391a6 100644 --- a/packages/integration-tests/src/shared/templates.js +++ b/packages/integration-tests/src/shared/templates.js @@ -13,45 +13,8 @@ exports.app = function(cmpName) { exports.todoApp = function(cmpName) { return ` - import { registerWireService, register as registerAdapter, ValueChangedEvent } from 'wire-service'; - import { createElement, register } from 'lwc'; + import { createElement } from 'lwc'; import Cmp from 'integration/${cmpName}'; - import { getTodo, getObservable } from 'todo'; - - registerWireService(register); - - // Register the wire adapter for @wire(getTodo). - registerAdapter(getTodo, function getTodoWireAdapter(wiredEventTarget) { - var subscription; - var config; - wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: undefined, error: undefined })); - var observer = { - next: function(data) { wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: data, error: undefined })); }, - error: function(error) { wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: undefined, error: error })); } - }; - wiredEventTarget.addEventListener('connect', function() { - var observable = getObservable(config); - if (observable) { - subscription = observable.subscribe(observer); - return; - } - }); - wiredEventTarget.addEventListener('disconnect', function() { - subscription.unsubscribe(); - }); - wiredEventTarget.addEventListener('config', function(newConfig) { - config = newConfig; - if (subscription) { - subscription.unsubscribe(); - subscription = undefined; - } - var observable = getObservable(config); - if (observable) { - subscription = observable.subscribe(observer); - return; - } - }); - }); var element = createElement('integration-${cmpName}', { is: Cmp }); document.body.appendChild(element); diff --git a/packages/integration-tests/src/shared/todo.js b/packages/integration-tests/src/shared/todo.js index 28cbf05b6c..41c158134c 100644 --- a/packages/integration-tests/src/shared/todo.js +++ b/packages/integration-tests/src/shared/todo.js @@ -1,89 +1,116 @@ -(function(global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' - ? factory(exports) - : typeof define === 'function' && window.define.amd - ? window.define(['exports'], factory) - : factory((global.Todo = {})); -})(this, function(exports) { - 'use strict'; +import { register, ValueChangedEvent } from 'wire-service'; - function getSubject(initialValue, initialError) { - var observer; +function getSubject(initialValue, initialError) { + var observer; - function next(value) { - observer.next(value); - } - - function error(err) { - observer.error(err); - } - - function complete() { - observer.complete(); - } - - var observable = { - subscribe: function(obs) { - observer = obs; - if (initialValue) { - next(initialValue); - } - if (initialError) { - error(initialError); - } - return { - unsubscribe: function() {}, - }; - }, - }; + function next(value) { + observer.next(value); + } - return { - next: next, - error: error, - complete: complete, - observable: observable, - }; + function error(err) { + observer.error(err); } - function generateTodo(id, completed) { - return { - id: id, - title: 'task ' + id, - completed: completed, - }; + function complete() { + observer.complete(); } - var TODO = [ - generateTodo(0, true), - generateTodo(1, false), - // intentionally skip 2 - generateTodo(3, true), - generateTodo(4, true), - // intentionally skip 5 - generateTodo(6, false), - generateTodo(7, false), - ].reduce(function(acc, value) { - acc[value.id] = value; - return acc; - }, {}); + var observable = { + subscribe: function(obs) { + observer = obs; + if (initialValue) { + next(initialValue); + } + if (initialError) { + error(initialError); + } + return { + unsubscribe: function() {}, + }; + }, + }; - function getObservable(config) { - if (!config || !('id' in config)) { - return undefined; - } + return { + next: next, + error: error, + complete: complete, + observable: observable, + }; +} - var todo = TODO[config.id]; - if (!todo) { - var subject = getSubject(undefined, { message: 'Todo not found' }); - return subject.observable; - } +function generateTodo(id, completed) { + return { + id: id, + title: 'task ' + id, + completed: completed, + }; +} - return getSubject(todo).observable; +var TODO = [ + generateTodo(0, true), + generateTodo(1, false), + // intentionally skip 2 + generateTodo(3, true), + generateTodo(4, true), + // intentionally skip 5 + generateTodo(6, false), + generateTodo(7, false), +].reduce(function(acc, value) { + acc[value.id] = value; + return acc; +}, {}); + +function getObservable(config) { + if (!config || !('id' in config)) { + return undefined; + } + + var todo = TODO[config.id]; + if (!todo) { + var subject = getSubject(undefined, { message: 'Todo not found' }); + return subject.observable; } - const getTodo = Symbol('getTodo'); + return getSubject(todo).observable; +} + +const getTodo = function() {}; - exports.getTodo = getTodo; - exports.getObservable = getObservable; - Object.defineProperty(exports, '__esModule', { value: true }); +register(getTodo, function getTodoWireAdapter(wiredEventTarget) { + var subscription; + var config; + wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: undefined, error: undefined })); + var observer = { + next: function(data) { + wiredEventTarget.dispatchEvent(new ValueChangedEvent({ data: data, error: undefined })); + }, + error: function(error) { + wiredEventTarget.dispatchEvent( + new ValueChangedEvent({ data: undefined, error: error }) + ); + }, + }; + wiredEventTarget.addEventListener('connect', function() { + var observable = getObservable(config); + if (observable) { + subscription = observable.subscribe(observer); + } + }); + wiredEventTarget.addEventListener('disconnect', function() { + subscription.unsubscribe(); + }); + wiredEventTarget.addEventListener('config', function(newConfig) { + config = newConfig; + if (subscription) { + subscription.unsubscribe(); + subscription = undefined; + } + var observable = getObservable(config); + if (observable) { + subscription = observable.subscribe(observer); + } + }); }); + +export { getTodo }; +export { getObservable };