From 8245ff45110494e5b2353802a2b46d3c810ee1a9 Mon Sep 17 00:00:00 2001 From: eikozyrev Date: Thu, 17 Jul 2025 11:36:22 +0300 Subject: [PATCH] feat(modules): fix inject style in safari --- .changeset/strange-seas-wait.md | 6 + packages/arui-scripts-modules/README.md | 1 + .../src/module-loader/create-module-loader.ts | 4 + .../utils/__tests__/dom-utils.test.ts | 220 ++++++++++++++++++ .../utils/__tests__/is-safari.test.ts | 19 ++ .../src/module-loader/utils/dom-utils.ts | 108 +++++++-- .../module-loader/utils/fetch-resources.ts | 21 +- .../src/module-loader/utils/is-safari.ts | 1 + 8 files changed, 352 insertions(+), 28 deletions(-) create mode 100644 .changeset/strange-seas-wait.md create mode 100644 packages/arui-scripts-modules/src/module-loader/utils/__tests__/dom-utils.test.ts create mode 100644 packages/arui-scripts-modules/src/module-loader/utils/__tests__/is-safari.test.ts create mode 100644 packages/arui-scripts-modules/src/module-loader/utils/is-safari.ts diff --git a/.changeset/strange-seas-wait.md b/.changeset/strange-seas-wait.md new file mode 100644 index 00000000..46fb51e1 --- /dev/null +++ b/.changeset/strange-seas-wait.md @@ -0,0 +1,6 @@ +--- +'@alfalab/scripts-modules': minor +--- + +Изменена логика добавления стилей для Safari, теперь вместо встраивания через тэг `link` будет происходить встраивание через `inline` стили тэгом `style`. +Чтобы отключить это поведение можно передать `disableInlineStyleSafari: true` в `createModuleLoader` diff --git a/packages/arui-scripts-modules/README.md b/packages/arui-scripts-modules/README.md index d9aae45d..efbbb069 100644 --- a/packages/arui-scripts-modules/README.md +++ b/packages/arui-scripts-modules/README.md @@ -46,6 +46,7 @@ const loader = createModuleLoader({ resourceCache: 'single-item', // политика кеширования ресурсов модуля. Если 'none' - ресурсы модуля будут удалены из кеша после его удаления со страницы. Если 'single-item' - в кеше будет храниться значения для текущего значения loaderParams. resourcesTargetNode: document.head, // DOM-нода, в которую будут монтироваться ресурсы модуля (css и js) shareScope // параметр, который необходимо указать если shareScope модуля отличается от default + disableInlineStyleSafari, // флаг, отключающий встраивание inline стилей в Safari onBeforeResourcesMount: (moduleId, resources) => {}, // коллбек, который будет вызван перед монтированием ресурсов onBeforeModuleMount: (moduleId, resources) => {}, // коллбек, который будет вызван перед монтированием модуля onAfterModuleMount: (moduleId, resources, module) => {}, // коллбек, который будет вызван после монтирования модуля diff --git a/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts b/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts index 85cd5887..80eb7c39 100644 --- a/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts +++ b/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts @@ -52,6 +52,8 @@ export type CreateModuleLoaderParams< resourcesCache?: 'none' | 'single-item'; /** shareScope модуля, если отличается от default */ shareScope?: string; + /** флаг, отключающий встраивание inline стилей в Safari */ + disableInlineStyleSafari?: boolean; }; const consumerCounter = getConsumerCounter(); @@ -72,6 +74,7 @@ export function createModuleLoader< onBeforeModuleUnmount, onAfterModuleUnmount, shareScope, + disableInlineStyleSafari, }: CreateModuleLoaderParams): Loader< GetResourcesParams, ModuleExportType @@ -180,6 +183,7 @@ export function createModuleLoader< styles: moduleResources.styles, baseUrl: moduleResources.moduleState.baseUrl, abortSignal, + disableInlineStyleSafari, }); } diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/dom-utils.test.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/dom-utils.test.ts new file mode 100644 index 00000000..82cb5e83 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/dom-utils.test.ts @@ -0,0 +1,220 @@ +import { removeModuleResources, scriptsFetcher, stylesFetcher } from '../dom-utils'; + +const DATA_APP_ID_ATTRIBUTE = 'data-parent-app-id'; +const MODULE_TEST_ID = 'globalSearch'; +const SAFARI_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15'; + +const findResourcesNodes = () => + document.head.querySelectorAll(`[${DATA_APP_ID_ATTRIBUTE}="${MODULE_TEST_ID}"]`); + +describe('dom utils', () => { + describe('removeModuleResources', () => { + it('should remove module resources', () => { + const script = document.createElement('script'); + const link = document.createElement('link'); + const style = document.createElement('style'); + + script.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + link.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + style.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + document.head.append(script); + document.head.append(link); + document.head.append(style); + + expect(findResourcesNodes().length).toBe(3); + + removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] }); + expect(findResourcesNodes().length).toBe(0); + }); + it('should skip remove if no target nodes', () => { + const script = document.createElement('script'); + const link = document.createElement('link'); + const style = document.createElement('style'); + + script.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + link.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + style.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID); + document.head.append(script); + document.head.append(link); + document.head.append(style); + + expect(findResourcesNodes().length).toBe(3); + + removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [undefined] }); + expect(findResourcesNodes().length).toBe(3); + + removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] }); + }); + }); + + describe('resource fetchers', () => { + let timerId: number; + + beforeEach(() => { + global.fetch = jest.fn(async () => ({ text: async () => '' })) as jest.Mock; + removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] }); + + timerId = setTimeout(() => { + findResourcesNodes().forEach((node) => { + node.dispatchEvent(new Event('load')); + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = undefined as never; + clearTimeout(timerId); + }); + + it('should fetch scripts', async () => { + await scriptsFetcher({ + urls: ['https://example.com/script.js'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: undefined, + }); + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(1); + expect(nodes[0].tagName).toBe('SCRIPT'); + expect((nodes[0] as HTMLScriptElement).src).toBe('https://example.com/script.js'); + expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID); + }); + + it('should fetch styles', async () => { + await stylesFetcher({ + urls: ['https://example.com/style.css'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: undefined, + }); + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(1); + expect(nodes[0].tagName).toBe('LINK'); + expect((nodes[0] as HTMLLinkElement).href).toBe('https://example.com/style.css'); + expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID); + }); + + it('should inject inline styles in Safari', async () => { + jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT); + + await stylesFetcher({ + urls: ['https://example.com/style.css'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: undefined, + }); + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(1); + expect(nodes[0].tagName).toBe('STYLE'); + expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID); + }); + + it('should create link instead of style tag if disableInlineStyleSafari = true in Safari', async () => { + jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT); + + await stylesFetcher({ + urls: ['https://example.com/style.css'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: undefined, + disableInlineStyleSafari: true, + }); + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(1); + expect(nodes[0].tagName).toBe('LINK'); + expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID); + }); + + it('should not inject resources if the abort signal is aborted', async () => { + const abortController = new AbortController(); + + abortController.abort(); + + try { + await scriptsFetcher({ + urls: ['https://example.com/script.js'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: abortController.signal, + }); + } catch (error) { + expect((error as Error).toString()).toBe('Error: The operation was aborted.'); + } + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(0); + }); + + it('should not inject resources if the abort signal is aborted in Safari', async () => { + jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT); + + const abortController = new AbortController(); + + abortController.abort(); + + try { + await stylesFetcher({ + urls: ['https://example.com/style.css'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: abortController.signal, + }); + } catch (error) { + expect((error as Error).toString()).toBe('Error: The operation was aborted.'); + } + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(0); + }); + + it('should not inject resources if has load error', async () => { + clearTimeout(timerId); + timerId = setTimeout(() => { + findResourcesNodes().forEach((node) => { + node.dispatchEvent(new Event('error')); + }); + }); + + try { + await scriptsFetcher({ + urls: ['https://example.com/script.js'], + targetNode: document.head, + attributes: { + [DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID, + }, + abortSignal: undefined, + }); + } catch (error) { + expect((error as Error).toString()).toBe('[object Event]'); + } + + const nodes = findResourcesNodes(); + + expect(nodes.length).toBe(0); + }); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/is-safari.test.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/is-safari.test.ts new file mode 100644 index 00000000..af90c14c --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/is-safari.test.ts @@ -0,0 +1,19 @@ +import { isSafari } from '../is-safari'; + +describe('isSafari', () => { + it('should return true if the user agent is Safari', () => { + jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15', + ); + + expect(isSafari()).toBe(true); + }); + + it('should return false if the user agent is not Safari', () => { + jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ); + + expect(isSafari()).toBe(false); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/dom-utils.ts b/packages/arui-scripts-modules/src/module-loader/utils/dom-utils.ts index 02be03a2..b68eb20c 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/dom-utils.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/dom-utils.ts @@ -1,3 +1,5 @@ +import { isSafari } from './is-safari'; + type ResourceFetcherParams = { /** * адреса подключаемых ресурсов @@ -15,11 +17,20 @@ type ResourceFetcherParams = { * AbortSignal, который будет использован для отмены загрузки ресурсов */ abortSignal?: AbortSignal; + /** + * Отключает встраивание inline стилей в Safari + */ + disableInlineStyleSafari?: boolean; }; type GenericResourceFetcherParams = ResourceFetcherParams & { createTag: (src: string) => HTMLElement; -} + createFetcher: ( + src: string, + element: HTMLElement, + abortSignal?: AbortSignal, + ) => () => Promise; +}; function resourceFetcher({ urls, @@ -27,12 +38,16 @@ function resourceFetcher({ attributes, abortSignal, createTag, + createFetcher, }: GenericResourceFetcherParams): Promise { - return Promise.all(urls.map((src) => { - const tag = createTag(src); - - return appendTag(tag, targetNode, attributes, abortSignal); - })); + return Promise.all( + urls.map((src) => { + const tag = createTag(src); + const fetcher = createFetcher(src, tag, abortSignal); + + return appendTag(tag, targetNode, attributes, fetcher); + }), + ); } /** @@ -51,7 +66,8 @@ export function scriptsFetcher(params: ResourceFetcherParams): Promise createElementFetcher(element, abortSignal), + }); } /** @@ -62,6 +78,10 @@ export async function stylesFetcher(params: ResourceFetcherParams): Promise { + if (!params.disableInlineStyleSafari && isSafari()) { + return document.createElement('style'); + } + const link = document.createElement('link'); link.rel = 'stylesheet'; @@ -69,7 +89,14 @@ export async function stylesFetcher(params: ResourceFetcherParams): Promise { + if (!params.disableInlineStyleSafari && isSafari()) { + return createContentFetcher(src, element, abortSignal); + } + + return createElementFetcher(element, abortSignal); + }, }); } @@ -88,7 +115,10 @@ type RemoveModuleResourcesParams = { * @param moduleId ID приложения, ресурсы которого мы удаляем * @param targetNodes Список HTML элементов, в которых мы ищем ресурсы для удаления */ -export function removeModuleResources({ moduleId, targetNodes }: RemoveModuleResourcesParams): void { +export function removeModuleResources({ + moduleId, + targetNodes, +}: RemoveModuleResourcesParams): void { targetNodes.forEach((targetNode) => { if (!targetNode) { return; @@ -108,29 +138,59 @@ function nodeListToArray(nodeList: NodeListOf): T[] { return [].slice.call(nodeList); } -function appendTag( +async function appendTag( element: HTMLElement, targetNode: Node, attributes: Record, - abortSignal?: AbortSignal, + fetcher: ReturnType, ): Promise { Object.keys(attributes).forEach((key) => { element.setAttribute(key, attributes[key]); }); - return new Promise((resolve, reject) => { - element.addEventListener('load', () => { - if (abortSignal?.aborted) { - // Если во время загрузки ресурса пришел сигнал об отмене, то удаляем ресурс из DOM + targetNode.appendChild(element); + + await fetcher(); + + return element; +} + +const ABORT_ERROR = new DOMException('The operation was aborted.'); + +function createElementFetcher(element: HTMLElement, abortSignal?: AbortSignal) { + return () => + new Promise((resolve, reject) => { + element.addEventListener('load', () => { + if (abortSignal?.aborted) { + // Если во время загрузки ресурса пришел сигнал об отмене, то удаляем ресурс из DOM + element.remove(); + reject(ABORT_ERROR); + } else { + resolve(element); + } + }); + element.addEventListener('error', (error) => { + // Если во время загрузки ресурса произошла ошибка, то удаляем ресурс из DOM element.remove(); - reject(new DOMException('The operation was aborted.')); - } else { - resolve(element); - } - }); - element.addEventListener('error', (error) => { - reject(error); + reject(error); + }); }); - targetNode.appendChild(element); - }); +} + +function createContentFetcher(href: string, element: HTMLElement, abortSignal?: AbortSignal) { + return async () => { + const response = await fetch(href); + const text = await response.text(); + + if (abortSignal?.aborted) { + element.remove(); + // Если во время загрузки ресурса пришел сигнал об отмене, то удаляем ресурс из DOM + throw ABORT_ERROR; + } + + // eslint-disable-next-line no-param-reassign + element.textContent = text; + + return element; + }; } diff --git a/packages/arui-scripts-modules/src/module-loader/utils/fetch-resources.ts b/packages/arui-scripts-modules/src/module-loader/utils/fetch-resources.ts index 16ba83fc..37d6d424 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/fetch-resources.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/fetch-resources.ts @@ -1,4 +1,5 @@ import { scriptsFetcher, stylesFetcher } from './dom-utils'; +import { isSafari } from './is-safari'; import { urlSegmentWithoutEndSlash } from './normalize-url-segment'; type MountModuleResourcesParams = { @@ -10,6 +11,7 @@ type MountModuleResourcesParams = { styles: string[]; baseUrl: string; abortSignal?: AbortSignal; + disableInlineStyleSafari?: boolean; }; /** @@ -26,6 +28,7 @@ export async function fetchResources({ styles, baseUrl, abortSignal, + disableInlineStyleSafari, }: MountModuleResourcesParams) { const cssTagsAttributes: Record = { [DATA_APP_ID_ATTRIBUTE]: moduleId, @@ -42,9 +45,16 @@ export async function fetchResources({ const scriptsUrls = scripts.map((src) => `${urlSegmentWithoutEndSlash(baseUrl)}/${src}`); const stylesUrls = styles.map((src) => `${urlSegmentWithoutEndSlash(baseUrl)}/${src}`); + const scriptTag = 'script'; + const styleTag = !disableInlineStyleSafari && isSafari() ? 'link' : 'style'; + // находим и удяляем ресурсы того же самого модуля, которые были добавлены ранее - const previouslyAddedScripts = Array.from(jsTargetNode.querySelectorAll(`script[${DATA_APP_ID_ATTRIBUTE}="${moduleId}"]`)); - const previouslyAddedStyles = Array.from(cssTargetNode.querySelectorAll(`link[${DATA_APP_ID_ATTRIBUTE}="${moduleId}"]`)); + const previouslyAddedScripts = Array.from( + jsTargetNode.querySelectorAll(`${scriptTag}[${DATA_APP_ID_ATTRIBUTE}="${moduleId}"]`), + ); + const previouslyAddedStyles = Array.from( + cssTargetNode.querySelectorAll(`${styleTag}[${DATA_APP_ID_ATTRIBUTE}="${moduleId}"]`), + ); previouslyAddedScripts.forEach((script) => script.remove()); previouslyAddedStyles.forEach((style) => style.remove()); @@ -61,6 +71,7 @@ export async function fetchResources({ targetNode: cssTargetNode, attributes: cssTagsAttributes, abortSignal, + disableInlineStyleSafari, }), ]); } @@ -68,7 +79,7 @@ export async function fetchResources({ type GetTargetNodesParams = { resourcesTargetNode: HTMLElement | undefined; cssTargetSelector: string | undefined; -} +}; export function getResourcesTargetNodes({ resourcesTargetNode, @@ -81,7 +92,9 @@ export function getResourcesTargetNodes({ const possibleCssTarget = document.querySelector(cssTargetSelector); if (possibleCssTarget) { - cssResourcesTargetNode = possibleCssTarget.shadowRoot ? possibleCssTarget.shadowRoot : possibleCssTarget; + cssResourcesTargetNode = possibleCssTarget.shadowRoot + ? possibleCssTarget.shadowRoot + : possibleCssTarget; } } diff --git a/packages/arui-scripts-modules/src/module-loader/utils/is-safari.ts b/packages/arui-scripts-modules/src/module-loader/utils/is-safari.ts new file mode 100644 index 00000000..9debe491 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/is-safari.ts @@ -0,0 +1 @@ +export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent);