Skip to content

Commit 0f992fc

Browse files
authored
Merge pull request #20384 from mozilla/investigate-react-v2
fix(fxa-settings): Preserve URL hash in query-fix.js for mobile webviews
2 parents 21706ba + 0745302 commit 0f992fc

2 files changed

Lines changed: 114 additions & 9 deletions

File tree

packages/fxa-settings/public/query-fix.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
* application logic (e.g., routing, query parameter parsing) sees the updated URL. */
1919

2020
(function encodeUrlQuery() {
21+
// Snapshot the hash now. On mobile WebViews (iOS/Android), later reads of
22+
// window.location.hash can return '' even though the hash was present at load.
23+
const originalHref = window.location.href;
24+
const hashIndex = originalHref.indexOf('#');
25+
const hash =
26+
window.location.hash ||
27+
(hashIndex === -1 ? '' : originalHref.substring(hashIndex));
28+
2129
const { search } = window.location;
2230
if (!search) return;
2331
const newSearch =
@@ -31,15 +39,6 @@
3139
})
3240
.join('&');
3341
if (newSearch !== search) {
34-
// IMPORTANT: preserve the URL hash fragment. The pairing supplicant
35-
// flow encodes channel_id and channel_key in the fragment. On iOS
36-
// WKWebView, window.location.hash may be empty when inline scripts
37-
// run early, so fall back to parsing the hash from the full href.
38-
const hash =
39-
window.location.hash ||
40-
(window.location.href.includes('#')
41-
? window.location.href.substring(window.location.href.indexOf('#'))
42-
: '');
4342
window.history.replaceState({}, '', newSearch + hash);
4443
}
4544
})();
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
// query-fix.js is a plain JS script with no exports, so we test it by reading
6+
// the file from disk and executing it with vm.runInNewContext inside a sandbox
7+
// that provides fake window.location and a jest.fn() for history.replaceState.
8+
// Each test builds a FakeLocation matching a specific URL scenario and asserts
9+
// on what replaceState was (or wasn't) called with.
10+
11+
import { readFileSync } from 'fs';
12+
import path from 'path';
13+
import { runInNewContext } from 'vm';
14+
15+
type FakeLocation = {
16+
readonly href: string;
17+
readonly hash: string;
18+
readonly search: string;
19+
};
20+
21+
function runQueryFix(location: FakeLocation) {
22+
const replaceState = jest.fn();
23+
const script = readFileSync(
24+
path.join(__dirname, '../../public/query-fix.js'),
25+
'utf8'
26+
);
27+
28+
runInNewContext(script, {
29+
location,
30+
window: {
31+
location,
32+
history: {
33+
replaceState,
34+
},
35+
},
36+
});
37+
38+
return replaceState;
39+
}
40+
41+
describe('query-fix.js', () => {
42+
it('re-encodes query parameters without changing URLs that have no hash', () => {
43+
const replaceState = runQueryFix({
44+
href: 'https://accounts.example.com/pair/supp?scope=profile+sync',
45+
hash: '',
46+
search: '?scope=profile+sync',
47+
});
48+
49+
expect(replaceState).toHaveBeenCalledWith({}, '', '?scope=profile%2Bsync');
50+
});
51+
52+
it('preserves the pairing hash when query parameters are re-encoded', () => {
53+
const hash = '#channel_id=abc&channel_key=def';
54+
const replaceState = runQueryFix({
55+
href: `https://accounts.example.com/pair/supp?scope=profile+sync${hash}`,
56+
hash,
57+
search: '?scope=profile+sync',
58+
});
59+
60+
expect(replaceState).toHaveBeenCalledWith(
61+
{},
62+
'',
63+
'?scope=profile%2Bsync#channel_id=abc&channel_key=def'
64+
);
65+
});
66+
67+
it('preserves the original pairing hash if later location reads lose it', () => {
68+
const hash = '#channel_id=abc&channel_key=def';
69+
const hrefWithHash = `https://accounts.example.com/pair/supp?scope=profile+sync${hash}`;
70+
const hrefWithoutHash =
71+
'https://accounts.example.com/pair/supp?scope=profile+sync';
72+
let queryWasRead = false;
73+
74+
const location = {
75+
get href() {
76+
return queryWasRead ? hrefWithoutHash : hrefWithHash;
77+
},
78+
get hash() {
79+
return queryWasRead ? '' : hash;
80+
},
81+
get search() {
82+
queryWasRead = true;
83+
return '?scope=profile+sync';
84+
},
85+
};
86+
87+
const replaceState = runQueryFix(location);
88+
89+
expect(replaceState).toHaveBeenCalledWith(
90+
{},
91+
'',
92+
'?scope=profile%2Bsync#channel_id=abc&channel_key=def'
93+
);
94+
});
95+
96+
it('does not rewrite the URL when the query does not need re-encoding', () => {
97+
const hash = '#channel_id=abc&channel_key=def';
98+
const replaceState = runQueryFix({
99+
href: `https://accounts.example.com/pair/supp?scope=profile%2Bsync${hash}`,
100+
hash,
101+
search: '?scope=profile%2Bsync',
102+
});
103+
104+
expect(replaceState).not.toHaveBeenCalled();
105+
});
106+
});

0 commit comments

Comments
 (0)