one
Version:
One is a new React Framework that makes Vite serve both native and web.
179 lines (155 loc) • 5.65 kB
text/typescript
import { afterEach, beforeEach, expect, test } from 'vitest'
import { createMemoryHistory } from './createMemoryHistory'
// Minimal window mock to exercise createMemoryHistory without jsdom.
// The module reads `window.history.state`, calls `pushState`/`replaceState`,
// listens for `popstate`, reads `location.hash`, and touches `window.document.title`.
type Entry = { state: any; path: string }
type DomMock = {
firePopState: (delta: number) => void
entries: Entry[]
getCursor: () => number
}
let dom: DomMock
function setupDomMock(initialPath = '/'): DomMock {
const entries: Entry[] = [{ state: null, path: initialPath }]
let cursor = 0
const listeners: Array<(ev: any) => void> = []
const mockHistory: any = {
get state() {
return entries[cursor].state
},
pushState(state: any, _title: string, url: string) {
entries.length = cursor + 1
entries.push({ state, path: url })
cursor = entries.length - 1
},
replaceState(state: any, _title: string, url: string) {
entries[cursor] = { state, path: url }
},
go() {
// not exercised in these tests; the real code path triggers popstate via
// a fake event instead
},
get length() {
return entries.length
},
}
const mockLocation: any = {
get pathname() {
const p = entries[cursor].path
return p.split('?')[0].split('#')[0]
},
get search() {
const p = entries[cursor].path
const q = p.indexOf('?')
if (q === -1) return ''
return p.slice(q).split('#')[0]
},
get hash() {
const p = entries[cursor].path
const h = p.indexOf('#')
return h === -1 ? '' : p.slice(h)
},
}
const mockDocument: any = { title: '' }
const mockWindow: any = {
history: mockHistory,
location: mockLocation,
document: mockDocument,
addEventListener(type: string, handler: any) {
if (type === 'popstate') listeners.push(handler)
},
removeEventListener(type: string, handler: any) {
if (type === 'popstate') {
const i = listeners.indexOf(handler)
if (i > -1) listeners.splice(i, 1)
}
},
}
;(globalThis as any).window = mockWindow
;(globalThis as any).location = mockLocation
;(globalThis as any).document = mockDocument
return {
entries,
getCursor: () => cursor,
firePopState: (delta: number) => {
const next = cursor + delta
if (next < 0 || next >= entries.length) return
cursor = next
for (const l of listeners.slice()) l({ state: entries[cursor].state })
},
}
}
function teardownDomMock() {
delete (globalThis as any).window
delete (globalThis as any).location
delete (globalThis as any).document
}
// Minimal NavigationState-shaped objects. createMemoryHistory treats `state`
// opaquely — it only stores it on HistoryRecord.state — so we just need stable
// object identity to prove the correct record is returned.
const stackState = (routeNames: string[], index: number) =>
({
key: 'stack-root',
index,
routeNames,
routes: routeNames.map((name) => ({ key: `${name}-k`, name })),
stale: false,
type: 'stack',
}) as any
beforeEach(() => {
dom = setupDomMock()
})
afterEach(() => {
teardownDomMock()
})
test('browser back then forward restores the pushed stack (regression for external-popstate index drift)', () => {
const history = createMemoryHistory()
// Step 0: initial load at '/', state = [index]
const s0 = stackState(['index'], 0)
history.replace({ path: '/', state: s0 })
expect(history.index).toBe(0)
// Step 1: router.push('/todo/abc'), state = [index, todo]
const s1 = stackState(['index', 'todo'], 1)
history.push({ path: '/todo/abc', state: s1 })
expect(history.index).toBe(1)
expect(history.get(1)?.state).toBe(s1)
// Step 2: simulate the runtime sequence on browser back:
// a) popstate fires externally
// b) useLinking's listen callback reads history.index + history.get(index),
// then does navigation.resetRoot(record.state). That triggers
// onStateChange, which (because path === pendingPath) calls
// history.replace({path:'/', state: s0}).
//
// The bug: createMemoryHistory's closure `index` is NOT updated by external
// popstate, so history.replace writes to items[staleIndex] — overwriting
// the '/todo/abc' record we just navigated away from.
const popStateLog: Array<{ path: string; recordPath?: string; recordState: any }> = []
const unlisten = history.listen(() => {
const idx = history.index
const rec = history.get(idx)
popStateLog.push({
path: (globalThis as any).window.location.pathname,
recordPath: rec?.path,
recordState: rec?.state,
})
// emulate useLinking's post-resetRoot replace (same path, same state)
history.replace({ path: '/', state: s0 })
})
dom.firePopState(-1) // browser back
// Listener saw the correct stored record for '/'
expect(popStateLog).toHaveLength(1)
expect(popStateLog[0].recordPath).toBe('/')
expect(popStateLog[0].recordState).toBe(s0)
// Step 3: browser forward. The critical assertion: the '/todo/abc' record
// should still be intact with the full state s1.
dom.firePopState(1)
expect(popStateLog).toHaveLength(2)
expect(popStateLog[1].recordPath).toBe('/todo/abc')
// This is the line that fails without the fix: record.state would have been
// overwritten to s0 during step 2's stale-index history.replace, or the id
// lookup would miss entirely and return items[0].
expect(popStateLog[1].recordState).toBe(s1)
expect(history.index).toBe(1)
unlisten()
})