Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/embedded-scroll-restoration.md
Original file line number Diff line number Diff line change
@@ -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.
122 changes: 122 additions & 0 deletions packages/widget/src/AppDefault.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } = {}

// A route node stub: every builder method returns the same node so the chained
// addChildren() calls in AppDefault keep working.
const routeNode = (options: Record<string, unknown>) => {
const node: Record<string, unknown> = { options }
node.addChildren = () => node
node._addFileChildren = () => node
node.update = () => node
return node
}

return { state, routeNode }
})

vi.mock('@tanstack/react-router', () => ({
createRootRoute: (options: Record<string, unknown>) => h.routeNode(options),
createRoute: (options: Record<string, unknown>) => h.routeNode(options),
createMemoryHistory: () => ({}),
createRouter: (options: Record<string, unknown>) => {
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(<AppDefault />))

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(<AppDefault />))

expect(h.state.captured?.defaultPreload).toBe('intent')
expect(h.state.captured?.history).toBeDefined()
expect(h.state.captured?.routeTree).toBeDefined()
})
})
8 changes: 8 additions & 0 deletions packages/widget/src/AppDefault.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RouterProvider router={router} />
Expand Down