UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

179 lines (155 loc) 5.65 kB
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() })