Skip to content

Commit 20e4455

Browse files
authored
Remove gap in page lifecycle check for setup listeners (#1773)
Merge PR #1773: Remove gap in page lifecycle check for setup listeners This change addresses a gap in the lifecycle checks for page setup listeners, ensuring that listeners are registered at the correct time and improving overall reliability during page transitions. Thanks to Copilot for the contribution.
1 parent a5dd1c3 commit 20e4455

File tree

2 files changed

+257
-1
lines changed

2 files changed

+257
-1
lines changed

node_package/src/pageLifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function initializePageEventListeners(): void {
6767
}
6868
isPageLifecycleInitialized = true;
6969

70-
if (document.readyState === 'complete') {
70+
if (document.readyState !== 'loading') {
7171
setupPageNavigationListeners();
7272
} else {
7373
document.addEventListener('DOMContentLoaded', setupPageNavigationListeners);
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
// Mock the turbolinksUtils module before importing pageLifecycle
6+
jest.mock('../src/turbolinksUtils.ts', () => ({
7+
debugTurbolinks: jest.fn(),
8+
turbolinksInstalled: jest.fn(() => false),
9+
turbolinksSupported: jest.fn(() => false),
10+
turboInstalled: jest.fn(() => false),
11+
turbolinksVersion5: jest.fn(() => false),
12+
}));
13+
14+
// Import will be done dynamically in tests to allow module reset
15+
16+
describe('pageLifecycle', () => {
17+
let originalReadyState;
18+
let addEventListenerSpy;
19+
let removeEventListenerSpy;
20+
21+
// Helper function to set document.readyState
22+
const setReadyState = (state) => {
23+
Object.defineProperty(document, 'readyState', {
24+
value: state,
25+
writable: true,
26+
});
27+
};
28+
29+
// We use require here instead of a global import at the top because we need to dynamically reload the module in each test.
30+
// This allows us to reset the module state between tests using jest.resetModules(), ensuring test isolation and preventing state leakage.
31+
// eslint-disable-next-line global-require
32+
const importPageLifecycle = () => require('../src/pageLifecycle.ts');
33+
34+
// Helper function to create navigation library mock
35+
const createNavigationMock = (overrides = {}) => ({
36+
debugTurbolinks: jest.fn(),
37+
turbolinksInstalled: jest.fn(() => false),
38+
turbolinksSupported: jest.fn(() => false),
39+
turboInstalled: jest.fn(() => false),
40+
turbolinksVersion5: jest.fn(() => false),
41+
...overrides,
42+
});
43+
44+
beforeEach(() => {
45+
// Store the original readyState
46+
originalReadyState = document.readyState;
47+
48+
// Mock document.addEventListener and removeEventListener
49+
addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(() => {});
50+
removeEventListenerSpy = jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
51+
52+
// Reset DOM state - use Object.defineProperty to set readyState
53+
setReadyState('loading');
54+
55+
// Reset all global state by reloading the module AFTER setting up mocks
56+
jest.resetModules();
57+
});
58+
59+
afterEach(() => {
60+
// Restore original readyState
61+
Object.defineProperty(document, 'readyState', {
62+
value: originalReadyState,
63+
writable: true,
64+
});
65+
66+
// Restore spies
67+
addEventListenerSpy.mockRestore();
68+
removeEventListenerSpy.mockRestore();
69+
});
70+
71+
it('should initialize page event listeners immediately when document.readyState is "complete"', () => {
72+
setReadyState('complete');
73+
const callback = jest.fn();
74+
const { onPageLoaded } = importPageLifecycle();
75+
76+
onPageLoaded(callback);
77+
78+
// Since no navigation library is mocked, callbacks should run immediately
79+
expect(callback).toHaveBeenCalledTimes(1);
80+
// Should not add DOMContentLoaded listener since readyState is not 'loading'
81+
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
82+
});
83+
84+
it('should initialize page event listeners immediately when document.readyState is "interactive"', () => {
85+
setReadyState('interactive');
86+
const callback = jest.fn();
87+
const { onPageLoaded } = importPageLifecycle();
88+
89+
onPageLoaded(callback);
90+
91+
// Since no navigation library is mocked, callbacks should run immediately
92+
expect(callback).toHaveBeenCalledTimes(1);
93+
// Should not add DOMContentLoaded listener since readyState is not 'loading'
94+
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
95+
});
96+
97+
it('should wait for DOMContentLoaded when document.readyState is "loading"', () => {
98+
setReadyState('loading');
99+
const callback = jest.fn();
100+
const { onPageLoaded } = importPageLifecycle();
101+
102+
onPageLoaded(callback);
103+
104+
// Should not call callback immediately since readyState is 'loading'
105+
expect(callback).not.toHaveBeenCalled();
106+
// Verify that a DOMContentLoaded listener was added when readyState is 'loading'
107+
expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
108+
});
109+
110+
describe('with Turbo navigation library', () => {
111+
beforeEach(() => {
112+
jest.doMock('../src/turbolinksUtils.ts', () =>
113+
createNavigationMock({
114+
turboInstalled: jest.fn(() => true),
115+
}),
116+
);
117+
});
118+
119+
afterEach(() => {
120+
jest.dontMock('../src/turbolinksUtils.ts');
121+
});
122+
123+
it('should set up Turbo event listeners when Turbo is installed', () => {
124+
setReadyState('complete');
125+
const { onPageLoaded } = importPageLifecycle();
126+
const callback = jest.fn();
127+
128+
onPageLoaded(callback);
129+
130+
// Should add Turbo event listeners
131+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbo:before-render', expect.any(Function));
132+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbo:render', expect.any(Function));
133+
// Callback should be called immediately
134+
expect(callback).toHaveBeenCalledTimes(1);
135+
});
136+
});
137+
138+
describe('with Turbolinks 5 navigation library', () => {
139+
beforeEach(() => {
140+
jest.doMock('../src/turbolinksUtils.ts', () =>
141+
createNavigationMock({
142+
turbolinksInstalled: jest.fn(() => true),
143+
turbolinksSupported: jest.fn(() => true),
144+
turbolinksVersion5: jest.fn(() => true),
145+
}),
146+
);
147+
});
148+
149+
afterEach(() => {
150+
jest.dontMock('../src/turbolinksUtils.ts');
151+
});
152+
153+
it('should set up Turbolinks 5 event listeners when Turbolinks 5 is installed', () => {
154+
setReadyState('complete');
155+
const { onPageLoaded } = importPageLifecycle();
156+
const callback = jest.fn();
157+
158+
onPageLoaded(callback);
159+
160+
// Should add Turbolinks 5 event listeners
161+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbolinks:before-render', expect.any(Function));
162+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbolinks:render', expect.any(Function));
163+
// Callback should be called immediately
164+
expect(callback).toHaveBeenCalledTimes(1);
165+
});
166+
});
167+
168+
describe('with Turbolinks 2 navigation library', () => {
169+
beforeEach(() => {
170+
jest.doMock('../src/turbolinksUtils.ts', () =>
171+
createNavigationMock({
172+
turbolinksInstalled: jest.fn(() => true),
173+
turbolinksSupported: jest.fn(() => true),
174+
}),
175+
);
176+
});
177+
178+
afterEach(() => {
179+
jest.dontMock('../src/turbolinksUtils.ts');
180+
});
181+
182+
it('should set up Turbolinks 2 event listeners when Turbolinks 2 is installed', () => {
183+
setReadyState('complete');
184+
const { onPageLoaded } = importPageLifecycle();
185+
const callback = jest.fn();
186+
187+
onPageLoaded(callback);
188+
189+
// Should add Turbolinks 2 event listeners
190+
expect(addEventListenerSpy).toHaveBeenCalledWith('page:before-unload', expect.any(Function));
191+
expect(addEventListenerSpy).toHaveBeenCalledWith('page:change', expect.any(Function));
192+
// Turbolinks 2 does NOT call callbacks immediately - only sets up listeners
193+
expect(callback).not.toHaveBeenCalled();
194+
});
195+
});
196+
197+
describe('multiple callbacks', () => {
198+
it('should handle multiple page loaded callbacks', () => {
199+
setReadyState('complete');
200+
const { onPageLoaded } = importPageLifecycle();
201+
const callback1 = jest.fn();
202+
const callback2 = jest.fn();
203+
const callback3 = jest.fn();
204+
205+
onPageLoaded(callback1);
206+
onPageLoaded(callback2);
207+
onPageLoaded(callback3);
208+
209+
// Since no navigation library is mocked (all return false), callbacks should be called immediately
210+
expect(callback1).toHaveBeenCalledTimes(1);
211+
expect(callback2).toHaveBeenCalledTimes(1);
212+
expect(callback3).toHaveBeenCalledTimes(1);
213+
});
214+
});
215+
216+
describe('server-side rendering', () => {
217+
it('should not initialize when window is undefined', () => {
218+
// Mock window as undefined
219+
const originalWindow = global.window;
220+
delete global.window;
221+
222+
const { onPageLoaded } = importPageLifecycle();
223+
const callback = jest.fn();
224+
225+
onPageLoaded(callback);
226+
227+
// Should not call callback or add event listeners
228+
expect(callback).not.toHaveBeenCalled();
229+
expect(addEventListenerSpy).not.toHaveBeenCalled();
230+
231+
// Restore window
232+
global.window = originalWindow;
233+
});
234+
});
235+
236+
describe('preventing duplicate initialization', () => {
237+
it('should not initialize listeners multiple times', () => {
238+
setReadyState('loading');
239+
const { onPageLoaded } = importPageLifecycle();
240+
const callback1 = jest.fn();
241+
const callback2 = jest.fn();
242+
243+
// First call should initialize and call addEventListener
244+
onPageLoaded(callback1);
245+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
246+
247+
// Second call should not add more listeners (isPageLifecycleInitialized is true)
248+
onPageLoaded(callback2);
249+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
250+
251+
// Both callbacks should be called
252+
expect(callback1).not.toHaveBeenCalled();
253+
expect(callback2).not.toHaveBeenCalled();
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)