Skip to content

Commit 9f6f052

Browse files
authored
Merge pull request #336 from core-ds/feat/safari-modules
feat(modules): fix inject style in safari
2 parents 7c8a716 + 8245ff4 commit 9f6f052

8 files changed

Lines changed: 352 additions & 28 deletions

File tree

.changeset/strange-seas-wait.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@alfalab/scripts-modules': minor
3+
---
4+
5+
Изменена логика добавления стилей для Safari, теперь вместо встраивания через тэг `link` будет происходить встраивание через `inline` стили тэгом `style`.
6+
Чтобы отключить это поведение можно передать `disableInlineStyleSafari: true` в `createModuleLoader`

packages/arui-scripts-modules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const loader = createModuleLoader({
4646
resourceCache: 'single-item', // политика кеширования ресурсов модуля. Если 'none' - ресурсы модуля будут удалены из кеша после его удаления со страницы. Если 'single-item' - в кеше будет храниться значения для текущего значения loaderParams.
4747
resourcesTargetNode: document.head, // DOM-нода, в которую будут монтироваться ресурсы модуля (css и js)
4848
shareScope // параметр, который необходимо указать если shareScope модуля отличается от default
49+
disableInlineStyleSafari, // флаг, отключающий встраивание inline стилей в Safari
4950
onBeforeResourcesMount: (moduleId, resources) => {}, // коллбек, который будет вызван перед монтированием ресурсов
5051
onBeforeModuleMount: (moduleId, resources) => {}, // коллбек, который будет вызван перед монтированием модуля
5152
onAfterModuleMount: (moduleId, resources, module) => {}, // коллбек, который будет вызван после монтирования модуля

packages/arui-scripts-modules/src/module-loader/create-module-loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export type CreateModuleLoaderParams<
5252
resourcesCache?: 'none' | 'single-item';
5353
/** shareScope модуля, если отличается от default */
5454
shareScope?: string;
55+
/** флаг, отключающий встраивание inline стилей в Safari */
56+
disableInlineStyleSafari?: boolean;
5557
};
5658

5759
const consumerCounter = getConsumerCounter();
@@ -72,6 +74,7 @@ export function createModuleLoader<
7274
onBeforeModuleUnmount,
7375
onAfterModuleUnmount,
7476
shareScope,
77+
disableInlineStyleSafari,
7578
}: CreateModuleLoaderParams<ModuleExportType, GetResourcesParams, ModuleState>): Loader<
7679
GetResourcesParams,
7780
ModuleExportType
@@ -180,6 +183,7 @@ export function createModuleLoader<
180183
styles: moduleResources.styles,
181184
baseUrl: moduleResources.moduleState.baseUrl,
182185
abortSignal,
186+
disableInlineStyleSafari,
183187
});
184188
}
185189

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { removeModuleResources, scriptsFetcher, stylesFetcher } from '../dom-utils';
2+
3+
const DATA_APP_ID_ATTRIBUTE = 'data-parent-app-id';
4+
const MODULE_TEST_ID = 'globalSearch';
5+
const SAFARI_USER_AGENT =
6+
'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';
7+
8+
const findResourcesNodes = () =>
9+
document.head.querySelectorAll(`[${DATA_APP_ID_ATTRIBUTE}="${MODULE_TEST_ID}"]`);
10+
11+
describe('dom utils', () => {
12+
describe('removeModuleResources', () => {
13+
it('should remove module resources', () => {
14+
const script = document.createElement('script');
15+
const link = document.createElement('link');
16+
const style = document.createElement('style');
17+
18+
script.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
19+
link.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
20+
style.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
21+
document.head.append(script);
22+
document.head.append(link);
23+
document.head.append(style);
24+
25+
expect(findResourcesNodes().length).toBe(3);
26+
27+
removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] });
28+
expect(findResourcesNodes().length).toBe(0);
29+
});
30+
it('should skip remove if no target nodes', () => {
31+
const script = document.createElement('script');
32+
const link = document.createElement('link');
33+
const style = document.createElement('style');
34+
35+
script.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
36+
link.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
37+
style.setAttribute(DATA_APP_ID_ATTRIBUTE, MODULE_TEST_ID);
38+
document.head.append(script);
39+
document.head.append(link);
40+
document.head.append(style);
41+
42+
expect(findResourcesNodes().length).toBe(3);
43+
44+
removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [undefined] });
45+
expect(findResourcesNodes().length).toBe(3);
46+
47+
removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] });
48+
});
49+
});
50+
51+
describe('resource fetchers', () => {
52+
let timerId: number;
53+
54+
beforeEach(() => {
55+
global.fetch = jest.fn(async () => ({ text: async () => '' })) as jest.Mock;
56+
removeModuleResources({ moduleId: MODULE_TEST_ID, targetNodes: [document.head] });
57+
58+
timerId = setTimeout(() => {
59+
findResourcesNodes().forEach((node) => {
60+
node.dispatchEvent(new Event('load'));
61+
});
62+
});
63+
});
64+
65+
afterEach(() => {
66+
jest.restoreAllMocks();
67+
global.fetch = undefined as never;
68+
clearTimeout(timerId);
69+
});
70+
71+
it('should fetch scripts', async () => {
72+
await scriptsFetcher({
73+
urls: ['https://example.com/script.js'],
74+
targetNode: document.head,
75+
attributes: {
76+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
77+
},
78+
abortSignal: undefined,
79+
});
80+
81+
const nodes = findResourcesNodes();
82+
83+
expect(nodes.length).toBe(1);
84+
expect(nodes[0].tagName).toBe('SCRIPT');
85+
expect((nodes[0] as HTMLScriptElement).src).toBe('https://example.com/script.js');
86+
expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID);
87+
});
88+
89+
it('should fetch styles', async () => {
90+
await stylesFetcher({
91+
urls: ['https://example.com/style.css'],
92+
targetNode: document.head,
93+
attributes: {
94+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
95+
},
96+
abortSignal: undefined,
97+
});
98+
99+
const nodes = findResourcesNodes();
100+
101+
expect(nodes.length).toBe(1);
102+
expect(nodes[0].tagName).toBe('LINK');
103+
expect((nodes[0] as HTMLLinkElement).href).toBe('https://example.com/style.css');
104+
expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID);
105+
});
106+
107+
it('should inject inline styles in Safari', async () => {
108+
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT);
109+
110+
await stylesFetcher({
111+
urls: ['https://example.com/style.css'],
112+
targetNode: document.head,
113+
attributes: {
114+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
115+
},
116+
abortSignal: undefined,
117+
});
118+
119+
const nodes = findResourcesNodes();
120+
121+
expect(nodes.length).toBe(1);
122+
expect(nodes[0].tagName).toBe('STYLE');
123+
expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID);
124+
});
125+
126+
it('should create link instead of style tag if disableInlineStyleSafari = true in Safari', async () => {
127+
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT);
128+
129+
await stylesFetcher({
130+
urls: ['https://example.com/style.css'],
131+
targetNode: document.head,
132+
attributes: {
133+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
134+
},
135+
abortSignal: undefined,
136+
disableInlineStyleSafari: true,
137+
});
138+
139+
const nodes = findResourcesNodes();
140+
141+
expect(nodes.length).toBe(1);
142+
expect(nodes[0].tagName).toBe('LINK');
143+
expect(nodes[0].getAttribute(DATA_APP_ID_ATTRIBUTE)).toBe(MODULE_TEST_ID);
144+
});
145+
146+
it('should not inject resources if the abort signal is aborted', async () => {
147+
const abortController = new AbortController();
148+
149+
abortController.abort();
150+
151+
try {
152+
await scriptsFetcher({
153+
urls: ['https://example.com/script.js'],
154+
targetNode: document.head,
155+
attributes: {
156+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
157+
},
158+
abortSignal: abortController.signal,
159+
});
160+
} catch (error) {
161+
expect((error as Error).toString()).toBe('Error: The operation was aborted.');
162+
}
163+
164+
const nodes = findResourcesNodes();
165+
166+
expect(nodes.length).toBe(0);
167+
});
168+
169+
it('should not inject resources if the abort signal is aborted in Safari', async () => {
170+
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(SAFARI_USER_AGENT);
171+
172+
const abortController = new AbortController();
173+
174+
abortController.abort();
175+
176+
try {
177+
await stylesFetcher({
178+
urls: ['https://example.com/style.css'],
179+
targetNode: document.head,
180+
attributes: {
181+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
182+
},
183+
abortSignal: abortController.signal,
184+
});
185+
} catch (error) {
186+
expect((error as Error).toString()).toBe('Error: The operation was aborted.');
187+
}
188+
189+
const nodes = findResourcesNodes();
190+
191+
expect(nodes.length).toBe(0);
192+
});
193+
194+
it('should not inject resources if has load error', async () => {
195+
clearTimeout(timerId);
196+
timerId = setTimeout(() => {
197+
findResourcesNodes().forEach((node) => {
198+
node.dispatchEvent(new Event('error'));
199+
});
200+
});
201+
202+
try {
203+
await scriptsFetcher({
204+
urls: ['https://example.com/script.js'],
205+
targetNode: document.head,
206+
attributes: {
207+
[DATA_APP_ID_ATTRIBUTE]: MODULE_TEST_ID,
208+
},
209+
abortSignal: undefined,
210+
});
211+
} catch (error) {
212+
expect((error as Error).toString()).toBe('[object Event]');
213+
}
214+
215+
const nodes = findResourcesNodes();
216+
217+
expect(nodes.length).toBe(0);
218+
});
219+
});
220+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { isSafari } from '../is-safari';
2+
3+
describe('isSafari', () => {
4+
it('should return true if the user agent is Safari', () => {
5+
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(
6+
'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',
7+
);
8+
9+
expect(isSafari()).toBe(true);
10+
});
11+
12+
it('should return false if the user agent is not Safari', () => {
13+
jest.spyOn(navigator, 'userAgent', 'get').mockReturnValue(
14+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
15+
);
16+
17+
expect(isSafari()).toBe(false);
18+
});
19+
});

0 commit comments

Comments
 (0)