From 4b295322395b835011165a5f2b62e3f31ddc52b2 Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Sat, 4 Feb 2023 13:31:58 +0100 Subject: [PATCH 1/7] refactor(Device): internal getters to better support SSR --- .editorconfig | 2 +- packages/base/src/Device.ts | 142 +++++++++++++++++------ packages/base/src/getSharedResource.ts | 11 +- packages/base/src/theming/CustomStyle.ts | 12 +- 4 files changed, 126 insertions(+), 41 deletions(-) diff --git a/.editorconfig b/.editorconfig index 3306971d1510..e8b1d0a51edc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,7 +5,7 @@ root = true [*] charset = utf-8 -[*.{css,html,java,js,json,less,txt}] +[*.{css,html,java,js,json,less,txt,ts}] trim_trailing_whitespace = true end_of_line = lf tab_width = 4 diff --git a/packages/base/src/Device.ts b/packages/base/src/Device.ts index 700c8ac455cb..5c26b29082cd 100644 --- a/packages/base/src/Device.ts +++ b/packages/base/src/Device.ts @@ -1,30 +1,96 @@ -const ua = navigator.userAgent; -const touch = "ontouchstart" in window || navigator.maxTouchPoints > 0; -const ie = /(msie|trident)/i.test(ua); -const chrome = !ie && /(Chrome|CriOS)/.test(ua); -const firefox = /Firefox/.test(ua); -const safari = !ie && !chrome && /(Version|PhantomJS)\/(\d+\.\d+).*Safari/.test(ua); -const webkit = !ie && /webkit/.test(ua); -const windows = navigator.platform.indexOf("Win") !== -1; -const iOS = !!(navigator.platform.match(/iPhone|iPad|iPod/)) || !!(navigator.userAgent.match(/Mac/) && "ontouchend" in document); -const android = !windows && /Android/.test(ua); -const androidPhone = android && /(?=android)(?=.*mobile)/i.test(ua); -const ipad = /ipad/i.test(ua) || (/Macintosh/i.test(ua) && "ontouchend" in document); -// With iOS 13 the string 'iPad' was removed from the user agent string through a browser setting, which is applied on all sites by default: -// "Request Desktop Website -> All websites" (for more infos see: https://forums.developer.apple.com/thread/119186). -// Therefore the OS is detected as MACINTOSH instead of iOS and the device is a tablet if the Device.support.touch is true. +const internals = { + get userAgent() { + if (typeof window !== "undefined") { + return navigator.userAgent; + } + return ""; + }, + get touch() { + if (typeof window === "undefined") { + return false; + } + return "ontouchstart" in window || navigator.maxTouchPoints > 0; + }, + get ie() { + if (typeof window === "undefined") { + return false; + } + return /(msie|trident)/i.test(internals.userAgent); + }, + get chrome() { + if (typeof window === "undefined") { + return false; + } + return !internals.ie && /(Chrome|CriOS)/.test(internals.userAgent); + }, + get firefox() { + if (typeof window === "undefined") { + return false; + } + return /Firefox/.test(internals.userAgent); + }, + get safari() { + if (typeof window === "undefined") { + return false; + } + return !internals.ie && !internals.chrome && /(Version|PhantomJS)\/(\d+\.\d+).*Safari/.test(internals.userAgent); + }, + get webkit() { + if (typeof window === "undefined") { + return false; + } + return !internals.ie && /webkit/.test(internals.userAgent); + }, + get windows() { + if (typeof window === "undefined") { + return false; + } + return navigator.platform.indexOf("Win") !== -1; + }, + get iOS() { + if (typeof window === "undefined") { + return false; + } + return !!(navigator.platform.match(/iPhone|iPad|iPod/)) || !!(internals.userAgent.match(/Mac/) && "ontouchend" in document); + }, + get android() { + if (typeof window === "undefined") { + return false; + } + return !internals.windows && /Android/.test(internals.userAgent); + }, + get androidPhone() { + if (typeof window === "undefined") { + return false; + } + return internals.android && /(?=android)(?=.*mobile)/i.test(internals.userAgent); + }, + get ipad() { + if (typeof window === "undefined") { + return false; + } + // With iOS 13 the string 'iPad' was removed from the user agent string through a browser setting, which is applied on all sites by default: + // "Request Desktop Website -> All websites" (for more infos see: https://forums.developer.apple.com/thread/119186). + // Therefore the OS is detected as MACINTOSH instead of iOS and the device is a tablet if the Device.support.touch is true. + return /ipad/i.test(internals.userAgent) || (/Macintosh/i.test(internals.userAgent) && "ontouchend" in document); + }, +}; let windowsVersion: number; let webkitVersion: number; let tablet: boolean; const isWindows8OrAbove = () => { - if (!windows) { + if (typeof window === "undefined") { + return false; + } + + if (!internals.windows) { return false; } if (windowsVersion === undefined) { - const matches = ua.match(/Windows NT (\d+).(\d)/); + const matches = internals.userAgent.match(/Windows NT (\d+).(\d)/); windowsVersion = matches ? parseFloat(matches[1]) : 0; } @@ -32,12 +98,16 @@ const isWindows8OrAbove = () => { }; const isWebkit537OrAbove = () => { - if (!webkit) { + if (typeof window === "undefined") { + return false; + } + + if (!internals.webkit) { return false; } if (webkitVersion === undefined) { - const matches = ua.match(/(webkit)[ /]([\w.]+)/); + const matches = internals.userAgent.match(/(webkit)[ /]([\w.]+)/); webkitVersion = matches ? parseFloat(matches[1]) : 0; } @@ -45,28 +115,32 @@ const isWebkit537OrAbove = () => { }; const detectTablet = () => { + if (typeof window === "undefined") { + return false; + } + if (tablet !== undefined) { return; } - if (ipad) { + if (internals.ipad) { tablet = true; return; } - if (touch) { + if (internals.touch) { if (isWindows8OrAbove()) { tablet = true; return; } - if (chrome && android) { - tablet = !/Mobile Safari\/[.0-9]+/.test(ua); + if (internals.chrome && internals.android) { + tablet = !/Mobile Safari\/[.0-9]+/.test(internals.userAgent); return; } let densityFactor = window.devicePixelRatio ? window.devicePixelRatio : 1; // may be undefined in Windows Phone devices - if (android && isWebkit537OrAbove()) { + if (internals.android && isWebkit537OrAbove()) { densityFactor = 1; } @@ -74,23 +148,23 @@ const detectTablet = () => { return; } - tablet = (ie && ua.indexOf("Touch") !== -1) || (android && !androidPhone); + tablet = (internals.ie && internals.userAgent.indexOf("Touch") !== -1) || (internals.android && !internals.androidPhone); }; -const supportsTouch = (): boolean => touch; -const isIE = (): boolean => ie; -const isSafari = (): boolean => safari; -const isChrome = (): boolean => chrome; -const isFirefox = (): boolean => firefox; +const supportsTouch = (): boolean => internals.touch; +const isIE = (): boolean => internals.ie; +const isSafari = (): boolean => internals.safari; +const isChrome = (): boolean => internals.chrome; +const isFirefox = (): boolean => internals.firefox; const isTablet = (): boolean => { detectTablet(); - return (touch || isWindows8OrAbove()) && tablet; + return (internals.touch || isWindows8OrAbove()) && tablet; }; const isPhone = (): boolean => { detectTablet(); - return touch && !tablet; + return internals.touch && !tablet; }; const isDesktop = (): boolean => { @@ -102,11 +176,11 @@ const isCombi = (): boolean => { }; const isIOS = (): boolean => { - return iOS; + return internals.iOS; }; const isAndroid = (): boolean => { - return android || androidPhone; + return internals.android || internals.androidPhone; }; export { diff --git a/packages/base/src/getSharedResource.ts b/packages/base/src/getSharedResource.ts index cef4a61c817e..7f935c019ec5 100644 --- a/packages/base/src/getSharedResource.ts +++ b/packages/base/src/getSharedResource.ts @@ -1,6 +1,11 @@ import getSingletonElementInstance from "./util/getSingletonElementInstance.js"; -const getSharedResourcesInstance = () => getSingletonElementInstance("ui5-shared-resources", document.head); +const getSharedResourcesInstance = (): Record | null => { + if (typeof document === "undefined") { + return null; + } + return getSingletonElementInstance("ui5-shared-resources", document.head) as unknown as Record; +}; /** * Use this method to initialize/get resources that you would like to be shared among UI5 Web Components runtime instances. @@ -15,6 +20,10 @@ const getSharedResource = (namespace: string, initialValue: T): T => { const parts = namespace.split("."); let current = getSharedResourcesInstance() as Record; + if (!current) { + return initialValue; + } + for (let i = 0; i < parts.length; i++) { const part = parts[i]; const lastPart = i === parts.length - 1; diff --git a/packages/base/src/theming/CustomStyle.ts b/packages/base/src/theming/CustomStyle.ts index aa3c55a1401d..1f12944b5be7 100644 --- a/packages/base/src/theming/CustomStyle.ts +++ b/packages/base/src/theming/CustomStyle.ts @@ -4,22 +4,22 @@ import EventProvider from "../EventProvider.js"; type CustomCSSChangeCallback = (tag: string) => void; -const eventProvider = getSharedResource("CustomStyle.eventProvider", new EventProvider()); +const getEventProvider = () => getSharedResource("CustomStyle.eventProvider", new EventProvider()); const CUSTOM_CSS_CHANGE = "CustomCSSChange"; const attachCustomCSSChange = (listener: CustomCSSChangeCallback) => { - eventProvider.attachEvent(CUSTOM_CSS_CHANGE, listener); + getEventProvider().attachEvent(CUSTOM_CSS_CHANGE, listener); }; const detachCustomCSSChange = (listener: CustomCSSChangeCallback) => { - eventProvider.detachEvent(CUSTOM_CSS_CHANGE, listener); + getEventProvider().detachEvent(CUSTOM_CSS_CHANGE, listener); }; const fireCustomCSSChange = (tag: string) => { - return eventProvider.fireEvent(CUSTOM_CSS_CHANGE, tag); + return getEventProvider().fireEvent(CUSTOM_CSS_CHANGE, tag); }; -const customCSSFor = getSharedResource>>("CustomStyle.customCSSFor", {}); +const getCustomCSSFor = () => getSharedResource>>("CustomStyle.customCSSFor", {}); // Listen to the eventProvider, in case other copies of this CustomStyle module fire this // event, and this copy would therefore need to reRender the ui5 webcomponents; but @@ -32,6 +32,7 @@ attachCustomCSSChange((tag: string) => { }); const addCustomCSS = (tag: string, css: string) => { + const customCSSFor = getCustomCSSFor(); if (!customCSSFor[tag]) { customCSSFor[tag] = []; } @@ -51,6 +52,7 @@ const addCustomCSS = (tag: string, css: string) => { }; const getCustomCSS = (tag: string) => { + const customCSSFor = getCustomCSSFor(); return customCSSFor[tag] ? customCSSFor[tag].join("") : ""; }; From 295e6d3a8ef348a2290154fe573687afd7645580 Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Mon, 6 Feb 2023 13:47:09 +0100 Subject: [PATCH 2/7] refactor(Device): extract ssr detection into helper function --- packages/base/src/Device.ts | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/base/src/Device.ts b/packages/base/src/Device.ts index 5c26b29082cd..3e60d6df7d70 100644 --- a/packages/base/src/Device.ts +++ b/packages/base/src/Device.ts @@ -1,72 +1,74 @@ +const isSSR = () => typeof window === "undefined"; + const internals = { get userAgent() { - if (typeof window !== "undefined") { - return navigator.userAgent; + if (isSSR()) { + return ""; } - return ""; + return navigator.userAgent; }, get touch() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return "ontouchstart" in window || navigator.maxTouchPoints > 0; }, get ie() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return /(msie|trident)/i.test(internals.userAgent); }, get chrome() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return !internals.ie && /(Chrome|CriOS)/.test(internals.userAgent); }, get firefox() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return /Firefox/.test(internals.userAgent); }, get safari() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return !internals.ie && !internals.chrome && /(Version|PhantomJS)\/(\d+\.\d+).*Safari/.test(internals.userAgent); }, get webkit() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return !internals.ie && /webkit/.test(internals.userAgent); }, get windows() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return navigator.platform.indexOf("Win") !== -1; }, get iOS() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return !!(navigator.platform.match(/iPhone|iPad|iPod/)) || !!(internals.userAgent.match(/Mac/) && "ontouchend" in document); }, get android() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return !internals.windows && /Android/.test(internals.userAgent); }, get androidPhone() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } return internals.android && /(?=android)(?=.*mobile)/i.test(internals.userAgent); }, get ipad() { - if (typeof window === "undefined") { + if (isSSR()) { return false; } // With iOS 13 the string 'iPad' was removed from the user agent string through a browser setting, which is applied on all sites by default: @@ -81,7 +83,7 @@ let webkitVersion: number; let tablet: boolean; const isWindows8OrAbove = () => { - if (typeof window === "undefined") { + if (isSSR()) { return false; } @@ -98,7 +100,7 @@ const isWindows8OrAbove = () => { }; const isWebkit537OrAbove = () => { - if (typeof window === "undefined") { + if (isSSR()) { return false; } @@ -115,7 +117,7 @@ const isWebkit537OrAbove = () => { }; const detectTablet = () => { - if (typeof window === "undefined") { + if (isSSR()) { return false; } From 209c88839791adf0cfb1ccb14e2639f2c8ea0c3a Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Tue, 7 Feb 2023 15:02:31 +0100 Subject: [PATCH 3/7] fix: don't parse config when in SSR --- packages/base/src/InitialConfiguration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/src/InitialConfiguration.ts b/packages/base/src/InitialConfiguration.ts index 3d01bbc04aa6..c5e8a130c789 100644 --- a/packages/base/src/InitialConfiguration.ts +++ b/packages/base/src/InitialConfiguration.ts @@ -178,7 +178,7 @@ const applyOpenUI5Configuration = () => { }; const initConfiguration = () => { - if (initialized) { + if (typeof window === "undefined" || initialized) { return; } From f070c3331ab3c021a38d56dd5247efa28235b79f Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Tue, 7 Feb 2023 15:02:40 +0100 Subject: [PATCH 4/7] fix: early exit boot when in SSR --- packages/base/src/Boot.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/base/src/Boot.ts b/packages/base/src/Boot.ts index 97eab5bf5915..74c94dc70dfb 100644 --- a/packages/base/src/Boot.ts +++ b/packages/base/src/Boot.ts @@ -27,6 +27,10 @@ const boot = async (): Promise => { } const bootExecutor = async (resolve: PromiseResolve) => { + if (typeof window === "undefined") { + resolve(); + return; + } registerCurrentRuntime(); const openUI5Support = getFeature("OpenUI5Support"); From aac92620d29d6de9cdcd3e281d4e1eac3e8cac13 Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Wed, 8 Feb 2023 10:01:39 +0100 Subject: [PATCH 5/7] refactor: use document instead of window --- packages/base/src/Boot.ts | 2 +- packages/base/src/Device.ts | 2 +- packages/base/src/InitialConfiguration.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/base/src/Boot.ts b/packages/base/src/Boot.ts index 74c94dc70dfb..e5ae29d59bab 100644 --- a/packages/base/src/Boot.ts +++ b/packages/base/src/Boot.ts @@ -27,7 +27,7 @@ const boot = async (): Promise => { } const bootExecutor = async (resolve: PromiseResolve) => { - if (typeof window === "undefined") { + if (typeof document === "undefined") { resolve(); return; } diff --git a/packages/base/src/Device.ts b/packages/base/src/Device.ts index 3e60d6df7d70..d1ff8ccd530e 100644 --- a/packages/base/src/Device.ts +++ b/packages/base/src/Device.ts @@ -1,4 +1,4 @@ -const isSSR = () => typeof window === "undefined"; +const isSSR = () => typeof document === "undefined"; const internals = { get userAgent() { diff --git a/packages/base/src/InitialConfiguration.ts b/packages/base/src/InitialConfiguration.ts index c5e8a130c789..54835cd08b3c 100644 --- a/packages/base/src/InitialConfiguration.ts +++ b/packages/base/src/InitialConfiguration.ts @@ -178,7 +178,7 @@ const applyOpenUI5Configuration = () => { }; const initConfiguration = () => { - if (typeof window === "undefined" || initialized) { + if (typeof document === "undefined" || initialized) { return; } From f62abf21207e7bbdddb9a731e95151222f7d46fc Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Wed, 8 Feb 2023 10:47:38 +0100 Subject: [PATCH 6/7] chore: use variable instead of function --- packages/base/src/Device.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/base/src/Device.ts b/packages/base/src/Device.ts index d1ff8ccd530e..40dfbc945974 100644 --- a/packages/base/src/Device.ts +++ b/packages/base/src/Device.ts @@ -1,74 +1,74 @@ -const isSSR = () => typeof document === "undefined"; +const isSSR = typeof document === "undefined"; const internals = { get userAgent() { - if (isSSR()) { + if (isSSR) { return ""; } return navigator.userAgent; }, get touch() { - if (isSSR()) { + if (isSSR) { return false; } return "ontouchstart" in window || navigator.maxTouchPoints > 0; }, get ie() { - if (isSSR()) { + if (isSSR) { return false; } return /(msie|trident)/i.test(internals.userAgent); }, get chrome() { - if (isSSR()) { + if (isSSR) { return false; } return !internals.ie && /(Chrome|CriOS)/.test(internals.userAgent); }, get firefox() { - if (isSSR()) { + if (isSSR) { return false; } return /Firefox/.test(internals.userAgent); }, get safari() { - if (isSSR()) { + if (isSSR) { return false; } return !internals.ie && !internals.chrome && /(Version|PhantomJS)\/(\d+\.\d+).*Safari/.test(internals.userAgent); }, get webkit() { - if (isSSR()) { + if (isSSR) { return false; } return !internals.ie && /webkit/.test(internals.userAgent); }, get windows() { - if (isSSR()) { + if (isSSR) { return false; } return navigator.platform.indexOf("Win") !== -1; }, get iOS() { - if (isSSR()) { + if (isSSR) { return false; } return !!(navigator.platform.match(/iPhone|iPad|iPod/)) || !!(internals.userAgent.match(/Mac/) && "ontouchend" in document); }, get android() { - if (isSSR()) { + if (isSSR) { return false; } return !internals.windows && /Android/.test(internals.userAgent); }, get androidPhone() { - if (isSSR()) { + if (isSSR) { return false; } return internals.android && /(?=android)(?=.*mobile)/i.test(internals.userAgent); }, get ipad() { - if (isSSR()) { + if (isSSR) { return false; } // With iOS 13 the string 'iPad' was removed from the user agent string through a browser setting, which is applied on all sites by default: @@ -83,7 +83,7 @@ let webkitVersion: number; let tablet: boolean; const isWindows8OrAbove = () => { - if (isSSR()) { + if (isSSR) { return false; } @@ -100,7 +100,7 @@ const isWindows8OrAbove = () => { }; const isWebkit537OrAbove = () => { - if (isSSR()) { + if (isSSR) { return false; } @@ -117,7 +117,7 @@ const isWebkit537OrAbove = () => { }; const detectTablet = () => { - if (isSSR()) { + if (isSSR) { return false; } From 230dc891cc00fd7692e5b4010204402261328aa7 Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Wed, 8 Feb 2023 14:33:12 +0100 Subject: [PATCH 7/7] test: add test --- packages/base/package-scripts.js | 6 +++++- packages/base/src/Device.ts | 3 +++ packages/base/test/ssr/Device.mjs | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 packages/base/test/ssr/Device.mjs diff --git a/packages/base/package-scripts.js b/packages/base/package-scripts.js index 79dcc4ed714c..1b435796924f 100644 --- a/packages/base/package-scripts.js +++ b/packages/base/package-scripts.js @@ -58,7 +58,11 @@ const scripts = { styles: 'chokidar "src/css/*.css" -c "nps generateStyles"' }, start: "nps prepare watch.withBundle", - test: `node "${LIB}/test-runner/test-runner.js"`, + test: { + default: 'concurrently "nps test.wdio"', + ssr: `mocha test/ssr`, + wdio: `node "${LIB}/test-runner/test-runner.js"` + }, }; diff --git a/packages/base/src/Device.ts b/packages/base/src/Device.ts index 40dfbc945974..c4aa58a2c974 100644 --- a/packages/base/src/Device.ts +++ b/packages/base/src/Device.ts @@ -170,6 +170,9 @@ const isPhone = (): boolean => { }; const isDesktop = (): boolean => { + if (isSSR) { + return false; + } return (!isTablet() && !isPhone()) || isWindows8OrAbove(); }; diff --git a/packages/base/test/ssr/Device.mjs b/packages/base/test/ssr/Device.mjs new file mode 100644 index 000000000000..c9f93dbbab4f --- /dev/null +++ b/packages/base/test/ssr/Device.mjs @@ -0,0 +1,21 @@ +import {assert} from "chai"; +import * as Device from "../../dist/Device.js"; + +describe('SSR / Device', () => { + + it('all detections should return false', () => { + assert.strictEqual(Device.supportsTouch(), false, `'supportsTouch' should be false`); + assert.strictEqual(Device.isIE(), false, `'isIE' should be false`); + assert.strictEqual(Device.isSafari(), false, `'isSafari' should be false`); + assert.strictEqual(Device.isChrome(), false, `'isChrome' should be false`); + assert.strictEqual(Device.isFirefox(), false, `'isFirefox' should be false`); + assert.strictEqual(Device.isPhone(), false, `'isPhone' should be false`); + assert.strictEqual(Device.isTablet(), false, `'isTablet' should be false`); + assert.strictEqual(Device.isDesktop(), false, `'isDesktop' should be false`); + assert.strictEqual(Device.isCombi(), false, `'isCombi' should be false`); + assert.strictEqual(Device.isIOS(), false, `'isIOS' should be false`); + assert.strictEqual(Device.isAndroid(), false, `'isAndroid' should be false`); + }) +}) + +