@@ -99,6 +99,123 @@ afterEach(async () => {
9999} )
100100
101101describe ( 'native settings navigation' , ( ) => {
102+ it . each ( [
103+ { kind : 'Map' , state : new Map ( [ [ 'router' , 'map-state' ] ] ) } ,
104+ { kind : 'Date' , state : new Date ( '2026-01-01T00:00:00Z' ) } ,
105+ { kind : 'typed array' , state : new Uint8Array ( [ 1 , 2 , 3 ] ) } ,
106+ { kind : 'array' , state : [ 'preserved' , 'state' ] } ,
107+ ] ) ( 'preserves $kind state through push and replacement' , async ( { state } ) => {
108+ window . history . pushState ( state , '' , '/structured-state' )
109+ expect ( window . history . state ) . toEqual ( state )
110+ window . history . replaceState ( state , '' , '/replaced-state' )
111+ expect ( window . history . state ) . toEqual ( state )
112+ window . history . back ( )
113+ await settle ( )
114+ window . history . forward ( )
115+ await settle ( )
116+ expect ( window . history . state ) . toEqual ( state )
117+ } )
118+
119+ it ( 'does not reuse confirmation from a traversal that emitted no event for a later draft' , async ( ) => {
120+ act ( ( ) => root . render ( < LinkedEditor /> ) )
121+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
122+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
123+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
124+ act ( ( ) => {
125+ setter . call ( field , 'original draft' )
126+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
127+ } )
128+ const steps = window . history . length
129+ window . history . go ( - steps )
130+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
131+ await settle ( )
132+ expect ( window . location . pathname ) . toBe ( '/editor' )
133+ expect ( field . value ) . toBe ( '' )
134+ for ( let index = 0 ; index < steps ; index ++ )
135+ window . history . pushState ( { router : 'later' } , '' , `/later-${ index } ` )
136+ const currentPath = window . location . pathname
137+ act ( ( ) => {
138+ setter . call ( field , 'new draft' )
139+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
140+ } )
141+ nativeGo . call ( window . history , - steps )
142+ await settle ( )
143+ expect ( window . location . pathname ) . toBe ( currentPath )
144+ expect ( routedPaths ) . toEqual ( [ ] )
145+ expect ( field . value ) . toBe ( 'new draft' )
146+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . not . toBeNull ( )
147+ } )
148+
149+ it . each ( [ 'pushState' , 'replaceState' ] as const ) (
150+ 'cancels a stale confirmation when %s changes its source' ,
151+ async ( method ) => {
152+ act ( ( ) => root . render ( < LinkedEditor /> ) )
153+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
154+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
155+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
156+ act ( ( ) => {
157+ setter . call ( field , 'authored' )
158+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
159+ } )
160+ window . history . back ( )
161+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . not . toBeNull ( )
162+ window . history [ method ] ( { router : 'new-source' } , '' , '/new-source' )
163+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . toBeNull ( )
164+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
165+ await settle ( )
166+ expect ( window . location . pathname ) . toBe ( '/new-source' )
167+ expect ( field . value ) . toBe ( 'authored' )
168+ }
169+ )
170+
171+ it . each ( [
172+ { kind : 'metadata-only replacement' , native : false } ,
173+ { kind : 'metadata-only replacement' , native : true } ,
174+ { kind : 'rejected push' , native : false } ,
175+ { kind : 'rejected replacement' , native : false } ,
176+ ] ) (
177+ 'preserves the active confirmation after a $kind (native: $native)' ,
178+ async ( { kind, native } ) => {
179+ render ( true )
180+ if ( native ) {
181+ nativeGo . call ( window . history , - 1 )
182+ await settle ( )
183+ } else window . history . back ( )
184+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . not . toBeNull ( )
185+ if ( kind === 'metadata-only replacement' )
186+ window . history . replaceState ( { router : 'updated-metadata' } , '' , window . location . href )
187+ else {
188+ const method = kind === 'rejected push' ? 'pushState' : 'replaceState'
189+ expect ( ( ) => window . history [ method ] ( { } , '' , 'https://other.example.com' ) ) . toThrow ( )
190+ }
191+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . not . toBeNull ( )
192+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
193+ await settle ( )
194+ expect ( window . location . pathname ) . toBe ( '/prior' )
195+ }
196+ )
197+
198+ it ( 'supersedes an older leave dialog when programmatic traversal stays on the same page' , async ( ) => {
199+ act ( ( ) => root . render ( < LinkedEditor /> ) )
200+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
201+ const link = container . querySelector < HTMLAnchorElement > ( 'a[href="/other"]' )
202+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
203+ if ( ! field || ! link || ! setter ) throw new Error ( 'Missing linked editor' )
204+ window . history . pushState ( { router : 'hash' } , '' , '/editor#details' )
205+ act ( ( ) => {
206+ setter . call ( field , 'authored' )
207+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
208+ link . dispatchEvent ( new MouseEvent ( 'click' , { bubbles : true , cancelable : true } ) )
209+ } )
210+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . not . toBeNull ( )
211+ window . history . back ( )
212+ await settle ( )
213+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . toBeNull ( )
214+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
215+ expect ( window . location . pathname ) . toBe ( '/editor' )
216+ expect ( field . value ) . toBe ( 'authored' )
217+ } )
218+
102219 it ( 'guards programmatic Forward to an entry created before tracking began' , async ( ) => {
103220 nativePush . call ( window . history , { router : 'legacy-forward' } , '' , '/legacy-forward' )
104221 nativeGo . call ( window . history , - 1 )
0 commit comments