From d55eba85ff4860bacaff1988c5e1aa56eaa4a225 Mon Sep 17 00:00:00 2001 From: Nayden Naydenov <31909318+nnaydenow@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:26:49 +0300 Subject: [PATCH] feat: enhance feature initialization (#9479) Previously, feature loading depended on the import order, requiring features to be imported before the component definition. This caused issues in some situations, especially when creating chunks because the imports can be reordered by the build tools. With this change, we have split the features into two types: library-specific and component-specific. Library-specific features need to be imported before the boot process, otherwise, it can cause serious issues, because the need to re-render all components and manipulate the DOM (including scripts, styles, and meta tags). Component-specific features can now be imported without a specific order, and components that depend on these features will automatically update, enabling the feature on the next rendering of the component. Fixes: #8175 --- packages/base/src/FeaturesRegistry.ts | 54 +++++++++++++++++++ packages/base/src/UI5Element.ts | 15 +++++- packages/base/src/UI5ElementMetadata.ts | 9 ++++ packages/base/src/decorators/customElement.ts | 7 +++ packages/main/src/ColorPalette.ts | 34 +++++++----- packages/main/src/ColorPaletteDialog.hbs | 36 ++++++------- packages/main/src/Input.hbs | 2 +- packages/main/src/Input.ts | 49 ++++++++--------- packages/main/src/InputPopover.hbs | 2 +- .../src/features/ColorPaletteMoreColors.ts | 8 +-- .../main/src/features/InputSuggestions.ts | 9 ++-- 11 files changed, 157 insertions(+), 68 deletions(-) diff --git a/packages/base/src/FeaturesRegistry.ts b/packages/base/src/FeaturesRegistry.ts index 85c8afcb3503..33e12e9243ab 100644 --- a/packages/base/src/FeaturesRegistry.ts +++ b/packages/base/src/FeaturesRegistry.ts @@ -1,4 +1,21 @@ +import EventProvider from "./EventProvider.js"; +import type UI5Element from "./UI5Element.js"; + +abstract class ComponentFeature { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-empty-function + constructor(...args: any[]) {} + static define?: () => Promise; + static dependencies?: Array +} + const features = new Map(); +const componentFeatures = new Map(); +const subscribers = new Map>(); + +const EVENT_NAME = "componentFeatureLoad"; +const eventProvider = new EventProvider(); + +const featureLoadEventName = (name: string) => `${EVENT_NAME}_${name}`; const registerFeature = (name: string, feature: object) => { features.set(name, feature); @@ -8,7 +25,44 @@ const getFeature = (name: string): T => { return features.get(name) as T; }; +const registerComponentFeature = async (name: string, feature: typeof ComponentFeature) => { + await Promise.all(feature.dependencies?.map(dep => dep.define()) || []); + await feature.define?.(); + + componentFeatures.set(name, feature); + notifyForFeatureLoad(name); +}; + +const getComponentFeature = (name: string): T => { + return componentFeatures.get(name) as T; +}; + +const subscribeForFeatureLoad = (name: string, klass: typeof UI5Element, callback: () => void) => { + const subscriber = subscribers.get(klass); + const isSubscribed = subscriber?.includes(name); + + if (isSubscribed) { + return; + } + + if (!subscriber) { + subscribers.set(klass, [name]); + } else { + subscriber.push(name); + } + + eventProvider.attachEvent(featureLoadEventName(name), callback); +}; + +const notifyForFeatureLoad = (name: string) => { + eventProvider.fireEvent(featureLoadEventName(name), undefined); +}; + export { registerFeature, getFeature, + registerComponentFeature, + getComponentFeature, + subscribeForFeatureLoad, + ComponentFeature, }; diff --git a/packages/base/src/UI5Element.ts b/packages/base/src/UI5Element.ts index cae08029c808..306f3e85aac4 100644 --- a/packages/base/src/UI5Element.ts +++ b/packages/base/src/UI5Element.ts @@ -37,6 +37,7 @@ import type { } from "./types.js"; import { attachFormElementInternals, setFormValue } from "./features/InputElementsFormSupport.js"; import type { IFormInputElement } from "./features/InputElementsFormSupport.js"; +import { subscribeForFeatureLoad } from "./FeaturesRegistry.js"; const DEV_MODE = true; let autoId = 0; @@ -1168,6 +1169,11 @@ abstract class UI5Element extends HTMLElement { return []; } + static cacheUniqueDependencies(this: typeof UI5Element): void { + const filtered = this.dependencies.filter((dep, index, deps) => deps.indexOf(dep) === index); + uniqueDependenciesCache.set(this, filtered); + } + /** * Returns a list of the unique dependencies for this UI5 Web Component * @@ -1175,8 +1181,7 @@ abstract class UI5Element extends HTMLElement { */ static getUniqueDependencies(this: typeof UI5Element): Array { if (!uniqueDependenciesCache.has(this)) { - const filtered = this.dependencies.filter((dep, index, deps) => deps.indexOf(dep) === index); - uniqueDependenciesCache.set(this, filtered); + this.cacheUniqueDependencies(); } return uniqueDependenciesCache.get(this) || []; @@ -1212,6 +1217,12 @@ abstract class UI5Element extends HTMLElement { const tag = this.getMetadata().getTag(); + const features = this.getMetadata().getFeatures(); + + features.forEach(feature => { + subscribeForFeatureLoad(feature, this, this.cacheUniqueDependencies.bind(this)); + }); + const definedLocally = isTagRegistered(tag); const definedGlobally = customElements.get(tag); diff --git a/packages/base/src/UI5ElementMetadata.ts b/packages/base/src/UI5ElementMetadata.ts index 0a3a889dcf07..b9ff81fee95b 100644 --- a/packages/base/src/UI5ElementMetadata.ts +++ b/packages/base/src/UI5ElementMetadata.ts @@ -41,6 +41,7 @@ type Metadata = { languageAware?: boolean, formAssociated?: boolean, shadowRootOptions?: Partial + features?: Array }; type State = Record>; @@ -96,6 +97,14 @@ class UI5ElementMetadata { return this.metadata.tag || ""; } + /** + * Returns the tag of the UI5 Element without the scope + * @private + */ + getFeatures(): Array { + return this.metadata.features || []; + } + /** * Returns the tag of the UI5 Element * @public diff --git a/packages/base/src/decorators/customElement.ts b/packages/base/src/decorators/customElement.ts index d00e19bc3a5f..4f4808f1280b 100644 --- a/packages/base/src/decorators/customElement.ts +++ b/packages/base/src/decorators/customElement.ts @@ -20,6 +20,7 @@ const customElement = (tagNameOrComponentSettings: string | { fastNavigation?: boolean, formAssociated?: boolean, shadowRootOptions?: Partial, + features?: Array, } = {}): ClassDecorator => { return (target: any) => { if (!Object.prototype.hasOwnProperty.call(target, "metadata")) { @@ -38,12 +39,18 @@ const customElement = (tagNameOrComponentSettings: string | { fastNavigation, formAssociated, shadowRootOptions, + features, } = tagNameOrComponentSettings; target.metadata.tag = tag; if (languageAware) { target.metadata.languageAware = languageAware; } + + if (features) { + target.metadata.features = features; + } + if (themeAware) { target.metadata.themeAware = themeAware; } diff --git a/packages/main/src/ColorPalette.ts b/packages/main/src/ColorPalette.ts index 78df1bf7aac1..fd2bc56b3359 100644 --- a/packages/main/src/ColorPalette.ts +++ b/packages/main/src/ColorPalette.ts @@ -17,7 +17,7 @@ import { isUp, isTabNext, } from "@ui5/webcomponents-base/dist/Keys.js"; -import { getFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js"; +import { getComponentFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js"; import ColorPaletteTemplate from "./generated/templates/ColorPaletteTemplate.lit.js"; import ColorPaletteItem from "./ColorPaletteItem.js"; import Button from "./Button.js"; @@ -73,10 +73,11 @@ type ColorPaletteItemClickEventDetail = { @customElement({ tag: "ui5-color-palette", renderer: litRender, + features: ["ColorPaletteMoreColors"], template: ColorPaletteTemplate, styles: [ColorPaletteCss, ColorPaletteDialogCss], get dependencies() { - const colorPaletteMoreColors = getFeature("ColorPaletteMoreColors"); + const colorPaletteMoreColors = getComponentFeature("ColorPaletteMoreColors"); return ([ColorPaletteItem, Button] as Array).concat(colorPaletteMoreColors ? colorPaletteMoreColors.dependencies : []); }, }) @@ -172,19 +173,14 @@ class ColorPalette extends UI5Element { _itemNavigation: ItemNavigation; _itemNavigationRecentColors: ItemNavigation; _recentColors: Array; - moreColorsFeature: ColorPaletteMoreColors | Record = {}; + moreColorsFeature?: ColorPaletteMoreColors; _currentlySelected?: ColorPaletteItem; _shouldFocusRecentColors = false; static i18nBundle: I18nBundle; static async onDefine() { - const colorPaletteMoreColors = getFeature("ColorPaletteMoreColors"); - - [ColorPalette.i18nBundle] = await Promise.all([ - getI18nBundle("@ui5/webcomponents"), - colorPaletteMoreColors ? colorPaletteMoreColors.init() : Promise.resolve(), - ]); + ColorPalette.i18nBundle = await getI18nBundle("@ui5/webcomponents"); } constructor() { @@ -218,17 +214,19 @@ class ColorPalette extends UI5Element { }); if (this.showMoreColors) { - const ColorPaletteMoreColorsClass = getFeature("ColorPaletteMoreColors"); + const ColorPaletteMoreColorsClass = getComponentFeature("ColorPaletteMoreColors"); if (ColorPaletteMoreColorsClass) { this.moreColorsFeature = new ColorPaletteMoreColorsClass(); - } else { - throw new Error(`You have to import "@ui5/webcomponents/dist/features/ColorPaletteMoreColors.js" module to use the more-colors functionality.`); } } this.onPhone = isPhone(); } + get _effectiveShowMoreColors() { + return !!(this.showMoreColors && this.moreColorsFeature); + } + onAfterRendering() { if (this._shouldFocusRecentColors && this.hasRecentColors) { this.recentColorsElements[0].selected = true; @@ -528,6 +526,18 @@ class ColorPalette extends UI5Element { return [...this.effectiveColorItems, ...this.recentColorsElements]; } + get colorPaletteDialogTitle() { + return this.moreColorsFeature?.colorPaletteDialogTitle; + } + + get colorPaletteDialogOKButton() { + return this.moreColorsFeature?.colorPaletteDialogOKButton; + } + + get colorPaletteCancelButton() { + return this.moreColorsFeature?.colorPaletteCancelButton; + } + /** * Returns the selected color. */ diff --git a/packages/main/src/ColorPaletteDialog.hbs b/packages/main/src/ColorPaletteDialog.hbs index d4c61ed69d11..cbf27bff12c5 100644 --- a/packages/main/src/ColorPaletteDialog.hbs +++ b/packages/main/src/ColorPaletteDialog.hbs @@ -1,20 +1,20 @@ -{{#if _showMoreColors}} - -
- -
+{{#if _effectiveShowMoreColors}} + +
+ +
- -
+ +
{{/if}} diff --git a/packages/main/src/Input.hbs b/packages/main/src/Input.hbs index e72102f68241..579882f05e9c 100644 --- a/packages/main/src/Input.hbs +++ b/packages/main/src/Input.hbs @@ -60,7 +60,7 @@ {{> postContent }} - {{#if showSuggestions}} + {{#if _effectiveShowSuggestions}} {{suggestionsText}} {{availableSuggestionsCount}} diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index 544843f21d34..71a263b0b1ff 100644 --- a/packages/main/src/Input.ts +++ b/packages/main/src/Input.ts @@ -16,7 +16,7 @@ import { isAndroid, } from "@ui5/webcomponents-base/dist/Device.js"; import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; -import { getFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js"; +import { getComponentFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js"; import { isUp, isDown, @@ -201,8 +201,9 @@ type InputSuggestionScrollEventDetail = { ValueStateMessageCss, SuggestionsCss, ], + features: ["InputSuggestions"], get dependencies() { - const Suggestions = getFeature("InputSuggestions"); + const Suggestions = getComponentFeature("InputSuggestions"); return ([Popover, ResponsivePopover, Icon] as Array).concat(Suggestions ? Suggestions.dependencies : []); }, }) @@ -585,6 +586,10 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return Input.i18nBundle.getText(FORM_TEXTFIELD_REQUIRED); } + get _effectiveShowSuggestions() { + return !!(this.showSuggestions && this.Suggestions); + } + get formValidity(): ValidityStateFlags { return { valueMissing: this.required && !this.value }; } @@ -716,7 +721,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement onAfterRendering() { const innerInput = this.getInputDOMRefSync()!; - if (this.Suggestions && this.showSuggestions && this.Suggestions._getPicker()) { + if (this.showSuggestions && this.Suggestions?._getPicker()) { this._listWidth = this.Suggestions._getListWidth(); } @@ -800,13 +805,13 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } _handleUp(e: KeyboardEvent) { - if (this.Suggestions && this.Suggestions.isOpened()) { + if (this.Suggestions?.isOpened()) { this.Suggestions.onUp(e); } } _handleDown(e: KeyboardEvent) { - if (this.Suggestions && this.Suggestions.isOpened()) { + if (this.Suggestions?.isOpened()) { this.Suggestions.onDown(e); } } @@ -825,7 +830,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _handleEnter(e: KeyboardEvent) { // if a group item is focused, this is false - const suggestionItemPressed = !!(this.Suggestions && this.Suggestions.onEnter(e)); + const suggestionItemPressed = !!(this.Suggestions?.onEnter(e)); const innerInput = this.getInputDOMRefSync()!; const matchingItem = this._selectableItems.find(item => { return item.text === this.value; @@ -861,7 +866,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _handlePageUp(e: KeyboardEvent) { if (this._isSuggestionsFocused) { - this.Suggestions!.onPageUp(e); + this.Suggestions?.onPageUp(e); } else { e.preventDefault(); } @@ -869,7 +874,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _handlePageDown(e: KeyboardEvent) { if (this._isSuggestionsFocused) { - this.Suggestions!.onPageDown(e); + this.Suggestions?.onPageDown(e); } else { e.preventDefault(); } @@ -877,13 +882,13 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _handleHome(e: KeyboardEvent) { if (this._isSuggestionsFocused) { - this.Suggestions!.onHome(e); + this.Suggestions?.onHome(e); } } _handleEnd(e: KeyboardEvent) { if (this._isSuggestionsFocused) { - this.Suggestions!.onEnd(e); + this.Suggestions?.onEnd(e); } } @@ -900,7 +905,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return; } - if (isOpen && this.Suggestions!._isItemOnTarget()) { + if (isOpen && this.Suggestions?._isItemOnTarget()) { // Restore the value. this.value = this.typedInValue || this.valueBeforeSelectionStart; @@ -971,8 +976,8 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement this._isValueStateFocused = false; this.hasSuggestionItemSelected = false; - this.Suggestions._deselectItems(); - this.Suggestions._clearItemFocus(); + this.Suggestions?._deselectItems(); + this.Suggestions?._clearItemFocus(); } _click() { @@ -1216,12 +1221,9 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return; } - const Suggestions = getFeature("InputSuggestions"); - + const Suggestions = getComponentFeature("InputSuggestions"); if (Suggestions) { this.Suggestions = new Suggestions(this, "suggestionItems", true, false); - } else { - throw new Error(`You have to import "@ui5/webcomponents/dist/features/InputSuggestions.js" module to use ui5-input suggestions`); } } @@ -1337,7 +1339,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return Promise.resolve(false); } - return this.Suggestions._isScrollable(); + return this.Suggestions?._isScrollable(); } onItemMouseDown(e: MouseEvent) { @@ -1573,7 +1575,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } get availableSuggestionsCount() { - if (this.showSuggestions && (this.value || this.Suggestions!.isOpened())) { + if (this.showSuggestions && (this.value || this.Suggestions?.isOpened())) { const nonGroupItems = this._selectableItems; switch (nonGroupItems.length) { @@ -1600,7 +1602,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } get _isSuggestionsFocused() { - return !this.focused && this.Suggestions && this.Suggestions.isOpened(); + return !this.focused && this.Suggestions?.isOpened(); } /** @@ -1683,12 +1685,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } static async onDefine() { - const Suggestions = getFeature("InputSuggestions"); - - [Input.i18nBundle] = await Promise.all([ - getI18nBundle("@ui5/webcomponents"), - Suggestions ? Suggestions.init() : Promise.resolve(), - ]); + Input.i18nBundle = await getI18nBundle("@ui5/webcomponents"); } } diff --git a/packages/main/src/InputPopover.hbs b/packages/main/src/InputPopover.hbs index 5d50ac15c5fe..b18d4a33d5b2 100644 --- a/packages/main/src/InputPopover.hbs +++ b/packages/main/src/InputPopover.hbs @@ -1,4 +1,4 @@ -{{#if showSuggestions}} +{{#if _effectiveShowSuggestions}}