Skip to content

Commit b05f89f

Browse files
committed
Resume Flow fix: Extract query-style params embedded in the hash fragment
1 parent 934fd82 commit b05f89f

File tree

2 files changed

+322
-5
lines changed

2 files changed

+322
-5
lines changed

src/lib/appSwitchResume.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,54 @@ export type AppSwitchResumeParams = {|
1616
checkoutState: "onApprove" | "onCancel" | "onError",
1717
|};
1818

19+
// When the merchant's return_url contains a hash fragment (e.g. /checkout/#payment),
20+
// PayPal params (token, PayerID) end up inside the hash as /checkout/#payment?token=...&PayerID=...
21+
// because the base URL cannot be modified (iOS Safari uses it for tab matching in app switch).
22+
// This helper extracts query-style params embedded in the hash fragment
23+
function getParamsFromHashFragment(): { [string]: string } {
24+
const hashString =
25+
window.location.hash && String(window.location.hash).slice(1);
26+
if (!hashString) {
27+
return {};
28+
}
29+
30+
// Check for ? delimiter first (e.g. #payment?token=...)
31+
const questionMarkIndex = hashString.indexOf("?");
32+
if (questionMarkIndex !== -1) {
33+
const queryString = hashString.slice(questionMarkIndex + 1);
34+
return Object.fromEntries(new URLSearchParams(queryString));
35+
}
36+
37+
// Fallback to & delimiter (e.g. #payment&token=...)
38+
const ampersandIndex = hashString.indexOf("&");
39+
if (ampersandIndex !== -1) {
40+
const queryString = hashString.slice(ampersandIndex + 1);
41+
return Object.fromEntries(new URLSearchParams(queryString));
42+
}
43+
44+
return {};
45+
}
46+
1947
// The Web fallback flow uses different set of query params then appswitch flow.
2048
function getAppSwitchParamsWebFallback(): AppSwitchResumeParams | null {
2149
try {
22-
const params = Object.fromEntries(
50+
const searchParams = Object.fromEntries(
2351
// eslint-disable-next-line compat/compat
2452
new URLSearchParams(window.location.search)
2553
);
54+
55+
// If no PayPal params found in query string, check if they are embedded
56+
// inside the hash fragment. This happens when the merchant's return_url
57+
// contains a hash (e.g. /checkout/#payment) and PayPal params were appended
58+
// after the fragment: /checkout/#payment?token=...&PayerID=...
59+
const params =
60+
searchParams.token ||
61+
searchParams.vaultSetupToken ||
62+
searchParams.approval_token_id ||
63+
searchParams.approval_session_id
64+
? searchParams
65+
: { ...getParamsFromHashFragment(), ...searchParams };
66+
2667
const {
2768
button_session_id: buttonSessionID,
2869
fundingSource,
@@ -70,7 +111,9 @@ export function getAppSwitchResumeParams(): AppSwitchResumeParams | null {
70111
const questionMarkIndex = hashString.indexOf("?");
71112

72113
if (questionMarkIndex !== -1) {
73-
[hash, queryString] = hashString.split("?");
114+
// Do not use .split() here, as the merchant return_url may also contain "?"
115+
hash = hashString.slice(0, questionMarkIndex);
116+
queryString = hashString.slice(questionMarkIndex + 1);
74117
} else {
75118
const ampersandIndex = hashString.indexOf("&");
76119

src/lib/appSwithResume.test.js

Lines changed: 277 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,24 @@ describe("app switch resume flow", () => {
7070
expect(isAppSwitchResumeFlow()).toEqual(false);
7171
});
7272

73-
test("should test null fetching resume params with invalid callback passed", () => {
73+
test("should extract resume params from hash with non-action prefix via web fallback", () => {
74+
// When hash is not a known action (e.g. #Unknown) but contains PayPal params,
75+
// the web fallback should extract them from the hash fragment.
7476
vi.spyOn(window, "location", "get").mockReturnValue({
7577
hash: `#Unknown?button_session_id=${buttonSessionID}&token=${orderID}&fundingSource=${fundingSource}`,
78+
search: "",
7679
});
7780

7881
const params = getAppSwitchResumeParams();
7982

8083
expect.assertions(2);
81-
expect(params).toEqual(null);
82-
expect(isAppSwitchResumeFlow()).toEqual(false);
84+
expect(params).toEqual({
85+
buttonSessionID,
86+
checkoutState: "onCancel",
87+
fundingSource,
88+
orderID,
89+
});
90+
expect(isAppSwitchResumeFlow()).toEqual(true);
8391
});
8492

8593
test("should test fetching multiple resume params when parameters are correctly passed", () => {
@@ -140,4 +148,270 @@ describe("app switch resume flow", () => {
140148
});
141149
expect(isAppSwitchResumeFlow()).toEqual(true);
142150
});
151+
152+
test("should extract resume params when merchant return_url has hash fragment with ? delimiter", () => {
153+
vi.spyOn(window, "location", "get").mockReturnValue({
154+
hash: `#payment?button_session_id=${buttonSessionID}&token=${orderID}&fundingSource=${fundingSource}&PayerID=PP-payer-122`,
155+
search: "",
156+
});
157+
158+
const params = getAppSwitchResumeParams();
159+
160+
expect(params).toEqual({
161+
buttonSessionID,
162+
checkoutState: "onApprove",
163+
fundingSource,
164+
orderID,
165+
payerID: "PP-payer-122",
166+
});
167+
expect(isAppSwitchResumeFlow()).toEqual(true);
168+
});
169+
170+
test("should extract resume params when merchant return_url has hash fragment without PayerID (cancel)", () => {
171+
vi.spyOn(window, "location", "get").mockReturnValue({
172+
hash: `#payment?button_session_id=${buttonSessionID}&token=${orderID}&fundingSource=${fundingSource}`,
173+
search: "",
174+
});
175+
176+
const params = getAppSwitchResumeParams();
177+
178+
expect(params).toEqual({
179+
buttonSessionID,
180+
checkoutState: "onCancel",
181+
fundingSource,
182+
orderID,
183+
});
184+
expect(isAppSwitchResumeFlow()).toEqual(true);
185+
});
186+
187+
test("should extract resume params when hash uses & delimiter instead of ?", () => {
188+
// Real-world case: URL like /ppcp-js-sdk?clientSideDelay=0#payment&token=...&PayerID=...
189+
vi.spyOn(window, "location", "get").mockReturnValue({
190+
hash: `#payment&token=${orderID}&PayerID=PP-payer-122&button_session_id=${buttonSessionID}`,
191+
search: "?clientSideDelay=0&serverSideDelay=0",
192+
});
193+
194+
const params = getAppSwitchResumeParams();
195+
196+
expect(params).toEqual({
197+
buttonSessionID,
198+
checkoutState: "onApprove",
199+
orderID,
200+
payerID: "PP-payer-122",
201+
});
202+
expect(isAppSwitchResumeFlow()).toEqual(true);
203+
});
204+
205+
test("should extract vault resume params from hash fragment", () => {
206+
vi.spyOn(window, "location", "get").mockReturnValue({
207+
hash: `#/step3?button_session_id=${buttonSessionID}&token=${orderID}&approval_token_id=VA-3`,
208+
search: "",
209+
});
210+
211+
const params = getAppSwitchResumeParams();
212+
213+
expect(params).toEqual({
214+
buttonSessionID,
215+
checkoutState: "onCancel",
216+
orderID,
217+
vaultSetupToken: "VA-3",
218+
});
219+
expect(isAppSwitchResumeFlow()).toEqual(true);
220+
});
221+
222+
test("should prefer search params over hash params when both exist", () => {
223+
vi.spyOn(window, "location", "get").mockReturnValue({
224+
hash: `#payment?token=WRONG-TOKEN`,
225+
search: `?button_session_id=${buttonSessionID}&token=${orderID}&PayerID=PP-123`,
226+
});
227+
228+
const params = getAppSwitchResumeParams();
229+
230+
expect(params).toEqual({
231+
buttonSessionID,
232+
checkoutState: "onApprove",
233+
orderID,
234+
payerID: "PP-123",
235+
});
236+
});
237+
238+
test("should return null when hash has merchant fragment but no PayPal params", () => {
239+
vi.spyOn(window, "location", "get").mockReturnValue({
240+
hash: "#payment",
241+
search: "",
242+
});
243+
244+
const params = getAppSwitchResumeParams();
245+
246+
expect(params).toEqual(null);
247+
expect(isAppSwitchResumeFlow()).toEqual(false);
248+
});
249+
250+
test("should return null when hash has merchant fragment with unrelated params", () => {
251+
vi.spyOn(window, "location", "get").mockReturnValue({
252+
hash: "#/checkout?step=review&cart=abc123",
253+
search: "",
254+
});
255+
256+
const params = getAppSwitchResumeParams();
257+
258+
expect(params).toEqual(null);
259+
expect(isAppSwitchResumeFlow()).toEqual(false);
260+
});
261+
262+
test("should handle vaultSetupToken in search params", () => {
263+
vi.spyOn(window, "location", "get").mockReturnValue({
264+
hash: "",
265+
search: `?button_session_id=${buttonSessionID}&vaultSetupToken=VA-123&PayerID=PP-456`,
266+
});
267+
268+
const params = getAppSwitchResumeParams();
269+
270+
expect(params).toEqual({
271+
buttonSessionID,
272+
checkoutState: "onApprove",
273+
payerID: "PP-456",
274+
vaultSetupToken: "VA-123",
275+
});
276+
expect(isAppSwitchResumeFlow()).toEqual(true);
277+
});
278+
279+
test("should handle approval_token_id in search params", () => {
280+
vi.spyOn(window, "location", "get").mockReturnValue({
281+
hash: "",
282+
search: `?button_session_id=${buttonSessionID}&approval_token_id=AT-789`,
283+
});
284+
285+
const params = getAppSwitchResumeParams();
286+
287+
expect(params).toEqual({
288+
buttonSessionID,
289+
checkoutState: "onCancel",
290+
vaultSetupToken: "AT-789",
291+
});
292+
expect(isAppSwitchResumeFlow()).toEqual(true);
293+
});
294+
295+
test("should handle approval_session_id in search params", () => {
296+
vi.spyOn(window, "location", "get").mockReturnValue({
297+
hash: "",
298+
search: `?button_session_id=${buttonSessionID}&approval_session_id=AS-999`,
299+
});
300+
301+
const params = getAppSwitchResumeParams();
302+
303+
expect(params).toEqual({
304+
buttonSessionID,
305+
checkoutState: "onCancel",
306+
vaultSetupToken: "AS-999",
307+
});
308+
expect(isAppSwitchResumeFlow()).toEqual(true);
309+
});
310+
311+
describe("hash URL variations", () => {
312+
// Tests for all supported hash URL formats that the web fallback must handle.
313+
// PayPal appends params to the URL after app switch; the exact position depends
314+
// on the merchant's return_url structure.
315+
316+
test("#hash - plain hash with no params returns null", () => {
317+
vi.spyOn(window, "location", "get").mockReturnValue({
318+
hash: "#hash",
319+
search: "",
320+
});
321+
322+
const params = getAppSwitchResumeParams();
323+
324+
expect(params).toEqual(null);
325+
expect(isAppSwitchResumeFlow()).toEqual(false);
326+
});
327+
328+
test("?query=param#hash - PayPal params in search, merchant hash fragment", () => {
329+
vi.spyOn(window, "location", "get").mockReturnValue({
330+
hash: "#hash",
331+
search: `?button_session_id=${buttonSessionID}&token=${orderID}&PayerID=PP-payer-122`,
332+
});
333+
334+
const params = getAppSwitchResumeParams();
335+
336+
expect(params).toEqual({
337+
buttonSessionID,
338+
checkoutState: "onApprove",
339+
orderID,
340+
payerID: "PP-payer-122",
341+
});
342+
expect(isAppSwitchResumeFlow()).toEqual(true);
343+
});
344+
345+
test("#hash?query=param - PayPal params embedded in hash after ?", () => {
346+
vi.spyOn(window, "location", "get").mockReturnValue({
347+
hash: `#hash?button_session_id=${buttonSessionID}&token=${orderID}&PayerID=PP-payer-122`,
348+
search: "",
349+
});
350+
351+
const params = getAppSwitchResumeParams();
352+
353+
expect(params).toEqual({
354+
buttonSessionID,
355+
checkoutState: "onApprove",
356+
orderID,
357+
payerID: "PP-payer-122",
358+
});
359+
expect(isAppSwitchResumeFlow()).toEqual(true);
360+
});
361+
362+
test("/#/checkout/completed - SPA-style hash path with no params returns null", () => {
363+
vi.spyOn(window, "location", "get").mockReturnValue({
364+
hash: "#/checkout/completed",
365+
search: "",
366+
});
367+
368+
const params = getAppSwitchResumeParams();
369+
370+
expect(params).toEqual(null);
371+
expect(isAppSwitchResumeFlow()).toEqual(false);
372+
});
373+
374+
test("/#/checkout/completed?query=param - PayPal params after SPA-style hash path", () => {
375+
vi.spyOn(window, "location", "get").mockReturnValue({
376+
hash: `#/checkout/completed?button_session_id=${buttonSessionID}&token=${orderID}&PayerID=PP-payer-122`,
377+
search: "",
378+
});
379+
380+
const params = getAppSwitchResumeParams();
381+
382+
expect(params).toEqual({
383+
buttonSessionID,
384+
checkoutState: "onApprove",
385+
orderID,
386+
payerID: "PP-payer-122",
387+
});
388+
expect(isAppSwitchResumeFlow()).toEqual(true);
389+
});
390+
});
391+
392+
test("should return null when web fallback throws error", () => {
393+
// Mock location.search as a getter that throws an error
394+
// eslint-disable-next-line compat/compat
395+
const originalURLSearchParams = window.URLSearchParams;
396+
// eslint-disable-next-line compat/compat
397+
window.URLSearchParams = class {
398+
constructor() {
399+
throw new Error("Invalid URL");
400+
}
401+
};
402+
403+
vi.spyOn(window, "location", "get").mockReturnValue({
404+
hash: "",
405+
search: "?invalid",
406+
});
407+
408+
const params = getAppSwitchResumeParams();
409+
410+
expect(params).toEqual(null);
411+
expect(isAppSwitchResumeFlow()).toEqual(false);
412+
413+
// Restore original URLSearchParams
414+
// eslint-disable-next-line compat/compat
415+
window.URLSearchParams = originalURLSearchParams;
416+
});
143417
});

0 commit comments

Comments
 (0)