From fc8da6ff1708e9a3c593932cf0431d2be8fd5874 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 8 Mar 2022 14:59:30 -0500 Subject: [PATCH] feat: enable multiple instances of fast-element on a page at once (#5718) * feat: enable multiple instances of fast-element on a page at once * Change files Co-authored-by: EisenbergEffect --- ...-a472079b-2d1a-4c93-a8e4-786429b46d18.json | 7 + .../fast-element/docs/api-report.md | 60 +- .../src/components/fast-definitions.ts | 3 +- .../web-components/fast-element/src/dom.ts | 143 ++-- .../src/observation/observable.ts | 652 +++++++++--------- .../fast-element/src/templating/binding.ts | 5 +- 6 files changed, 467 insertions(+), 403 deletions(-) create mode 100644 change/@microsoft-fast-element-a472079b-2d1a-4c93-a8e4-786429b46d18.json diff --git a/change/@microsoft-fast-element-a472079b-2d1a-4c93-a8e4-786429b46d18.json b/change/@microsoft-fast-element-a472079b-2d1a-4c93-a8e4-786429b46d18.json new file mode 100644 index 00000000000..8b829a7f6a4 --- /dev/null +++ b/change/@microsoft-fast-element-a472079b-2d1a-4c93-a8e4-786429b46d18.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: enable multiple instances of fast-element on a page at once", + "packageName": "@microsoft/fast-element", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 31ece83ecda..2fb991b5652 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -49,7 +49,7 @@ export class AttributeDefinition implements Accessor { onAttributeChangedCallback(element: HTMLElement, value: any): void; readonly Owner: Function; setValue(source: HTMLElement, newValue: any): void; -} + } // @public export type AttributeMode = "reflect" | "boolean" | "fromView"; @@ -209,10 +209,10 @@ export const DOM: Readonly<{ supportsAdoptedStyleSheets: boolean; setHTMLPolicy(policy: TrustedTypesPolicy): void; createHTML(html: string): string; - setUpdateMode(isAsync: boolean): void; - queueUpdate(callable: Callable): void; + setUpdateMode: (isAsync: boolean) => void; + queueUpdate: (callable: Callable) => void; nextUpdate(): Promise; - processUpdates(): void; + processUpdates: () => void; setAttribute(element: HTMLElement, attributeName: string, value: any): void; setBooleanAttribute(element: HTMLElement, attributeName: string, value: boolean): void; }>; @@ -274,8 +274,15 @@ export class ExecutionContext { length: number; parent: TParent; parentContext: ExecutionContext; + // @internal + static setEvent(event: Event | null): void; } +// Warning: (ae-internal-missing-underscore) The name "FAST" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export const FAST: FASTGlobal; + // @public export interface FASTElement extends HTMLElement { $emit(type: string, detail?: any, options?: Omit): boolean | void; @@ -301,8 +308,8 @@ export class FASTElementDefinition { readonly attributes: ReadonlyArray; define(registry?: CustomElementRegistry): this; readonly elementOptions?: ElementDefinitionOptions; - static forType(type: TType): FASTElementDefinition | undefined; - readonly isDefined: boolean; + static readonly forType: (key: TType_1) => FASTElementDefinition | undefined; + get isDefined(): boolean; readonly name: string; readonly propertyLookup: Record; readonly shadowOptions?: ShadowRootInit; @@ -311,9 +318,20 @@ export class FASTElementDefinition { readonly type: TType; } +// Warning: (ae-internal-missing-underscore) The name "FASTGlobal" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export interface FASTGlobal { + getById(id: string | number): T | null; + // (undocumented) + getById(id: string | number, initialize: () => T): T; + readonly versions: string[]; +} + // @public export type Global = typeof globalThis & { trustedTypes: TrustedTypes; + readonly FAST: FASTGlobal; }; // @public @@ -358,6 +376,20 @@ export abstract class InlinableHTMLDirective extends AspectedHTMLDirective { abstract readonly rawAspect?: string; } +// Warning: (ae-internal-missing-underscore) The name "KernelServiceId" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export const enum KernelServiceId { + // (undocumented) + contextEvent = 3, + // (undocumented) + elementRegistry = 4, + // (undocumented) + observable = 2, + // (undocumented) + updateQueue = 1 +} + // @public export const Markup: Readonly<{ marker: string; @@ -394,12 +426,12 @@ export const nullableNumberConverter: ValueConverter; // @public export const Observable: Readonly<{ setArrayObserverFactory(factory: (collection: any[]) => Notifier): void; - getNotifier(source: any): Notifier; + getNotifier: (source: any) => Notifier; track(source: unknown, propertyName: string): void; trackVolatile(): void; notify(source: unknown, args: any): void; defineProperty(target: {}, nameOrAccessor: string | Accessor): void; - getAccessors(target: {}): Accessor[]; + getAccessors: (target: {}) => Accessor[]; binding(binding: Binding, initialSubscriber?: Subscriber | undefined, isVolatileBinding?: boolean): BindingObserver; isVolatileBinding(binding: Binding): boolean; }>; @@ -467,14 +499,14 @@ export class RepeatBehavior implements Behavior, Subscriber { // @internal (undocumented) handleChange(source: any, args: Splice[]): void; unbind(): void; -} + } // @public export class RepeatDirective extends HTMLDirective { constructor(itemsBinding: Binding, templateBinding: Binding, options: RepeatOptions); createBehavior(targets: ViewBehaviorTargets): RepeatBehavior; createPlaceholder: (index: number) => string; -} + } // @public export interface RepeatOptions { @@ -482,11 +514,6 @@ export interface RepeatOptions { recycle?: boolean; } -// Warning: (ae-internal-missing-underscore) The name "setCurrentEvent" should be prefixed with an underscore because the declaration is marked as @internal -// -// @internal (undocumented) -export function setCurrentEvent(event: Event | null): void; - // @public export function slotted(propertyOrOptions: (keyof T & string) | SlottedDirectiveOptions): CaptureType; @@ -617,7 +644,7 @@ export class ViewTemplate impl readonly directives: ReadonlyArray; readonly html: string | HTMLTemplateElement; render(source: TSource, host: Node, hostBindingTarget?: Element): HTMLView; -} + } // @public export function volatile(target: {}, name: string | Accessor, descriptor: PropertyDescriptor): PropertyDescriptor; @@ -625,6 +652,7 @@ export function volatile(target: {}, name: string | Accessor, descriptor: Proper // @public export function when(binding: Binding, templateOrTemplateBinding: SyntheticViewTemplate | Binding): CaptureType; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/web-components/fast-element/src/components/fast-definitions.ts b/packages/web-components/fast-element/src/components/fast-definitions.ts index 3b20062dd54..0b8737063ab 100644 --- a/packages/web-components/fast-element/src/components/fast-definitions.ts +++ b/packages/web-components/fast-element/src/components/fast-definitions.ts @@ -1,5 +1,6 @@ -import { isString, Mutable } from "../interfaces.js"; +import { isString } from "../interfaces.js"; import { Observable } from "../observation/observable.js"; +import { FAST, KernelServiceId } from "../platform.js"; import { ComposableStyles, ElementStyles } from "../styles/element-styles.js"; import type { ElementViewTemplate } from "../templating/template.js"; import { AttributeConfiguration, AttributeDefinition } from "./attributes.js"; diff --git a/packages/web-components/fast-element/src/dom.ts b/packages/web-components/fast-element/src/dom.ts index 0c9fa1c31ce..c4686a1a8eb 100644 --- a/packages/web-components/fast-element/src/dom.ts +++ b/packages/web-components/fast-element/src/dom.ts @@ -1,5 +1,5 @@ import type { Callable } from "./interfaces.js"; -import { $global, TrustedTypesPolicy } from "./platform.js"; +import { $global, KernelServiceId, TrustedTypesPolicy } from "./platform.js"; /* eslint-disable */ const fastHTMLPolicy: TrustedTypesPolicy = $global.trustedTypes.createPolicy( @@ -10,31 +10,82 @@ const fastHTMLPolicy: TrustedTypesPolicy = $global.trustedTypes.createPolicy( ); /* eslint-enable */ -let htmlPolicy: TrustedTypesPolicy = fastHTMLPolicy; -const updateQueue: Callable[] = []; -const pendingErrors: any[] = []; -const rAF = $global.requestAnimationFrame; -let updateAsync = true; - -function throwFirstError(): void { - if (pendingErrors.length) { - throw pendingErrors.shift(); +const updateQueue = $global.FAST.getById(KernelServiceId.updateQueue, () => { + const tasks: Callable[] = []; + const pendingErrors: any[] = []; + const rAF = $global.requestAnimationFrame; + let updateAsync = true; + + function throwFirstError(): void { + if (pendingErrors.length) { + throw pendingErrors.shift(); + } } -} - -function tryRunTask(task: Callable): void { - try { - (task as any).call(); - } catch (error) { - if (updateAsync) { - pendingErrors.push(error); - setTimeout(throwFirstError, 0); - } else { - updateQueue.length = 0; - throw error; + + function tryRunTask(task: Callable): void { + try { + (task as any).call(); + } catch (error) { + if (updateAsync) { + pendingErrors.push(error); + setTimeout(throwFirstError, 0); + } else { + tasks.length = 0; + throw error; + } + } + } + + function process(): void { + const capacity = 1024; + let index = 0; + + while (index < tasks.length) { + tryRunTask(tasks[index]); + index++; + + // Prevent leaking memory for long chains of recursive calls to `DOM.queueUpdate`. + // If we call `DOM.queueUpdate` within a task scheduled by `DOM.queueUpdate`, the queue will + // grow, but to avoid an O(n) walk for every task we execute, we don't + // shift tasks off the queue after they have been executed. + // Instead, we periodically shift 1024 tasks off the queue. + if (index > capacity) { + // Manually shift all values starting at the index back to the + // beginning of the queue. + for ( + let scan = 0, newLength = tasks.length - index; + scan < newLength; + scan++ + ) { + tasks[scan] = tasks[scan + index]; + } + + tasks.length -= index; + index = 0; + } + } + + tasks.length = 0; + } + + function enqueue(callable: Callable): void { + tasks.push(callable); + + if (tasks.length < 2) { + updateAsync ? rAF(process) : process(); } } -} + + return Object.freeze({ + enqueue, + process, + setUpdateMode(isAsync: boolean) { + updateAsync = isAsync; + }, + }); +}); + +let htmlPolicy: TrustedTypesPolicy = fastHTMLPolicy; /** * Common DOM APIs. @@ -84,27 +135,19 @@ export const DOM = Object.freeze({ * ordering will still be preserved so that nested tasks do not run until * after parent tasks complete. */ - setUpdateMode(isAsync: boolean) { - updateAsync = isAsync; - }, + setUpdateMode: updateQueue.setUpdateMode, /** * Schedules DOM update work in the next async batch. * @param callable - The callable function or object to queue. */ - queueUpdate(callable: Callable) { - updateQueue.push(callable); - - if (updateQueue.length < 2) { - updateAsync ? rAF(DOM.processUpdates) : DOM.processUpdates(); - } - }, + queueUpdate: updateQueue.enqueue, /** * Resolves with the next DOM update. */ nextUpdate(): Promise { - return new Promise(DOM.queueUpdate); + return new Promise(updateQueue.enqueue); }, /** @@ -114,37 +157,7 @@ export const DOM = Object.freeze({ * This also forces nextUpdate promises * to resolve. */ - processUpdates(): void { - const capacity = 1024; - let index = 0; - - while (index < updateQueue.length) { - tryRunTask(updateQueue[index]); - index++; - - // Prevent leaking memory for long chains of recursive calls to `DOM.queueUpdate`. - // If we call `DOM.queueUpdate` within a task scheduled by `DOM.queueUpdate`, the queue will - // grow, but to avoid an O(n) walk for every task we execute, we don't - // shift tasks off the queue after they have been executed. - // Instead, we periodically shift 1024 tasks off the queue. - if (index > capacity) { - // Manually shift all values starting at the index back to the - // beginning of the queue. - for ( - let scan = 0, newLength = updateQueue.length - index; - scan < newLength; - scan++ - ) { - updateQueue[scan] = updateQueue[scan + index]; - } - - updateQueue.length -= index; - index = 0; - } - } - - updateQueue.length = 0; - }, + processUpdates: updateQueue.process, /** * Sets an attribute value on an element. diff --git a/packages/web-components/fast-element/src/observation/observable.ts b/packages/web-components/fast-element/src/observation/observable.ts index e4e10c9aea6..01bee669fb7 100644 --- a/packages/web-components/fast-element/src/observation/observable.ts +++ b/packages/web-components/fast-element/src/observation/observable.ts @@ -1,16 +1,9 @@ import { DOM } from "../dom.js"; import { isFunction, isString } from "../interfaces.js"; +import { FAST, KernelServiceId } from "../platform.js"; import { PropertyChangeNotifier, SubscriberSet } from "./notifier.js"; import type { Notifier, Subscriber } from "./notifier.js"; -const volatileRegex = /(:|&&|\|\||if)/; -const notifierLookup = new WeakMap(); -const accessorLookup = new WeakMap(); -let watcher: BindingObserverImplementation | undefined = void 0; -let createArrayObserver = (array: any[]): Notifier => { - throw new Error("Must call enableArrayObservation before observing arrays."); -}; - /** * Represents a getter/setter property accessor on an object. * @public @@ -35,60 +28,79 @@ export interface Accessor { setValue(source: any, value: any): void; } -class DefaultObservableAccessor implements Accessor { - private field: string; - private callback: string; - - constructor(public name: string) { - this.field = `_${name}`; - this.callback = `${name}Changed`; - } - - getValue(source: any): any { - if (watcher !== void 0) { - watcher.watch(source, this.name); - } - - return source[this.field]; - } - - setValue(source: any, newValue: any): void { - const field = this.field; - const oldValue = source[field]; - - if (oldValue !== newValue) { - source[field] = newValue; +/** + * The signature of an arrow function capable of being evaluated + * as part of a template binding update. + * @public + */ +export type Binding = ( + source: TSource, + context: ExecutionContext +) => TReturn; - const callback = source[this.callback]; +/** + * A record of observable property access. + * @public + */ +export interface ObservationRecord { + /** + * The source object with an observable property that was accessed. + */ + propertySource: any; - if (isFunction(callback)) { - callback.call(source, oldValue, newValue); - } + /** + * The name of the observable property on {@link ObservationRecord.propertySource} that was accessed. + */ + propertyName: string; +} - /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ - getNotifier(source).notify(this.name); - } - } +interface SubscriptionRecord extends ObservationRecord { + notifier: Notifier; + next: SubscriptionRecord | undefined; } /** - * Common Observable APIs. + * Enables evaluation of and subscription to a binding. * @public */ -export const Observable = Object.freeze({ +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +export interface BindingObserver + extends Notifier { /** - * @internal - * @param factory - The factory used to create array observers. + * Begins observing the binding for the source and returns the current value. + * @param source - The source that the binding is based on. + * @param context - The execution context to execute the binding within. + * @returns The value of the binding. */ - setArrayObserverFactory(factory: (collection: any[]) => Notifier): void { - createArrayObserver = factory; - }, + observe(source: TSource, context: ExecutionContext): TReturn; /** - * Gets a notifier for an object or Array. - * @param source - The object or Array to get the notifier for. + * Unsubscribe from all dependent observables of the binding. */ - getNotifier(source: any): Notifier { + disconnect(): void; + + /** + * Gets {@link ObservationRecord|ObservationRecords} that the {@link BindingObserver} + * is observing. + */ + records(): IterableIterator; +} + +/** + * Common Observable APIs. + * @public + */ +export const Observable = FAST.getById(KernelServiceId.observable, () => { + const queueUpdate = DOM.queueUpdate; + const volatileRegex = /(:|&&|\|\||if)/; + const notifierLookup = new WeakMap(); + const accessorLookup = new WeakMap(); + let watcher: BindingObserverImplementation | undefined = void 0; + let createArrayObserver = (array: any[]): Notifier => { + throw new Error("Must call enableArrayObservation before observing arrays."); + }; + + function getNotifier(source: any): Notifier { let found = source.$fastController ?? notifierLookup.get(source); if (found === void 0) { @@ -101,65 +113,9 @@ export const Observable = Object.freeze({ } return found; - }, - - /** - * Records a property change for a source object. - * @param source - The object to record the change against. - * @param propertyName - The property to track as changed. - */ - track(source: unknown, propertyName: string): void { - watcher && watcher.watch(source, propertyName); - }, - - /** - * Notifies watchers that the currently executing property getter or function is volatile - * with respect to its observable dependencies. - */ - trackVolatile(): void { - watcher && (watcher.needsRefresh = true); - }, - - /** - * Notifies subscribers of a source object of changes. - * @param source - the object to notify of changes. - * @param args - The change args to pass to subscribers. - */ - notify(source: unknown, args: any): void { - /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ - getNotifier(source).notify(args); - }, - - /** - * Defines an observable property on an object or prototype. - * @param target - The target object to define the observable on. - * @param nameOrAccessor - The name of the property to define as observable; - * or a custom accessor that specifies the property name and accessor implementation. - */ - defineProperty(target: {}, nameOrAccessor: string | Accessor): void { - if (isString(nameOrAccessor)) { - nameOrAccessor = new DefaultObservableAccessor(nameOrAccessor); - } - - this.getAccessors(target).push(nameOrAccessor); - - Reflect.defineProperty(target, nameOrAccessor.name, { - enumerable: true, - get(this: any) { - return (nameOrAccessor as Accessor).getValue(this); - }, - set(this: any, newValue: any) { - (nameOrAccessor as Accessor).setValue(this, newValue); - }, - }); - }, + } - /** - * Finds all the observable accessors defined on the target, - * including its prototype chain. - * @param target - The target object to search for accessor on. - */ - getAccessors(target: {}): Accessor[] { + function getAccessors(target: {}): Accessor[] { let accessors = accessorLookup.get(target); if (accessors === void 0) { @@ -176,43 +132,258 @@ export const Observable = Object.freeze({ } return accessors; - }, + } - /** - * Creates a {@link BindingObserver} that can watch the - * provided {@link Binding} for changes. - * @param binding - The binding to observe. - * @param initialSubscriber - An initial subscriber to changes in the binding value. - * @param isVolatileBinding - Indicates whether the binding's dependency list must be re-evaluated on every value evaluation. - */ - binding( - binding: Binding, - initialSubscriber?: Subscriber, - isVolatileBinding: boolean = this.isVolatileBinding(binding) - ): BindingObserver { - /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ - return new BindingObserverImplementation( - binding, - initialSubscriber, - isVolatileBinding - ); - }, + class DefaultObservableAccessor implements Accessor { + private field: string; + private callback: string; - /** - * Determines whether a binding expression is volatile and needs to have its dependency list re-evaluated - * on every evaluation of the value. - * @param binding - The binding to inspect. - */ - isVolatileBinding( - binding: Binding - ): boolean { - return volatileRegex.test(binding.toString()); - }, -}); + constructor(public name: string) { + this.field = `_${name}`; + this.callback = `${name}Changed`; + } + + getValue(source: any): any { + if (watcher !== void 0) { + watcher.watch(source, this.name); + } + + return source[this.field]; + } + + setValue(source: any, newValue: any): void { + const field = this.field; + const oldValue = source[field]; + + if (oldValue !== newValue) { + source[field] = newValue; -const getNotifier = Observable.getNotifier; -const trackVolatile = Observable.trackVolatile; -const queueUpdate = DOM.queueUpdate; + const callback = source[this.callback]; + + if (isFunction(callback)) { + callback.call(source, oldValue, newValue); + } + + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + getNotifier(source).notify(this.name); + } + } + } + + class BindingObserverImplementation + extends SubscriberSet + implements BindingObserver { + public needsRefresh: boolean = true; + private needsQueue: boolean = true; + + private first: SubscriptionRecord = this as any; + private last: SubscriptionRecord | null = null; + private propertySource: any = void 0; + private propertyName: string | undefined = void 0; + private notifier: Notifier | undefined = void 0; + private next: SubscriptionRecord | undefined = void 0; + + constructor( + private binding: Binding, + initialSubscriber?: Subscriber, + private isVolatileBinding: boolean = false + ) { + super(binding, initialSubscriber); + } + + public observe(source: TSource, context: ExecutionContext): TReturn { + if (this.needsRefresh && this.last !== null) { + this.disconnect(); + } + + const previousWatcher = watcher; + watcher = this.needsRefresh ? this : void 0; + this.needsRefresh = this.isVolatileBinding; + const result = this.binding(source, context); + watcher = previousWatcher; + + return result; + } + + public disconnect(): void { + if (this.last !== null) { + let current = this.first; + + while (current !== void 0) { + current.notifier.unsubscribe(this, current.propertyName); + current = current.next!; + } + + this.last = null; + this.needsRefresh = this.needsQueue = true; + } + } + + /** @internal */ + public watch(propertySource: unknown, propertyName: string): void { + const prev = this.last; + const notifier = getNotifier(propertySource); + const current: SubscriptionRecord = prev === null ? this.first : ({} as any); + + current.propertySource = propertySource; + current.propertyName = propertyName; + current.notifier = notifier; + + notifier.subscribe(this, propertyName); + + if (prev !== null) { + if (!this.needsRefresh) { + // Declaring the variable prior to assignment below circumvents + // a bug in Angular's optimization process causing infinite recursion + // of this watch() method. Details https://github.com/microsoft/fast/issues/4969 + let prevValue; + watcher = void 0; + /* eslint-disable-next-line */ + prevValue = prev.propertySource[prev.propertyName]; + watcher = this; + + if (propertySource === prevValue) { + this.needsRefresh = true; + } + } + + prev.next = current; + } + + this.last = current!; + } + + /** @internal */ + handleChange(): void { + if (this.needsQueue) { + this.needsQueue = false; + queueUpdate(this); + } + } + + /** @internal */ + call(): void { + if (this.last !== null) { + this.needsQueue = true; + this.notify(this); + } + } + + public *records(): IterableIterator { + let next = this.first; + + while (next !== void 0) { + yield next; + next = next.next!; + } + } + } + + return Object.freeze({ + /** + * @internal + * @param factory - The factory used to create array observers. + */ + setArrayObserverFactory(factory: (collection: any[]) => Notifier): void { + createArrayObserver = factory; + }, + + /** + * Gets a notifier for an object or Array. + * @param source - The object or Array to get the notifier for. + */ + getNotifier, + + /** + * Records a property change for a source object. + * @param source - The object to record the change against. + * @param propertyName - The property to track as changed. + */ + track(source: unknown, propertyName: string): void { + watcher && watcher.watch(source, propertyName); + }, + + /** + * Notifies watchers that the currently executing property getter or function is volatile + * with respect to its observable dependencies. + */ + trackVolatile(): void { + watcher && (watcher.needsRefresh = true); + }, + + /** + * Notifies subscribers of a source object of changes. + * @param source - the object to notify of changes. + * @param args - The change args to pass to subscribers. + */ + notify(source: unknown, args: any): void { + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + getNotifier(source).notify(args); + }, + + /** + * Defines an observable property on an object or prototype. + * @param target - The target object to define the observable on. + * @param nameOrAccessor - The name of the property to define as observable; + * or a custom accessor that specifies the property name and accessor implementation. + */ + defineProperty(target: {}, nameOrAccessor: string | Accessor): void { + if (isString(nameOrAccessor)) { + nameOrAccessor = new DefaultObservableAccessor(nameOrAccessor); + } + + getAccessors(target).push(nameOrAccessor); + + Reflect.defineProperty(target, nameOrAccessor.name, { + enumerable: true, + get(this: any) { + return (nameOrAccessor as Accessor).getValue(this); + }, + set(this: any, newValue: any) { + (nameOrAccessor as Accessor).setValue(this, newValue); + }, + }); + }, + + /** + * Finds all the observable accessors defined on the target, + * including its prototype chain. + * @param target - The target object to search for accessor on. + */ + getAccessors, + + /** + * Creates a {@link BindingObserver} that can watch the + * provided {@link Binding} for changes. + * @param binding - The binding to observe. + * @param initialSubscriber - An initial subscriber to changes in the binding value. + * @param isVolatileBinding - Indicates whether the binding's dependency list must be re-evaluated on every value evaluation. + */ + binding( + binding: Binding, + initialSubscriber?: Subscriber, + isVolatileBinding: boolean = this.isVolatileBinding(binding) + ): BindingObserver { + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + return new BindingObserverImplementation( + binding, + initialSubscriber, + isVolatileBinding + ); + }, + + /** + * Determines whether a binding expression is volatile and needs to have its dependency list re-evaluated + * on every evaluation of the value. + * @param binding - The binding to inspect. + */ + isVolatileBinding( + binding: Binding + ): boolean { + return volatileRegex.test(binding.toString()); + }, + }); +}); /** * Decorator: Defines an observable property on the target. @@ -238,21 +409,24 @@ export function volatile( ): PropertyDescriptor { return Object.assign({}, descriptor, { get(this: any) { - trackVolatile(); + Observable.trackVolatile(); return descriptor.get!.apply(this); }, }); } -let currentEvent: Event | null = null; +const contextEvent = FAST.getById(KernelServiceId.contextEvent, () => { + let current: Event | null = null; -/** - * @param event - The event to set as current for the context. - * @internal - */ -export function setCurrentEvent(event: Event | null): void { - currentEvent = event; -} + return { + get() { + return current; + }, + set(event: Event | null) { + current = event; + }, + }; +}); /** * Provides additional contextual information available to behaviors and expressions. @@ -283,7 +457,7 @@ export class ExecutionContext { * The current event within an event handler. */ public get event(): Event { - return currentEvent!; + return contextEvent.get()!; } /** @@ -325,6 +499,15 @@ export class ExecutionContext { public get isLast(): boolean { return this.index === this.length - 1; } + + /** + * Sets the event for the current execution context. + * @param event - The event to set. + * @internal + */ + public static setEvent(event: Event | null): void { + contextEvent.set(event); + } } Observable.defineProperty(ExecutionContext.prototype, "index"); @@ -335,170 +518,3 @@ Observable.defineProperty(ExecutionContext.prototype, "length"); * @public */ export const defaultExecutionContext = Object.seal(new ExecutionContext()); - -/** - * The signature of an arrow function capable of being evaluated - * as part of a template binding update. - * @public - */ -export type Binding = ( - source: TSource, - context: ExecutionContext -) => TReturn; - -/** - * A record of observable property access. - * @public - */ -export interface ObservationRecord { - /** - * The source object with an observable property that was accessed. - */ - propertySource: any; - - /** - * The name of the observable property on {@link ObservationRecord.propertySource} that was accessed. - */ - propertyName: string; -} - -interface SubscriptionRecord extends ObservationRecord { - notifier: Notifier; - next: SubscriptionRecord | undefined; -} - -/** - * Enables evaluation of and subscription to a binding. - * @public - */ -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ -export interface BindingObserver - extends Notifier { - /** - * Begins observing the binding for the source and returns the current value. - * @param source - The source that the binding is based on. - * @param context - The execution context to execute the binding within. - * @returns The value of the binding. - */ - observe(source: TSource, context: ExecutionContext): TReturn; - - /** - * Unsubscribe from all dependent observables of the binding. - */ - disconnect(): void; - - /** - * Gets {@link ObservationRecord|ObservationRecords} that the {@link BindingObserver} - * is observing. - */ - records(): IterableIterator; -} - -class BindingObserverImplementation - extends SubscriberSet - implements BindingObserver { - public needsRefresh: boolean = true; - private needsQueue: boolean = true; - - private first: SubscriptionRecord = this as any; - private last: SubscriptionRecord | null = null; - private propertySource: any = void 0; - private propertyName: string | undefined = void 0; - private notifier: Notifier | undefined = void 0; - private next: SubscriptionRecord | undefined = void 0; - - constructor( - private binding: Binding, - initialSubscriber?: Subscriber, - private isVolatileBinding: boolean = false - ) { - super(binding, initialSubscriber); - } - - public observe(source: TSource, context: ExecutionContext): TReturn { - if (this.needsRefresh && this.last !== null) { - this.disconnect(); - } - - const previousWatcher = watcher; - watcher = this.needsRefresh ? this : void 0; - this.needsRefresh = this.isVolatileBinding; - const result = this.binding(source, context); - watcher = previousWatcher; - - return result; - } - - public disconnect(): void { - if (this.last !== null) { - let current = this.first; - - while (current !== void 0) { - current.notifier.unsubscribe(this, current.propertyName); - current = current.next!; - } - - this.last = null; - this.needsRefresh = this.needsQueue = true; - } - } - - /** @internal */ - public watch(propertySource: unknown, propertyName: string): void { - const prev = this.last; - const notifier = getNotifier(propertySource); - const current: SubscriptionRecord = prev === null ? this.first : ({} as any); - - current.propertySource = propertySource; - current.propertyName = propertyName; - current.notifier = notifier; - - notifier.subscribe(this, propertyName); - - if (prev !== null) { - if (!this.needsRefresh) { - // Declaring the variable prior to assignment below circumvents - // a bug in Angular's optimization process causing infinite recursion - // of this watch() method. Details https://github.com/microsoft/fast/issues/4969 - let prevValue; - watcher = void 0; - /* eslint-disable-next-line */ - prevValue = prev.propertySource[prev.propertyName]; - watcher = this; - - if (propertySource === prevValue) { - this.needsRefresh = true; - } - } - - prev.next = current; - } - - this.last = current!; - } - - /** @internal */ - handleChange(): void { - if (this.needsQueue) { - this.needsQueue = false; - queueUpdate(this); - } - } - - /** @internal */ - call(): void { - if (this.last !== null) { - this.needsQueue = true; - this.notify(this); - } - } - - public *records(): IterableIterator { - let next = this.first; - - while (next !== void 0) { - yield next; - next = next.next!; - } - } -} diff --git a/packages/web-components/fast-element/src/templating/binding.ts b/packages/web-components/fast-element/src/templating/binding.ts index a333a04969e..b6e0ebae64b 100644 --- a/packages/web-components/fast-element/src/templating/binding.ts +++ b/packages/web-components/fast-element/src/templating/binding.ts @@ -5,7 +5,6 @@ import { BindingObserver, ExecutionContext, Observable, - setCurrentEvent, } from "../observation/observable.js"; import { InlinableHTMLDirective, @@ -449,9 +448,9 @@ class EventListener extends BindingBase { const source = target.$fastSource; const context = target.$fastContext; - setCurrentEvent(event); + ExecutionContext.setEvent(event); const result = this.directive.binding(source, context); - setCurrentEvent(null); + ExecutionContext.setEvent(null); if (result !== true) { event.preventDefault();