Skip to content

Commit 855ea03

Browse files
authored
fix: Delay missing styles check until document is fully loaded (#4017)
1 parent 12283fe commit 855ea03

File tree

3 files changed

+95
-54
lines changed

3 files changed

+95
-54
lines changed

src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@ test('emits style check error only once', async () => {
3737
await new Promise(resolve => setTimeout(resolve, 1100));
3838
expect(consoleMock).toHaveBeenCalledTimes(0);
3939
});
40+
41+
test('no check if component is instantly unmounted', async () => {
42+
const { unmount } = render(<Test key={1} />);
43+
unmount();
44+
45+
await new Promise(resolve => setTimeout(resolve, 1100));
46+
expect(consoleMock).toHaveBeenCalledTimes(0);
47+
});

src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { waitFor } from '@testing-library/react';
5+
46
import { GIT_SHA } from '../../../../../lib/components/internal/environment';
5-
import { checkMissingStyles } from '../../../../../lib/components/internal/hooks/use-base-component/styles-check';
7+
import {
8+
checkMissingStyles,
9+
documentReadyAndIdle,
10+
} from '../../../../../lib/components/internal/hooks/use-base-component/styles-check';
611
import { metrics } from '../../../../../lib/components/internal/metrics';
7-
import { idleWithDelay } from '../styles-check';
812

913
jest.mock('../../../../../lib/components/internal/environment', () => ({
1014
...jest.requireActual('../../../../../lib/components/internal/environment'),
@@ -112,50 +116,70 @@ describe('checkMissingStyles', () => {
112116
});
113117
});
114118

115-
describe('idleWithDelay', () => {
119+
describe('documentReadyAndIdle', () => {
120+
function createDocumentMock(readyState: DocumentReadyState): Document {
121+
return {
122+
readyState,
123+
defaultView: {
124+
addEventListener: jest.fn() as Window['addEventListener'],
125+
},
126+
} as Document;
127+
}
128+
116129
beforeEach(() => {
117-
jest.useFakeTimers();
118130
// simulate requestIdleCallback for JSDOM
119131
globalThis.requestIdleCallback = cb => setTimeout(cb, 0);
120132
});
121133

122134
afterEach(() => {
123-
jest.useRealTimers();
124135
// @ts-expect-error reset to initial state
125136
globalThis.requestIdleCallback = undefined;
126137
});
127138

128-
test('does nothing if requestIdleCallback not supported', () => {
129-
// @ts-expect-error simulate missing API
130-
globalThis.requestIdleCallback = undefined;
139+
test('runs callback when document is idle', async () => {
140+
const document = createDocumentMock('complete');
131141
const cb = jest.fn();
132-
expect(requestIdleCallback).toBe(undefined);
133-
idleWithDelay(cb);
134-
jest.runAllTimers();
135-
expect(cb).not.toHaveBeenCalled();
142+
documentReadyAndIdle(document, new AbortController().signal).then(cb);
143+
await waitFor(
144+
() => {
145+
expect(document.defaultView!.addEventListener).not.toHaveBeenCalled();
146+
expect(cb).toHaveBeenCalled();
147+
},
148+
{ timeout: 2000 }
149+
);
136150
});
137151

138-
test('runs callback after a delay', () => {
152+
test('waits for document to be loaded if it is not ready', async () => {
153+
const document = createDocumentMock('loading');
139154
const cb = jest.fn();
140-
idleWithDelay(cb);
141-
jest.runAllTimers();
142-
expect(cb).toHaveBeenCalled();
155+
documentReadyAndIdle(document, new AbortController().signal).then(cb);
156+
expect(document.defaultView!.addEventListener).toHaveBeenCalledWith('load', expect.any(Function), { once: true });
157+
(document.defaultView!.addEventListener as any).mock.lastCall[1]();
158+
await waitFor(
159+
() => {
160+
expect(cb).toHaveBeenCalled();
161+
},
162+
{ timeout: 2000 }
163+
);
143164
});
144165

145-
test('delay can be aborted before setTimeout phase', () => {
146-
const cb = jest.fn();
147-
const abort = idleWithDelay(cb);
148-
abort!();
149-
jest.runAllTimers();
150-
expect(cb).not.toHaveBeenCalled();
166+
test('delay can be aborted before loading state phase', async () => {
167+
const document = createDocumentMock('loading');
168+
const onAbort = jest.fn();
169+
const abortController = new AbortController();
170+
documentReadyAndIdle(document, abortController.signal).then(() => {}, onAbort);
171+
expect(document.defaultView!.addEventListener).toHaveBeenCalledWith('load', expect.any(Function), { once: true });
172+
abortController.abort();
173+
await waitFor(() => expect(onAbort).toHaveBeenCalledWith(new DOMException('Aborted', 'AbortError')));
151174
});
152175

153-
test('delay can be aborted before requestIdleCallback phase', () => {
154-
const cb = jest.fn();
155-
const abort = idleWithDelay(cb);
156-
jest.runOnlyPendingTimers(); // flush setTimeout
157-
abort!();
158-
jest.runOnlyPendingTimers(); // flush following requestIdleCallback
159-
expect(cb).not.toHaveBeenCalled();
176+
test('delay can be aborted before requestIdleCallback phase', async () => {
177+
const document = createDocumentMock('complete');
178+
const onAbort = jest.fn();
179+
const abortController = new AbortController();
180+
documentReadyAndIdle(document, abortController.signal).then(() => {}, onAbort);
181+
expect(document.defaultView!.addEventListener).not.toHaveBeenCalled();
182+
abortController.abort();
183+
await waitFor(() => expect(onAbort).toHaveBeenCalledWith(new DOMException('Aborted', 'AbortError')));
160184
});
161185
});

src/internal/hooks/use-base-component/styles-check.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,51 @@ export function checkMissingStyles(ownerDocument: Document) {
1717
}
1818
}
1919

20-
export function idleWithDelay(cb: () => void) {
21-
// if idle callbacks not supported, we simply do not collect the metric
22-
if (typeof requestIdleCallback !== 'function') {
23-
return;
20+
function documentReady(document: Document, callback: () => void) {
21+
if (document.readyState === 'complete') {
22+
callback();
23+
} else {
24+
document.defaultView?.addEventListener('load', () => callback(), { once: true });
2425
}
25-
let aborted = false;
26+
}
2627

27-
setTimeout(() => {
28-
if (aborted) {
29-
return;
30-
}
31-
requestIdleCallback(() => {
32-
if (aborted) {
33-
return;
34-
}
35-
cb();
28+
export function documentReadyAndIdle(document: Document, signal: AbortSignal) {
29+
return new Promise<void>((resolve, reject) => {
30+
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
31+
documentReady(document, () => {
32+
setTimeout(() => {
33+
requestIdleCallback(() => resolve());
34+
}, 1000);
3635
});
37-
}, 1000);
38-
39-
return () => {
40-
aborted = true;
41-
};
36+
});
4237
}
4338

4439
const checkedDocs = new WeakMap<Document, boolean>();
45-
const checkMissingStylesOnce = (elementRef: React.RefObject<HTMLElement>) => {
46-
const ownerDocument = elementRef.current?.ownerDocument ?? document;
47-
const checked = checkedDocs.get(ownerDocument);
40+
const checkMissingStylesOnce = (document: Document) => {
41+
const checked = checkedDocs.get(document);
4842
if (!checked) {
49-
checkMissingStyles(ownerDocument);
50-
checkedDocs.set(ownerDocument, true);
43+
checkMissingStyles(document);
44+
checkedDocs.set(document, true);
5145
}
5246
};
5347

5448
export function useMissingStylesCheck(elementRef: React.RefObject<HTMLElement>) {
5549
useEffect(() => {
56-
return idleWithDelay(() => checkMissingStylesOnce(elementRef));
50+
// if idle callbacks not supported, we simply do not collect the metric
51+
if (typeof requestIdleCallback !== 'function') {
52+
return;
53+
}
54+
const ownerDocument = elementRef.current?.ownerDocument ?? document;
55+
const abortController = new AbortController();
56+
documentReadyAndIdle(ownerDocument, abortController.signal).then(
57+
() => checkMissingStylesOnce(ownerDocument),
58+
error => {
59+
// istanbul ignore next
60+
if (error.name !== 'AbortError') {
61+
throw error;
62+
}
63+
}
64+
);
65+
return () => abortController.abort();
5766
}, [elementRef]);
5867
}

0 commit comments

Comments
 (0)