Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/strange-seas-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@alfalab/scripts-modules': minor
---

Изменена логика добавления стилей для Safari, теперь вместо встраивания через тэг `link` будет происходить встраивание через `inline` стили тэгом `style`.
Чтобы отключить это поведение можно передать `disableInlineStyleSafari: true` в `createModuleLoader`
1 change: 1 addition & 0 deletions packages/arui-scripts-modules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {}, // коллбек, который будет вызван после монтирования модуля
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type CreateModuleLoaderParams<
resourcesCache?: 'none' | 'single-item';
/** shareScope модуля, если отличается от default */
shareScope?: string;
/** флаг, отключающий встраивание inline стилей в Safari */
disableInlineStyleSafari?: boolean;
};

const consumerCounter = getConsumerCounter();
Expand All @@ -72,6 +74,7 @@ export function createModuleLoader<
onBeforeModuleUnmount,
onAfterModuleUnmount,
shareScope,
disableInlineStyleSafari,
}: CreateModuleLoaderParams<ModuleExportType, GetResourcesParams, ModuleState>): Loader<
GetResourcesParams,
ModuleExportType
Expand Down Expand Up @@ -180,6 +183,7 @@ export function createModuleLoader<
styles: moduleResources.styles,
baseUrl: moduleResources.moduleState.baseUrl,
abortSignal,
disableInlineStyleSafari,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading