Skip to content

Commit e071690

Browse files
committed
feat: add ConfigContext infrastructure for instance-scoped configuration
Introduces React Context pattern for config access, enabling future support for multiple WormholeConnect instances on the same page. Changes: - Add ConfigContext with ConfigProvider and useConfig() hook - Wrap WormholeConnect with ConfigProvider - Update AppRouter to use useConfig() (example migration) - Add TestConfigContext pattern in testHelpers for test isolation - Mark singleton export with @deprecated JSDoc - Fix TypeScript dev server errors (@types/color, env.d.ts) The singleton remains functional for backwards compatibility. Migration can proceed incrementally file-by-file.
1 parent 0e29327 commit e071690

9 files changed

Lines changed: 410 additions & 471 deletions

File tree

package-lock.json

Lines changed: 174 additions & 389 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
"@playwright/test": "1.54.1",
155155
"@testing-library/jest-dom": "^6.8.0",
156156
"@testing-library/react": "^16.3.0",
157+
"@types/color": "^4.2.0",
157158
"@types/node": "^20",
158159
"@types/node-fetch": "^2.6.3",
159160
"@types/react": "^19.1.4",

src/AppRouter.tsx

Lines changed: 20 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import React, { useCallback, useContext, useEffect, useRef } from 'react';
1+
import React, { useContext, useEffect, useRef } from 'react';
22
import { useDispatch, useSelector } from 'react-redux';
33
import { useTheme } from '@mui/material/styles';
44

55
import './App.css';
66
import type { RootState } from './store';
77
import { clearRedeem } from './store/redeem';
88
import { clearTransfer } from './store/transferInput';
9-
import { isEmptyObject, usePrevious } from './utils';
10-
import type { WormholeConnectConfig } from './config/types';
11-
import { setConfig } from './config';
12-
import config from './config';
9+
import { usePrevious } from './utils';
10+
import { useConfig } from './contexts/ConfigContext';
1311

1412
import Terms from './views/Terms';
1513
import TxSearch from './views/TxSearch';
@@ -80,56 +78,32 @@ const AppRouterContent = () => {
8078
);
8179
};
8280

83-
interface Props {
84-
config?: WormholeConnectConfig;
85-
}
86-
8781
// since this will be embedded, we'll have to use pseudo routes instead of relying on the url
88-
function AppRouter(props: Props) {
82+
function AppRouter() {
8983
const dispatch = useDispatch();
90-
91-
const hasSetSsgConfig = useRef(false);
92-
const isInitialLoad = useRef(true);
84+
const config = useConfig();
9385
const route = useSelector((state: RootState) => state.router.route);
9486

95-
const loadConfig = useCallback((customConfig: WormholeConnectConfig) => {
96-
if (!isEmptyObject(customConfig)) {
97-
setConfig(customConfig);
98-
}
99-
100-
hasSetSsgConfig.current = true;
101-
config.triggerEvent({
102-
type: 'config',
103-
config: customConfig,
104-
});
105-
}, []);
87+
// Track config changes to clear transfer state when config is updated
88+
const prevConfig = usePrevious(config);
89+
const hasInitialized = useRef(false);
10690

107-
if (!hasSetSsgConfig.current) {
108-
// This runs once in SSG step (server-side pre-rendering)
109-
if (props.config) {
110-
loadConfig(props.config);
111-
}
112-
if (route !== 'bridge') {
113-
// The route may not be bridge on initial load if the component was re-rendered after client side navigation
114-
dispatch(setRoute('bridge'));
91+
// SSG route initialization - ensure we start on bridge route
92+
useEffect(() => {
93+
if (!hasInitialized.current) {
94+
hasInitialized.current = true;
95+
if (route !== 'bridge') {
96+
dispatch(setRoute('bridge'));
97+
}
11598
}
116-
}
99+
}, [route, dispatch]);
117100

101+
// Clear transfer state when config changes (after initial load)
118102
useEffect(() => {
119-
if (isInitialLoad.current) {
120-
isInitialLoad.current = false;
121-
config.triggerEvent({
122-
type: 'load',
123-
config: props.config,
124-
});
125-
} else {
126-
if (props.config) {
127-
loadConfig(props.config);
128-
dispatch(clearTransfer());
129-
}
103+
if (prevConfig && prevConfig !== config) {
104+
dispatch(clearTransfer());
130105
}
131-
}, [props.config, loadConfig, dispatch]);
132-
// END config loading code
106+
}, [config, prevConfig, dispatch]);
133107

134108
return <AppRouterContent />;
135109
}

src/WormholeConnect.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { WormholeConnectTheme } from 'theme';
1212
import { RouteProvider } from './contexts/RouteContext';
1313
import { TokensProvider } from './contexts/TokensContext';
1414
import WalletProvider from './contexts/wallet/WalletProvider';
15+
import { ConfigProvider } from './contexts/ConfigContext';
1516
import type { WormholeConnectWalletProvider } from './utils/wallet/types';
1617
import { internalWalletProvider } from './utils/wallet/InternalWalletProvider';
1718

@@ -56,13 +57,15 @@ export default function WormholeConnect({
5657
<ThemeProvider theme={muiTheme}>
5758
<ScopedCssBaseline enableColorScheme>
5859
<ErrorBoundary>
59-
<WalletProvider provider={walletProvider}>
60-
<TokensProvider>
61-
<RouteProvider>
62-
<AppRouter config={config} />
63-
</RouteProvider>
64-
</TokensProvider>
65-
</WalletProvider>
60+
<ConfigProvider config={config}>
61+
<WalletProvider provider={walletProvider}>
62+
<TokensProvider>
63+
<RouteProvider>
64+
<AppRouter />
65+
</RouteProvider>
66+
</TokensProvider>
67+
</WalletProvider>
68+
</ConfigProvider>
6669
</ErrorBoundary>
6770
</ScopedCssBaseline>
6871
</ThemeProvider>

src/config/index.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,28 @@ export function buildConfig(
171171

172172
// Running buildConfig with no argument generates the default configuration
173173
const config = buildConfig();
174+
175+
/**
176+
* @deprecated Direct import of config singleton is deprecated.
177+
* Use `useConfig()` hook from 'contexts/ConfigContext' instead.
178+
*
179+
* This singleton will be removed in a future version to support
180+
* multiple WormholeConnect instances on the same page.
181+
*
182+
* Migration:
183+
* ```tsx
184+
* // Before (deprecated)
185+
* import config from 'config';
186+
* const network = config.network;
187+
*
188+
* // After (recommended)
189+
* import { useConfig } from 'contexts/ConfigContext';
190+
* function MyComponent() {
191+
* const config = useConfig();
192+
* const network = config.network;
193+
* }
194+
* ```
195+
*/
174196
export default config;
175197

176198
export async function getWormholeContextV2(): Promise<WormholeV2<Network>> {
@@ -214,8 +236,14 @@ export async function newWormholeContextV2(): Promise<WormholeV2<Network>> {
214236
);
215237
}
216238

217-
// setConfig can be called afterwards to override the default config with integrator-provided config
218-
239+
/**
240+
* @deprecated setConfig() is deprecated and will be removed in a future version.
241+
* Configuration is now handled by ConfigProvider. Pass config directly to
242+
* <WormholeConnect config={...} /> instead.
243+
*
244+
* This function mutates the global singleton which prevents multiple
245+
* WormholeConnect instances from having independent configurations.
246+
*/
219247
export function setConfig(customConfig: WormholeConnectConfig = {}) {
220248
const newConfig: InternalConfig<Network> = buildConfig(customConfig);
221249

src/contexts/ConfigContext.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as React from 'react';
2+
import type { Network } from '@wormhole-foundation/sdk';
3+
import type { WormholeConnectConfig, InternalConfig } from '../config/types';
4+
import { buildConfig, setConfig } from '../config';
5+
import config from '../config';
6+
7+
export type ConfigContextType = InternalConfig<Network>;
8+
9+
const ConfigContext = React.createContext<ConfigContextType | null>(null);
10+
11+
export interface ConfigProviderProps {
12+
config?: WormholeConnectConfig;
13+
children: React.ReactNode;
14+
}
15+
16+
/**
17+
* ConfigProvider builds and provides instance-scoped configuration.
18+
*
19+
* Phase 1: During transition, this provider also calls setConfig() to maintain
20+
* backwards compatibility with the global singleton. This ensures existing code
21+
* that imports `config` directly continues to work.
22+
*
23+
* Future phases will remove the setConfig() call and the global singleton entirely.
24+
*/
25+
export const ConfigProvider: React.FC<ConfigProviderProps> = ({
26+
config: userConfig,
27+
children,
28+
}) => {
29+
// Build instance-specific config from user config
30+
// Memoize to prevent rebuilding on every render
31+
const internalConfig = React.useMemo(() => {
32+
// Build the config for this instance
33+
const builtConfig = buildConfig(userConfig);
34+
35+
// Phase 1: Also update global singleton for backwards compatibility
36+
// This ensures files that directly import config still work
37+
// TODO: Remove in Phase 5 when singleton is eliminated
38+
if (userConfig) {
39+
setConfig(userConfig);
40+
}
41+
42+
return builtConfig;
43+
}, [userConfig]);
44+
45+
// Trigger events on config changes (mirrors AppRouter behavior)
46+
const hasInitialized = React.useRef(false);
47+
React.useEffect(() => {
48+
if (!hasInitialized.current) {
49+
hasInitialized.current = true;
50+
config.triggerEvent({
51+
type: 'load',
52+
config: userConfig,
53+
});
54+
} else {
55+
config.triggerEvent({
56+
type: 'config',
57+
config: userConfig,
58+
});
59+
}
60+
}, [userConfig]);
61+
62+
return (
63+
<ConfigContext.Provider value={internalConfig}>
64+
{children}
65+
</ConfigContext.Provider>
66+
);
67+
};
68+
69+
/**
70+
* useConfig returns the instance-scoped configuration.
71+
*
72+
* This hook must be used within a ConfigProvider. It returns the InternalConfig
73+
* object built from the user-provided WormholeConnectConfig.
74+
*
75+
* @example
76+
* ```tsx
77+
* function MyComponent() {
78+
* const config = useConfig();
79+
* return <div>Network: {config.network}</div>;
80+
* }
81+
* ```
82+
*
83+
* @throws Error if used outside of ConfigProvider
84+
*/
85+
export function useConfig(): ConfigContextType {
86+
const contextValue = React.useContext(ConfigContext);
87+
88+
if (contextValue === null) {
89+
throw new Error(
90+
'useConfig must be used within a ConfigProvider. ' +
91+
'Ensure your component is wrapped in <WormholeConnect> or <ConfigProvider>.',
92+
);
93+
}
94+
95+
return contextValue;
96+
}
97+
98+
export { ConfigContext };

src/env.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
interface ImportMetaEnv {
44
// env
55
REACT_APP_CONNECT_ENV: string;
6+
REACT_APP_CONNECT_VERSION?: string;
7+
REACT_APP_CONNECT_GIT_HASH?: string;
68

79
// wallet connect
810
REACT_APP_WALLET_CONNECT_PROJECT_ID: string;
@@ -66,6 +68,14 @@ interface ImportMetaEnv {
6668
REACT_APP_PLUME_TESTNET_RPC: string;
6769
REACT_APP_XRPLEVM_TESTNET_RPC: string;
6870
REACT_APP_MONAD_TESTNET_RPC: string;
71+
REACT_APP_LINEA_TESTNET_RPC?: string;
72+
REACT_APP_SONIC_TESTNET_RPC?: string;
73+
REACT_APP_INK_TESTNET_RPC?: string;
74+
75+
// test private keys (dev only)
76+
REACT_APP_TEST_EVM_PK?: string;
77+
REACT_APP_SOL_PRIVATE_KEY?: string;
78+
REACT_APP_SUI_PRIVATE_KEY?: string;
6979

7080
// devnet
7181
REACT_APP_ETHEREUM_DEVNET_RPC: string;

src/utils/testHelpers.ts

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/utils/testHelpers.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { ReactNode } from 'react';
2+
import * as React from 'react';
3+
import type { Token } from 'config/tokens';
4+
5+
// Test-only context that mirrors ConfigContext without importing it
6+
// This avoids triggering SDK import chains during tests
7+
const TestConfigContext = React.createContext<unknown>(null);
8+
9+
// Export for tests that need direct access
10+
export { TestConfigContext };
11+
12+
/**
13+
* Test wrapper that provides ConfigContext directly with a pre-built config.
14+
*
15+
* This bypasses ConfigProvider's buildConfig() call which has deep SDK dependencies.
16+
* Use this for unit tests that need useConfig() to return a specific mock config.
17+
*
18+
* IMPORTANT: Tests using this wrapper MUST mock 'contexts/ConfigContext' to use
19+
* TestConfigContext, otherwise useConfig() won't receive the mock config.
20+
*
21+
* For integration tests, use ConfigProvider directly with a real config.
22+
*/
23+
export interface TestWrapperOptions {
24+
/**
25+
* Pre-built config object to provide via context.
26+
* This should be a partial InternalConfig with the fields your test needs.
27+
*/
28+
config?: Record<string, unknown>;
29+
}
30+
31+
export const createTestWrapper = (options: TestWrapperOptions = {}) => {
32+
const { config } = options;
33+
34+
const TestWrapper = ({ children }: { children: ReactNode }) => (
35+
<TestConfigContext.Provider value={config}>
36+
{children}
37+
</TestConfigContext.Provider>
38+
);
39+
40+
return TestWrapper;
41+
};
42+
43+
/**
44+
* Helper to create mock tokens for testing
45+
*/
46+
export const createMockToken = (overrides: Partial<Token> = {}): Token => {
47+
const chain = overrides.chain || 'Ethereum';
48+
const addressString = overrides.addressString || '0x1234567890abcdef';
49+
const address = overrides.address || addressString;
50+
51+
return {
52+
chain,
53+
addressString,
54+
address,
55+
symbol: 'TEST',
56+
name: 'Test Token',
57+
decimals: 18,
58+
isNativeGasToken: false,
59+
isTokenBridgeWrappedToken: false,
60+
isBuiltin: false,
61+
tokenId: {
62+
chain,
63+
address,
64+
},
65+
...overrides,
66+
} as Token;
67+
};

0 commit comments

Comments
 (0)