Skip to content

Commit ea7eb4d

Browse files
committed
fix(settings): expire stale history confirmations safely
1 parent 6d6cf6a commit ea7eb4d

2 files changed

Lines changed: 138 additions & 11 deletions

File tree

‎apps/sim/components/settings/use-settings-browser-navigation.test.tsx‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,123 @@ afterEach(async () => {
9999
})
100100

101101
describe('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)

‎apps/sim/components/settings/use-settings-browser-navigation.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,24 @@ function installBrowserNavigationGuard() {
4646
const trackedRoutes = new Map<number, string>()
4747
const route = (url: URL) => url.pathname + url.search
4848

49-
const stamp = (data: unknown, index: number, entryGeneration = generation) =>
50-
data == null || isRecordLike(data)
49+
const stamp = (data: unknown, index: number, entryGeneration = generation) => {
50+
const prototype = isRecordLike(data) ? Object.getPrototypeOf(data) : undefined
51+
return data == null || prototype === Object.prototype || prototype === null
5152
? { ...toRecord(data), [HISTORY_INDEX]: index, [HISTORY_GENERATION]: entryGeneration }
5253
: data
54+
}
55+
const cancelPendingTraversal = () => {
56+
allowTraversal = false
57+
restoring = false
58+
pendingDelta = 0
59+
useSettingsDirtyStore.getState().cancelLeave()
60+
}
61+
62+
const traverse = (delta: number) => {
63+
pendingDelta = delta
64+
allowTraversal = true
65+
originalGo.call(history, delta)
66+
}
5367

5468
originalReplace.call(history, stamp(history.state, currentIndex), '', window.location.href)
5569
trackedRoutes.set(currentIndex, route(currentUrl))
@@ -59,6 +73,7 @@ function installBrowserNavigationGuard() {
5973
const nextGeneration = previousIndex === null ? generateId() : generation
6074
const nextIndex = (previousIndex ?? -1) + 1
6175
originalPush.call(history, stamp(data, nextIndex, nextGeneration), unused, url)
76+
cancelPendingTraversal()
6277
if (generation !== nextGeneration) trackedRoutes.clear()
6378
generation = nextGeneration
6479
currentIndex = nextIndex
@@ -71,12 +86,14 @@ function installBrowserNavigationGuard() {
7186
history.replaceState = (data: unknown, unused, url) => {
7287
const index = entryIndex(history.state)
7388
originalReplace.call(history, index === null ? data : stamp(data, index), unused, url)
89+
if (index !== currentIndex || window.location.href !== currentUrl.href) cancelPendingTraversal()
7490
currentIndex = index
7591
currentUrl = new URL(window.location.href)
7692
if (index !== null) trackedRoutes.set(index, route(currentUrl))
7793
}
7894

7995
history.go = (delta) => {
96+
cancelPendingTraversal()
8097
if (!delta) {
8198
originalGo.call(history, delta)
8299
return
@@ -96,11 +113,7 @@ function installBrowserNavigationGuard() {
96113
originalGo.call(history, delta)
97114
return
98115
}
99-
requestLeave(() => {
100-
pendingDelta = delta
101-
allowTraversal = true
102-
originalGo.call(history, delta)
103-
})
116+
requestLeave(() => traverse(delta))
104117
}
105118
history.back = () => history.go(-1)
106119
history.forward = () => history.go(1)
@@ -153,10 +166,7 @@ function installBrowserNavigationGuard() {
153166
}
154167
restoring = false
155168
const requestedDelta = pendingDelta
156-
useSettingsDirtyStore.getState().requestLeave(() => {
157-
allowTraversal = true
158-
originalGo.call(history, requestedDelta)
159-
})
169+
useSettingsDirtyStore.getState().requestLeave(() => traverse(requestedDelta))
160170
return
161171
}
162172
const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()

0 commit comments

Comments
 (0)