22 * @jest -environment jsdom
33 */
44
5- import { onPageLoaded } from '../src/pageLifecycle.ts' ;
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
615
716describe ( 'pageLifecycle' , ( ) => {
817 let originalReadyState ;
9- let setupPageNavigationListenersSpy ;
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+ } ) ;
1043
1144 beforeEach ( ( ) => {
1245 // Store the original readyState
1346 originalReadyState = document . readyState ;
14-
15- // Reset the isPageLifecycleInitialized state by reloading the module
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
1656 jest . resetModules ( ) ;
17-
18- // Mock setupPageNavigationListeners to track when it's called
19- setupPageNavigationListenersSpy = jest . fn ( ) ;
20-
21- // We need to mock the internal function - this is a bit tricky since it's not exported
22- // For this test, we'll verify the behavior indirectly by checking when callbacks are executed
2357 } ) ;
2458
2559 afterEach ( ( ) => {
2660 // Restore original readyState
27- Object . defineProperty ( document , 'readyState' , {
28- value : originalReadyState ,
29- writable : true
61+ Object . defineProperty ( document , 'readyState' , {
62+ value : originalReadyState ,
63+ writable : true ,
3064 } ) ;
65+
66+ // Restore spies
67+ addEventListenerSpy . mockRestore ( ) ;
68+ removeEventListenerSpy . mockRestore ( ) ;
3169 } ) ;
3270
3371 it ( 'should initialize page event listeners immediately when document.readyState is "complete"' , ( ) => {
34- // Set readyState to 'complete'
35- Object . defineProperty ( document , 'readyState' , {
36- value : 'complete' ,
37- writable : true
38- } ) ;
39-
72+ setReadyState ( 'complete' ) ;
4073 const callback = jest . fn ( ) ;
41-
42- // Import fresh module with the mocked readyState
43- const { onPageLoaded } = require ( '../src/pageLifecycle.ts' ) ;
44-
45- // This should trigger immediate execution when readyState is 'complete'
74+ const { onPageLoaded } = importPageLifecycle ( ) ;
75+
4676 onPageLoaded ( callback ) ;
47-
48- // The callback should be called immediately since we're treating 'complete' as already loaded
49- // Note: The actual implementation may vary, this test verifies the behavior exists
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 ) ) ;
5082 } ) ;
5183
5284 it ( 'should initialize page event listeners immediately when document.readyState is "interactive"' , ( ) => {
53- // Set readyState to 'interactive' (not 'loading')
54- Object . defineProperty ( document , 'readyState' , {
55- value : 'interactive' ,
56- writable : true
57- } ) ;
58-
85+ setReadyState ( 'interactive' ) ;
5986 const callback = jest . fn ( ) ;
60-
61- // Import fresh module with the mocked readyState
62- const { onPageLoaded } = require ( '../src/pageLifecycle.ts' ) ;
63-
64- // This should trigger immediate setup since readyState is not 'loading'
87+ const { onPageLoaded } = importPageLifecycle ( ) ;
88+
6589 onPageLoaded ( callback ) ;
66-
67- // Verify that we don't wait for DOMContentLoaded when readyState is already 'interactive'
68- // The specific implementation details may vary, but the key is that it doesn't wait
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 ) ) ;
6995 } ) ;
7096
7197 it ( 'should wait for DOMContentLoaded when document.readyState is "loading"' , ( ) => {
72- // Set readyState to 'loading'
73- Object . defineProperty ( document , 'readyState' , {
74- value : 'loading' ,
75- writable : true
76- } ) ;
77-
98+ setReadyState ( 'loading' ) ;
7899 const callback = jest . fn ( ) ;
79-
80- // Import fresh module with the mocked readyState
81- const { onPageLoaded } = require ( '../src/pageLifecycle.ts' ) ;
82-
83- // Add event listener to capture DOMContentLoaded listeners
84- const addEventListenerSpy = jest . spyOn ( document , 'addEventListener' ) ;
85-
100+ const { onPageLoaded } = importPageLifecycle ( ) ;
101+
86102 onPageLoaded ( callback ) ;
87-
103+
104+ // Should not call callback immediately since readyState is 'loading'
105+ expect ( callback ) . not . toHaveBeenCalled ( ) ;
88106 // Verify that a DOMContentLoaded listener was added when readyState is 'loading'
89107 expect ( addEventListenerSpy ) . toHaveBeenCalledWith ( 'DOMContentLoaded' , expect . any ( Function ) ) ;
90-
91- addEventListenerSpy . mockRestore ( ) ;
92108 } ) ;
93- } ) ;
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