Skip to content

Commit 9baa251

Browse files
eabdelmoneimclaude
andauthored
[SDK] Fix SiteLink and SiteEmbed stripping URL hash fragments (#8663)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7f36303 commit 9baa251

File tree

3 files changed

+131
-15
lines changed

3 files changed

+131
-15
lines changed

packages/thirdweb/src/react/web/ui/SiteLink.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,25 @@ describe("SiteLink", () => {
6262
expect(anchor).toBeTruthy();
6363
await waitFor(() => expect(anchor?.href).toContain("walletId=inApp"));
6464
});
65+
66+
it("preserves hash fragment for hash-routed URLs", async () => {
67+
const testUrl = "https://snapshot.org/#/s:wampei.eth";
68+
const { container } = render(
69+
<SiteLink client={TEST_CLIENT} href={testUrl}>
70+
Test Link
71+
</SiteLink>,
72+
{
73+
setConnectedWallet: true,
74+
},
75+
);
76+
77+
const anchor = container.querySelector("a");
78+
expect(anchor).toBeTruthy();
79+
await waitFor(() => {
80+
const href = anchor?.href ?? "";
81+
// Hash fragment must be preserved in the URL
82+
expect(href).toContain("#/s:wampei.eth");
83+
expect(href).toContain("walletId=");
84+
});
85+
});
6586
});

packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { getUrlToken } from "./get-url-token.js";
33

44
describe.runIf(global.window !== undefined)("getUrlToken", () => {
@@ -48,9 +48,9 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
4848
const result = getUrlToken();
4949

5050
expect(result).toEqual({
51-
authCookie: null,
52-
authFlow: null,
53-
authProvider: null,
51+
authCookie: undefined,
52+
authFlow: undefined,
53+
authProvider: undefined,
5454
authResult: { token: "abc" },
5555
walletId: "123",
5656
});
@@ -63,8 +63,8 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
6363

6464
expect(result).toEqual({
6565
authCookie: "myCookie",
66-
authFlow: null,
67-
authProvider: null,
66+
authFlow: undefined,
67+
authProvider: undefined,
6868
authResult: undefined,
6969
walletId: "123",
7070
});
@@ -81,7 +81,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
8181

8282
expect(result).toEqual({
8383
authCookie: "myCookie",
84-
authFlow: null,
84+
authFlow: undefined,
8585
authProvider: "provider1",
8686
authResult: { token: "xyz" },
8787
walletId: "123",
@@ -92,4 +92,60 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => {
9292
"?walletId=123&authResult=%7B%22token%22%3A%22xyz%22%7D&authProvider=provider1&authCookie=myCookie",
9393
);
9494
});
95+
96+
it("should preserve hash fragment when cleaning up URL", () => {
97+
Object.defineProperty(window, "location", {
98+
value: {
99+
...window.location,
100+
search: "?walletId=123&authCookie=myCookie",
101+
hash: "#/s:wampei.eth",
102+
pathname: "/",
103+
},
104+
writable: true,
105+
});
106+
107+
const pushStateSpy = vi.spyOn(window.history, "pushState");
108+
109+
const result = getUrlToken();
110+
111+
expect(result).toEqual({
112+
authCookie: "myCookie",
113+
authFlow: undefined,
114+
authProvider: undefined,
115+
authResult: undefined,
116+
walletId: "123",
117+
});
118+
119+
// Verify pushState was called with the hash preserved
120+
expect(pushStateSpy).toHaveBeenCalledWith({}, "", "/#/s:wampei.eth");
121+
pushStateSpy.mockRestore();
122+
});
123+
124+
it("should parse auth params embedded inside the hash fragment", () => {
125+
Object.defineProperty(window, "location", {
126+
value: {
127+
...window.location,
128+
search: "",
129+
hash: "#/s:wampei.eth?walletId=123&authCookie=myCookie",
130+
pathname: "/",
131+
},
132+
writable: true,
133+
});
134+
135+
const pushStateSpy = vi.spyOn(window.history, "pushState");
136+
137+
const result = getUrlToken();
138+
139+
expect(result).toEqual({
140+
authCookie: "myCookie",
141+
authFlow: undefined,
142+
authProvider: undefined,
143+
authResult: undefined,
144+
walletId: "123",
145+
});
146+
147+
// Verify pushState preserves hash path but strips auth params from it
148+
expect(pushStateSpy).toHaveBeenCalledWith({}, "", "/#/s:wampei.eth");
149+
pushStateSpy.mockRestore();
150+
});
95151
});

packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,68 @@ export function getUrlToken():
1919
return undefined;
2020
}
2121

22-
const queryString = window.location.search;
23-
const params = new URLSearchParams(queryString);
24-
const authResultString = params.get("authResult");
25-
const walletId = params.get("walletId") as WalletId | undefined;
26-
const authProvider = params.get("authProvider") as AuthOption | undefined;
27-
const authCookie = params.get("authCookie") as string | undefined;
28-
const authFlow = params.get("authFlow") as "connect" | "link" | undefined;
22+
// Read params from the standard query string
23+
const params = new URLSearchParams(window.location.search);
24+
25+
// Also check for params embedded inside the hash fragment (e.g. #/route?walletId=...)
26+
// This supports hash-routed apps where params may be placed after the hash path
27+
let hashParams: URLSearchParams | undefined;
28+
const hash = window.location.hash || "";
29+
let cleanHash = hash;
30+
const hashQueryIndex = hash.indexOf("?");
31+
if (hashQueryIndex !== -1) {
32+
hashParams = new URLSearchParams(hash.substring(hashQueryIndex));
33+
cleanHash = hash.substring(0, hashQueryIndex);
34+
}
35+
36+
const walletId = (params.get("walletId") ??
37+
hashParams?.get("walletId") ??
38+
undefined) as WalletId | undefined;
39+
const authResultString =
40+
params.get("authResult") ?? hashParams?.get("authResult") ?? undefined;
41+
const authProvider = (params.get("authProvider") ??
42+
hashParams?.get("authProvider") ??
43+
undefined) as AuthOption | undefined;
44+
const authCookie = (params.get("authCookie") ??
45+
hashParams?.get("authCookie") ??
46+
undefined) as string | undefined;
47+
const authFlow = (params.get("authFlow") ??
48+
hashParams?.get("authFlow") ??
49+
undefined) as "connect" | "link" | undefined;
2950

3051
if ((authCookie || authResultString) && walletId) {
3152
const authResult = (() => {
3253
if (authResultString) {
3354
params.delete("authResult");
55+
hashParams?.delete("authResult");
3456
return JSON.parse(decodeURIComponent(authResultString));
3557
}
3658
})();
3759
params.delete("walletId");
3860
params.delete("authProvider");
3961
params.delete("authCookie");
4062
params.delete("authFlow");
63+
hashParams?.delete("walletId");
64+
hashParams?.delete("authProvider");
65+
hashParams?.delete("authCookie");
66+
hashParams?.delete("authFlow");
67+
68+
const remainingSearch = params.toString();
69+
const searchString = remainingSearch ? `?${remainingSearch}` : "";
70+
71+
// Reconstruct hash, preserving the hash path and any remaining non-auth params
72+
let hashString = cleanHash;
73+
if (hashParams) {
74+
const remainingHashParams = hashParams.toString();
75+
if (remainingHashParams) {
76+
hashString = `${cleanHash}?${remainingHashParams}`;
77+
}
78+
}
79+
4180
window.history.pushState(
4281
{},
4382
"",
44-
`${window.location.pathname}?${params.toString()}`,
83+
`${window.location.pathname}${searchString}${hashString}`,
4584
);
4685
return { authCookie, authFlow, authProvider, authResult, walletId };
4786
}

0 commit comments

Comments
 (0)