Files
d12b2b2617 refactor: optimize useLoadFeature with shared registry and reduced re-renders (#7154)
* 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]>
2026-02-12 16:05:20 +01:00

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
  • createFeatureHandle helper function working
  • ESLint restricted import rules configured
  • Feature flag FEATURES.NO_FEE_NOVEMBER exists
  • Semantic mapping 'no-fee-campaign': FEATURES.NO_FEE_NOVEMBER exists in createFeatureHandle.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:

  1. apps/web/src/components/dashboard/index.tsx
  2. apps/web/src/components/tx-flow/actions/Execute/ExecuteForm.tsx
  3. apps/web/src/components/tx/ExecutionMethodSelector/index.tsx
  4. apps/web/src/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer.tsx
  5. apps/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

  1. Don't use lazy() in feature.ts - The entire feature is already lazy-loaded
  2. Don't include hooks in contract - Export them directly from index.ts
  3. Don't nest structure - Use flat structure in contract and feature.ts
  4. Don't import from internal folders - Use feature handle and direct hook imports
  5. Don't change business logic - Maintain 100% functional parity

Verification Checklist

  • contract.ts created with flat structure
  • feature.ts created with direct imports
  • index.ts exports 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:

  1. Verify all acceptance scenarios from spec
  2. Run bundle analysis to confirm code-splitting
  3. Test on chains with feature enabled and disabled
  4. Update Storybook stories if needed
  5. Document any edge cases discovered