Skip to content

Commit be50968

Browse files
authored
fix: Ensure LocationFull validates state data before relaying it to history API (#55)
* fix: Ensure LocationFull validates state data before relaying it to history API * chore(tests): Rename LocationFull's test file and do proper cleanup * tests: Add unit tests for unsubscription function for location.on()
1 parent 79960a1 commit be50968

File tree

2 files changed

+109
-22
lines changed

2 files changed

+109
-22
lines changed

src/lib/core/LocationFull.svelte.test.ts renamed to src/lib/core/LocationFull.test.ts

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
22
import { LocationFull } from "./LocationFull.js";
33
import type { State, Location } from "$lib/types.js";
4-
import { flushSync } from "svelte";
54
import { joinPaths } from "./RouterEngine.svelte.js";
65

76
describe("LocationFull", () => {
@@ -58,14 +57,46 @@ describe("LocationFull", () => {
5857
test("Should register the provided callback for the 'beforeNavigate' event.", () => {
5958
// Arrange.
6059
const callback = vi.fn();
61-
location.on('beforeNavigate', callback);
60+
const unSub = location.on('beforeNavigate', callback);
6261

6362
// Act.
6463
globalThis.window.history.pushState(null, '', 'http://example.com/other');
65-
flushSync();
6664

6765
// Assert.
6866
expect(callback).toHaveBeenCalledOnce();
67+
68+
// Cleanup.
69+
unSub();
70+
});
71+
test("Should unregister the provided callback when the returned function is called.", () => {
72+
// Arrange.
73+
const callback = vi.fn();
74+
const unSub = location.on('beforeNavigate', callback);
75+
76+
// Act.
77+
unSub();
78+
79+
// Assert.
80+
globalThis.window.history.pushState(null, '', 'http://example.com/other');
81+
expect(callback).not.toHaveBeenCalled();
82+
});
83+
test("Should not affect other handlers when unregistering one of the event handlers.", () => {
84+
// Arrange.
85+
const callback1 = vi.fn();
86+
const callback2 = vi.fn();
87+
const unSub1 = location.on('beforeNavigate', callback1);
88+
const unSub2 = location.on('beforeNavigate', callback2);
89+
90+
// Act.
91+
unSub1();
92+
93+
// Assert.
94+
globalThis.window.history.pushState(null, '', 'http://example.com/other');
95+
expect(callback1).not.toHaveBeenCalled();
96+
expect(callback2).toHaveBeenCalledOnce();
97+
98+
// Cleanup.
99+
unSub2();
69100
});
70101
test.each([
71102
{
@@ -79,13 +110,12 @@ describe("LocationFull", () => {
79110
])("Should provide the URL, state and method $method via the event object of 'beforeNavigate'.", ({ method, stateFn }) => {
80111
// Arrange.
81112
const callback = vi.fn();
82-
const state = { test: 'value' };
83-
location.on('beforeNavigate', callback);
113+
const state = { path: { test: 'value' }, hash: {} };
114+
const unSub = location.on('beforeNavigate', callback);
84115

85116
// Act.
86117
// @ts-expect-error stateFn cannot enumerate history.
87118
globalThis.window.history[stateFn](state, '', 'http://example.com/other');
88-
flushSync();
89119

90120
// Assert.
91121
expect(callback).toHaveBeenCalledWith({
@@ -96,16 +126,18 @@ describe("LocationFull", () => {
96126
cancelReason: undefined,
97127
cancel: expect.any(Function)
98128
});
129+
130+
// Cleanup.
131+
unSub();
99132
});
100133
test("Should set wasCancelled to true and cancelReason to the provided reason when the event is cancelled to subsequent callbacks.", () => {
101134
// Arrange.
102135
const callback = vi.fn();
103-
location.on('beforeNavigate', (event) => event.cancel('test'));
104-
location.on('beforeNavigate', callback);
136+
const unSub1 = location.on('beforeNavigate', (event) => event.cancel('test'));
137+
const unSub2 = location.on('beforeNavigate', callback);
105138

106139
// Act.
107140
globalThis.window.history.pushState(null, '', 'http://example.com/other');
108-
flushSync();
109141

110142
// Assert.
111143
expect(callback).toHaveBeenCalledWith({
@@ -116,17 +148,20 @@ describe("LocationFull", () => {
116148
cancelReason: 'test',
117149
cancel: expect.any(Function)
118150
});
151+
152+
// Cleanup.
153+
unSub1();
154+
unSub2();
119155
});
120156
test("Should ignore cancellation reasons from callbacks if the event has already been cancelled.", () => {
121157
// Arrange.
122158
const callback = vi.fn();
123-
location.on('beforeNavigate', (event) => event.cancel('test'));
124-
location.on('beforeNavigate', (event) => event.cancel('ignored'));
125-
location.on('beforeNavigate', callback);
159+
const unSub1 = location.on('beforeNavigate', (event) => event.cancel('test'));
160+
const unSub2 = location.on('beforeNavigate', (event) => event.cancel('ignored'));
161+
const unSub3 = location.on('beforeNavigate', callback);
126162

127163
// Act.
128164
globalThis.window.history.pushState(null, '', 'http://example.com/other');
129-
flushSync();
130165

131166
// Assert.
132167
expect(callback).toHaveBeenCalledWith({
@@ -137,34 +172,66 @@ describe("LocationFull", () => {
137172
cancelReason: 'test',
138173
cancel: expect.any(Function)
139174
});
175+
176+
// Cleanup.
177+
unSub1();
178+
unSub2();
179+
unSub3();
180+
});
181+
test.each([
182+
'pushState' as const,
183+
'replaceState' as const,
184+
])("Should ultimately push the state data via the %s method set by beforeNavigate handlers in event.state.", (stateFn) => {
185+
// Arrange.
186+
const state = { path: { test: 'value' }, hash: {} };
187+
const callback = vi.fn((event) => {
188+
event.state = state;
189+
});
190+
const unSub = location.on('beforeNavigate', callback);
191+
192+
// Act.
193+
globalThis.window.history[stateFn](null, '', 'http://example.com/other');
194+
195+
// Assert.
196+
expect(callback).toHaveBeenCalledOnce();
197+
expect(globalThis.window.history.state).deep.equal(state);
198+
199+
// Cleanup.
200+
unSub();
140201
});
141202
test("Should register the provided callback for the 'navigationCancelled' event.", () => {
142203
// Arrange.
143204
const callback = vi.fn();
144-
location.on('beforeNavigate', (event) => event.cancel());
145-
location.on('navigationCancelled', callback);
205+
const unSub1 = location.on('beforeNavigate', (event) => event.cancel());
206+
const unSub2 = location.on('navigationCancelled', callback);
146207

147208
// Act.
148209
globalThis.window.history.pushState(null, '', 'http://example.com/other');
149-
flushSync();
150210

151211
// Assert.
152212
expect(callback).toHaveBeenCalledOnce();
213+
214+
// Cleanup.
215+
unSub1();
216+
unSub2();
153217
});
154218
test("Should transfer the cause of cancellation and the state to the 'navigationCancelled' event.", () => {
155219
// Arrange.
156220
const callback = vi.fn();
157221
const reason = 'test';
158222
const state = { test: 'value' };
159-
location.on('beforeNavigate', (event) => event.cancel(reason));
160-
location.on('navigationCancelled', callback);
223+
const unSub1 = location.on('beforeNavigate', (event) => event.cancel(reason));
224+
const unSub2 = location.on('navigationCancelled', callback);
161225

162226
// Act.
163227
globalThis.window.history.pushState(state, '', 'http://example.com/other');
164-
flushSync();
165228

166229
// Assert.
167230
expect(callback).toHaveBeenCalledWith({ url: 'http://example.com/other', cause: 'test', method: 'push', state });
231+
232+
// Cleanup.
233+
unSub1();
234+
unSub2();
168235
});
169236
});
170237
describe('url', () => {
@@ -177,7 +244,6 @@ describe("LocationFull", () => {
177244

178245
// Act.
179246
globalThis.window.history[fn](null, '', newUrl);
180-
flushSync();
181247

182248
// Assert.
183249
expect(location.url.href).toBe(newUrl);
@@ -193,12 +259,28 @@ describe("LocationFull", () => {
193259

194260
// Act.
195261
globalThis.window.history[fn](state, '', 'http://example.com/new');
196-
flushSync();
197262

198263
// Assert.
199264
expect(location.getState(false)).toEqual(state.path);
200265
expect(location.getState(true)).toEqual(state.hash.single);
201266
expect(location.getState('p1')).toEqual(state.hash.p1);
202267
});
203268
});
269+
describe('Navigation Interception', () => {
270+
test.each([
271+
'pushState' as const,
272+
'replaceState' as const,
273+
])("Should preserve the previous valid state whenever %s is called with non-conformant state.", (stateFn) => {
274+
// Arrange.
275+
const validState = { path: { test: 'value' }, hash: {} };
276+
globalThis.window.history[stateFn](validState, '', 'http://example.com/');
277+
const state = { test: 'value' };
278+
279+
// Act.
280+
globalThis.window.history[stateFn](state, '', 'http://example.com/other');
281+
282+
// Assert.
283+
expect(globalThis.window.history.state).deep.equals(validState);
284+
});
285+
});
204286
});

src/lib/core/LocationFull.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BeforeNavigateEvent, Events, NavigationCancelledEvent, NavigationEvent } from "$lib/types.js";
2+
import { isConformantState } from "./isConformantState.js";
23
import { LocationLite } from "./LocationLite.svelte.js";
34
import { LocationState } from "./LocationState.svelte.js";
45

@@ -51,12 +52,16 @@ export class LocationFull extends LocationLite {
5152
for (let sub of Object.values(this.#eventSubs.navigationCancelled)) {
5253
sub({
5354
url,
54-
state,
55+
state: event.state,
5556
method,
5657
cause: event.cancelReason,
5758
});
5859
}
5960
} else {
61+
if (!isConformantState(event.state)) {
62+
console.warn("Warning: Non-conformant state object passed to history." + method + "State. Previous state will prevail.");
63+
event.state = this.#innerState.state;
64+
}
6065
const navFn = method === 'push' ? this.#originalPushState : this.#originalReplaceState;
6166
navFn(event.state, '', url);
6267
this.url.href = globalThis.window?.location.href;

0 commit comments

Comments
 (0)