Skip to content
Open
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
90 changes: 48 additions & 42 deletions src/networkIdleObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,51 +99,57 @@ class ResourceLoadingIdleObservable {
public didNetworkTimeOut = false;
private cleanupTimeout?: number; // time out if resource never resolves

private registerResourceLoadListener = () => {
// watch for added or updated script tags
const o = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (
node instanceof HTMLScriptElement ||
node instanceof HTMLLinkElement ||
node instanceof HTMLImageElement ||
node instanceof HTMLIFrameElement
) {
this.add(node);
} else if (node.hasChildNodes() && node instanceof HTMLElement) {
// images may be mounted within large subtrees, this is less
// common with link/script elements
node.querySelectorAll('img').forEach(this.add);
}
});
});
});

// watch for new tags added anywhere in the document
o.observe(window.document.documentElement, {childList: true, subtree: true});

// as resources load, remove them from pendingResources
['load', 'error'].forEach((eventType) => {
window.document.addEventListener(
eventType,
(event) => {
if (
event.target instanceof HTMLScriptElement ||
event.target instanceof HTMLLinkElement ||
event.target instanceof HTMLImageElement ||
event.target instanceof HTMLIFrameElement
) {
this.remove(event.target);
}
},
{capture: true}
);
});
};

constructor() {
// watch out for SSR
if (typeof window !== 'undefined' && window?.MutationObserver) {
window.addEventListener('load', () => {
// watch for added or updated script tags
const o = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (
node instanceof HTMLScriptElement ||
node instanceof HTMLLinkElement ||
node instanceof HTMLImageElement ||
node instanceof HTMLIFrameElement
) {
this.add(node);
} else if (node.hasChildNodes() && node instanceof HTMLElement) {
// images may be mounted within large subtrees, this is less
// common with link/script elements
node.querySelectorAll('img').forEach(this.add);
}
});
});
});

// watch for new tags added anywhere in the document
o.observe(window.document.documentElement, {childList: true, subtree: true});

// as resources load, remove them from pendingResources
['load', 'error'].forEach((eventType) => {
window.document.addEventListener(
eventType,
(event) => {
if (
event.target instanceof HTMLScriptElement ||
event.target instanceof HTMLLinkElement ||
event.target instanceof HTMLImageElement ||
event.target instanceof HTMLIFrameElement
) {
this.remove(event.target);
}
},
{capture: true}
);
});
});
if (document.readyState === 'loading') {
window.addEventListener('load', this.registerResourceLoadListener, {once: true});
} else {
this.registerResourceLoadListener();
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions test/e2e/late-load/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<head>
<!-- Deliberately do NOT include /dist/index.min.js here. Instead we append it after the window 'load' event to reproduce the failure mode -->
<script>
// after the real window 'load' fires, append TTVC and analytics, then add a delayed image
window.addEventListener('load', function () {
// small delay so load has definitely finished
setTimeout(() => {
const analytics = document.createElement('script');
analytics.src = '/analytics.js';
// when analytics is ready, add a resource that should be tracked
analytics.onload = () => {
// add an image that loads with a delay so it would normally be observed
console.log('Analytics script loaded');
const img = document.createElement('img');
img.src = '/150.png?delay=500';
document.body.appendChild(img);
};

const ttvc = document.createElement('script');
ttvc.src = '/dist/index.min.js';
document.head.appendChild(ttvc);
ttvc.onload = () => {
console.log('TTVC script loaded');
document.head.appendChild(analytics);
};
}, 50);
});
</script>
</head>

<body>
<h1>late load test</h1>
</body>
24 changes: 24 additions & 0 deletions test/e2e/late-load/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {expect, test} from '@playwright/test';

import {entryCountIs, getEntriesAndErrors} from '../../util/entries';

const IMG_DELAY = 500;

test.describe('TTVC late load', () => {
test('library loaded after window.load should not miss resource tracking', async ({page}) => {
// Navigate to the page and wait for window load.
// On window load the page will dynamically import and initialize TTVC
// And after TTVC is initialized will add an img resource
// This tests that for SPAs where ttvc is loaded after window.load, resource tracking still works
await page.goto(`/test/late-load`, {waitUntil: 'load'});

// Wait for ttvc
await entryCountIs(page, 1, 3000);

const {entries, errors} = await getEntriesAndErrors(page);
expect(entries.length).toBe(1);
expect(errors.length).toBe(0);

expect(entries[0].duration).toBeGreaterThan(IMG_DELAY);
});
});
2 changes: 1 addition & 1 deletion test/util/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const getEntriesAndErrors = (page: Page) =>
* Wait until at least {count} performance entries have been logged.
*/
export const entryCountIs = async (page: Page, count: number, timeout = 5000): Promise<void> => {
await page.waitForFunction((count) => window.entries.length >= count, count, {
await page.waitForFunction((count) => window.entries && window.entries.length >= count, count, {
polling: 500,
timeout: timeout,
});
Expand Down