diff --git a/packages/base/cypress/specs/ConfigurationSync.cy.tsx b/packages/base/cypress/specs/ConfigurationSync.cy.tsx new file mode 100644 index 000000000000..8388ec797ba4 --- /dev/null +++ b/packages/base/cypress/specs/ConfigurationSync.cy.tsx @@ -0,0 +1,73 @@ +import { fireConfigChange, attachConfigChange, getSharedValue } from "../../src/config/ConfigurationSync.js"; +import { setTheme, getTheme } from "../../src/config/Theme.js"; +import { setLanguage, getLanguage } from "../../src/config/Language.js"; +import EventProvider from "../../src/EventProvider.js"; +import getSharedResource from "../../src/getSharedResource.js"; + +describe("ConfigurationSync", () => { + describe("Shared value storage", () => { + it("fireConfigChange stores values readable via getSharedValue", () => { + fireConfigChange("testSetting", "testValue"); + + cy.wrap({ getSharedValue }) + .invoke("getSharedValue", "testSetting") + .should("equal", "testValue"); + }); + + it("getSharedValue returns undefined for unknown settings", () => { + cy.wrap({ getSharedValue }) + .invoke("getSharedValue", "nonExistent") + .should("equal", undefined); + }); + }); + + describe("Skip-guard", () => { + it("handler is NOT called when the same runtime fires", () => { + const handler = cy.stub().as("handler"); + attachConfigChange("skipTest", handler); + + fireConfigChange("skipTest", "value"); + + cy.get("@handler").should("not.have.been.called"); + }); + }); + + describe("Cross-runtime handler", () => { + it("handler is called only for its own setting name", () => { + const handlerA = cy.stub().as("handlerA"); + const handlerB = cy.stub().as("handlerB"); + attachConfigChange("settingA", handlerA); + attachConfigChange("settingB", handlerB); + + // Simulate a cross-runtime fire by calling the shared EventProvider directly, + // bypassing the skip-guard that fireConfigChange sets for the current runtime. + const ep = getSharedResource("ConfigChange.eventProvider", new EventProvider()); + ep.fireEvent("configChange", { name: "settingA", value: "cross-value" }); + + cy.get("@handlerA").should("have.been.calledOnce").and("have.been.calledWith", "cross-value"); + cy.get("@handlerB").should("not.have.been.called"); + }); + }); + + describe("Theme integration", () => { + it("setTheme stores value in shared map", () => { + cy.wrap({ setTheme }) + .invoke("setTheme", "sap_horizon_hcb"); + + cy.wrap({ getSharedValue }) + .invoke("getSharedValue", "theme") + .should("equal", "sap_horizon_hcb"); + }); + }); + + describe("Language integration", () => { + it("setLanguage stores value in shared map", () => { + cy.wrap({ setLanguage }) + .invoke("setLanguage", "de"); + + cy.wrap({ getSharedValue }) + .invoke("getSharedValue", "language") + .should("equal", "de"); + }); + }); +}); diff --git a/packages/base/src/config/ConfigurationSync.ts b/packages/base/src/config/ConfigurationSync.ts new file mode 100644 index 000000000000..1df7770bf522 --- /dev/null +++ b/packages/base/src/config/ConfigurationSync.ts @@ -0,0 +1,50 @@ +import getSharedResource from "../getSharedResource.js"; +import EventProvider from "../EventProvider.js"; + +type ConfigChangeDetail = { name: string; value: unknown }; + +const getEventProvider = () => getSharedResource("ConfigChange.eventProvider", new EventProvider()); +const getSharedValues = () => getSharedResource>("ConfigChange.values", {}); + +const CONFIG_CHANGE = "configChange"; + +// Module-level skip flags — each runtime copy has its own Set, +// so a runtime's own handler correctly skips when it fires. +const skipFlags = new Set(); + +/** + * Stores value in shared map and fires a cross-runtime config change event. + * The firing runtime's own handler is skipped via the skip-guard pattern. + */ +const fireConfigChange = (name: string, value: unknown): void => { + getSharedValues()[name] = value; + + skipFlags.add(name); + try { + getEventProvider().fireEvent(CONFIG_CHANGE, { name, value }); + } finally { + skipFlags.delete(name); + } +}; + +/** + * Registers a per-setting cross-runtime listener. + * The handler is only called when another runtime fires the change. + */ +const attachConfigChange = (name: string, handler: (value: any) => void): void => { // eslint-disable-line + getEventProvider().attachEvent(CONFIG_CHANGE, (detail: ConfigChangeDetail) => { + if (detail.name === name && !skipFlags.has(name)) { + handler(detail.value); + } + }); +}; + +/** + * Reads the last-set value from the shared values map. + * Used by late-booting runtimes to pick up values already set by others. + */ +const getSharedValue = (name: string): T | undefined => { + return getSharedValues()[name] as T | undefined; +}; + +export { fireConfigChange, attachConfigChange, getSharedValue }; diff --git a/packages/base/src/config/Language.ts b/packages/base/src/config/Language.ts index 6c2777927553..b6fdf96e55e1 100644 --- a/packages/base/src/config/Language.ts +++ b/packages/base/src/config/Language.ts @@ -7,6 +7,7 @@ import { reRenderAllUI5Elements } from "../Render.js"; import { DEFAULT_LANGUAGE } from "../generated/AssetParameters.js"; import { isBooted } from "../Boot.js"; import { attachConfigurationReset } from "./ConfigurationReset.js"; +import { fireConfigChange, attachConfigChange, getSharedValue } from "./ConfigurationSync.js"; let curLanguage: string | undefined; let fetchDefaultLanguage: boolean | undefined; @@ -25,6 +26,17 @@ attachConfigurationReset(() => { // will trigger a re-render of all language-aware components. let languageChangePending = false; +attachConfigChange("language", (language: string) => { + curLanguage = language; + languageChangePending = true; + fireLanguageChange(language).then(() => { + languageChangePending = false; + if (isBooted()) { + reRenderAllUI5Elements({ languageAware: true }); + } + }); +}); + const getLanguageChangePending = () => languageChangePending; /** @@ -34,7 +46,7 @@ const getLanguageChangePending = () => languageChangePending; */ const getLanguage = (): string | undefined => { if (curLanguage === undefined) { - curLanguage = getConfiguredLanguage(); + curLanguage = getSharedValue("language") ?? getConfiguredLanguage(); } return curLanguage; }; @@ -55,6 +67,8 @@ const setLanguage = async (language: string): Promise => { languageChangePending = true; curLanguage = language; + fireConfigChange("language", language); + await fireLanguageChange(language); languageChangePending = false; diff --git a/packages/base/src/config/Theme.ts b/packages/base/src/config/Theme.ts index 0cea7adcb994..4246912864ae 100644 --- a/packages/base/src/config/Theme.ts +++ b/packages/base/src/config/Theme.ts @@ -5,6 +5,7 @@ import getThemeDesignerTheme from "../theming/getThemeDesignerTheme.js"; import { DEFAULT_THEME, SUPPORTED_THEMES } from "../generated/AssetParameters.js"; import { boot, isBooted } from "../Boot.js"; import { attachConfigurationReset } from "./ConfigurationReset.js"; +import { fireConfigChange, attachConfigChange, getSharedValue } from "./ConfigurationSync.js"; let curTheme: string | undefined; let curBaseTheme: string | undefined; @@ -13,6 +14,13 @@ attachConfigurationReset(() => { curTheme = undefined; }); +attachConfigChange("theme", (theme: string) => { + curTheme = theme; + if (isBooted()) { + applyTheme(curTheme).then(() => reRenderAllUI5Elements({ themeAware: true })); + } +}); + /** * Returns the current theme. * @public @@ -20,7 +28,7 @@ attachConfigurationReset(() => { */ const getTheme = (): string => { if (curTheme === undefined) { - curTheme = getConfiguredTheme(); + curTheme = getSharedValue("theme") ?? getConfiguredTheme(); } return curTheme; @@ -39,6 +47,8 @@ const setTheme = async (theme: string): Promise => { curTheme = theme; + fireConfigChange("theme", theme); + if (isBooted()) { // Update CSS Custom Properties await applyTheme(curTheme);