From aa94b5e40c74799a9a3a41bafb528ac72e146cf2 Mon Sep 17 00:00:00 2001 From: kimsanaka Date: Tue, 29 Sep 2026 14:32:01 -0700 Subject: [PATCH] fix(widget): stop embedded widget from scrolling the host page on navigation AppDefault creates a TanStack router on a memory history and passed no scroll options. router-core runs setupScrollRestoration on every client router whatever the history, and its onRendered handler calls window.scrollTo({ top: 0, left: 0 }) after each navigation unless that navigation passed resetScroll: false. The widget's own navigations never pass it, so opening the From/To token list, selecting a token, settings or route details scrolled the host page to the top. On a phone, where the widget usually sits below the fold, every tap moved the user away from the widget. The router is now created with scrollRestoration: () => false. router-core calls that function from onRendered and returns early when it is false, before any scroll work, so nothing touches the window the widget does not own. The test pins both halves: that the option is a function and that it returns false, plus that the router still uses a memory history with preload by intent. Removing the option makes it fail. --- .changeset/embedded-scroll-restoration.md | 16 +++ packages/widget/src/AppDefault.test.tsx | 122 ++++++++++++++++++++++ packages/widget/src/AppDefault.tsx | 8 ++ 3 files changed, 146 insertions(+) create mode 100644 .changeset/embedded-scroll-restoration.md create mode 100644 packages/widget/src/AppDefault.test.tsx diff --git a/.changeset/embedded-scroll-restoration.md b/.changeset/embedded-scroll-restoration.md new file mode 100644 index 000000000..8bd466f6f --- /dev/null +++ b/.changeset/embedded-scroll-restoration.md @@ -0,0 +1,16 @@ +--- +'@lifi/widget': patch +--- + +Stop the embedded widget from scrolling the host page to the top on every internal navigation. + +`AppDefault` creates a TanStack router on a memory history and set no scroll options. router-core +runs `setupScrollRestoration` on every client router whatever the history, and its `onRendered` +handler calls `window.scrollTo({ top: 0, left: 0 })` after each navigation unless that navigation +passed `resetScroll: false`. The widget's own navigations never pass it, so opening the From/To token +list, selecting a token, settings or route details threw the host page back to the top — on a phone, +where the widget usually sits below the fold, every tap moved the user away from the widget. + +The router is now created with `scrollRestoration: () => false`. `onRendered` returns before any +scrolling, which is what an embedded memory-history router wants: it should never scroll the window +it does not own. No public API changes. diff --git a/packages/widget/src/AppDefault.test.tsx b/packages/widget/src/AppDefault.test.tsx new file mode 100644 index 000000000..914615c5f --- /dev/null +++ b/packages/widget/src/AppDefault.test.tsx @@ -0,0 +1,122 @@ +/** @vitest-environment happy-dom */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => { + const state: { captured?: Record } = {} + + // A route node stub: every builder method returns the same node so the chained + // addChildren() calls in AppDefault keep working. + const routeNode = (options: Record) => { + const node: Record = { options } + node.addChildren = () => node + node._addFileChildren = () => node + node.update = () => node + return node + } + + return { state, routeNode } +}) + +vi.mock('@tanstack/react-router', () => ({ + createRootRoute: (options: Record) => h.routeNode(options), + createRoute: (options: Record) => h.routeNode(options), + createMemoryHistory: () => ({}), + createRouter: (options: Record) => { + h.state.captured = options + return { options } + }, + RouterProvider: () => null, +})) + +// RouterProvider is stubbed out, so no page ever renders. They are still imported, and +// their module graph reaches react-i18next, whose ESM build trips the test resolver. +// Stubbing the pages keeps this test on AppDefault's own router wiring. +vi.mock('./AppLayout.js', () => ({ AppLayout: () => null })) +vi.mock('./components/NotFound.js', () => ({ NotFound: () => null })) +vi.mock('./pages/ActivitiesPage/ActivitiesPage.js', () => ({ + ActivitiesPage: () => null, +})) +vi.mock('./pages/LanguagesPage.js', () => ({ LanguagesPage: () => null })) +vi.mock('./pages/MainPage/MainPage.js', () => ({ MainPage: () => null })) +vi.mock('./pages/RoutePriorityPage.js', () => ({ + RoutePriorityPage: () => null, +})) +vi.mock('./pages/RoutesPage/RoutesPage.js', () => ({ RoutesPage: () => null })) +vi.mock('./pages/SelectChainPage/SelectChainPage.js', () => ({ + SelectChainPage: () => null, +})) +vi.mock('./pages/SelectEnabledToolsPage.js', () => ({ + SelectEnabledToolsPage: () => null, +})) +vi.mock('./pages/SelectTokenPage/SelectTokenPage.js', () => ({ + SelectTokenPage: () => null, +})) +vi.mock('./pages/SendToWallet/BookmarksPage.js', () => ({ + BookmarksPage: () => null, +})) +vi.mock('./pages/SendToWallet/ConnectedWalletsPage.js', () => ({ + ConnectedWalletsPage: () => null, +})) +vi.mock('./pages/SendToWallet/RecentWalletsPage.js', () => ({ + RecentWalletsPage: () => null, +})) +vi.mock('./pages/SendToWallet/SendToConfiguredWalletPage.js', () => ({ + SendToConfiguredWalletPage: () => null, +})) +vi.mock('./pages/SendToWallet/SendToWalletPage.js', () => ({ + SendToWalletPage: () => null, +})) +vi.mock('./pages/SettingsPage/SettingsPage.js', () => ({ + SettingsPage: () => null, +})) +vi.mock('./pages/SettingsPage/SlippageSettings/SlippagePage.js', () => ({ + SlippagePage: () => null, +})) +vi.mock('./pages/TransactionDetailsPage/TransactionDetailsPage.js', () => ({ + TransactionDetailsPage: () => null, +})) +vi.mock('./pages/TransactionPage/TransactionPage.js', () => ({ + TransactionPage: () => null, +})) + +import { AppDefault } from './AppDefault.js' + +let root: Root + +describe('AppDefault embedded router', () => { + beforeEach(() => { + h.state.captured = undefined + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + }) + + it('turns scroll restoration off so internal navigation cannot scroll the host page', async () => { + await act(async () => root.render()) + + const scrollRestoration = h.state.captured?.scrollRestoration + expect(typeof scrollRestoration).toBe('function') + + // router-core calls this from onRendered and returns early when it is false, which is + // what stops window.scrollTo({ top: 0, left: 0 }) from firing on every navigation. + const shouldRestore = scrollRestoration as (opts: { + location: unknown + }) => boolean + expect(shouldRestore({ location: {} })).toBe(false) + }) + + it('still creates the router on a memory history with preload by intent', async () => { + await act(async () => root.render()) + + expect(h.state.captured?.defaultPreload).toBe('intent') + expect(h.state.captured?.history).toBeDefined() + expect(h.state.captured?.routeTree).toBeDefined() + }) +}) diff --git a/packages/widget/src/AppDefault.tsx b/packages/widget/src/AppDefault.tsx index 11918cee0..cdbee2055 100644 --- a/packages/widget/src/AppDefault.tsx +++ b/packages/widget/src/AppDefault.tsx @@ -276,6 +276,14 @@ export const AppDefault = (): JSX.Element => { routeTree, history: createMemoryHistory({ initialEntries: ['/'] }), defaultPreload: 'intent', + // The widget is embedded in a host page, usually below the fold. router-core runs + // setupScrollRestoration on every client router regardless of the history, and its + // onRendered handler calls window.scrollTo({ top: 0, left: 0 }) after each navigation + // unless resetScroll was false for that navigation. The widget's own navigations never + // pass it, so every internal screen change - opening a token list, settings, route + // details - threw the host page back to the top. Returning false here exits onRendered + // before any scrolling, which is what an embedded memory-history router wants. + scrollRestoration: () => false, }) ) return