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