diff --git a/src/bundles/connected.js b/src/bundles/connected.js index a3c1acff5..a7f0b3e6a 100644 --- a/src/bundles/connected.js +++ b/src/bundles/connected.js @@ -1,4 +1,5 @@ import { createSelector } from 'redux-bundler' +import { contextBridge } from '../helpers/context-bridge' /** * @typedef {Object} Model @@ -80,6 +81,24 @@ const connected = { return state } }, + + /** + * Bridge ipfsConnected state to context bridge for use by React contexts + */ + reactConnectedToBridge: createSelector( + 'selectIpfsConnected', + (ipfsConnected) => { + contextBridge.setContext('selectIpfsConnected', ipfsConnected) + } + ), + + reactIsNodeInfoOpenToBridge: createSelector( + 'selectIsNodeInfoOpen', + (isNodeInfoOpen) => { + contextBridge.setContext('selectIsNodeInfoOpen', isNodeInfoOpen) + } + ), + ...actions, ...selectors } diff --git a/src/bundles/identity.js b/src/bundles/identity.js deleted file mode 100644 index 990f2f2b6..000000000 --- a/src/bundles/identity.js +++ /dev/null @@ -1,34 +0,0 @@ -import { createAsyncResourceBundle, createSelector } from 'redux-bundler' - -// Matches APP_IDLE and peer bandwidth update intervals -export const IDENTITY_REFRESH_INTERVAL_MS = 5000 - -const bundle = createAsyncResourceBundle({ - name: 'identity', - actionBaseType: 'IDENTITY', - getPromise: ({ getIpfs }) => getIpfs().id().catch((err) => { - console.error('Failed to get identity', err) - }), - staleAfter: IDENTITY_REFRESH_INTERVAL_MS, - persist: false, - checkIfOnline: false -}) - -bundle.selectIdentityLastSuccess = state => state.identity.lastSuccess - -// Update identity after we (re)connect with ipfs -bundle.reactIdentityFetch = createSelector( - 'selectIpfsConnected', - 'selectIdentityIsLoading', - 'selectIdentityLastSuccess', - 'selectConnectedLastError', - (connected, isLoading, idLastSuccess, connLastError) => { - if (connected && !isLoading) { - if (!idLastSuccess || connLastError > idLastSuccess) { - return { actionCreator: 'doFetchIdentity' } - } - } - } -) - -export default bundle diff --git a/src/bundles/index.js b/src/bundles/index.js index 1021b308b..5e6f8312d 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -15,7 +15,6 @@ import toursBundle from './tours.js' import notifyBundle from './notify.js' import connectedBundle from './connected.js' import retryInitBundle from './retry-init.js' -import identityBundle from './identity.js' import bundleCache from '../lib/bundle-cache.js' import ipfsDesktop from './ipfs-desktop.js' import repoStats from './repo-stats.js' @@ -31,7 +30,6 @@ export default composeBundles( }), appIdle({ idleTimeout: 5000 }), ipfsProvider, - identityBundle, routesBundle, redirectsBundle, toursBundle, diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 5a0cf3b9f..65ea3422f 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -6,6 +6,8 @@ import last from 'it-last' import * as Enum from '../lib/enum.js' import { perform } from './task.js' import { readSetting, writeSetting } from './local-storage.js' +import { contextBridge } from '../helpers/context-bridge' +import { createSelector } from 'redux-bundler' /** * @typedef {import('ipfs').IPFSService} IPFSService @@ -305,7 +307,12 @@ const selectors = { /** * @param {State} state */ - selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection + selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection, + /** + * Returns the IPFS instance. This is the same instance that getIpfs() returns. + * Used by the identity context to access IPFS directly. + */ + selectIpfs: () => ipfs } /** @@ -524,6 +531,17 @@ const bundle = { getExtraArgs () { return extra }, + + /** + * Bridge ipfs instance to context bridge for use by React contexts + */ + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ), + ...selectors, ...actions } diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index 6d1252360..141f0c24f 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -7,10 +7,22 @@ import { multiaddr } from '@multiformats/multiaddr' import ms from 'milliseconds' import ip from 'ip' import memoize from 'p-memoize' +import { createContextSelector } from '../helpers/context-bridge' import pkgJson from '../../package.json' const { dependencies } = pkgJson +/** + * Selector that reads identity from the context bridge + * Returns the same format as the original selectIdentity for compatibility + */ +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity +} + // After this time interval, we re-check the locations for each peer // once again through PeerLocationResolver. const UPDATE_EVERY = ms.seconds(1) @@ -55,7 +67,7 @@ function createPeersLocations (opts) { 'selectPeers', 'selectPeerLocations', 'selectBootstrapPeers', - 'selectIdentity', // ipfs.id info for local node, used for detecting local peers + selectIdentityData, // ipfs.id info from identity context, used for detecting local peers (peers, locations = {}, bootstrapPeers, identity) => peers && Promise.all(peers.map(async (peer) => { const peerId = peer.peer const locationObj = locations ? locations[peerId] : null diff --git a/src/contexts/identity-context.tsx b/src/contexts/identity-context.tsx new file mode 100644 index 000000000..9b93fffd6 --- /dev/null +++ b/src/contexts/identity-context.tsx @@ -0,0 +1,199 @@ +import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react' +import { useBridgeContext, useBridgeSelector } from '../helpers/context-bridge' + +/** + * Identity data structure + */ +export interface IdentityData { + /** The IPFS peer ID */ + id?: string + /** The public key */ + publicKey?: string + /** List of multiaddresses */ + addresses?: string[] + /** Agent version */ + agentVersion?: string + /** Protocol version */ + protocolVersion?: string +} + +/** + * Identity context value + */ +export interface IdentityContextValue { + /** The identity data, undefined if not loaded */ + identity?: IdentityData + /** Whether identity is being fetched for the first time */ + isLoading: boolean + /** Whether there was an error fetching identity */ + hasError: boolean + /** Last successful fetch timestamp */ + lastSuccess?: number + /** Function to manually refetch identity */ + refetch: () => void + /** + * Whether identity is being updated (loading, but we already have a good identity response) + */ + isRefreshing: boolean +} + +/** + * Identity state for the reducer + */ +interface IdentityState { + identity?: IdentityData + isLoading: boolean + hasError: boolean + lastSuccess?: number +} + +/** + * Actions for the identity reducer + */ +type IdentityAction = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; payload: { identity: IdentityData; timestamp: number } } + | { type: 'FETCH_ERROR' } + +/** + * Identity reducer + */ +function identityReducer (state: IdentityState, action: IdentityAction): IdentityState { + switch (action.type) { + case 'FETCH_START': + return { + ...state, + isLoading: true, + hasError: false + } + case 'FETCH_SUCCESS': + return { + ...state, + identity: action.payload.identity, + isLoading: false, + hasError: false, + lastSuccess: action.payload.timestamp + } + case 'FETCH_ERROR': + return { + ...state, + isLoading: false, + hasError: true + } + default: + return state + } +} + +/** + * Initial state + */ +const initialState: IdentityState = { + identity: undefined, + isLoading: false, + hasError: false, + lastSuccess: undefined +} + +/** + * Identity context + */ +const IdentityContext = createContext(undefined) +IdentityContext.displayName = 'IdentityContext' + +/** + * Identity Provider Props + */ +interface IdentityProviderProps { + children: ReactNode +} + +/** + * Identity provider component using context bridge for redux selectors + */ +const IdentityProviderImpl: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + + // keep last good identity to prevent UI flash + const lastGoodIdentityRef = useRef(state.identity) + useEffect(() => { + if (state.identity) lastGoodIdentityRef.current = state.identity + }, [state.identity]) + + const identityStable = state.identity ?? lastGoodIdentityRef.current + const isInitialLoading = identityStable == null && state.isLoading + const isRefreshing = identityStable != null && state.isLoading + + const fetchIdentity = useCallback(async () => { + if (!ipfsConnected || !ipfs) return + try { + dispatch({ type: 'FETCH_START' }) + const identity = await ipfs.id() + dispatch({ + type: 'FETCH_SUCCESS', + payload: { identity, timestamp: Date.now() } + }) + } catch (error) { + console.error('Failed to fetch identity:', error) + dispatch({ type: 'FETCH_ERROR' }) + } + }, [ipfs, ipfsConnected]) + + useEffect(() => { + if (ipfsConnected && !state.isLoading) { + if (!state.identity || !state.lastSuccess) { + fetchIdentity() + } + } + }, [ipfsConnected, fetchIdentity, state.isLoading, state.identity, state.lastSuccess]) + + useEffect(() => { + if (!shouldPoll || !ipfsConnected || !state.lastSuccess) return + + const REFRESH_INTERVAL = 5000 + const timeSinceLastSuccess = Date.now() - state.lastSuccess + + if (timeSinceLastSuccess < REFRESH_INTERVAL) { + const timeout = setTimeout(fetchIdentity, REFRESH_INTERVAL - timeSinceLastSuccess) + return () => clearTimeout(timeout) + } else { + fetchIdentity() + } + }, [shouldPoll, ipfsConnected, state.lastSuccess, fetchIdentity]) + + const contextValue: IdentityContextValue = useMemo(() => ({ + identity: identityStable, + isLoading: isInitialLoading, + isRefreshing, + hasError: state.hasError, + lastSuccess: state.lastSuccess, + refetch: fetchIdentity + }), [identityStable, isInitialLoading, isRefreshing, state.hasError, state.lastSuccess, fetchIdentity]) + + useBridgeContext('identity', contextValue) + + return ( + + {children} + + ) +} + +/** + * Hook to consume the identity context + */ +export function useIdentity (): IdentityContextValue { + const context = useContext(IdentityContext) + if (context === undefined) { + throw new Error('useIdentity must be used within an IdentityProvider') + } + return context +} + +/** + * Identity provider component + */ +export const IdentityProvider = IdentityProviderImpl diff --git a/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md b/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md new file mode 100644 index 000000000..73586b404 --- /dev/null +++ b/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md @@ -0,0 +1,477 @@ +# Migration Guide: Redux-Bundler to React Context + +This guide shows how to completely replace redux-bundler bundles with React context while maintaining compatibility with existing dependent bundles. + +## Overview + +The migration approach has two main strategies: + +1. **React Components**: Migrate directly to use context hooks +2. **Redux Bundles**: Use the context bridge to access context values + +## Step 1: Set Up the Context Bridge + +First, add the context bridge to your app root and ensure redux bundles bridge their values: + +```tsx +// src/index.js +import { ContextBridgeProvider } from './helpers/context-bridge.jsx' + +function App() { + return ( + + + {/* Your app - contexts added per page as needed */} + + + ) +} +``` + +**Update redux bundles to bridge their values:** + +```js +// src/bundles/ipfs-provider.js +import { contextBridge } from '../helpers/context-bridge.jsx' +import { createSelector } from 'redux-bundler' + +const bundle = { + // ... existing bundle code + + // Bridge ipfs instance to context bridge + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ), + + // Bridge connection status to context bridge + reactIpfsConnectedToBridge: createSelector( + 'selectIpfsConnected', + (ipfsConnected) => { + contextBridge.setContext('selectIpfsConnected', ipfsConnected) + } + ) +} +``` + +## Step 2: Create Self-Contained Context + +### Before (Redux Bundle) +```js +// src/bundles/identity.js +const bundle = createAsyncResourceBundle({ + name: 'identity', + getPromise: ({ getIpfs }) => getIpfs().id(), + // ... +}) +``` + +### After (React Context) +```tsx +// src/contexts/identity-context.tsx - Current implementation +const IdentityProviderImpl: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // ✅ Get values from redux bundles via bridge (redux bundles are still source of truth) + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + + // ✅ React context is now source of truth for identity data + const fetchIdentity = useCallback(async () => { + if (!ipfsConnected || !ipfs) return + const identity = await ipfs.id() + dispatch({ type: 'FETCH_SUCCESS', payload: { identity, timestamp: Date.now() } }) + }, [ipfs, ipfsConnected]) + + // Auto-fetch and polling logic + useEffect(() => { /* fetch logic */ }, [ipfsConnected, fetchIdentity]) + useEffect(() => { /* polling logic */ }, [shouldPoll, ipfsConnected, fetchIdentity]) + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // ✅ Expose to redux bundles that still need identity data + useBridgeContext('identity', contextValue) + + return ( + + {children} + + ) +} +``` + +**📍 Current State**: Identity context is the source of truth for identity data, but consumes IPFS/connection data from redux bundles that haven't been migrated yet. + +## Step 3: Update Dependent Bundles + +### Before (Using Redux Selector) +```js +// src/bundles/peer-locations.js +bundle.selectPeerLocationsForSwarm = createSelector( + 'selectPeers', + 'selectPeerLocations', + 'selectBootstrapPeers', + 'selectIdentity', // ❌ Redux selector + (peers, locations, bootstrapPeers, identity) => { + // Use identity.addresses... + } +) +``` + +### After (Using Context Bridge) +```js +// src/bundles/peer-locations.js +import { createContextSelector } from '../helpers/context-bridge.jsx' + +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity +} + +bundle.selectPeerLocationsForSwarm = createSelector( + 'selectPeers', + 'selectPeerLocations', + 'selectBootstrapPeers', + selectIdentityData, // ✅ Context bridge + (peers, locations, bootstrapPeers, identity) => { + // Same usage as before + } +) +``` + +## Step 4: Update React Components + +### Before (Redux Connect) +```js +// src/status/NodeInfo.js +const NodeInfo = ({ identity, t }) => { + return
{identity?.id}
+} + +export default connect('selectIdentity', NodeInfo) +``` + +### After (React Hooks) +```tsx +// src/status/NodeInfo.tsx +const NodeInfo = ({ t }) => { + const { identity, isLoading, hasError, refetch } = useIdentity() + + if (isLoading) return
Loading...
+ if (hasError) return + + return
{identity?.id}
+} + +export default NodeInfo // No connect needed! +``` + +## Step 5: Remove Original Bundle + +Once all dependencies are migrated: + +1. Remove the original bundle from `src/bundles/index.js` +2. Remove the bundle file entirely +3. Remove any remaining imports + +```js +// src/bundles/index.js +export default composeBundles( + // ...other bundles + // identityBundle, ❌ Remove this line + // ...other bundles +) +``` + +## Step 6: Final Migration to Pure React Contexts + +Once all `connect()` usage is removed and everything uses `useBridgeSelector`, you can start migrating to pure React contexts that use hooks directly instead of the bridge. + +### Phase 1: Mixed Context Dependencies + +Some contexts may depend on others, and while bundles still exist, we still need to use the bridge to expose those context values to bundles. Use direct hooks between contexts, and use the bridge to expose those context values to bundles: + +```tsx +// src/contexts/identity-context.tsx - Mixed: some dependencies migrated, some not +const IdentityProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // ✅ These contexts are migrated - use direct hooks + const { ipfs, isConnected } = useIpfs() // Direct hook (IPFS context is source of truth) + const { isNodeInfoOpen } = useConnected() // Direct hook (Connected context is source of truth) + + // ❌ Don't use bridge to consume - use direct hooks instead + // const ipfs = useBridgeSelector('selectIpfs') // Old way + + const fetchIdentity = useCallback(async () => { + if (!isConnected || !ipfs) return + const identity = await ipfs.id() + dispatch({ type: 'FETCH_SUCCESS', payload: { identity, timestamp: Date.now() } }) + }, [ipfs, isConnected]) + + // Auto-fetch and polling logic + useEffect(() => { /* fetch logic */ }, [isConnected, fetchIdentity]) + useEffect(() => { /* polling logic */ }, [isNodeInfoOpen, isConnected, fetchIdentity]) + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // ✅ Still expose to redux bundles that haven't migrated yet + useBridgeContext('identity', contextValue) + + return {children} +} +``` + +### Phase 2: Context-to-Context Communication + +Direct context dependencies instead of bridge: + +```tsx +// src/contexts/peer-locations-context.tsx +const PeerLocationsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [locations, setLocations] = useState({}) + + // ✅ All dependencies are React contexts - use direct hooks + const { peers } = usePeers() // Direct hook (Peers context is source of truth) + const { identity } = useIdentity() // Direct hook (Identity context is source of truth) + + // ❌ Don't use bridge selectors when React contexts are source of truth + // const identity = createContextSelector('identity')() // Old way + + const updateLocations = useCallback(async () => { + // Use identity.addresses directly from React context + const newLocations = await fetchLocationsForPeers(peers, identity?.addresses) + setLocations(newLocations) + }, [peers, identity?.addresses]) + + const contextValue = { locations, updateLocations } + + // ✅ Still expose to any remaining redux bundles that need peer location data + useBridgeContext('peerLocations', contextValue) + + return {children} +} +``` + +### Phase 3: Remove Bridge Entirely + +Once all bundles are migrated to contexts, remove bridge usage: + +```tsx +// src/contexts/identity-context.tsx - Pure React, no bridge +const IdentityProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // Pure React context dependencies + const { ipfs, isConnected } = useIpfs() + const { isNodeInfoOpen } = useConnected() + + // Same logic as before... + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // No more bridge needed! 🎉 + return {children} +} +``` + +### Component Usage - Same Throughout + +React components use the same hooks regardless of implementation: + +```tsx +// Always the same - bridge or no bridge +const NodeInfo = () => { + const { identity, isLoading, hasError, refetch } = useIdentity() + const { locations } = usePeerLocations() + + // Component logic stays identical +} +``` + +## Migration Checklist + +### Phase 1: Bridge-Based Migration (Per Bundle) + +- [ ] **Step 1**: Ensure redux bundles bridge their values with `contextBridge.setContext` +- [ ] **Step 2**: Create self-contained context using `useBridgeSelector` for redux dependencies +- [ ] **Step 3**: Add bridge registration with `useBridgeContext()` to expose context to bundles +- [ ] **Step 4**: Identify all dependent bundles and update them to use `createContextSelector()` +- [ ] **Step 5**: Update React components to use context hooks instead of `connect()` +- [ ] **Step 6**: Test that all functionality works (initial load, updates, polling) +- [ ] **Step 7**: Remove original bundle from bundle composition +- [ ] **Step 8**: Delete original bundle file + +### Phase 2: Pure React Context Migration + +Once all bundles are migrated and no `connect()` usage remains: + +- [ ] **Step 9**: Migrate contexts to use direct hooks instead of `useBridgeSelector` +- [ ] **Step 10**: Implement context-to-context communication with direct hooks +- [ ] **Step 11**: Keep `useBridgeContext()` for any remaining redux bundles +- [ ] **Step 12**: Test all inter-context dependencies work correctly + +### Phase 3: Remove Bridge System (Final) + +When all redux bundles are migrated to contexts: + +- [ ] **Step 13**: Remove all `useBridgeContext()` calls from contexts +- [ ] **Step 14**: Remove `ContextBridgeProvider` from app root +- [ ] **Step 15**: Delete bridge system files (`context-bridge.tsx`, etc.) +- [ ] **Step 16**: Clean up any remaining bridge imports +- [ ] **Step 17**: Final testing - pure React context architecture! 🎉 + +### Key Patterns: + +**React Context Pattern:** +```tsx +// ✅ Consume redux-bundler values (redux is source of truth) +const ipfsConnected = useBridgeSelector('selectIpfsConnected') +const someReduxValue = useBridgeSelector('selectSomeValue') + +// ✅ Expose context values to redux bundles (React is source of truth) +useBridgeContext('myContext', contextValue) + +// ✅ Use direct hooks when both are React contexts +const { otherData } = useOtherContext() +``` + +**Redux Bundle Pattern:** +```js +// ✅ Expose redux values to React contexts (redux is source of truth) +reactSomethingToBridge: createSelector( + 'selectSomething', + (value) => contextBridge.setContext('selectSomething', value) +) + +// ✅ Consume React context values (React is source of truth) +const selectContextData = createContextSelector('myContext') +``` + +**🎯 Bridge Direction Rules:** +- **Redux → React**: Use `contextBridge.setContext()` in redux reactors +- **React → Redux**: Use `useBridgeContext()` in React providers +- **React → React**: Use direct hooks (no bridge needed) + +### Bundle Dependencies Map + +Use this to track what needs updating: + +``` +identity bundle +├── bundles/ +│ ├── peer-locations.js (uses selectIdentity) +│ └── other-bundle.js +├── components/ +│ ├── status/NodeInfo.js (uses selectIdentity) +│ ├── status/NodeInfoAdvanced.js (uses selectIdentity) +│ └── status/StatusPage.js (uses doFetchIdentity) +``` + +## Key Hooks and Bridge Functions + +### `useBridgeSelector(contextName: string): T | undefined` +**Use in React contexts** to reactively access redux-bundler values **when redux-bundler is the source of truth**: + +```tsx +const IdentityProvider = ({ children }) => { + // ✅ Redux-bundler bundles are still the source of truth for these values + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + + // Use these redux values in your React context logic... +} +``` + +**⚠️ Only use when:** Redux-bundler bundle is the source of truth and hasn't been migrated yet. + +### `useBridgeContext(contextName: string, value: T): void` +**Use in React contexts** to expose context values to redux bundles **when React context is the source of truth**: + +```tsx +const IdentityProvider = ({ children }) => { + // ✅ React context is the source of truth for identity data + const contextValue = { identity, isLoading, hasError, refetch } + + // Expose to redux bundles that haven't been migrated yet + useBridgeContext('identity', contextValue) + + return {children} +} +``` + +**⚠️ Only use when:** React context is the source of truth and redux bundles still need access to the context value. + +### `createContextSelector(contextName: string)` +**Use in redux bundles** to access context values: + +```js +// src/bundles/peer-locations.js +import { createContextSelector } from '../helpers/context-bridge.jsx' + +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity // Access specific properties +} +``` + +### `contextBridge.setContext(contextName: string, value: T)` +**Use in redux bundle reactors** to expose redux values to React contexts **when redux-bundler is the source of truth**: + +```js +// src/bundles/ipfs-provider.js +import { contextBridge } from '../helpers/context-bridge.jsx' + +const bundle = { + // ✅ Redux bundle is the source of truth for IPFS instance + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ) +} +``` + +**⚠️ Only use when:** Redux bundle is the source of truth and React contexts need access. + +## Benefits After Migration + +✅ **Modern React patterns** - hooks instead of redux-bundler HOCs +✅ **Better TypeScript support** - full type safety +✅ **Easier testing** - mock context instead of redux store +✅ **Simpler state management** - no redux boilerplate +✅ **Performance optimizations** - targeted re-renders +✅ **Reduced bundle size** - remove redux-bundler dependencies + +## Migration Pattern for Other Bundles + +This same pattern works for any redux-bundler bundle: + +1. **Files Bundle** → `FilesProvider` + `useFiles()` +2. **Config Bundle** → `ConfigProvider` + `useConfig()` +3. **Peers Bundle** → `PeersProvider` + `usePeers()` + +Each context can still access IPFS and other redux state during the transition, making migration safe and gradual. + +## Troubleshooting + +**Context value is undefined in bundles** +- Check that the context provider is above the redux Provider +- Verify `useBridgeContext()` is called in the provider + +**Bundle selector not updating** +- Redux selectors may need to depend on additional state to trigger updates +- Consider using bundle reactors for context subscriptions + +**Performance issues** +- Use `useMemo()` for expensive context value calculations +- Split large contexts into smaller, focused ones diff --git a/src/helpers/connected-context-provider.tsx b/src/helpers/connected-context-provider.tsx new file mode 100644 index 000000000..6da32956f --- /dev/null +++ b/src/helpers/connected-context-provider.tsx @@ -0,0 +1,113 @@ +/** + * @see {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + */ +import React, { createContext, useContext, useMemo, ReactNode } from 'react' +// @ts-expect-error - redux-bundler-react is not typed +import { connect } from 'redux-bundler-react' + +/** + * Configuration for creating a connected context provider + */ +export interface ConnectedContextConfig { + /** The name of the context (for debugging) */ + name: string + /** + * Function that takes redux selectors and returns the context value + * @param selectors - Object containing all the connected selectors/actions + * @returns The value to provide in the context + */ + selector: (selectors: any) => T + /** + * Array of selector/action names to connect to redux-bundler + * e.g., ['selectIdentity', 'selectIdentityIsLoading', 'doFetchIdentity'] + */ + reduxSelectors: string[] + /** Optional default value for the context */ + defaultValue?: T +} + +/** + * Result of creating a connected context + */ +export interface ConnectedContextResult { + /** The React context */ + Context: React.Context + /** The connected provider component */ + Provider: React.ComponentType<{ children: ReactNode }> + /** Hook to consume the context */ + useContext: () => T +} + +/** + * Creates a React context with a provider that is connected to redux-bundler selectors. + * This enables gradual migration from redux-bundler to React context while maintaining + * access to other redux state. + * + * See {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + * + * @param config - Configuration for the connected context + * @returns Object containing the Context, Provider, and useContext hook + * + * @example + * ```tsx + * // Create a connected identity context + * const { Provider: IdentityProvider, useContext: useIdentity } = createConnectedContextProvider({ + * name: 'Identity', + * selector: ({ identity, identityIsLoading, doFetchIdentity }) => ({ + * identity, + * isLoading: identityIsLoading, + * refetch: doFetchIdentity + * }), + * reduxSelectors: ['selectIdentity', 'selectIdentityIsLoading', 'doFetchIdentity'] + * }) + * + * // Use in components + * function MyComponent() { + * const { identity, isLoading, refetch } = useIdentity() + * // ... + * } + * ``` + */ +export function createConnectedContextProvider ( + config: ConnectedContextConfig +): ConnectedContextResult { + const { name, selector, reduxSelectors, defaultValue } = config + + // Create the React context + const Context = createContext(defaultValue) + Context.displayName = `${name}Context` + + // Create the provider component that connects to redux + const ConnectedProvider = connect( + ...reduxSelectors, + (props: any) => { + const { children, ...reduxProps } = props + + // Use the selector function to transform redux state into context value + const contextValue = useMemo(() => { + return selector(reduxProps) + }, [reduxProps]) + + return ( + + {children} + + ) + } + ) + + // Create the hook to consume the context + const useContextHook = (): T => { + const context = useContext(Context) + if (context === undefined) { + throw new Error(`use${name} must be used within a ${name}Provider`) + } + return context + } + + return { + Context, + Provider: ConnectedProvider as unknown as React.FC<{ children: ReactNode }>, + useContext: useContextHook + } +} diff --git a/src/helpers/context-bridge.tsx b/src/helpers/context-bridge.tsx new file mode 100644 index 000000000..9d6b9334e --- /dev/null +++ b/src/helpers/context-bridge.tsx @@ -0,0 +1,155 @@ +import React, { useContext, useEffect, createContext, ReactNode } from 'react' + +/** + * Global store for context values that redux bundles can access + * + * See {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + */ +class ContextBridge { + private contexts: Map = new Map() + private subscribers: Map void>> = new Map() + + /** + * Set a context value and notify subscribers + */ + setContext (name: string, value: T): void { + this.contexts.set(name, value) + const subs = this.subscribers.get(name) + if (subs) { + subs.forEach(callback => callback(value)) + } + } + + /** + * Get the current value of a context + */ + getContext (name: string): T | undefined { + return this.contexts.get(name) + } + + /** + * Subscribe to changes in a context value + */ + subscribe (name: string, callback: (value: T) => void): () => void { + if (!this.subscribers.has(name)) { + this.subscribers.set(name, new Set()) + } + this.subscribers.get(name)!.add(callback) + + // Return unsubscribe function + return () => { + const subs = this.subscribers.get(name) + if (subs) { + subs.delete(callback) + } + } + } + + /** + * Check if a context is available + */ + hasContext (name: string): boolean { + return this.contexts.has(name) + } +} + +/** + * Global instance of the context bridge + */ +export const contextBridge = new ContextBridge() + +/** + * Props for the ContextBridgeProvider + */ +interface ContextBridgeProviderProps { + children: ReactNode +} + +/** + * Context for the bridge itself (to trigger re-renders when needed) + */ +const BridgeContext = createContext(contextBridge) + +/** + * Provider that makes the context bridge available + */ +export const ContextBridgeProvider: React.FC = ({ children }) => { + return ( + + {children} + + ) +} + +/** + * Hook that registers a context value with the bridge + */ +export function useBridgeContext (name: string, contextValue: T): void { + const bridge = useContext(BridgeContext) + + useEffect(() => { + bridge.setContext(name, contextValue) + }, [bridge, name, contextValue]) +} + +/** + * Higher-order component that automatically bridges a context to redux bundles + */ +export function withContextBridge ( + contextName: string, + ContextToUse: React.Context +) { + return function BridgeWrapper ({ children }: { children: ReactNode }) { + const contextValue = useContext(ContextToUse) + const bridge = useContext(BridgeContext) + + useEffect(() => { + if (contextValue !== undefined) { + bridge.setContext(contextName, contextValue) + } + }, [bridge, contextValue]) + + return <>{children} + } +} + +/** + * Create a selector that reads from a context bridge (non-reactive) + */ +export function createContextSelector (contextName: string) { + return () => contextBridge.getContext(contextName) +} + +/** + * Hook that reactively subscribes to a context bridge value + */ +export function useBridgeSelector (contextName: string): T | undefined { + const [value, setValue] = React.useState(() => + contextBridge.getContext(contextName) + ) + + React.useEffect(() => { + // Set initial value + const currentValue = contextBridge.getContext(contextName) + setValue(currentValue) + + // Subscribe to changes + const unsubscribe = contextBridge.subscribe(contextName, (newValue) => { + setValue(newValue) + }) + + return unsubscribe + }, [contextName]) + + return value +} + +/** + * Create a subscription to context changes for use in bundle reactors + */ +export function createContextSubscription ( + contextName: string, + callback: (value: T) => void +): () => void { + return contextBridge.subscribe(contextName, callback) +} diff --git a/src/index.js b/src/index.js index 72b53a1ee..5ac1181b2 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import i18n from './i18n.js' import { DndProvider } from 'react-dnd' import DndBackend from './lib/dnd-backend.js' import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' +import { ContextBridgeProvider } from './helpers/context-bridge.jsx' const appVersion = process.env.REACT_APP_VERSION const gitRevision = process.env.REACT_APP_GIT_REV @@ -33,15 +34,17 @@ async function render () { const store = getStore(initialData) ReactDOM.render( - - - - - - - - - + + + + + + + + + + + , document.getElementById('root') ) diff --git a/src/status/NodeInfo.js b/src/status/NodeInfo.js index 64f2ec275..e5021258c 100644 --- a/src/status/NodeInfo.js +++ b/src/status/NodeInfo.js @@ -1,41 +1,20 @@ import React from 'react' -import { withTranslation } from 'react-i18next' -import { connect } from 'redux-bundler-react' +import { useTranslation } from 'react-i18next' +import { useIdentity } from '../contexts/identity-context.jsx' import VersionLink from '../components/version-link/VersionLink.js' import { Definition, DefinitionList } from '../components/definition/Definition.js' -class NodeInfo extends React.Component { - getField (obj, field, fn) { - if (obj && obj[field]) { - if (fn) { - return fn(obj[field]) - } +const NodeInfo = () => { + const { identity } = useIdentity() + const { t } = useTranslation('app') - return obj[field] - } - - return '' - } - - getVersion (identity) { - const raw = this.getField(identity, 'agentVersion') - return raw ? raw.split('/').join(' ') : '' - } - - render () { - const { t, identity } = this.props - - return ( - - - } /> - - - ) - } + return ( + + + } /> + + + ) } -export default connect( - 'selectIdentity', - withTranslation('app')(NodeInfo) -) +export default NodeInfo diff --git a/src/status/NodeInfoAdvanced.js b/src/status/NodeInfoAdvanced.js index 2b3e9a081..b4d7ff6fa 100644 --- a/src/status/NodeInfoAdvanced.js +++ b/src/status/NodeInfoAdvanced.js @@ -1,7 +1,8 @@ -import React from 'react' +import React, { useMemo } from 'react' import { multiaddr } from '@multiformats/multiaddr' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' +import { useIdentity } from '../contexts/identity-context.jsx' import Address from '../components/address/Address.js' import Details from '../components/details/Details.js' import ProviderLink from '../components/provider-link/ProviderLink.js' @@ -15,25 +16,20 @@ function isMultiaddr (addr) { return false } } -const getField = (obj, field, fn) => { - if (obj && obj[field]) { - if (fn) { - return fn(obj[field]) - } - return obj[field] - } +const NodeInfoAdvanced = ({ t, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeInfoOpen, doSetIsNodeInfoOpen }) => { + const { identity, isLoading } = useIdentity() + const loadingString = t('loading') - return '' -} + const addressComponent = useMemo(() => { + if (isLoading || identity?.addresses == null) return loadingString + return [...new Set(identity.addresses)].sort().map(addr =>
) + }, [identity?.addresses, isLoading, loadingString]) -const NodeInfoAdvanced = ({ t, identity, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeInfoOpen, doSetIsNodeInfoOpen }) => { - let publicKey = null - let addresses = null - if (identity) { - publicKey = getField(identity, 'publicKey') - addresses = [...new Set(identity.addresses)].sort().map(addr =>
) - } + const publicKeyComponent = useMemo(() => { + if (isLoading) return loadingString + return identity?.publicKey ?? null + }, [identity?.publicKey, isLoading, loadingString]) const handleSummaryClick = (ev) => { doSetIsNodeInfoOpen(!isNodeInfoOpen) @@ -53,25 +49,24 @@ const NodeInfoAdvanced = ({ t, identity, ipfsProvider, ipfsApiAddress, gatewayUr {ipfsProvider === 'httpClient' ? + (
{isMultiaddr(ipfsApiAddress) ? (
) : asAPIString(ipfsApiAddress) } - {t('app:actions.edit')} + {t('app:actions.edit')}
) } /> : } /> } - - + + ) } export default connect( - 'selectIdentity', 'selectIpfsProvider', 'selectIpfsApiAddress', 'selectGatewayUrl', diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index 2c973f74d..214c5884e 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -1,8 +1,9 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Helmet } from 'react-helmet' import { withTranslation, Trans } from 'react-i18next' import { connect } from 'redux-bundler-react' import ReactJoyride from 'react-joyride' +import { IdentityProvider } from '../contexts/identity-context.jsx' import StatusConnected from './StatusConnected.js' import BandwidthStatsDisabled from './BandwidthStatsDisabled.js' import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' @@ -15,7 +16,6 @@ import AnalyticsBanner from '../components/analytics-banner/AnalyticsBanner.js' import { statusTour } from '../lib/tours.js' import { getJoyrideLocales } from '../helpers/i8n.js' import withTour from '../components/tour/withTour.js' -import { IDENTITY_REFRESH_INTERVAL_MS } from '../bundles/identity.js' const StatusPage = ({ t, @@ -27,27 +27,8 @@ const StatusPage = ({ doToggleShowAnalyticsBanner, toursEnabled, handleJoyrideCallback, - nodeBandwidthEnabled, - doFetchIdentity, - isNodeInfoOpen + nodeBandwidthEnabled }) => { - // Refresh identity when page mounts - useEffect(() => { - if (ipfsConnected) { - doFetchIdentity() - } - }, [ipfsConnected, doFetchIdentity]) - - // Refresh identity when Advanced section is open - useEffect(() => { - if (ipfsConnected && isNodeInfoOpen) { - const intervalId = setInterval(() => { - doFetchIdentity() - }, IDENTITY_REFRESH_INTERVAL_MS) - - return () => clearInterval(intervalId) - } - }, [ipfsConnected, isNodeInfoOpen, doFetchIdentity]) return (
@@ -60,10 +41,12 @@ const StatusPage = ({ ? (
- -
- -
+ + +
+ +
+
) : ( @@ -115,10 +98,8 @@ export default connect( 'selectShowAnalyticsBanner', 'selectShowAnalyticsComponents', 'selectToursEnabled', - 'selectIsNodeInfoOpen', 'doEnableAnalytics', 'doDisableAnalytics', 'doToggleShowAnalyticsBanner', - 'doFetchIdentity', withTour(withTranslation('status')(StatusPage)) ) diff --git a/tsconfig.json b/tsconfig.json index ac9429f20..116166797 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,8 @@ "src/icons/GlyphPinCloud.js", "src/icons/GlyphPin.js", "src/icons/StrokeCube.js", - "src/files/type-from-ext/extToType.js" + "src/files/type-from-ext/extToType.js", + "src/helpers/context-bridge.tsx", + "src/helpers/connected-context-provider.tsx" ] }