mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-10-30 15:19:21 +00:00
* refactor: reduce useLoadFeature re-renders
Three targeted optimizations to cut unnecessary re-renders during
feature loading:
1. Replace 3 separate useState calls in useAsync (packages/utils) with
a single useReducer. Each state transition is now one dispatch = one
render, eliminating double-renders from setData + setLoading firing
in separate microtasks on promise resolution.
2. Cache loaded feature module in a ref inside useLoadFeature. Survives
isEnabled flicker during chain switches (true -> undefined -> true)
by returning the cached module from a resolved promise instead of
re-running import(). Feature modules are static so re-fetching is
pure waste.
3. Stabilize the stub proxy reference in useLoadFeature. The Proxy is
created once per hook instance and reads meta values ($isLoading,
$isDisabled, etc.) from a ref. The object reference stays the same
across all not-ready states, so children receiving the feature object
don't re-render on meta changes alone.
* refactor: eliminate $isLoading from useLoadFeature
Remove the intermediate loading state from the feature loading lifecycle.
Features now transition directly from not-ready to ready in a single
render, reducing unnecessary re-renders. Replace useAsync with a simpler
useEffect + useState that only triggers one state update.
The useAsync optimizations (useReducer) are kept in packages/utils as
they benefit all consumers.
* refactor: reduce useLoadFeature cyclomatic complexity to 9
Extract getFeature, getError, and toError helpers to move branch
points out of the main hook function. Simplify the final useMemo
condition since $isReady is derived from !!feature.
* refactor: add shared feature registry to useLoadFeature
Replace per-instance cachedFeatureRef with a module-level registry
(featureCache Map) shared across all hook instances. When multiple
components call useLoadFeature(SameFeature), only the first triggers
a load — subsequent components get the result synchronously via the
useState initializer (1 render instead of 2).
Also deduplicates concurrent load() calls via a pendingLoads Map.
Errors are not cached globally so retry is possible on remount.
* revert: undo useAsync useReducer optimization
The useReducer replacement caused an infinite render loop when
asyncCall returns undefined — dispatch({ type: 'reset' }) always
creates a new state object reference, unlike the original useState
calls which bail out on same primitive values. This caused OOM
crashes in useAddressActivity tests.
The useLoadFeature optimizations (shared registry, stable proxy,
eliminated $isLoading) are independent and remain in place.
---------
Co-authored-by: Hanzo Dev <[email protected]>
6.1 KiB
6.1 KiB
Quickstart: Migrate No Fee Campaign to Feature Architecture
Date: 2025-01-27
Feature: 004-migrate-no-fee-campaign
Overview
This guide provides step-by-step instructions for migrating the No Fee Campaign feature to the new Feature Architecture pattern. The migration maintains 100% functional parity while enabling lazy loading and proper code-splitting.
Prerequisites
- Feature architecture infrastructure (
@/features/__core__) available createFeatureHandlehelper function working- ESLint restricted import rules configured
- Feature flag
FEATURES.NO_FEE_NOVEMBERexists - Semantic mapping
'no-fee-campaign': FEATURES.NO_FEE_NOVEMBERexists increateFeatureHandle.ts
Migration Steps
Step 1: Create Feature Contract
Create apps/web/src/features/no-fee-campaign/contract.ts:
import type NoFeeCampaignBanner from './components/NoFeeCampaignBanner'
import type NoFeeCampaignTransactionCard from './components/NoFeeCampaignTransactionCard'
import type GasTooHighBanner from './components/GasTooHighBanner'
export interface NoFeeCampaignContract {
NoFeeCampaignBanner: typeof NoFeeCampaignBanner
NoFeeCampaignTransactionCard: typeof NoFeeCampaignTransactionCard
GasTooHighBanner: typeof GasTooHighBanner
}
Step 2: Create Feature Implementation
Create apps/web/src/features/no-fee-campaign/feature.ts:
import type { NoFeeCampaignContract } from './contract'
// Direct imports - this file is already lazy-loaded
import NoFeeCampaignBanner from './components/NoFeeCampaignBanner'
import NoFeeCampaignTransactionCard from './components/NoFeeCampaignTransactionCard'
import GasTooHighBanner from './components/GasTooHighBanner'
// Flat structure - naming conventions determine stub behavior
export default {
NoFeeCampaignBanner,
NoFeeCampaignTransactionCard,
GasTooHighBanner,
} satisfies NoFeeCampaignContract
Step 3: Create Public API
Update apps/web/src/features/no-fee-campaign/index.ts:
import { createFeatureHandle } from '@/features/__core__'
import type { NoFeeCampaignContract } from './contract'
// Feature handle - uses semantic mapping (no-fee-campaign → FEATURES.NO_FEE_NOVEMBER)
export const NoFeeCampaignFeature = createFeatureHandle<NoFeeCampaignContract>('no-fee-campaign')
// Export contract type
export type { NoFeeCampaignContract } from './contract'
// Export hooks directly (always loaded, not in contract)
export { useIsNoFeeCampaignEnabled } from './hooks/useIsNoFeeCampaignEnabled'
export { useNoFeeCampaignEligibility } from './hooks/useNoFeeCampaignEligibility'
export { useGasTooHigh } from './hooks/useGasTooHigh'
Step 4: Update Consumer Code
Update all consumers to use useLoadFeature() pattern:
Before (direct import - VIOLATION)::
import NoFeeCampaignBanner from '@/features/no-fee-campaign/components/NoFeeCampaignBanner'
import { useNoFeeCampaignEligibility } from '@/features/no-fee-campaign/hooks/useNoFeeCampaignEligibility'
After:
import { NoFeeCampaignFeature, useNoFeeCampaignEligibility } from '@/features/no-fee-campaign'
import { useLoadFeature } from '@/features/__core__'
function MyComponent() {
// Prefer destructuring for cleaner component usage
const { NoFeeCampaignBanner } = useLoadFeature(NoFeeCampaignFeature)
const eligibility = useNoFeeCampaignEligibility()
return <NoFeeCampaignBanner />
}
Consumer locations to update:
apps/web/src/components/dashboard/index.tsxapps/web/src/components/tx-flow/actions/Execute/ExecuteForm.tsxapps/web/src/components/tx/ExecutionMethodSelector/index.tsxapps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsxapps/web/src/pages/balances/index.tsx
Step 5: Verify Migration
Run quality gates:
# Type check
yarn workspace @safe-global/web type-check
# Lint (should show no restricted import warnings)
yarn workspace @safe-global/web lint
# Format
yarn workspace @safe-global/web prettier
# Tests (all should pass)
yarn workspace @safe-global/web test
Step 6: Verify Bundle Splitting
Build and verify code-splitting:
yarn workspace @safe-global/web build
# Check that No Fee Campaign code is in separate chunk
ls -la apps/web/.next/static/chunks/ | grep -i no-fee
Key Patterns
Component Usage
// Prefer destructuring for cleaner component usage
// Components render null when not ready (no check needed)
const { NoFeeCampaignBanner } = useLoadFeature(NoFeeCampaignFeature)
return <NoFeeCampaignBanner onDismiss={handleDismiss} />
Hook Usage
// Hooks imported directly, always safe to call
import { useNoFeeCampaignEligibility } from '@/features/no-fee-campaign'
const { isEligible, remaining, limit } = useNoFeeCampaignEligibility()
Explicit State Handling
const noFeeFeature = useLoadFeature(NoFeeCampaignFeature)
if (noFeeFeature.$isDisabled) return null
if (!noFeeFeature.$isReady) return <Skeleton />
// Destructure after state checks
const { NoFeeCampaignBanner } = noFeeFeature
return <NoFeeCampaignBanner />
Common Pitfalls
- Don't use lazy() in feature.ts - The entire feature is already lazy-loaded
- Don't include hooks in contract - Export them directly from index.ts
- Don't nest structure - Use flat structure in contract and feature.ts
- Don't import from internal folders - Use feature handle and direct hook imports
- Don't change business logic - Maintain 100% functional parity
Verification Checklist
contract.tscreated with flat structurefeature.tscreated with direct importsindex.tsexports feature handle and hooks- All consumers updated to use
useLoadFeature() - Type check passes
- Lint passes (no restricted import warnings)
- Tests pass
- Bundle analysis shows code-splitting
- Feature works identically to before migration
Next Steps
After migration:
- Verify all acceptance scenarios from spec
- Run bundle analysis to confirm code-splitting
- Test on chains with feature enabled and disabled
- Update Storybook stories if needed
- Document any edge cases discovered