From 6e0ed97423f12b428e22868051c3e4dea675b9cc Mon Sep 17 00:00:00 2001 From: ByoungYong Kim Date: Wed, 17 Sep 2025 09:12:06 +0200 Subject: [PATCH] Feat/lazy loaded singleton (#2570) * feat(singleton-manager): lazifyInstation functionality * Update getLocalizeManager to register itself lazily Co-authored by: Thijs Louisse --------- Co-authored-by: Byoungyoung Kim --- .changeset/poor-penguins-wait.md | 5 ++ .changeset/seven-hotels-vanish.md | 5 ++ packages/singleton-manager/src/index.js | 2 + .../src/lazifyInstantiation.js | 57 +++++++++++++++++++ packages/ui/components/icon/src/icons.js | 16 +++++- .../ui/components/icon/test/hammer.svg.js | 7 ++- packages/ui/components/icon/test/heart.svg.js | 7 ++- .../ui/components/icon/test/lion-icon.test.js | 14 +++-- .../localize/src/getLocalizeManager.js | 28 +++++---- .../localize/test/getLocalizeManager.test.js | 22 ++++--- .../test/side-effect-free-entrypoint.test.js | 12 +++- .../ui/components/overlays/src/singleton.js | 18 ++++-- 12 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 .changeset/poor-penguins-wait.md create mode 100644 .changeset/seven-hotels-vanish.md create mode 100644 packages/singleton-manager/src/lazifyInstantiation.js diff --git a/.changeset/poor-penguins-wait.md b/.changeset/poor-penguins-wait.md new file mode 100644 index 000000000..e0996d635 --- /dev/null +++ b/.changeset/poor-penguins-wait.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +Make the manager singletons get instantiated and registered lazily. diff --git a/.changeset/seven-hotels-vanish.md b/.changeset/seven-hotels-vanish.md new file mode 100644 index 000000000..5e9963931 --- /dev/null +++ b/.changeset/seven-hotels-vanish.md @@ -0,0 +1,5 @@ +--- +'singleton-manager': minor +--- + +Add `lazifyInstantiation` method to singleton-manager. It will help create side-effect-free app setups, avoiding hosisting problems during bundling conflicts and/or long-winded, multi-file setup logic. diff --git a/packages/singleton-manager/src/index.js b/packages/singleton-manager/src/index.js index 73fb58d52..1272ef296 100644 --- a/packages/singleton-manager/src/index.js +++ b/packages/singleton-manager/src/index.js @@ -1,4 +1,6 @@ import { SingletonManagerClass } from './SingletonManagerClass.js'; +export { lazifyInstantiation } from './lazifyInstantiation.js'; + export { SingletonManagerClass }; export const singletonManager = new SingletonManagerClass(); diff --git a/packages/singleton-manager/src/lazifyInstantiation.js b/packages/singleton-manager/src/lazifyInstantiation.js new file mode 100644 index 000000000..f9028ed17 --- /dev/null +++ b/packages/singleton-manager/src/lazifyInstantiation.js @@ -0,0 +1,57 @@ +// TODO: testing is done now indirectly via @lion/ui tests. Aditionally, add dedicated tests for this package + +/** + * Lazifies the instantiation of singletons. It does so by wrapping the singleton in a Proxy, which delays + * instantiation until one of its members gets called/set for the first time. + * This is important in cases where a singleton needs to be overridden on an app level: + * let's say there are two versions of @lion/ui in an app that need to synchronize their locale. + * We want to make sure that the latest LocalizeManager instance (localize) of the latest lion version (that has the latest features and is backwards compatible with earlier versions) is registered before others. + * We see often that this complicates setup/index files (the need of extra files to make sure logic gets executed before side-effectful imports. Also, build processes like Rollup tend to merge files and therefore hoist imports and change execution order. + * This method aims to solve above problems. + * @type {(func: () => T) => T} + */ +const lazifyInstantiation = registerSingleton => { + let /** @type any */ singletonInstance = null; + + const getSingletonInstance = () => { + if (singletonInstance === null) { + singletonInstance = registerSingleton(); + } + + return singletonInstance; + }; + + /** @type {any} */ const proxy = new Proxy( + {}, + { + get(_target, prop) { + const instance = getSingletonInstance(); + // Somehow addEventListener and removeEventListner throws Illegal Invocation error without binding + if (prop === 'addEventListener' || prop === 'removeEventListener') { + return Reflect.get(instance, prop).bind(instance); + } + + if (prop === '__instance_for_testing') { + return instance; + } + + return Reflect.get(instance, prop, instance); + }, + set(_target, prop, value) { + return Reflect.set(getSingletonInstance(), prop, value); + }, + // Used for sinon.spy + getOwnPropertyDescriptor(_target, prop) { + return Reflect.getOwnPropertyDescriptor(getSingletonInstance(), prop); + }, + // Used for sinon.spy + getPrototypeOf() { + return Reflect.getPrototypeOf(getSingletonInstance()); + }, + }, + ); + + return proxy; +}; + +export { lazifyInstantiation }; diff --git a/packages/ui/components/icon/src/icons.js b/packages/ui/components/icon/src/icons.js index 6e89aedde..84d04a7c5 100644 --- a/packages/ui/components/icon/src/icons.js +++ b/packages/ui/components/icon/src/icons.js @@ -1,4 +1,16 @@ -import { singletonManager } from 'singleton-manager'; +import { singletonManager, lazifyInstantiation } from 'singleton-manager'; import { IconManager } from './IconManager.js'; -export const icons = singletonManager.get('@lion/ui::icons::0.x') || new IconManager(); +/** + * @returns {IconManager} + */ +function getIconManager() { + if (!singletonManager.has('@lion/ui::icons::0.x')) { + const iconManager = new IconManager(); + singletonManager.set('@lion/ui::icons::0.x', iconManager); + } + + return singletonManager.get('@lion/ui::icons::0.x'); +} + +export const icons = lazifyInstantiation(getIconManager); diff --git a/packages/ui/components/icon/test/hammer.svg.js b/packages/ui/components/icon/test/hammer.svg.js index 3e743b776..6a891d012 100644 --- a/packages/ui/components/icon/test/hammer.svg.js +++ b/packages/ui/components/icon/test/hammer.svg.js @@ -1,2 +1,5 @@ -export default /** @param {(strings: TemplateStringsArray, ... expr: string[]) => string} tag */ tag => - tag``; +// @ts-nocheck +export default /** @type {import("lit").TemplateResult} */ ( + tag => + tag`` +); diff --git a/packages/ui/components/icon/test/heart.svg.js b/packages/ui/components/icon/test/heart.svg.js index afe55340f..dd75196cb 100644 --- a/packages/ui/components/icon/test/heart.svg.js +++ b/packages/ui/components/icon/test/heart.svg.js @@ -1,2 +1,5 @@ -export default /** @param {(strings: TemplateStringsArray, ... expr: string[]) => string} tag */ tag => - tag``; +// @ts-nocheck +export default /** @type {import("lit").TemplateResult} */ ( + tag => + tag`` +); diff --git a/packages/ui/components/icon/test/lion-icon.test.js b/packages/ui/components/icon/test/lion-icon.test.js index c9ad9c8f2..c998424c9 100644 --- a/packages/ui/components/icon/test/lion-icon.test.js +++ b/packages/ui/components/icon/test/lion-icon.test.js @@ -183,13 +183,19 @@ describe('lion-icon', () => { try { icons.addIconResolver( 'foo', - // eslint-disable-next-line no-promise-executor-return - () => new Promise(resolve => setTimeout(() => resolve(heartSvg), 10)), + () => + /** @type {Promise} */ ( + // eslint-disable-next-line no-promise-executor-return + new Promise(resolve => setTimeout(() => resolve(heartSvg))) + ), ); icons.addIconResolver( 'bar', - // eslint-disable-next-line no-promise-executor-return - () => new Promise(resolve => setTimeout(() => resolve(hammerSvg), 4)), + () => + /** @type {Promise} */ ( + // eslint-disable-next-line no-promise-executor-return + new Promise(resolve => setTimeout(() => resolve(hammerSvg))) + ), ); const el = await fixture(html``); diff --git a/packages/ui/components/localize/src/getLocalizeManager.js b/packages/ui/components/localize/src/getLocalizeManager.js index 86314328f..75f6ccc75 100644 --- a/packages/ui/components/localize/src/getLocalizeManager.js +++ b/packages/ui/components/localize/src/getLocalizeManager.js @@ -1,5 +1,5 @@ // @ts-ignore -import { singletonManager } from 'singleton-manager'; +import { singletonManager, lazifyInstantiation } from 'singleton-manager'; import { LocalizeManager } from './LocalizeManager.js'; /** @@ -57,18 +57,24 @@ import { LocalizeManager } from './LocalizeManager.js'; * } * ``` * + * @deprecated Use `localize` directly instead (as this is always side-effect free now) + * * @returns {LocalizeManager} */ -export function getLocalizeManager() { - if (singletonManager.has('@lion/ui::localize::0.x')) { - return singletonManager.get('@lion/ui::localize::0.x'); +function getLocalizeManager() { + if (!singletonManager.has('@lion/ui::localize::0.x')) { + const localizeManager = new LocalizeManager({ + autoLoadOnLocaleChange: true, + fallbackLocale: 'en-GB', + }); + singletonManager.set('@lion/ui::localize::0.x', localizeManager); } - const localizeManager = new LocalizeManager({ - autoLoadOnLocaleChange: true, - fallbackLocale: 'en-GB', - }); - singletonManager.set('@lion/ui::localize::0.x', localizeManager); - - return localizeManager; + return singletonManager.get('@lion/ui::localize::0.x'); } + +function getLocalizeManagerLazily() { + return lazifyInstantiation(getLocalizeManager); +} + +export { getLocalizeManagerLazily as getLocalizeManager }; diff --git a/packages/ui/components/localize/test/getLocalizeManager.test.js b/packages/ui/components/localize/test/getLocalizeManager.test.js index 45a287c93..4e8d520ad 100644 --- a/packages/ui/components/localize/test/getLocalizeManager.test.js +++ b/packages/ui/components/localize/test/getLocalizeManager.test.js @@ -1,8 +1,10 @@ import { expect } from '@open-wc/testing'; +import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js'; // @ts-ignore import { singletonManager } from 'singleton-manager'; import { LocalizeManager } from '../src/LocalizeManager.js'; -import { getLocalizeManager } from '../src/getLocalizeManager.js'; + +/** @typedef {LocalizeManager & { __instance_for_testing?: LocalizeManager }} LocalizeManagerForTesting */ describe('getLocalizeManager', () => { beforeEach(() => { @@ -12,20 +14,26 @@ describe('getLocalizeManager', () => { it('gets a default instance when nothing registered on singletonManager with "@lion/ui::localize::0.x"', () => { expect(singletonManager.get('@lion/ui::localize::0.x')).to.be.undefined; - const localizeManager = getLocalizeManager(); - expect(localizeManager).to.equal(singletonManager.get('@lion/ui::localize::0.x')); + const /** @type {LocalizeManagerForTesting} */ localizeManager = getLocalizeManager(); + expect(localizeManager.__instance_for_testing).to.equal( + singletonManager.get('@lion/ui::localize::0.x'), + ); }); it('gets the same instance when called multiple times', () => { - const localizeManager = getLocalizeManager(); - const localizeManagerSecondCall = getLocalizeManager(); - expect(localizeManager).to.equal(localizeManagerSecondCall); + const /** @type {LocalizeManagerForTesting} */ localizeManager = getLocalizeManager(); + const /** @type {LocalizeManagerForTesting} */ localizeManagerSecondCall = getLocalizeManager(); + expect(localizeManager.__instance_for_testing).not.to.be.undefined; + expect(localizeManager.__instance_for_testing).to.equal( + localizeManagerSecondCall.__instance_for_testing, + ); }); it('gets the instance that was registered on singletonManager with "@lion/ui::localize::0.x"', () => { // Set your own for custom behavior or for deduping purposes class MyLocalizeManager extends LocalizeManager {} singletonManager.set('@lion/ui::localize::0.x', MyLocalizeManager); - expect(getLocalizeManager()).to.equal(MyLocalizeManager); + const /** @type {LocalizeManagerForTesting} */ localizeManager = getLocalizeManager(); + expect(localizeManager.__instance_for_testing).to.equal(MyLocalizeManager); }); }); diff --git a/packages/ui/components/localize/test/side-effect-free-entrypoint.test.js b/packages/ui/components/localize/test/side-effect-free-entrypoint.test.js index 36a54ab66..1ebcaab27 100644 --- a/packages/ui/components/localize/test/side-effect-free-entrypoint.test.js +++ b/packages/ui/components/localize/test/side-effect-free-entrypoint.test.js @@ -5,6 +5,7 @@ import { singletonManager } from 'singleton-manager'; /** * @typedef {import('../types/LocalizeMixinTypes.js').LocalizeMixin} LocalizeMixinHost + * @typedef {import('../../validate-messages/src/getLocalizedMessage.js').LocalizeManager & { __instance_for_testing?: import('../../validate-messages/src/getLocalizedMessage.js').LocalizeManager, aCallToRegisterLazilyLoadedInstance?: () => void }} LocalizeManagerForTesting */ describe('Entrypoints localize', () => { @@ -24,15 +25,20 @@ describe('Entrypoints localize', () => { }); it('"@lion/ui/localize.js" has side effects (c.q. registers itself on singletonManager)', async () => { - await import('@lion/ui/localize.js'); + const /** @type {{ localize: LocalizeManagerForTesting }} */ { localize } = await import( + '@lion/ui/localize.js' + ); + localize.aCallToRegisterLazilyLoadedInstance?.(); expect(singletonManagerSetSpy).to.have.been.calledOnce; - const { getLocalizeManager } = await import('@lion/ui/localize-no-side-effects.js'); + const /** @type {{ getLocalizeManager: () => LocalizeManagerForTesting }} */ { + getLocalizeManager, + } = await import('@lion/ui/localize-no-side-effects.js'); expect(singletonManagerSetSpy).to.have.been.calledWith( '@lion/ui::localize::0.x', - getLocalizeManager(), + getLocalizeManager().__instance_for_testing, ); }); }); diff --git a/packages/ui/components/overlays/src/singleton.js b/packages/ui/components/overlays/src/singleton.js index 26bacbb1f..6240eed11 100644 --- a/packages/ui/components/overlays/src/singleton.js +++ b/packages/ui/components/overlays/src/singleton.js @@ -1,6 +1,16 @@ -import { singletonManager } from 'singleton-manager'; +import { singletonManager, lazifyInstantiation } from 'singleton-manager'; import { OverlaysManager } from './OverlaysManager.js'; -export const overlays = - /** @type {OverlaysManager} */ - (singletonManager.get('@lion/ui::overlays::0.x')) || new OverlaysManager(); +/** + * @returns {OverlaysManager} + */ +function getOverlaysManager() { + if (!singletonManager.has('@lion/ui::overlays::0.x')) { + const overlaysManager = new OverlaysManager(); + singletonManager.set('@lion/ui::overlays::0.x', overlaysManager); + } + + return singletonManager.get('@lion/ui::overlays::0.x'); +} + +export const overlays = lazifyInstantiation(getOverlaysManager);