From bdd6c50ba2aba54832139779c3fcf6112ac838ed Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Mon, 22 Jun 2026 13:15:04 +0200 Subject: [PATCH 1/7] refactor(widget): carry per-tab config in _navigationTabs entries --- .../src/defaultWidgetConfig.ts | 11 +- .../widget/src/stores/navigationTabs/types.ts | 17 +-- .../navigationTabs/useNavigationTabsStore.tsx | 44 ++++---- .../src/stores/navigationTabs/utils.test.ts | 55 +++------ .../widget/src/stores/navigationTabs/utils.ts | 104 ++---------------- packages/widget/src/types/widget.ts | 15 ++- 6 files changed, 71 insertions(+), 175 deletions(-) diff --git a/packages/widget-playground/src/defaultWidgetConfig.ts b/packages/widget-playground/src/defaultWidgetConfig.ts index 86c176a7e..5bcf8b4f3 100644 --- a/packages/widget-playground/src/defaultWidgetConfig.ts +++ b/packages/widget-playground/src/defaultWidgetConfig.ts @@ -60,7 +60,16 @@ export const widgetBaseConfig: WidgetConfig = { ], variant: 'wide', // mode: 'split', - // _navigationTabs: ['default', 'private', 'refuel'], // ['swap-advanced', 'bridge-advanced', 'limit'] + // _navigationTabs: [ + // { tabKey: 'default', config: { variant: 'wide', mode: 'default' } }, + // { tabKey: 'private', config: { variant: 'compact', mode: 'split', modeOptions: { split: 'swap' } } }, + // { tabKey: 'refuel', config: { variant: 'wide', mode: 'refuel' } }, + // ], + // _navigationTabs: [ + // { tabKey: 'swap-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'swap' } } }, + // { tabKey: 'bridge-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'bridge' } } }, + // { tabKey: 'limit', config: { variant: 'compact', mode: 'limit' } }, + // ], // hiddenUI: { // chainSidebar: true, // }, diff --git a/packages/widget/src/stores/navigationTabs/types.ts b/packages/widget/src/stores/navigationTabs/types.ts index 29853a2e2..c601e00ba 100644 --- a/packages/widget/src/stores/navigationTabs/types.ts +++ b/packages/widget/src/stores/navigationTabs/types.ts @@ -1,20 +1,5 @@ import type { StoreApi, UseBoundStore } from 'zustand' -import type { - ModeOptions, - NavigationTabKey, - WidgetMode, - WidgetVariant, -} from '../../types/widget.js' - -export interface NavigationTab { - /** Stable, language-independent identity; resolved to a label via a hook. */ - key: NavigationTabKey - /** When omitted, the tab inherits `config.variant`. */ - variant?: WidgetVariant - /** When omitted, the tab inherits `config.mode`. */ - mode?: WidgetMode - modeOptions?: ModeOptions -} +import type { NavigationTabKey } from '../../types/widget.js' export interface NavigationTabsState { /** Tabs to render in the header, resolved from config (empty when none). */ diff --git a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx index e65eed124..992126e7e 100644 --- a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx +++ b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx @@ -17,15 +17,9 @@ import type { SplitMode, WidgetConfig, } from '../../types/widget.js' +import { getSplitMode } from '../../utils/mode.js' import type { NavigationTabsState, NavigationTabsStore } from './types.js' -import { - getInitialActiveTab, - getNavigationTabKeys, - getTabMode, - getTabModeOptions, - getTabSplitMode, - getTabVariant, -} from './utils.js' +import { getInitialActiveTab, getNavigationTabs } from './utils.js' const NavigationTabsStoreContext = createContext( null @@ -35,7 +29,8 @@ export function NavigationTabsStoreProvider({ children, config, }: PropsWithChildren<{ config: WidgetConfig }>): JSX.Element { - const tabs = getNavigationTabKeys(config) + const navigationTabs = getNavigationTabs(config) + const tabs = navigationTabs.map((tab) => tab.tabKey) const initialActiveTab = getInitialActiveTab(config) // Recreate (re-seeding tabs + active tab) only when the config-driven inputs // change — not on runtime tab clicks, which must be preserved. @@ -49,22 +44,20 @@ export function NavigationTabsStoreProvider({ signatureRef.current = signature } - // Override the widget config with the active tab's mode, variant and - // modeOptions (each falling back to the config), so the rest of the widget - // reads tab-driven config transparently. No-op when there is no active tab. + // Layer the active tab's config overrides on top of the widget config, so the + // rest of the widget reads tab-driven config transparently. Omitted fields + // fall back to the widget config. No-op when there is no active tab. const widgetConfig = useWidgetConfig() const activeTab = store((state) => state.activeTab) const tabConfig = useMemo(() => { - if (!activeTab) { + const activeConfig = navigationTabs.find( + (tab) => tab.tabKey === activeTab + )?.config + if (!activeConfig) { return widgetConfig } - return { - ...widgetConfig, - mode: getTabMode(widgetConfig, activeTab), - variant: getTabVariant(widgetConfig, activeTab), - modeOptions: getTabModeOptions(widgetConfig, activeTab), - } - }, [widgetConfig, activeTab]) + return { ...widgetConfig, ...activeConfig } + }, [widgetConfig, activeTab, navigationTabs]) return ( @@ -90,9 +83,16 @@ export function useNavigationTabsStore( return useStore(useShallow(selector)) } -/** Effective split mode derived from the active tab. */ +/** Effective split mode derived from the active tab's resolved config. */ export function useSplitMode(): SplitMode | undefined { - return useNavigationTabsStore((state) => getTabSplitMode(state.activeTab)) + const activeTab = useNavigationTabsStore((state) => state.activeTab) + const config = useWidgetConfig() + if (!activeTab) { + return undefined + } + return config.mode === 'split' + ? getSplitMode(config.modeOptions?.split) + : undefined } const createNavigationTabsStore = ( diff --git a/packages/widget/src/stores/navigationTabs/utils.test.ts b/packages/widget/src/stores/navigationTabs/utils.test.ts index e2d1fae00..2706eae02 100644 --- a/packages/widget/src/stores/navigationTabs/utils.test.ts +++ b/packages/widget/src/stores/navigationTabs/utils.test.ts @@ -1,28 +1,24 @@ import { describe, expect, it } from 'vitest' -import type { WidgetConfig } from '../../types/widget.js' -import { - getInitialActiveTab, - getNavigationTabKeys, - getTabMode, - getTabModeOptions, - getTabSplitMode, - getTabVariant, - splitTabKeys, -} from './utils.js' +import type { NavigationTabConfig, WidgetConfig } from '../../types/widget.js' +import { getInitialActiveTab, getNavigationTabs } from './utils.js' const config = (overrides: Partial = {}): WidgetConfig => overrides as WidgetConfig -describe('getNavigationTabKeys', () => { +const tab = ( + tabKey: NavigationTabConfig['tabKey'], + config: NavigationTabConfig['config'] = {} +): NavigationTabConfig => ({ tabKey, config }) + +describe('getNavigationTabs', () => { it('prefers configured tabs, else split tabs, else none', () => { + const tabs = [tab('private', { mode: 'split' })] + expect(getNavigationTabs(config({ _navigationTabs: tabs }))).toBe(tabs) expect( - getNavigationTabKeys(config({ _navigationTabs: ['private'] })) - ).toEqual(['private']) - expect(getNavigationTabKeys(config({ mode: 'split' }))).toEqual( - splitTabKeys - ) + getNavigationTabs(config({ mode: 'split' })).map((t) => t.tabKey) + ).toEqual(['swap', 'bridge']) expect( - getNavigationTabKeys( + getNavigationTabs( config({ mode: 'split', modeOptions: { split: 'swap' } }) ) ).toEqual([]) @@ -32,7 +28,9 @@ describe('getNavigationTabKeys', () => { describe('getInitialActiveTab', () => { it('seeds the first configured tab or the split selection', () => { expect( - getInitialActiveTab(config({ _navigationTabs: ['refuel', 'private'] })) + getInitialActiveTab( + config({ _navigationTabs: [tab('refuel'), tab('private')] }) + ) ).toBe('refuel') expect( getInitialActiveTab( @@ -42,24 +40,3 @@ describe('getInitialActiveTab', () => { expect(getInitialActiveTab(config({ mode: 'default' }))).toBeUndefined() }) }) - -describe('getTabSplitMode', () => { - it('resolves split tabs to their side, undefined otherwise', () => { - expect(getTabSplitMode('swap-advanced')).toBe('swap') - expect(getTabSplitMode('bridge')).toBe('bridge') - expect(getTabSplitMode('default')).toBeUndefined() - }) -}) - -describe('per-tab config derivation', () => { - it('uses the tab preset, falling back to config', () => { - expect(getTabVariant(config({ variant: 'compact' }), 'default')).toBe( - 'wide' - ) - expect(getTabVariant(config({ variant: 'compact' }), 'swap')).toBe( - 'compact' - ) - expect(getTabMode(config({ mode: 'default' }), 'refuel')).toBe('refuel') - expect(getTabModeOptions(config(), 'private')).toEqual({ split: 'swap' }) - }) -}) diff --git a/packages/widget/src/stores/navigationTabs/utils.ts b/packages/widget/src/stores/navigationTabs/utils.ts index 47a424cdd..220f77f6c 100644 --- a/packages/widget/src/stores/navigationTabs/utils.ts +++ b/packages/widget/src/stores/navigationTabs/utils.ts @@ -1,72 +1,23 @@ import type { - InternalNavigationTabKey, - ModeOptions, + NavigationTabConfig, NavigationTabKey, - SplitMode, - SplitNavigationTabKey, WidgetConfig, - WidgetMode, - WidgetVariant, } from '../../types/widget.js' import { getSplitMode } from '../../utils/mode.js' -import type { NavigationTab } from './types.js' - -/** Configured navigation tabs by key. Labels are resolved from the key by a hook. */ -const navigationTabsByKey: Record = { - default: { key: 'default', variant: 'wide', mode: 'default' }, - private: { - key: 'private', - variant: 'compact', - mode: 'split', - modeOptions: { split: 'swap' }, - }, - refuel: { key: 'refuel', variant: 'wide', mode: 'refuel' }, - limit: { key: 'limit', variant: 'compact', mode: 'limit' }, - 'swap-advanced': { - key: 'swap-advanced', - variant: 'wide', - mode: 'split', - modeOptions: { split: 'swap' }, - }, - 'bridge-advanced': { - key: 'bridge-advanced', - variant: 'wide', - mode: 'split', - modeOptions: { split: 'bridge' }, - }, -} /** Split-mode tabs (Swap / Bridge), shown when no navigation tabs are configured. */ -const splitTabs: NavigationTab[] = [ +const splitTabs: NavigationTabConfig[] = [ + { tabKey: 'swap', config: { mode: 'split', modeOptions: { split: 'swap' } } }, { - key: 'swap', - mode: 'split', - modeOptions: { split: 'swap' }, - }, - { - key: 'bridge', - mode: 'split', - modeOptions: { split: 'bridge' }, + tabKey: 'bridge', + config: { mode: 'split', modeOptions: { split: 'bridge' } }, }, ] -const splitTabsByKey = Object.fromEntries( - splitTabs.map((tab) => [tab.key, tab]) -) as Record - -/** Combined lookup across configured and split tabs. */ -const tabByKey: Record = { - ...navigationTabsByKey, - ...splitTabsByKey, -} - -/** Tab keys shown in split mode when no navigation tabs are configured. */ -export const splitTabKeys: NavigationTabKey[] = splitTabs.map((tab) => tab.key) - /** Resolves which navigation tabs the header should render from the config. */ -export const getNavigationTabKeys = ( +export const getNavigationTabs = ( config: WidgetConfig -): NavigationTabKey[] => { +): NavigationTabConfig[] => { if (config._navigationTabs?.length) { return config._navigationTabs } @@ -75,7 +26,7 @@ export const getNavigationTabKeys = ( config.mode === 'split' && typeof config.modeOptions?.split !== 'string' ) { - return splitTabKeys + return splitTabs } return [] } @@ -89,46 +40,9 @@ export const getInitialActiveTab = ( config: WidgetConfig ): NavigationTabKey | undefined => { if (config._navigationTabs?.length) { - return config._navigationTabs[0] + return config._navigationTabs[0].tabKey } return config.mode === 'split' ? getSplitMode(config.modeOptions?.split) : undefined } - -/** Split mode (`swap`/`bridge`) implied by a tab, or `undefined` if not split. */ -export const getTabSplitMode = ( - key?: NavigationTabKey -): SplitMode | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.mode === 'split' - ? getSplitMode(tab.modeOptions?.split) - : undefined -} - -/** Variant for a tab, falling back to the widget config's variant when unset. */ -export const getTabVariant = ( - config: WidgetConfig, - key?: NavigationTabKey -): WidgetVariant | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.variant ?? config.variant -} - -/** Mode for a tab, falling back to the widget config's mode when unset. */ -export const getTabMode = ( - config: WidgetConfig, - key?: NavigationTabKey -): WidgetMode | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.mode ?? config.mode -} - -/** Mode options for a tab, falling back to the widget config's when unset. */ -export const getTabModeOptions = ( - config: WidgetConfig, - key?: NavigationTabKey -): ModeOptions | undefined => { - const tab = key ? tabByKey[key] : undefined - return tab?.modeOptions ?? config.modeOptions -} diff --git a/packages/widget/src/types/widget.ts b/packages/widget/src/types/widget.ts index 33e0ac311..909c24c8e 100644 --- a/packages/widget/src/types/widget.ts +++ b/packages/widget/src/types/widget.ts @@ -74,6 +74,16 @@ export type InternalNavigationTabKey = | 'limit' export type NavigationTabKey = InternalNavigationTabKey | SplitNavigationTabKey +/** + * A header navigation tab: a stable key (drives identity and label) paired with + * the widget config overrides applied while the tab is active. + * @internal Not part of the public API. + */ +export interface NavigationTabConfig { + tabKey: NavigationTabKey + config: Partial +} + export type Appearance = PaletteMode | 'system' export interface NavigationProps { /** @@ -369,11 +379,12 @@ export interface WidgetConfig { modeOptions?: ModeOptions /** * Header navigation tabs, in order. When set, the widget renders a tab bar - * and the active tab drives the displayed flow. Each entry is a tab key. + * and the active tab applies its `config` overrides on top of the widget + * config. Each entry pairs a tab key with its config. * * @internal Not part of the public API. */ - _navigationTabs?: NavigationTabKey[] + _navigationTabs?: NavigationTabConfig[] appearance?: Appearance theme?: WidgetTheme From dc8bbacc107ff5a067100abedd65130ab4b8525e Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Mon, 22 Jun 2026 17:26:52 +0200 Subject: [PATCH 2/7] feat(widget): apply active tab sdkConfig and lock tabs while executing --- .changeset/private-tab-api-url.md | 5 ++++ .../src/defaultWidgetConfig.ts | 15 ++++++++++ packages/widget/src/AppProvider.tsx | 17 ++++++----- .../Header/HeaderNavigationTabs.tsx | 5 ++++ .../src/components/Header/HeaderTabs.tsx | 8 +++++ packages/widget/src/stores/StoreProvider.tsx | 29 +++++++++---------- 6 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 .changeset/private-tab-api-url.md diff --git a/.changeset/private-tab-api-url.md b/.changeset/private-tab-api-url.md new file mode 100644 index 000000000..46139a666 --- /dev/null +++ b/.changeset/private-tab-api-url.md @@ -0,0 +1,5 @@ +--- +"@lifi/widget": minor +--- + +Apply a navigation tab's `sdkConfig` (including `apiUrl`) when the tab is active, so a tab can route through a dedicated backend endpoint. Tab switching is locked while a route is executing. diff --git a/packages/widget-playground/src/defaultWidgetConfig.ts b/packages/widget-playground/src/defaultWidgetConfig.ts index 5bcf8b4f3..e5bddc102 100644 --- a/packages/widget-playground/src/defaultWidgetConfig.ts +++ b/packages/widget-playground/src/defaultWidgetConfig.ts @@ -65,6 +65,21 @@ export const widgetBaseConfig: WidgetConfig = { // { tabKey: 'private', config: { variant: 'compact', mode: 'split', modeOptions: { split: 'swap' } } }, // { tabKey: 'refuel', config: { variant: 'wide', mode: 'refuel' } }, // ], + // Private tab routing through a dedicated backend endpoint. `keyPrefix` is + // mandatory whenever a tab overrides `sdkConfig.apiUrl` — it isolates the + // React Query cache so private-endpoint data isn't served from the public one. + // _navigationTabs: [ + // { tabKey: 'default', config: {} }, + // { + // tabKey: 'private', + // config: { + // sdkConfig: { apiUrl: 'https://li.quest/private/v1' }, + // requiredUI: { toAddress: true }, + // useRelayerRoutes: true, + // keyPrefix: 'private', + // }, + // }, + // ], // _navigationTabs: [ // { tabKey: 'swap-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'swap' } } }, // { tabKey: 'bridge-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'bridge' } } }, diff --git a/packages/widget/src/AppProvider.tsx b/packages/widget/src/AppProvider.tsx index e4488c67e..dba15bc0d 100644 --- a/packages/widget/src/AppProvider.tsx +++ b/packages/widget/src/AppProvider.tsx @@ -5,6 +5,7 @@ import { SDKClientProvider } from './providers/SDKClientProvider.js' import { ThemeProvider } from './providers/ThemeProvider/ThemeProvider.js' import { WalletProvider } from './providers/WalletProvider/WalletProvider.js' import { WidgetProvider } from './providers/WidgetProvider/WidgetProvider.js' +import { NavigationTabsStoreProvider } from './stores/navigationTabs/useNavigationTabsStore.js' import { StoreProvider } from './stores/StoreProvider.js' import { SettingsStoreProvider } from './stores/settings/SettingsStore.js' import type { WidgetConfigProps } from './types/widget.js' @@ -20,13 +21,15 @@ export const AppProvider: React.FC> = ({ - - - - {children} - - - + + + + + {children} + + + + diff --git a/packages/widget/src/components/Header/HeaderNavigationTabs.tsx b/packages/widget/src/components/Header/HeaderNavigationTabs.tsx index b766c001a..7dcb961e5 100644 --- a/packages/widget/src/components/Header/HeaderNavigationTabs.tsx +++ b/packages/widget/src/components/Header/HeaderNavigationTabs.tsx @@ -1,6 +1,7 @@ import type { JSX } from 'react' import { useNavigationTabLabel } from '../../stores/navigationTabs/useNavigationTabLabel.js' import { useNavigationTabsStore } from '../../stores/navigationTabs/useNavigationTabsStore.js' +import { useRouteExecutionIndicator } from '../../stores/routes/useRouteExecutionIndicator.js' import { HeaderTabs } from './HeaderTabs.js' export const HeaderNavigationTabs = (): JSX.Element | null => { @@ -10,6 +11,9 @@ export const HeaderNavigationTabs = (): JSX.Element | null => { store.setActiveTab, ]) const getLabel = useNavigationTabLabel() + // Lock tab switching while a route is executing — switching tabs can change + // the SDK config (e.g. apiUrl), which must not happen mid-execution. + const { active: isExecuting } = useRouteExecutionIndicator() if (!tabs.length || !activeTab) { return null @@ -20,6 +24,7 @@ export const HeaderNavigationTabs = (): JSX.Element | null => { tabs={tabs.map((key) => ({ key, label: getLabel(key) }))} value={activeTab} onChange={(key) => setActiveTab(key)} + disabled={isExecuting} /> ) } diff --git a/packages/widget/src/components/Header/HeaderTabs.tsx b/packages/widget/src/components/Header/HeaderTabs.tsx index 17bd5400b..f4ee82fd9 100644 --- a/packages/widget/src/components/Header/HeaderTabs.tsx +++ b/packages/widget/src/components/Header/HeaderTabs.tsx @@ -11,6 +11,9 @@ interface HeaderTabsProps { tabs: HeaderTab[] value: K onChange: (key: K) => void + /** Locks the bar while a route is executing, so the active tab can't switch + * the SDK config (e.g. apiUrl) out from under an in-flight execution. */ + disabled?: boolean } /** @@ -23,10 +26,14 @@ export const HeaderTabs = ({ tabs, value, onChange, + disabled, }: HeaderTabsProps): JSX.Element => { const { setFieldValue } = useFieldActions() const handleChange = (_: React.SyntheticEvent, key: K) => { + if (disabled) { + return + } setFieldValue('fromAmount', '') setFieldValue('fromToken', '') setFieldValue('toToken', '') @@ -40,6 +47,7 @@ export const HeaderTabs = ({ key={tab.key} value={tab.key} label={tab.label} + disabled={disabled} disableRipple /> ))} diff --git a/packages/widget/src/stores/StoreProvider.tsx b/packages/widget/src/stores/StoreProvider.tsx index 6552e2f91..5a82d97ff 100644 --- a/packages/widget/src/stores/StoreProvider.tsx +++ b/packages/widget/src/stores/StoreProvider.tsx @@ -4,7 +4,6 @@ import { BookmarkStoreProvider } from './bookmarks/BookmarkStore.js' import { ChainOrderStoreProvider } from './chains/ChainOrderStore.js' import { FormStoreProvider } from './form/FormStore.js' import { HeaderStoreProvider } from './header/useHeaderStore.js' -import { NavigationTabsStoreProvider } from './navigationTabs/useNavigationTabsStore.js' import { PinnedTokensStoreProvider } from './pinnedTokens/PinnedTokensStore.js' import { RouteExecutionStoreProvider } from './routes/RouteExecutionStore.js' @@ -14,20 +13,18 @@ export const StoreProvider: React.FC> = ({ formRef, }) => { return ( - - - - - - - - {children} - - - - - - - + + + + + + + {children} + + + + + + ) } From a1c3ea9952b74ac0e6e89de680ad5409a8611b5f Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Tue, 23 Jun 2026 16:46:24 +0200 Subject: [PATCH 3/7] refactor(widget): remove navigation tab locking and update component structure --- .changeset/private-tab-api-url.md | 5 ---- packages/widget/src/AppProvider.tsx | 17 +++++------ .../Header/HeaderNavigationTabs.tsx | 5 ---- .../src/components/Header/HeaderTabs.tsx | 8 ----- packages/widget/src/stores/StoreProvider.tsx | 29 ++++++++++--------- 5 files changed, 23 insertions(+), 41 deletions(-) delete mode 100644 .changeset/private-tab-api-url.md diff --git a/.changeset/private-tab-api-url.md b/.changeset/private-tab-api-url.md deleted file mode 100644 index 46139a666..000000000 --- a/.changeset/private-tab-api-url.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@lifi/widget": minor ---- - -Apply a navigation tab's `sdkConfig` (including `apiUrl`) when the tab is active, so a tab can route through a dedicated backend endpoint. Tab switching is locked while a route is executing. diff --git a/packages/widget/src/AppProvider.tsx b/packages/widget/src/AppProvider.tsx index dba15bc0d..e4488c67e 100644 --- a/packages/widget/src/AppProvider.tsx +++ b/packages/widget/src/AppProvider.tsx @@ -5,7 +5,6 @@ import { SDKClientProvider } from './providers/SDKClientProvider.js' import { ThemeProvider } from './providers/ThemeProvider/ThemeProvider.js' import { WalletProvider } from './providers/WalletProvider/WalletProvider.js' import { WidgetProvider } from './providers/WidgetProvider/WidgetProvider.js' -import { NavigationTabsStoreProvider } from './stores/navigationTabs/useNavigationTabsStore.js' import { StoreProvider } from './stores/StoreProvider.js' import { SettingsStoreProvider } from './stores/settings/SettingsStore.js' import type { WidgetConfigProps } from './types/widget.js' @@ -21,15 +20,13 @@ export const AppProvider: React.FC> = ({ - - - - - {children} - - - - + + + + {children} + + + diff --git a/packages/widget/src/components/Header/HeaderNavigationTabs.tsx b/packages/widget/src/components/Header/HeaderNavigationTabs.tsx index 7dcb961e5..b766c001a 100644 --- a/packages/widget/src/components/Header/HeaderNavigationTabs.tsx +++ b/packages/widget/src/components/Header/HeaderNavigationTabs.tsx @@ -1,7 +1,6 @@ import type { JSX } from 'react' import { useNavigationTabLabel } from '../../stores/navigationTabs/useNavigationTabLabel.js' import { useNavigationTabsStore } from '../../stores/navigationTabs/useNavigationTabsStore.js' -import { useRouteExecutionIndicator } from '../../stores/routes/useRouteExecutionIndicator.js' import { HeaderTabs } from './HeaderTabs.js' export const HeaderNavigationTabs = (): JSX.Element | null => { @@ -11,9 +10,6 @@ export const HeaderNavigationTabs = (): JSX.Element | null => { store.setActiveTab, ]) const getLabel = useNavigationTabLabel() - // Lock tab switching while a route is executing — switching tabs can change - // the SDK config (e.g. apiUrl), which must not happen mid-execution. - const { active: isExecuting } = useRouteExecutionIndicator() if (!tabs.length || !activeTab) { return null @@ -24,7 +20,6 @@ export const HeaderNavigationTabs = (): JSX.Element | null => { tabs={tabs.map((key) => ({ key, label: getLabel(key) }))} value={activeTab} onChange={(key) => setActiveTab(key)} - disabled={isExecuting} /> ) } diff --git a/packages/widget/src/components/Header/HeaderTabs.tsx b/packages/widget/src/components/Header/HeaderTabs.tsx index f4ee82fd9..17bd5400b 100644 --- a/packages/widget/src/components/Header/HeaderTabs.tsx +++ b/packages/widget/src/components/Header/HeaderTabs.tsx @@ -11,9 +11,6 @@ interface HeaderTabsProps { tabs: HeaderTab[] value: K onChange: (key: K) => void - /** Locks the bar while a route is executing, so the active tab can't switch - * the SDK config (e.g. apiUrl) out from under an in-flight execution. */ - disabled?: boolean } /** @@ -26,14 +23,10 @@ export const HeaderTabs = ({ tabs, value, onChange, - disabled, }: HeaderTabsProps): JSX.Element => { const { setFieldValue } = useFieldActions() const handleChange = (_: React.SyntheticEvent, key: K) => { - if (disabled) { - return - } setFieldValue('fromAmount', '') setFieldValue('fromToken', '') setFieldValue('toToken', '') @@ -47,7 +40,6 @@ export const HeaderTabs = ({ key={tab.key} value={tab.key} label={tab.label} - disabled={disabled} disableRipple /> ))} diff --git a/packages/widget/src/stores/StoreProvider.tsx b/packages/widget/src/stores/StoreProvider.tsx index 5a82d97ff..6552e2f91 100644 --- a/packages/widget/src/stores/StoreProvider.tsx +++ b/packages/widget/src/stores/StoreProvider.tsx @@ -4,6 +4,7 @@ import { BookmarkStoreProvider } from './bookmarks/BookmarkStore.js' import { ChainOrderStoreProvider } from './chains/ChainOrderStore.js' import { FormStoreProvider } from './form/FormStore.js' import { HeaderStoreProvider } from './header/useHeaderStore.js' +import { NavigationTabsStoreProvider } from './navigationTabs/useNavigationTabsStore.js' import { PinnedTokensStoreProvider } from './pinnedTokens/PinnedTokensStore.js' import { RouteExecutionStoreProvider } from './routes/RouteExecutionStore.js' @@ -13,18 +14,20 @@ export const StoreProvider: React.FC> = ({ formRef, }) => { return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ) } From b3e21f6c0649b74907a276ee2390e38a0eda703f Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Tue, 23 Jun 2026 17:11:26 +0200 Subject: [PATCH 4/7] refactor(widget): derive split mode from active tab config --- .../navigationTabs/useNavigationTabsStore.tsx | 14 +++---- .../src/stores/navigationTabs/utils.test.ts | 40 ++++++++++++++++++- .../widget/src/stores/navigationTabs/utils.ts | 24 +++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx index 992126e7e..c2249f6fd 100644 --- a/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx +++ b/packages/widget/src/stores/navigationTabs/useNavigationTabsStore.tsx @@ -17,9 +17,12 @@ import type { SplitMode, WidgetConfig, } from '../../types/widget.js' -import { getSplitMode } from '../../utils/mode.js' import type { NavigationTabsState, NavigationTabsStore } from './types.js' -import { getInitialActiveTab, getNavigationTabs } from './utils.js' +import { + getInitialActiveTab, + getNavigationTabs, + resolveSplitMode, +} from './utils.js' const NavigationTabsStoreContext = createContext( null @@ -87,12 +90,7 @@ export function useNavigationTabsStore( export function useSplitMode(): SplitMode | undefined { const activeTab = useNavigationTabsStore((state) => state.activeTab) const config = useWidgetConfig() - if (!activeTab) { - return undefined - } - return config.mode === 'split' - ? getSplitMode(config.modeOptions?.split) - : undefined + return resolveSplitMode(config, activeTab) } const createNavigationTabsStore = ( diff --git a/packages/widget/src/stores/navigationTabs/utils.test.ts b/packages/widget/src/stores/navigationTabs/utils.test.ts index 2706eae02..bf555428e 100644 --- a/packages/widget/src/stores/navigationTabs/utils.test.ts +++ b/packages/widget/src/stores/navigationTabs/utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { NavigationTabConfig, WidgetConfig } from '../../types/widget.js' -import { getInitialActiveTab, getNavigationTabs } from './utils.js' +import { + getInitialActiveTab, + getNavigationTabs, + resolveSplitMode, +} from './utils.js' const config = (overrides: Partial = {}): WidgetConfig => overrides as WidgetConfig @@ -40,3 +44,37 @@ describe('getInitialActiveTab', () => { expect(getInitialActiveTab(config({ mode: 'default' }))).toBeUndefined() }) }) + +describe('resolveSplitMode', () => { + it('derives split mode from the active tab when tabs are configured', () => { + const tabs = [ + tab('swap', { mode: 'split', modeOptions: { split: 'swap' } }), + tab('bridge', { mode: 'split', modeOptions: { split: 'bridge' } }), + ] + const widgetConfig = config({ _navigationTabs: tabs, mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'bridge')).toBe('bridge') + expect(resolveSplitMode(widgetConfig, 'swap')).toBe('swap') + }) + + it('returns undefined when the active tab is not in split mode', () => { + const tabs = [tab('refuel', { mode: 'refuel' }), tab('private')] + // Even though the widget config is split, the active (non-split) tab wins. + const widgetConfig = config({ _navigationTabs: tabs, mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'refuel')).toBeUndefined() + }) + + it('resolves implicit split tabs (swap/bridge) when none are configured', () => { + const widgetConfig = config({ mode: 'split' }) + expect(resolveSplitMode(widgetConfig, 'swap')).toBe('swap') + expect(resolveSplitMode(widgetConfig, 'bridge')).toBe('bridge') + }) + + it('falls back to the widget config when there are no tabs at all', () => { + expect( + resolveSplitMode( + config({ mode: 'split', modeOptions: { split: 'swap' } }) + ) + ).toBe('swap') + expect(resolveSplitMode(config({ mode: 'default' }))).toBeUndefined() + }) +}) diff --git a/packages/widget/src/stores/navigationTabs/utils.ts b/packages/widget/src/stores/navigationTabs/utils.ts index 220f77f6c..960b0c4a2 100644 --- a/packages/widget/src/stores/navigationTabs/utils.ts +++ b/packages/widget/src/stores/navigationTabs/utils.ts @@ -1,6 +1,7 @@ import type { NavigationTabConfig, NavigationTabKey, + SplitMode, WidgetConfig, } from '../../types/widget.js' import { getSplitMode } from '../../utils/mode.js' @@ -46,3 +47,26 @@ export const getInitialActiveTab = ( ? getSplitMode(config.modeOptions?.split) : undefined } + +/** + * Resolves the effective split mode. With tabs configured it derives solely from + * the active tab's own config; with no tabs at all it falls back to the widget + * config. + */ +export const resolveSplitMode = ( + config: WidgetConfig, + activeTab?: NavigationTabKey +): SplitMode | undefined => { + const navigationTabs = getNavigationTabs(config) + if (navigationTabs.length) { + const activeConfig = navigationTabs.find( + (tab) => tab.tabKey === activeTab + )?.config + return activeConfig?.mode === 'split' + ? getSplitMode(activeConfig.modeOptions?.split) + : undefined + } + return config.mode === 'split' + ? getSplitMode(config.modeOptions?.split) + : undefined +} From 6e3d2f684b59dbd26e2f19bdc39c7ed7d6702295 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Tue, 23 Jun 2026 17:20:16 +0200 Subject: [PATCH 5/7] refactor(widget): update config comments --- .../src/defaultWidgetConfig.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/widget-playground/src/defaultWidgetConfig.ts b/packages/widget-playground/src/defaultWidgetConfig.ts index e5bddc102..42923204f 100644 --- a/packages/widget-playground/src/defaultWidgetConfig.ts +++ b/packages/widget-playground/src/defaultWidgetConfig.ts @@ -62,24 +62,9 @@ export const widgetBaseConfig: WidgetConfig = { // mode: 'split', // _navigationTabs: [ // { tabKey: 'default', config: { variant: 'wide', mode: 'default' } }, - // { tabKey: 'private', config: { variant: 'compact', mode: 'split', modeOptions: { split: 'swap' } } }, + // { tabKey: 'private', config: { variant: 'compact', mode: 'default', requiredUI: { toAddress: true } } }, // { tabKey: 'refuel', config: { variant: 'wide', mode: 'refuel' } }, // ], - // Private tab routing through a dedicated backend endpoint. `keyPrefix` is - // mandatory whenever a tab overrides `sdkConfig.apiUrl` — it isolates the - // React Query cache so private-endpoint data isn't served from the public one. - // _navigationTabs: [ - // { tabKey: 'default', config: {} }, - // { - // tabKey: 'private', - // config: { - // sdkConfig: { apiUrl: 'https://li.quest/private/v1' }, - // requiredUI: { toAddress: true }, - // useRelayerRoutes: true, - // keyPrefix: 'private', - // }, - // }, - // ], // _navigationTabs: [ // { tabKey: 'swap-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'swap' } } }, // { tabKey: 'bridge-advanced', config: { variant: 'wide', mode: 'split', modeOptions: { split: 'bridge' } } }, From 8df9a504541f044ff781d0cad7a3b62447c7b75d Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Tue, 23 Jun 2026 17:59:51 +0200 Subject: [PATCH 6/7] chore: add changeset --- .changeset/per-tab-navigation-config.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/per-tab-navigation-config.md diff --git a/.changeset/per-tab-navigation-config.md b/.changeset/per-tab-navigation-config.md new file mode 100644 index 000000000..c0b19b4a7 --- /dev/null +++ b/.changeset/per-tab-navigation-config.md @@ -0,0 +1,5 @@ +--- +'@lifi/widget': minor +--- + +Internal: `_navigationTabs` entries now carry a per-tab `config` (`Partial`) instead of just a key, so an active tab can override any widget config field. Removes the built-in per-key preset table. `_navigationTabs` remains internal and is not part of the public API. From dfc0e3cb8f9618f001ef704c9b51ade280f2375c Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Wed, 24 Jun 2026 09:54:37 +0200 Subject: [PATCH 7/7] docs(widget): note non-overridable fields on NavigationTabConfig --- packages/widget/src/types/widget.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/widget/src/types/widget.ts b/packages/widget/src/types/widget.ts index 909c24c8e..8bff2a9b0 100644 --- a/packages/widget/src/types/widget.ts +++ b/packages/widget/src/types/widget.ts @@ -81,6 +81,11 @@ export type NavigationTabKey = InternalNavigationTabKey | SplitNavigationTabKey */ export interface NavigationTabConfig { tabKey: NavigationTabKey + /** + * Config overrides applied while this tab is active. Note that `sdkConfig`, + * `theme`, `appearance` and `languages` are read above the tab provider and + * are therefore not tab-overridable — setting them here has no effect. + */ config: Partial }