Skip to content

Commit a6d2a3f

Browse files
authored
fix: add prerender offset to tti events (#662)
1 parent 84b09e2 commit a6d2a3f

File tree

6 files changed

+563
-2
lines changed

6 files changed

+563
-2
lines changed

app/index.html

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,42 @@
114114
<button id="recordPageView" onclick="recordPageView()">
115115
Record Page View
116116
</button>
117+
<hr />
118+
<button id="prerenderedNav" onclick="navigatePrerendered()">
119+
Navigate with Prerendering
120+
</button>
121+
<button id="standardNav" onclick="navigateStandard()">
122+
Navigate without Prerendering
123+
</button>
124+
125+
<script>
126+
function navigatePrerendered() {
127+
const speculationRules = document.createElement('script');
128+
speculationRules.type = 'speculationrules';
129+
130+
// Define the prerender rule for index.html
131+
const rules = {
132+
prerender: [
133+
{
134+
source: 'list',
135+
urls: ['./time_to_interactive_event.html']
136+
}
137+
]
138+
};
139+
140+
speculationRules.textContent = JSON.stringify(rules);
141+
document.head.appendChild(speculationRules);
142+
143+
setTimeout(() => {
144+
window.location.href = './time_to_interactive_event.html';
145+
}, 300);
146+
}
147+
148+
function navigateStandard() {
149+
window.location.href = './time_to_interactive_event.html';
150+
}
151+
</script>
152+
<hr />
117153
<span id="request"></span>
118154
<span id="response"></span>
119155
<table>

src/plugins/event-plugins/TTIPlugin.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ export const TTI_EVENT_PLUGIN_ID = 'time-to-interactive';
77

88
export class TTIPlugin extends InternalPlugin {
99
protected fpsEnabled;
10+
private prerenderedPageLoad;
11+
private normalPageLoad;
1012

1113
constructor(fpsMeasurementEnabled = false) {
1214
super(TTI_EVENT_PLUGIN_ID);
1315
this.fpsEnabled = fpsMeasurementEnabled;
16+
this.prerenderedPageLoad = false;
17+
this.normalPageLoad = true;
18+
this.checkPrerenderingActivity();
1419
}
1520

1621
enable(): void {
@@ -26,9 +31,51 @@ export class TTIPlugin extends InternalPlugin {
2631
}
2732

2833
onload(): void {
29-
onTTI(this.handleTTI, { fpsEnabled: this.fpsEnabled });
34+
if (this.normalPageLoad || this.prerenderedPageLoad) {
35+
this.recordTTIEvent();
36+
}
3037
}
3138

39+
private recordTTIEvent = (): void => {
40+
onTTI(this.handleTTI, { fpsEnabled: this.fpsEnabled });
41+
};
42+
43+
private checkPrerenderingActivity = (): void => {
44+
if (
45+
typeof document !== 'undefined' &&
46+
typeof document.prerendering === 'boolean' &&
47+
document.prerendering
48+
) {
49+
this.normalPageLoad = false;
50+
document.addEventListener('prerenderingchange', () => {
51+
this.prerenderedPageLoad = true;
52+
this.recordTTIEvent();
53+
});
54+
}
55+
56+
if (
57+
typeof performance !== 'undefined' &&
58+
typeof performance.getEntriesByType === 'function'
59+
) {
60+
try {
61+
const entries = performance.getEntriesByType('navigation');
62+
if (entries && entries.length > 0) {
63+
const navigation = entries[0];
64+
if (
65+
navigation &&
66+
navigation.activationStart &&
67+
navigation.activationStart > 0
68+
) {
69+
this.prerenderedPageLoad = true;
70+
this.normalPageLoad = false;
71+
}
72+
}
73+
} catch (e) {
74+
console.debug('Error accessing Performance API:', e);
75+
}
76+
}
77+
};
78+
3279
private handleTTI = (metric: TTIMetric): void => {
3380
const ttiEvent: TimeToInteractiveEvent = {
3481
version: '1.0.0',
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
STATUS_202,
3+
REQUEST_BODY,
4+
RESPONSE_STATUS
5+
} from '../../../test-utils/integ-test-utils';
6+
import { Selector } from 'testcafe';
7+
import { TIME_TO_INTERACTIVE_EVENT_TYPE } from '../../utils/constant';
8+
9+
const testButton: Selector = Selector(`#testButton`);
10+
const dispatch: Selector = Selector(`#dispatch`);
11+
const prerenderButton: Selector = Selector(`#prerenderedNav`);
12+
const standardNavButton: Selector = Selector(`#standardNav`);
13+
14+
// Add a longer wait time to ensure TTI events are captured
15+
const TTI_WAIT_TIME = 1000;
16+
17+
fixture('TTI Plugin Prerender Navigation').page(
18+
'http://localhost:8080/index.html'
19+
);
20+
21+
test('prerendered navigation records TTI events', async (t: TestController) => {
22+
const browser = t.browser.name;
23+
// Skip firefox, till Firefox supports longtasks
24+
if (browser === 'Firefox') {
25+
return 'Test is skipped';
26+
}
27+
28+
// Click the prerendered navigation button to navigate to index.html
29+
await t.click(prerenderButton).wait(1000);
30+
31+
await t
32+
.click(testButton)
33+
.wait(100)
34+
.click(dispatch)
35+
.wait(3000)
36+
.click(dispatch)
37+
.expect(RESPONSE_STATUS.textContent)
38+
.eql(STATUS_202.toString())
39+
.expect(REQUEST_BODY.textContent)
40+
.contains('BatchId');
41+
42+
// Check if events were recorded
43+
await t
44+
.expect(RESPONSE_STATUS.textContent)
45+
.eql(STATUS_202.toString())
46+
.expect(REQUEST_BODY.textContent)
47+
.contains('BatchId');
48+
49+
// Verify TTI events were recorded
50+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
51+
(e) => e.type === TIME_TO_INTERACTIVE_EVENT_TYPE
52+
);
53+
54+
// We should have at least one TTI event
55+
await t.expect(events.length).gte(1);
56+
57+
// Check the TTI event structure
58+
if (events.length > 0) {
59+
const ttiEvent = JSON.parse(events[0].details);
60+
await t.expect(ttiEvent.value).typeOf('number');
61+
}
62+
63+
// Navigate back to the test page for the next test
64+
await t.navigateTo('http://localhost:8080/index.html');
65+
});
66+
67+
test('standard navigation records TTI events', async (t: TestController) => {
68+
const browser = t.browser.name;
69+
// Skip firefox, till Firefox supports longtasks
70+
if (browser === 'Firefox') {
71+
return 'Test is skipped';
72+
}
73+
74+
// Click the standard navigation button to navigate to index.html
75+
await t.click(standardNavButton).wait(1000);
76+
77+
// On index.html, wait for TTI to be calculated
78+
await t.wait(TTI_WAIT_TIME);
79+
80+
await t
81+
.click(testButton)
82+
.wait(100)
83+
.click(dispatch)
84+
.wait(3000)
85+
.click(dispatch)
86+
.expect(RESPONSE_STATUS.textContent)
87+
.eql(STATUS_202.toString())
88+
.expect(REQUEST_BODY.textContent)
89+
.contains('BatchId');
90+
91+
// Check if events were recorded
92+
await t
93+
.expect(RESPONSE_STATUS.textContent)
94+
.eql(STATUS_202.toString())
95+
.expect(REQUEST_BODY.textContent)
96+
.contains('BatchId');
97+
98+
// Verify TTI events were recorded
99+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
100+
(e) => e.type === TIME_TO_INTERACTIVE_EVENT_TYPE
101+
);
102+
103+
// We should have at least one TTI event
104+
await t.expect(events.length).gte(1);
105+
106+
// Check the TTI event structure
107+
if (events.length > 0) {
108+
const ttiEvent = JSON.parse(events[0].details);
109+
await t.expect(ttiEvent.value).typeOf('number');
110+
}
111+
112+
// Navigate back to the test page for the next test
113+
await t.navigateTo('http://localhost:8080/index.html');
114+
});

src/plugins/event-plugins/__tests__/TTIPlugin.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,28 @@ jest.mock('../../../time-to-interactive/TimeToInteractive', () => {
1717
});
1818

1919
describe('Time to Interactive - Plugin Tests', () => {
20+
let originalPerformance: any;
21+
2022
beforeEach(() => {
2123
// setup
2224
mockLongTaskPerformanceObserver();
2325
record.mockClear();
26+
27+
originalPerformance = global.performance;
28+
29+
global.performance = {
30+
...originalPerformance,
31+
getEntriesByType: jest.fn().mockReturnValue([
32+
{
33+
activationStart: 0
34+
}
35+
])
36+
};
2437
});
2538

2639
afterEach(() => {
2740
jest.clearAllMocks();
41+
global.performance = originalPerformance;
2842
});
2943

3044
test('When TTI resolves successfully, an event is recorded by plugin', async () => {
@@ -101,3 +115,108 @@ describe('Time to Interactive - Plugin Tests', () => {
101115
expect(record).toHaveBeenCalled();
102116
});
103117
});
118+
119+
describe('Prerendering Tests', () => {
120+
test('Plugin detects document.prerendering is true', async () => {
121+
const originalDescriptor = Object.getOwnPropertyDescriptor(
122+
document,
123+
'prerendering'
124+
);
125+
126+
try {
127+
// Define document.prerendering as true
128+
Object.defineProperty(document, 'prerendering', {
129+
configurable: true,
130+
value: true
131+
});
132+
133+
const plugin: TTIPlugin = new TTIPlugin();
134+
135+
plugin.load(context);
136+
137+
const prerenderingChangeEvent = new Event('prerenderingchange');
138+
document.dispatchEvent(prerenderingChangeEvent);
139+
140+
expect(plugin).toBeDefined();
141+
} finally {
142+
if (originalDescriptor) {
143+
Object.defineProperty(
144+
document,
145+
'prerendering',
146+
originalDescriptor
147+
);
148+
} else {
149+
delete (document as any).prerendering;
150+
}
151+
}
152+
});
153+
154+
test('Plugin handles missing Performance API gracefully', async () => {
155+
const originalPerformance = global.performance;
156+
157+
try {
158+
global.performance = {
159+
...originalPerformance,
160+
getEntriesByType: undefined
161+
} as any;
162+
163+
const plugin: TTIPlugin = new TTIPlugin();
164+
165+
plugin.load(context);
166+
167+
expect(plugin).toBeDefined();
168+
} finally {
169+
global.performance = originalPerformance;
170+
}
171+
});
172+
173+
test('Plugin handles Performance API errors gracefully', async () => {
174+
// Mock performance.getEntriesByType to throw an error
175+
global.performance.getEntriesByType = jest
176+
.fn()
177+
.mockImplementation(() => {
178+
throw new Error('Test error');
179+
});
180+
181+
const plugin: TTIPlugin = new TTIPlugin();
182+
183+
expect(() => plugin.load(context)).not.toThrow();
184+
});
185+
186+
test('Plugin records TTI event on normal page load', async () => {
187+
global.performance.getEntriesByType = jest.fn().mockReturnValue([
188+
{
189+
activationStart: 0 // Zero value indicates normal page load
190+
}
191+
]);
192+
193+
const plugin: TTIPlugin = new TTIPlugin();
194+
195+
plugin.load(context);
196+
197+
await new Promise((resolve) => process.nextTick(resolve));
198+
199+
expect(record).toHaveBeenCalledWith(TIME_TO_INTERACTIVE_EVENT_TYPE, {
200+
value: 201,
201+
version: '1.0.0'
202+
});
203+
});
204+
205+
test('Plugin records TTI event for prerendered page', async () => {
206+
global.performance.getEntriesByType = jest.fn().mockReturnValue([
207+
{
208+
activationStart: 100 // Non-zero value indicates prerendering
209+
}
210+
]);
211+
212+
const plugin: TTIPlugin = new TTIPlugin();
213+
214+
plugin.load(context);
215+
216+
await new Promise((resolve) => process.nextTick(resolve));
217+
expect(record).toHaveBeenCalledWith(TIME_TO_INTERACTIVE_EVENT_TYPE, {
218+
value: 201,
219+
version: '1.0.0'
220+
});
221+
});
222+
});

0 commit comments

Comments
 (0)