Skip to content

Commit ee6a9c6

Browse files
authored
fix(router): don't write an existing URL (#4392)
* fix(router): don't write an existing URL Every write puts a history entry, we never want to write the same as what's already written to the URL. The history router didn't have tests yet, so I added just one for this case, as not to lose time writing tests for existing cases. Demonstration of the bug being fixed in user land: https://codesandbox.io/s/vigilant-mayer-u3lyd?file=/src/app.js:2097-2279 (note that this searchFunction is very similar to the default one in Shopify) * remove type creation in favor of Required * add more tests * add test for external write scenario * add inline snapshots to see the route values
1 parent bae6ed2 commit ee6a9c6

File tree

2 files changed

+156
-13
lines changed

2 files changed

+156
-13
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import historyRouter from '../history';
2+
3+
const wait = (ms = 0) => new Promise(res => setTimeout(res, ms));
4+
5+
describe('life cycle', () => {
6+
beforeEach(() => {
7+
window.history.pushState(null, '-- divider --', 'http://localhost/');
8+
jest.restoreAllMocks();
9+
});
10+
11+
it('does not write the same url twice', async () => {
12+
const pushState = jest.spyOn(window.history, 'pushState');
13+
const router = historyRouter({
14+
writeDelay: 0,
15+
});
16+
17+
router.write({ some: 'state' });
18+
await wait(0);
19+
20+
router.write({ some: 'state' });
21+
await wait(0);
22+
23+
router.write({ some: 'state' });
24+
await wait(0);
25+
26+
expect(pushState).toHaveBeenCalledTimes(1);
27+
expect(pushState.mock.calls).toMatchInlineSnapshot(`
28+
Array [
29+
Array [
30+
Object {
31+
"some": "state",
32+
},
33+
"",
34+
"http://localhost/?some=state",
35+
],
36+
]
37+
`);
38+
});
39+
40+
it('does not write if already externally updated to desired URL', async () => {
41+
const pushState = jest.spyOn(window.history, 'pushState');
42+
const router = historyRouter({
43+
writeDelay: 0,
44+
});
45+
46+
const fakeState = { identifier: 'fake state' };
47+
48+
router.write({ some: 'state one' });
49+
50+
// external update before timeout passes
51+
window.history.pushState(
52+
fakeState,
53+
'',
54+
'http://localhost/?some=state%20two'
55+
);
56+
57+
// this write isn't needed anymore
58+
router.write({ some: 'state two' });
59+
await wait(0);
60+
61+
expect(pushState).toHaveBeenCalledTimes(1);
62+
expect(pushState.mock.calls).toMatchInlineSnapshot(`
63+
Array [
64+
Array [
65+
Object {
66+
"identifier": "fake state",
67+
},
68+
"",
69+
"http://localhost/?some=state%20two",
70+
],
71+
]
72+
`);
73+
74+
// proves that InstantSearch' write did not happen
75+
expect(history.state).toBe(fakeState);
76+
});
77+
78+
it('does not write the same url title twice', async () => {
79+
const title = jest.spyOn(window.document, 'title', 'set');
80+
const pushState = jest.spyOn(window.history, 'pushState');
81+
82+
const router = historyRouter({
83+
writeDelay: 0,
84+
windowTitle: state => `My Site - ${state.some}`,
85+
});
86+
87+
expect(title).toHaveBeenCalledTimes(1);
88+
expect(window.document.title).toBe('My Site - undefined');
89+
90+
router.write({ some: 'state' });
91+
await wait(0);
92+
93+
router.write({ some: 'state' });
94+
await wait(0);
95+
96+
router.write({ some: 'state' });
97+
await wait(0);
98+
99+
expect(pushState).toHaveBeenCalledTimes(1);
100+
expect(pushState.mock.calls).toMatchInlineSnapshot(`
101+
Array [
102+
Array [
103+
Object {
104+
"some": "state",
105+
},
106+
"My Site - state",
107+
"http://localhost/?some=state",
108+
],
109+
]
110+
`);
111+
112+
expect(title).toHaveBeenCalledTimes(2);
113+
expect(window.document.title).toBe('My Site - state');
114+
});
115+
116+
it('writes after timeout is done', async () => {
117+
const pushState = jest.spyOn(window.history, 'pushState');
118+
119+
const router = historyRouter({
120+
writeDelay: 0,
121+
});
122+
123+
router.write({ some: 'state' });
124+
router.write({ some: 'second' });
125+
router.write({ some: 'third' });
126+
await wait(0);
127+
128+
expect(pushState).toHaveBeenCalledTimes(1);
129+
expect(pushState.mock.calls).toMatchInlineSnapshot(`
130+
Array [
131+
Array [
132+
Object {
133+
"some": "third",
134+
},
135+
"",
136+
"http://localhost/?some=third",
137+
],
138+
]
139+
`);
140+
});
141+
});

src/lib/routers/history.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ type ParseURL = ({
1919
location: Location;
2020
}) => RouteState;
2121

22-
type BrowserHistoryProps = {
22+
type BrowserHistoryArgs = {
2323
windowTitle?: (routeState: RouteState) => string;
24-
writeDelay: number;
25-
createURL: CreateURL;
26-
parseURL: ParseURL;
24+
writeDelay?: number;
25+
createURL?: CreateURL;
26+
parseURL?: ParseURL;
2727
};
2828

2929
const defaultCreateURL: CreateURL = ({ qsModule, routeState, location }) => {
@@ -63,25 +63,25 @@ class BrowserHistory implements Router {
6363
/**
6464
* Transforms a UI state into a title for the page.
6565
*/
66-
private readonly windowTitle?: BrowserHistoryProps['windowTitle'];
66+
private readonly windowTitle?: BrowserHistoryArgs['windowTitle'];
6767
/**
6868
* Time in milliseconds before performing a write in the history.
6969
* It prevents from adding too many entries in the history and
7070
* makes the back button more usable.
7171
*
7272
* @default 400
7373
*/
74-
private readonly writeDelay: BrowserHistoryProps['writeDelay'];
74+
private readonly writeDelay: Required<BrowserHistoryArgs>['writeDelay'];
7575
/**
7676
* Creates a full URL based on the route state.
7777
* The storage adaptor maps all syncable keys to the query string of the URL.
7878
*/
79-
private readonly _createURL: BrowserHistoryProps['createURL'];
79+
private readonly _createURL: Required<BrowserHistoryArgs>['createURL'];
8080
/**
8181
* Parses the URL into a route state.
8282
* It should be symetrical to `createURL`.
8383
*/
84-
private readonly parseURL: BrowserHistoryProps['parseURL'];
84+
private readonly parseURL: Required<BrowserHistoryArgs>['parseURL'];
8585

8686
private writeTimer?: number;
8787
private _onPopState?(event: PopStateEvent): void;
@@ -96,7 +96,7 @@ class BrowserHistory implements Router {
9696
writeDelay = 400,
9797
createURL = defaultCreateURL,
9898
parseURL = defaultParseURL,
99-
}: BrowserHistoryProps = {} as BrowserHistoryProps
99+
}: BrowserHistoryArgs = {} as BrowserHistoryArgs
100100
) {
101101
this.windowTitle = windowTitle;
102102
this.writeTimer = undefined;
@@ -128,9 +128,11 @@ class BrowserHistory implements Router {
128128
}
129129

130130
this.writeTimer = window.setTimeout(() => {
131-
setWindowTitle(title);
131+
if (window.location.href !== url) {
132+
setWindowTitle(title);
132133

133-
window.history.pushState(routeState, title || '', url);
134+
window.history.pushState(routeState, title || '', url);
135+
}
134136
this.writeTimer = undefined;
135137
}, this.writeDelay);
136138
}
@@ -192,6 +194,6 @@ class BrowserHistory implements Router {
192194
}
193195
}
194196

195-
export default function(...args: BrowserHistoryProps[]): BrowserHistory {
196-
return new BrowserHistory(...args);
197+
export default function(props?: BrowserHistoryArgs): BrowserHistory {
198+
return new BrowserHistory(props);
197199
}

0 commit comments

Comments
 (0)