From 105db7942110698adc9d810c36d44d5f733e0291 Mon Sep 17 00:00:00 2001 From: Eiman Date: Wed, 4 Feb 2026 18:43:21 -0600 Subject: [PATCH] [SDK] Fix SiteLink hash fragment being stripped on target site SiteLink/SiteEmbed already preserve hash fragments via the URL API (searchParams.set keeps the hash at end). The real bug was in getUrlToken() which dropped window.location.hash when cleaning up auth params via pushState. Also updated getUrlToken() to parse auth params from inside the hash fragment as a fallback, supporting hash-routed apps where params may appear after #/route?params. Co-Authored-By: Claude Opus 4.5 --- .../src/react/web/ui/SiteLink.test.tsx | 21 ++++++ .../in-app/web/lib/get-url-token.test.tsx | 70 +++++++++++++++++-- .../wallets/in-app/web/lib/get-url-token.ts | 55 ++++++++++++--- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/SiteLink.test.tsx b/packages/thirdweb/src/react/web/ui/SiteLink.test.tsx index daf5dfe9cd3..0ceb0d1b767 100644 --- a/packages/thirdweb/src/react/web/ui/SiteLink.test.tsx +++ b/packages/thirdweb/src/react/web/ui/SiteLink.test.tsx @@ -62,4 +62,25 @@ describe("SiteLink", () => { expect(anchor).toBeTruthy(); await waitFor(() => expect(anchor?.href).toContain("walletId=inApp")); }); + + it("preserves hash fragment for hash-routed URLs", async () => { + const testUrl = "https://snapshot.org/#/s:wampei.eth"; + const { container } = render( + + Test Link + , + { + setConnectedWallet: true, + }, + ); + + const anchor = container.querySelector("a"); + expect(anchor).toBeTruthy(); + await waitFor(() => { + const href = anchor?.href ?? ""; + // Hash fragment must be preserved in the URL + expect(href).toContain("#/s:wampei.eth"); + expect(href).toContain("walletId="); + }); + }); }); diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx index b001ede5806..194b35f2fce 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getUrlToken } from "./get-url-token.js"; describe.runIf(global.window !== undefined)("getUrlToken", () => { @@ -48,9 +48,9 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { const result = getUrlToken(); expect(result).toEqual({ - authCookie: null, - authFlow: null, - authProvider: null, + authCookie: undefined, + authFlow: undefined, + authProvider: undefined, authResult: { token: "abc" }, walletId: "123", }); @@ -63,8 +63,8 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { expect(result).toEqual({ authCookie: "myCookie", - authFlow: null, - authProvider: null, + authFlow: undefined, + authProvider: undefined, authResult: undefined, walletId: "123", }); @@ -81,7 +81,7 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { expect(result).toEqual({ authCookie: "myCookie", - authFlow: null, + authFlow: undefined, authProvider: "provider1", authResult: { token: "xyz" }, walletId: "123", @@ -92,4 +92,60 @@ describe.runIf(global.window !== undefined)("getUrlToken", () => { "?walletId=123&authResult=%7B%22token%22%3A%22xyz%22%7D&authProvider=provider1&authCookie=myCookie", ); }); + + it("should preserve hash fragment when cleaning up URL", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "?walletId=123&authCookie=myCookie", + hash: "#/s:wampei.eth", + pathname: "/", + }, + writable: true, + }); + + const pushStateSpy = vi.spyOn(window.history, "pushState"); + + const result = getUrlToken(); + + expect(result).toEqual({ + authCookie: "myCookie", + authFlow: undefined, + authProvider: undefined, + authResult: undefined, + walletId: "123", + }); + + // Verify pushState was called with the hash preserved + expect(pushStateSpy).toHaveBeenCalledWith({}, "", "/#/s:wampei.eth"); + pushStateSpy.mockRestore(); + }); + + it("should parse auth params embedded inside the hash fragment", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "", + hash: "#/s:wampei.eth?walletId=123&authCookie=myCookie", + pathname: "/", + }, + writable: true, + }); + + const pushStateSpy = vi.spyOn(window.history, "pushState"); + + const result = getUrlToken(); + + expect(result).toEqual({ + authCookie: "myCookie", + authFlow: undefined, + authProvider: undefined, + authResult: undefined, + walletId: "123", + }); + + // Verify pushState preserves hash path but strips auth params from it + expect(pushStateSpy).toHaveBeenCalledWith({}, "", "/#/s:wampei.eth"); + pushStateSpy.mockRestore(); + }); }); diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts index ee9293f5bd8..ef28ffcd2c2 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts @@ -19,18 +19,40 @@ export function getUrlToken(): return undefined; } - const queryString = window.location.search; - const params = new URLSearchParams(queryString); - const authResultString = params.get("authResult"); - const walletId = params.get("walletId") as WalletId | undefined; - const authProvider = params.get("authProvider") as AuthOption | undefined; - const authCookie = params.get("authCookie") as string | undefined; - const authFlow = params.get("authFlow") as "connect" | "link" | undefined; + // Read params from the standard query string + const params = new URLSearchParams(window.location.search); + + // Also check for params embedded inside the hash fragment (e.g. #/route?walletId=...) + // This supports hash-routed apps where params may be placed after the hash path + let hashParams: URLSearchParams | undefined; + const hash = window.location.hash || ""; + let cleanHash = hash; + const hashQueryIndex = hash.indexOf("?"); + if (hashQueryIndex !== -1) { + hashParams = new URLSearchParams(hash.substring(hashQueryIndex)); + cleanHash = hash.substring(0, hashQueryIndex); + } + + const walletId = (params.get("walletId") ?? + hashParams?.get("walletId") ?? + undefined) as WalletId | undefined; + const authResultString = + params.get("authResult") ?? hashParams?.get("authResult") ?? undefined; + const authProvider = (params.get("authProvider") ?? + hashParams?.get("authProvider") ?? + undefined) as AuthOption | undefined; + const authCookie = (params.get("authCookie") ?? + hashParams?.get("authCookie") ?? + undefined) as string | undefined; + const authFlow = (params.get("authFlow") ?? + hashParams?.get("authFlow") ?? + undefined) as "connect" | "link" | undefined; if ((authCookie || authResultString) && walletId) { const authResult = (() => { if (authResultString) { params.delete("authResult"); + hashParams?.delete("authResult"); return JSON.parse(decodeURIComponent(authResultString)); } })(); @@ -38,10 +60,27 @@ export function getUrlToken(): params.delete("authProvider"); params.delete("authCookie"); params.delete("authFlow"); + hashParams?.delete("walletId"); + hashParams?.delete("authProvider"); + hashParams?.delete("authCookie"); + hashParams?.delete("authFlow"); + + const remainingSearch = params.toString(); + const searchString = remainingSearch ? `?${remainingSearch}` : ""; + + // Reconstruct hash, preserving the hash path and any remaining non-auth params + let hashString = cleanHash; + if (hashParams) { + const remainingHashParams = hashParams.toString(); + if (remainingHashParams) { + hashString = `${cleanHash}?${remainingHashParams}`; + } + } + window.history.pushState( {}, "", - `${window.location.pathname}?${params.toString()}`, + `${window.location.pathname}${searchString}${hashString}`, ); return { authCookie, authFlow, authProvider, authResult, walletId }; }