* 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]>
5.6 KiB
Data Model: Migrate No Fee Campaign to Feature Architecture
Date: 2025-01-27
Feature: 004-migrate-no-fee-campaign
Phase: 1 - Design & Contracts
Entities
No Fee Campaign Feature Handle
Type: Runtime object (FeatureHandle)
Purpose: Entry point for lazy loading the feature
Properties:
name: string- Feature identifier:'no-fee-campaign'useIsEnabled: () => boolean | undefined- Feature flag check hookload: () => Promise<{ default: NoFeeCampaignContract }>- Lazy loader function
Relationships:
- Created by:
createFeatureHandle('no-fee-campaign') - Used by:
useLoadFeature()hook - Maps to:
FEATURES.NO_FEE_NOVEMBERvia semantic mapping
State Transitions:
- Initial: Handle created (static, ~100 bytes)
- Enabled:
useIsEnabled()returnstrue→ triggersload() - Disabled:
useIsEnabled()returnsfalse→ no loading - Loading:
useIsEnabled()returnsundefined→ waiting for flag resolution
No Fee Campaign Contract
Type: TypeScript interface
Purpose: Defines public API surface (components only, no hooks)
Properties (flat structure):
NoFeeCampaignBanner: typeof NoFeeCampaignBanner- Dashboard banner componentNoFeeCampaignTransactionCard: typeof NoFeeCampaignTransactionCard- Transaction card componentGasTooHighBanner: typeof GasTooHighBanner- Gas limit warning banner
Relationships:
- Implemented by:
feature.ts(lazy-loaded) - Referenced by:
contract.ts(type definition) - Used by: TypeScript type inference for
useLoadFeature()
Validation Rules:
- All properties must be components (PascalCase naming)
- No hooks in contract (exported separately from index.ts)
- No services in contract (none exist for this feature)
- Must use
typeofpattern for IDE navigation
No Fee Campaign Eligibility
Type: Hook return value
Purpose: Represents eligibility state for sponsored transactions
Properties:
isEligible: boolean | undefined- Whether Safe is eligibleremaining: number | undefined- Remaining sponsored transactionslimit: number | undefined- Total limit for sponsored transactionsisLoading: boolean- Loading state for eligibility dataerror: Error | undefined- Error if eligibility check failedblockedAddress?: string- Blocked address if detected
Relationships:
- Provided by:
useNoFeeCampaignEligibility()hook - Used by: Components (NoFeeCampaignTransactionCard, ExecuteForm, ExecutionMethodSelector)
- Depends on: Backend API (
useRelayGetRelaysRemainingV1Query)
State Transitions:
- Initial:
isLoading: true,isEligible: undefined - Loading:
isLoading: true, data fetching - Eligible:
isEligible: true,remaining > 0,limit > 0 - Not Eligible:
isEligible: false(no limit or limit reached) - Blocked:
isEligible: false,blockedAddressset - Error:
errorset,isEligible: false
Validation Rules:
isEligiblerequires: feature enabled ANDlimit > 0AND not blockedremainingmust be<= limitwhen both are definedblockedAddresstakes precedence over eligibility check
Gas Limit Check
Type: Hook return value
Purpose: Determines if transaction gas exceeds campaign limit
Properties:
gasTooHigh: boolean | undefined- Whether gas limit exceedsMAX_GAS_LIMIT_NO_FEE_CAMPAIGN
Relationships:
- Provided by:
useGasTooHigh(safeTx)hook - Used by: ExecuteForm, ExecutionMethodSelector
- Depends on:
MAX_GAS_LIMIT_NO_FEE_CAMPAIGNconstant (1,000,000 gas)
Validation Rules:
gasTooHigh: truewhengasLimit > MAX_GAS_LIMIT_NO_FEE_CAMPAIGNgasTooHigh: undefinedwhengasLimitis not availablegasTooHigh: falsewhengasLimit <= MAX_GAS_LIMIT_NO_FEE_CAMPAIGN
Feature Meta Properties
Type: Properties added by useLoadFeature()
Purpose: State information about feature loading
Properties (prefixed with $):
$isDisabled: boolean- Feature flag is disabled for current chain$isReady: boolean- Feature is loaded and enabled$error: Error | undefined- Error if loading failed
State Transitions:
- Initial:
$isDisabled: false,$isReady: false - Ready:
$isDisabled: false,$isReady: true - Disabled:
$isDisabled: true,$isReady: false - Error:
$errorset,$isReady: false
Relationships Summary
NoFeeCampaignFeatureHandle
├── uses → createFeatureHandle('no-fee-campaign')
├── maps to → FEATURES.NO_FEE_NOVEMBER (semantic mapping)
└── loads → NoFeeCampaignContract
NoFeeCampaignContract
├── defines → NoFeeCampaignBanner
├── defines → NoFeeCampaignTransactionCard
└── defines → GasTooHighBanner
Hooks (exported from index.ts, not in contract)
├── useIsNoFeeCampaignEnabled() → boolean | undefined
├── useNoFeeCampaignEligibility() → Eligibility object
└── useGasTooHigh(safeTx) → boolean | undefined
Eligibility
├── depends on → Backend API (useRelayGetRelaysRemainingV1Query)
├── depends on → useBlockedAddress()
└── depends on → useIsNoFeeCampaignEnabled()
Gas Limit Check
└── depends on → MAX_GAS_LIMIT_NO_FEE_CAMPAIGN constant
Migration Impact
No data model changes - This is a pure refactoring task. All entities remain the same, only their organization and access patterns change:
- Before: Direct imports from feature folders
- After: Access via
useLoadFeature()and direct hook imports
Data flow unchanged:
- Eligibility data still comes from same backend API
- Gas limit checks still use same constant
- Blocked address detection still uses same logic
- All state transitions remain identical