@@ -26,10 +26,15 @@ function Surface({ dirty, blocked }: SurfaceProps) {
2626 return null
2727}
2828
29- function LinkedEditor ( ) {
29+ interface LinkedEditorProps {
30+ blocked ?: boolean
31+ }
32+
33+ function LinkedEditor ( { blocked } : LinkedEditorProps ) {
3034 const [ value , setValue ] = useState ( '' )
3135 const guard = useSettingsUnsavedGuard ( {
3236 isDirty : value . length > 0 ,
37+ navigationBlocked : blocked ,
3338 onDiscard : ( ) => setValue ( '' ) ,
3439 } )
3540 return (
@@ -116,6 +121,133 @@ describe('native settings navigation', () => {
116121 expect ( window . history . state ) . toEqual ( state )
117122 } )
118123
124+ it . each ( [ 'first Back' , 'last Forward' , 'large Back' , 'large Forward' ] as const ) (
125+ 'retains a draft when %s has no destination' ,
126+ async ( operation ) => {
127+ if ( operation === 'first Back' ) {
128+ nativeGo . call ( window . history , - ( window . history . length - 1 ) )
129+ await settle ( )
130+ }
131+ routedPaths = [ ]
132+ act ( ( ) => root . render ( < LinkedEditor /> ) )
133+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
134+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
135+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
136+ act ( ( ) => {
137+ setter . call ( field , 'authored' )
138+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
139+ } )
140+ const currentUrl = window . location . href
141+ if ( operation === 'first Back' ) window . history . back ( )
142+ else if ( operation === 'last Forward' ) window . history . forward ( )
143+ else window . history . go ( operation === 'large Back' ? - 2_147_483_647 : 2_147_483_647 )
144+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
145+ await settle ( )
146+ expect ( field . value ) . toBe ( 'authored' )
147+ expect ( window . location . href ) . toBe ( currentUrl )
148+ expect ( routedPaths ) . toEqual ( [ ] )
149+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . toBeNull ( )
150+ const unload = new Event ( 'beforeunload' , { cancelable : true } )
151+ window . dispatchEvent ( unload )
152+ expect ( unload . defaultPrevented ) . toBe ( true )
153+ }
154+ )
155+
156+ it . each ( [ false , true ] ) (
157+ 'discards a confirmed draft only after actual traversal (unstamped: %s)' ,
158+ async ( unstamped ) => {
159+ if ( unstamped ) {
160+ nativePush . call ( window . history , { router : 'legacy-forward' } , '' , '/legacy-forward' )
161+ nativeGo . call ( window . history , - 1 )
162+ await settle ( )
163+ }
164+ act ( ( ) => root . render ( < LinkedEditor /> ) )
165+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
166+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
167+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
168+ act ( ( ) => {
169+ setter . call ( field , 'authored' )
170+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
171+ } )
172+ if ( unstamped ) window . history . forward ( )
173+ else window . history . back ( )
174+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
175+ expect ( field . value ) . toBe ( 'authored' )
176+ await settle ( )
177+ expect ( window . location . pathname ) . toBe ( unstamped ? '/legacy-forward' : '/prior' )
178+ expect ( field . value ) . toBe ( '' )
179+ expect ( useSettingsDirtyStore . getState ( ) . isDirty ) . toBe ( false )
180+ }
181+ )
182+
183+ it ( 'retains a confirmed draft if a save begins before traversal' , async ( ) => {
184+ act ( ( ) => root . render ( < LinkedEditor /> ) )
185+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
186+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
187+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
188+ act ( ( ) => {
189+ setter . call ( field , 'authored' )
190+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
191+ } )
192+ window . history . back ( )
193+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
194+ act ( ( ) => root . render ( < LinkedEditor blocked /> ) )
195+ await settle ( )
196+ expect ( field . value ) . toBe ( 'authored' )
197+ expect ( window . location . pathname ) . toBe ( '/editor' )
198+ expect ( routedPaths ) . not . toContain ( '/prior' )
199+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . toBeNull ( )
200+ act ( ( ) => root . render ( < LinkedEditor /> ) )
201+ window . history . back ( )
202+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
203+ await settle ( )
204+ expect ( window . location . pathname ) . toBe ( '/prior' )
205+ expect ( field . value ) . toBe ( '' )
206+ } )
207+
208+ it . each ( [ - 1.5 , 4_294_967_295 ] ) (
209+ 'confirms the browser-converted traversal delta %s' ,
210+ async ( delta ) => {
211+ act ( ( ) => root . render ( < LinkedEditor /> ) )
212+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
213+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
214+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
215+ act ( ( ) => {
216+ setter . call ( field , 'authored' )
217+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
218+ } )
219+ window . history . go ( delta )
220+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
221+ await settle ( )
222+ expect ( window . location . pathname ) . toBe ( '/prior' )
223+ expect ( field . value ) . toBe ( '' )
224+ expect ( useSettingsDirtyStore . getState ( ) . pendingLeave ) . toBeNull ( )
225+ }
226+ )
227+
228+ it . each ( [ false , true ] ) (
229+ 'retains a draft when an unresolved traversal only changes the hash (saving: %s)' ,
230+ async ( blocked ) => {
231+ window . history . pushState ( new Date ( '2026-01-01T00:00:00Z' ) , '' , '/editor#opaque' )
232+ act ( ( ) => root . render ( < LinkedEditor /> ) )
233+ const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
234+ const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) ?. set
235+ if ( ! field || ! setter ) throw new Error ( 'Missing draft input' )
236+ act ( ( ) => {
237+ setter . call ( field , 'authored' )
238+ field . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) )
239+ } )
240+ window . history . back ( )
241+ act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
242+ act ( ( ) => root . render ( < LinkedEditor blocked = { blocked } /> ) )
243+ await settle ( )
244+ expect ( field . value ) . toBe ( 'authored' )
245+ expect ( window . location . pathname ) . toBe ( '/editor' )
246+ expect ( window . location . hash ) . toBe ( '' )
247+ expect ( useSettingsDirtyStore . getState ( ) . isDirty ) . toBe ( true )
248+ }
249+ )
250+
119251 it ( 'does not reuse confirmation from a traversal that emitted no event for a later draft' , async ( ) => {
120252 act ( ( ) => root . render ( < LinkedEditor /> ) )
121253 const field = container . querySelector < HTMLInputElement > ( 'input[aria-label="Draft"]' )
@@ -130,7 +262,7 @@ describe('native settings navigation', () => {
130262 act ( ( ) => useSettingsDirtyStore . getState ( ) . confirmLeave ( ) )
131263 await settle ( )
132264 expect ( window . location . pathname ) . toBe ( '/editor' )
133- expect ( field . value ) . toBe ( '' )
265+ expect ( field . value ) . toBe ( 'original draft ' )
134266 for ( let index = 0 ; index < steps ; index ++ )
135267 window . history . pushState ( { router : 'later' } , '' , `/later-${ index } ` )
136268 const currentPath = window . location . pathname
0 commit comments