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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* 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 <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
katspaugh
2026-02-12 16:05:20 +01:00
committed by GitHub
co-authored by Hanzo Dev
parent a938689393
commit 39e37998f9
25 changed files with 582 additions and 145 deletions
+3 -3
View File
@@ -149,10 +149,10 @@ function ParentComponent() {
// For explicit loading/disabled states:
function ParentWithStates() {
const { MyComponent, $isLoading, $isDisabled } = useLoadFeature(MyFeature)
const { MyComponent, $isReady, $isDisabled } = useLoadFeature(MyFeature)
if ($isLoading) return <Skeleton />
if ($isDisabled) return null
if (!$isReady) return <Skeleton />
return <MyComponent />
}
@@ -199,7 +199,7 @@ export default {
**Hooks Pattern:** Hooks are exported directly from `index.ts` (always loaded, not lazy) to avoid Rules of Hooks violations. Keep hooks lightweight with minimal imports. Put heavy logic in services (lazy-loaded).
See `apps/web/docs/feature-architecture.md` for the complete guide including proxy-based stubs and meta properties (`$isLoading`, `$isDisabled`, `$isReady`).
See `apps/web/docs/feature-architecture.md` for the complete guide including proxy-based stubs and meta properties (`$isDisabled`, `$isReady`, `$error`).
## Workflow
+24 -30
View File
@@ -99,10 +99,10 @@ function MyPageWithHooks() {
// If you need explicit loading/disabled handling:
function MyPageWithStates() {
const { WalletConnectWidget, $isLoading, $isDisabled } = useLoadFeature(WalletConnectFeature)
const { WalletConnectWidget, $isReady, $isDisabled } = useLoadFeature(WalletConnectFeature)
if ($isLoading) return <Skeleton />
if ($isDisabled) return null
if (!$isReady) return <Skeleton />
return <WalletConnectWidget />
}
@@ -123,12 +123,11 @@ function MyPageWithStates() {
**Meta properties** (prefixed with `$`) provide state information:
| Property | Type | Description |
| ------------- | --------- | ------------------------------------ |
| `$isLoading` | `boolean` | `true` while feature code is loading |
| `$isDisabled` | `boolean` | `true` if feature flag is off |
| `$isReady` | `boolean` | `true` when loaded and enabled |
| `$error` | `Error?` | Error if loading failed |
| Property | Type | Description |
| ------------- | --------- | ------------------------------ |
| `$isDisabled` | `boolean` | `true` if feature flag is off |
| `$isReady` | `boolean` | `true` when loaded and enabled |
| `$error` | `Error?` | Error if loading failed |
### Why Proxy-Based Stubs?
@@ -292,8 +291,8 @@ export const chartService = {
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ $isLoading│ │$isDisabled│ │ $isReady │
true │ │ true │ │ true │
!$isReady │ │$isDisabled│ │ $isReady │
(false) │ │ true │ │ true │
└───────────┘ └───────────┘ └───────────┘
│ │ │
▼ ▼ ▼
@@ -328,14 +327,12 @@ export type FeatureImplementation = Record<string, unknown>
* Meta properties added by useLoadFeature ($ prefix)
*/
export interface FeatureMeta {
/** True while feature code is loading */
$isLoading: boolean
/** True if feature flag is disabled */
$isDisabled: boolean
/** True when feature is loaded and enabled */
$isReady: boolean
/** Error if loading failed */
$error: Error | null
$error: Error | undefined
}
/**
@@ -543,7 +540,6 @@ function createFeatureProxy<T>(meta: FeatureMeta, impl?: T): T & FeatureMeta {
return new Proxy({} as T & FeatureMeta, {
get(_, prop: string) {
// Meta properties ($ prefix)
if (prop === '$isLoading') return meta.$isLoading
if (prop === '$isDisabled') return meta.$isDisabled
if (prop === '$isReady') return meta.$isReady
if (prop === '$error') return meta.$error
@@ -590,10 +586,9 @@ export function useLoadFeature<T extends FeatureImplementation>(handle: FeatureH
// Build meta state
const meta: FeatureMeta = {
$isLoading: isEnabled === true && !cached && loading.has(handle.name),
$isDisabled: isEnabled === false,
$isReady: isEnabled === true && !!cached,
$error: null,
$error: undefined,
}
// Always return proxy - never null
@@ -650,8 +645,8 @@ function Header() {
function HeaderWithStates() {
const wc = useLoadFeature(WalletConnectFeature)
if (wc.$isLoading) return <Skeleton />
if (wc.$isDisabled) return null
if (!wc.$isReady) return <Skeleton />
return <wc.WalletConnectWidget />
}
@@ -808,9 +803,9 @@ const feature = useLoadFeature(MyFeature)
// ^? {
// MyComponent: ComponentType<...>,
// useMyHook: () => ...,
// $isLoading: boolean,
// $isDisabled: boolean,
// $isReady: boolean,
// $error: Error | undefined,
// }
// No null check needed - always an object
@@ -946,7 +941,7 @@ export const WalletConnectFeature = createFeatureHandle('walletconnect', FEATURE
| Value | Meaning | Behavior |
| ----------- | ------------------------------------- | ----------------------- |
| `undefined` | Loading (chain config not yet loaded) | `$isLoading` is true |
| `undefined` | Loading (chain config not yet loaded) | `$isReady` is false |
| `false` | Feature disabled for current chain | `$isDisabled` is true |
| `true` | Feature enabled | `$isReady` becomes true |
@@ -1071,7 +1066,7 @@ Benefits:
- **No optional chaining**: Proxy stubs eliminate `?.` complexity for components
- **React hooks compliant**: Hooks are direct imports (always loaded), no Rules of Hooks violations
- **Type-safe**: Full TypeScript inference from the handle
- **Simple API**: Always returns an object, use `$isReady`/`$isLoading`/`$isDisabled` for state
- **Simple API**: Always returns an object, use `$isReady`/`$isDisabled` for state
- **Flat structure**: No nested `.components.` - just `feature.MyComponent`
- **IDE-friendly**: Cmd+click on `WalletConnectFeature` jumps to the handle definition
- **Tree-shakeable**: Unused features won't be bundled
@@ -1625,8 +1620,8 @@ function SafeShieldScanner() {
function SafeShieldScannerWithStates() {
const hn = useLoadFeature(HypernativeFeature)
if (hn.$isLoading) return <Skeleton />
if (hn.$isDisabled) return null
if (!hn.$isReady) return <Skeleton />
const scanner = useHypernativeScanner()
return <hn.Banner data={scanner.data} />
@@ -1671,7 +1666,7 @@ function SafeShieldScannerWithStates() {
- [ ] **Importing hooks directly from feature index** (e.g., `import { useMyHook } from '@/features/myfeature'`)
- [ ] **No optional chaining** - feature always returns an object (proxy stubs for components)
- [ ] Using **flat access**: `feature.MyComponent`, `feature.myService` (no nested `.components.`)
- [ ] Using meta properties (`$isLoading`, `$isDisabled`, `$isReady`) for explicit state handling
- [ ] Using meta properties (`$isDisabled`, `$isReady`, `$error`) for explicit state handling
- [ ] Type-safe (types inferred from handle)
- [ ] No direct imports from feature internal folders (except hooks from index)
@@ -1709,7 +1704,7 @@ The handle is imported at app startup, but it's tiny (~100 bytes). The actual fe
**Always returns an object** - never `null` or `undefined`. The object includes:
1. **Feature exports** (flat structure) - actual implementation when ready, proxy stubs otherwise
2. **Meta properties** (`$isLoading`, `$isDisabled`, `$isReady`, `$error`)
2. **Meta properties** (`$isDisabled`, `$isReady`, `$error`)
```typescript
import { WalletConnectFeature, useWcUri } from '@/features/walletconnect'
@@ -1727,17 +1722,16 @@ return <wc.Widget />
For explicit state handling, use meta properties:
```typescript
if (wc.$isLoading) return <Skeleton />
if (wc.$isDisabled) return null
if (!wc.$isReady) return <Skeleton />
return <wc.Widget />
```
| Meta Property | Type | Description |
| ------------- | --------- | ------------------------------------ |
| `$isLoading` | `boolean` | `true` while feature code is loading |
| `$isDisabled` | `boolean` | `true` if feature flag is off |
| `$isReady` | `boolean` | `true` when loaded and enabled |
| `$error` | `Error?` | Error if loading failed |
| Meta Property | Type | Description |
| ------------- | --------- | ------------------------------ |
| `$isDisabled` | `boolean` | `true` if feature flag is off |
| `$isReady` | `boolean` | `true` when loaded and enabled |
| `$error` | `Error?` | Error if loading failed |
### Q: How do I share types between features?
@@ -24,7 +24,6 @@ jest.mock('@/features/__core__', () => ({
...jest.requireActual('@/features/__core__'),
useLoadFeature: jest.fn(() => ({
$isReady: true,
$isLoading: false,
$isDisabled: false,
HypernativeTooltip: ({ children }: { children: React.ReactNode }) => (
<span style={{ display: 'flex' }}>{children}</span>
@@ -40,7 +40,6 @@ describe('Header', () => {
jest.resetAllMocks()
// Default: WalletConnect disabled - useLoadFeature always returns an object with stubs
mockUseLoadFeature.mockReturnValue({
$isLoading: false,
$isDisabled: true,
$isReady: false,
WalletConnectWidget: () => null,
@@ -25,7 +25,7 @@ const NoModules = () => {
const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => {
const { setTxFlow } = useContext(TxModalContext)
const [recovery] = useRecovery()
const { selectDelayModifierByAddress, $isLoading } = useLoadFeature(RecoveryFeature)
const { selectDelayModifierByAddress, $isReady } = useLoadFeature(RecoveryFeature)
const delayModifier = recovery && selectDelayModifierByAddress?.(recovery, moduleAddress)
const onRemove = () => {
@@ -53,7 +53,7 @@ const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string
onClick={onRemove}
color="error"
size="small"
disabled={!isOk || $isLoading}
disabled={!isOk || !$isReady}
title="Remove module"
>
<SvgIcon component={DeleteIcon} inheritViewBox color="error" fontSize="small" />
@@ -59,7 +59,6 @@ describe('SecurityLogin', () => {
// Setup useLoadFeature mock to return our mock component
mockUseLoadFeature.mockReturnValue({
$isLoading: false,
$isDisabled: false,
$isReady: true,
HnActivatedSettingsBanner: MockHnActivatedSettingsBanner,
@@ -63,7 +63,6 @@ describe('SafeHeaderInfo', () => {
mockUseLoadFeature.mockReturnValue({
SafeHeaderHnTooltip,
$isLoading: false,
$isDisabled: false,
$isReady: true,
})
@@ -0,0 +1,378 @@
import { renderHook, waitFor } from '@/tests/test-utils'
import { useLoadFeature, _resetFeatureRegistry } from '../useLoadFeature'
import type { FeatureHandle } from '../types'
// Shared mock feature implementation
const mockImpl = {
MyComponent: () => 'rendered',
myService: () => 'service-result',
}
type MockImpl = typeof mockImpl
/** Creates a FeatureHandle with controllable isEnabled and load behavior. */
function createMockHandle(
overrides: {
isEnabled?: boolean | undefined
loadError?: Error
} = {},
): FeatureHandle<MockImpl> {
return {
name: 'test-feature',
useIsEnabled: () => overrides.isEnabled,
load: () => {
if (overrides.loadError) {
return Promise.reject(overrides.loadError)
}
return Promise.resolve({ default: mockImpl })
},
}
}
/** Renders the hook and optionally waits for it to settle. */
async function renderFeatureHook(overrides: Parameters<typeof createMockHandle>[0] = {}) {
const handle = createMockHandle(overrides)
const rendered = renderHook(() => useLoadFeature(handle))
// If feature is enabled, wait for the async load to complete
if (overrides.isEnabled === true && !overrides.loadError) {
await waitFor(() => {
expect(rendered.result.current.$isReady).toBe(true)
})
} else if (overrides.loadError) {
await waitFor(() => {
expect(rendered.result.current.$error).toBeDefined()
})
}
return { ...rendered, handle }
}
afterEach(() => {
_resetFeatureRegistry()
})
describe('useLoadFeature', () => {
// ── Meta state lifecycle ──────────────────────────────────────
describe('meta state lifecycle', () => {
it.each([
{
scenario: 'flag still loading (isEnabled undefined)',
isEnabled: undefined as boolean | undefined,
expected: { $isDisabled: false, $isReady: false, $error: undefined },
},
{
scenario: 'disabled (isEnabled false)',
isEnabled: false as boolean | undefined,
expected: { $isDisabled: true, $isReady: false, $error: undefined },
},
])('should report correct meta when $scenario', ({ isEnabled, expected }) => {
const handle = createMockHandle({ isEnabled })
const { result } = renderHook(() => useLoadFeature(handle))
expect(result.current.$isDisabled).toBe(expected.$isDisabled)
expect(result.current.$isReady).toBe(expected.$isReady)
expect(result.current.$error).toBe(expected.$error)
})
it('should reach $isReady after successful load', async () => {
const { result } = await renderFeatureHook({ isEnabled: true })
expect(result.current.$isReady).toBe(true)
expect(result.current.$isDisabled).toBe(false)
expect(result.current.$error).toBeUndefined()
})
it('should set $error on load failure', async () => {
const loadError = new Error('load failed')
const { result } = await renderFeatureHook({ isEnabled: true, loadError })
expect(result.current.$error).toEqual(loadError)
expect(result.current.$isReady).toBe(false)
})
it('should not expose a $isLoading meta property', () => {
const handle = createMockHandle({ isEnabled: undefined })
const { result } = renderHook(() => useLoadFeature(handle))
// $isLoading was removed — only $isDisabled, $isReady, $error exist
expect((result.current as unknown as Record<string, unknown>).$isLoading).toBeUndefined()
})
})
// ── Feature implementation access ─────────────────────────────
describe('feature implementation', () => {
it('should expose loaded feature exports when ready', async () => {
const { result } = await renderFeatureHook({ isEnabled: true })
expect(result.current.MyComponent).toBe(mockImpl.MyComponent)
expect(result.current.myService).toBe(mockImpl.myService)
})
it('should include name and useIsEnabled on loaded feature', async () => {
const { result, handle } = await renderFeatureHook({ isEnabled: true })
expect(result.current.name).toBe('test-feature')
expect(result.current.useIsEnabled).toBe(handle.useIsEnabled)
})
})
// ── Stub proxy behavior ───────────────────────────────────────
describe('stub proxy', () => {
it('should return a component stub (returns null) for PascalCase names', () => {
const handle = createMockHandle({ isEnabled: undefined })
const { result } = renderHook(() => useLoadFeature(handle))
const stub = result.current.MyComponent
expect(typeof stub).toBe('function')
expect(stub()).toBeNull()
})
it('should return undefined for camelCase service names', () => {
const handle = createMockHandle({ isEnabled: undefined })
const { result } = renderHook(() => useLoadFeature(handle))
expect(result.current.myService).toBeUndefined()
})
it('should return a stable proxy reference across re-renders while not ready', () => {
const handle = createMockHandle({ isEnabled: undefined })
const { result, rerender } = renderHook(() => useLoadFeature(handle))
const firstRef = result.current
rerender()
const secondRef = result.current
expect(firstRef).toBe(secondRef)
})
it('should return a stable component stub reference across accesses', () => {
const handle = createMockHandle({ isEnabled: undefined })
const { result } = renderHook(() => useLoadFeature(handle))
const stub1 = result.current.MyComponent
const stub2 = result.current.MyComponent
expect(stub1).toBe(stub2)
})
})
// ── Shared feature registry ────────────────────────────────────
describe('shared registry', () => {
it('should provide feature synchronously to second hook when already loaded', async () => {
const loadSpy = jest.fn(() => Promise.resolve({ default: mockImpl }))
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => true,
load: loadSpy,
}
// First hook loads the feature
const { result: result1 } = renderHook(() => useLoadFeature(handle))
await waitFor(() => expect(result1.current.$isReady).toBe(true))
// Second hook should get it synchronously
let renderCount = 0
const { result: result2 } = renderHook(() => {
renderCount++
return useLoadFeature(handle)
})
expect(result2.current.$isReady).toBe(true)
expect(result2.current.MyComponent).toBe(mockImpl.MyComponent)
// Synchronous init means at most 2 renders (React StrictMode may double-invoke)
expect(renderCount).toBeLessThanOrEqual(2)
// load() should have been called exactly once (by the first hook)
expect(loadSpy).toHaveBeenCalledTimes(1)
})
it('should deduplicate concurrent load() calls for same feature', async () => {
const loadSpy = jest.fn(() => Promise.resolve({ default: mockImpl }))
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => true,
load: loadSpy,
}
// Mount two hooks simultaneously
const { result: r1 } = renderHook(() => useLoadFeature(handle))
const { result: r2 } = renderHook(() => useLoadFeature(handle))
await waitFor(() => {
expect(r1.current.$isReady).toBe(true)
expect(r2.current.$isReady).toBe(true)
})
expect(loadSpy).toHaveBeenCalledTimes(1)
})
it('should not cache errors globally, allowing retry on remount', async () => {
const loadError = new Error('network error')
let shouldFail = true
const loadSpy = jest.fn(() => (shouldFail ? Promise.reject(loadError) : Promise.resolve({ default: mockImpl })))
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => true,
load: loadSpy,
}
// First mount fails
const { result: r1, unmount } = renderHook(() => useLoadFeature(handle))
await waitFor(() => expect(r1.current.$error).toBeDefined())
unmount()
// Second mount should retry and succeed
shouldFail = false
const { result: r2 } = renderHook(() => useLoadFeature(handle))
await waitFor(() => expect(r2.current.$isReady).toBe(true))
expect(loadSpy).toHaveBeenCalledTimes(2)
})
it('should maintain separate registry entries per feature name', async () => {
const mockImpl2 = { OtherComponent: () => 'other' }
const handle1: FeatureHandle<MockImpl> = {
name: 'feature-a',
useIsEnabled: () => true,
load: () => Promise.resolve({ default: mockImpl }),
}
const handle2: FeatureHandle<typeof mockImpl2> = {
name: 'feature-b',
useIsEnabled: () => true,
load: () => Promise.resolve({ default: mockImpl2 }),
}
const { result: r1 } = renderHook(() => useLoadFeature(handle1))
const { result: r2 } = renderHook(() => useLoadFeature(handle2))
await waitFor(() => {
expect(r1.current.$isReady).toBe(true)
expect(r2.current.$isReady).toBe(true)
})
expect(r1.current.MyComponent).toBe(mockImpl.MyComponent)
expect((r2.current as unknown as Record<string, unknown>).OtherComponent).toBe(mockImpl2.OtherComponent)
})
})
// ── Feature module caching ────────────────────────────────────
describe('feature caching', () => {
it('should not re-trigger load when isEnabled flickers', async () => {
let isEnabled: boolean | undefined = true
const loadSpy = jest.fn(() => Promise.resolve({ default: mockImpl }))
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => isEnabled,
load: loadSpy,
}
const { result, rerender } = renderHook(() => useLoadFeature(handle))
await waitFor(() => {
expect(result.current.$isReady).toBe(true)
})
expect(loadSpy).toHaveBeenCalledTimes(1)
// Flicker: true -> undefined -> true (simulates chain switch)
isEnabled = undefined
rerender()
isEnabled = true
rerender()
await waitFor(() => {
expect(result.current.$isReady).toBe(true)
})
// load() should NOT have been called again
expect(loadSpy).toHaveBeenCalledTimes(1)
})
})
// ── Render count ──────────────────────────────────────────────
describe('render count', () => {
it('should render at most twice for initial mount -> ready', async () => {
let renderCount = 0
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => true,
load: () => Promise.resolve({ default: mockImpl }),
}
const { result } = renderHook(() => {
renderCount++
return useLoadFeature(handle)
})
await waitFor(() => {
expect(result.current.$isReady).toBe(true)
})
// Mount render + ready render (no intermediate loading state)
expect(renderCount).toBeLessThanOrEqual(3)
})
it('should render once for disabled feature (no async work)', () => {
let renderCount = 0
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => false,
load: jest.fn(),
}
renderHook(() => {
renderCount++
return useLoadFeature(handle)
})
// Single synchronous render — no loading transition
expect(renderCount).toBeLessThanOrEqual(2)
})
it('should not cause extra renders on isEnabled flicker after load', async () => {
let isEnabled: boolean | undefined = true
let renderCount = 0
const handle: FeatureHandle<MockImpl> = {
name: 'test-feature',
useIsEnabled: () => isEnabled,
load: () => Promise.resolve({ default: mockImpl }),
}
const { result, rerender } = renderHook(() => {
renderCount++
return useLoadFeature(handle)
})
await waitFor(() => {
expect(result.current.$isReady).toBe(true)
})
const countAfterReady = renderCount
// Flicker: true -> undefined -> true
isEnabled = undefined
rerender()
isEnabled = true
rerender()
await waitFor(() => {
expect(result.current.$isReady).toBe(true)
})
// 2 explicit rerenders + possible effect re-run for cache restore.
const flickerRenders = renderCount - countAfterReady
expect(flickerRenders).toBeLessThanOrEqual(5)
})
})
})
+148 -73
View File
@@ -1,7 +1,6 @@
'use client'
import { useMemo } from 'react'
import useAsync from '@safe-global/utils/hooks/useAsync'
import { useEffect, useMemo, useRef, useState } from 'react'
import { logError, Errors } from '@/services/exceptions'
import type { FeatureHandle, FeatureImplementation } from './types'
@@ -10,8 +9,6 @@ import type { FeatureHandle, FeatureImplementation } from './types'
* Prefixed with $ to avoid conflicts with feature exports.
*/
interface FeatureMeta {
/** True while feature code is loading */
$isLoading: boolean
/** True if feature flag is disabled */
$isDisabled: boolean
/** True when feature is loaded and ready to use */
@@ -20,22 +17,43 @@ interface FeatureMeta {
$error: Error | undefined
}
type LoadResult<T> = { feature: T } | { error: Error } | undefined
/** Extracts the feature from a load result, or undefined. */
function getFeature<T>(result: LoadResult<T>): T | undefined {
return result && 'feature' in result ? result.feature : undefined
}
/** Extracts the error from a load result, or undefined. */
function getError<T>(result: LoadResult<T>): Error | undefined {
return result && 'error' in result ? result.error : undefined
}
/** Coerces an unknown thrown value into an Error instance. */
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err))
}
/**
* Creates a proxy that provides automatic stubs based on naming conventions.
* - PascalCase → component returning null
* - useSomething → undefined (hooks not stubbed - see Hooks Pattern in docs)
* - camelCase → undefined (will throw if called, helping catch missing $isReady checks)
* The proxy is created once per hook instance and reads meta values from a ref,
* so its reference stays stable while the feature is not ready.
*
* - PascalCase -> component returning null
* - useSomething -> undefined (hooks not stubbed - see Hooks Pattern in docs)
* - camelCase -> undefined (will throw if called, helping catch missing $isReady checks)
*/
function createStubProxy<T extends FeatureImplementation>(meta: FeatureMeta): T & FeatureMeta {
function createStableStubProxy<T extends FeatureImplementation>(
metaRef: React.RefObject<FeatureMeta>,
): T & FeatureMeta {
const stubCache = new Map<string | symbol, unknown>()
return new Proxy({} as T & FeatureMeta, {
get(_, prop) {
// Return meta properties directly
if (prop === '$isLoading') return meta.$isLoading
if (prop === '$isDisabled') return meta.$isDisabled
if (prop === '$isReady') return meta.$isReady
if (prop === '$error') return meta.$error
// Meta properties read from the ref — always fresh
if (prop === '$isDisabled') return metaRef.current.$isDisabled
if (prop === '$isReady') return metaRef.current.$isReady
if (prop === '$error') return metaRef.current.$error
// Return cached stub if exists
if (stubCache.has(prop)) {
@@ -44,17 +62,7 @@ function createStubProxy<T extends FeatureImplementation>(meta: FeatureMeta): T
// Create stub based on naming convention
const name = String(prop)
let stub: unknown
if (name[0] === name[0].toUpperCase() && !name.startsWith('use')) {
// Component stub - renders null
stub = () => null
} else {
// Hooks and services - undefined (no stub)
// Hooks: undefined when not ready (component must not mount until ready)
// Services: undefined when not ready (will throw if called, catching missing $isReady checks)
stub = undefined
}
const stub = name[0] >= 'A' && name[0] <= 'Z' ? () => null : undefined
stubCache.set(prop, stub)
return stub
@@ -62,17 +70,75 @@ function createStubProxy<T extends FeatureImplementation>(meta: FeatureMeta): T
})
}
// ── Shared Feature Registry ──────────────────────────────────────
// Stores loaded features globally so multiple components calling
// useLoadFeature(SameFeature) share a single load and get the result
// synchronously on first render. Only successful loads are cached;
// errors remain per-instance so retry is possible on remount.
type CachedLoadResult = { feature: unknown }
/** Resolved features keyed by handle.name. */
const featureCache = new Map<string, CachedLoadResult>()
/** In-flight promises for deduplication. Removed on resolve/reject. */
const pendingLoads = new Map<string, Promise<CachedLoadResult>>()
/** Returns a cached load result from the registry, or undefined. */
function getCachedResult(name: string): CachedLoadResult | undefined {
return featureCache.get(name)
}
/** Returns a shared load promise, deduplicating concurrent loads. */
function getOrCreateLoadPromise<T extends FeatureImplementation>(handle: FeatureHandle<T>): Promise<CachedLoadResult> {
const existing = pendingLoads.get(handle.name)
if (existing) return existing
const promise = handle
.load()
.then((module) => {
const result: CachedLoadResult = {
feature: { name: handle.name, useIsEnabled: handle.useIsEnabled, ...module.default },
}
featureCache.set(handle.name, result)
pendingLoads.delete(handle.name)
return result
})
.catch((err) => {
pendingLoads.delete(handle.name)
throw err
})
pendingLoads.set(handle.name, promise)
return promise
}
/** @internal Clears the shared registry. Exported for test cleanup only. */
export function _resetFeatureRegistry(): void {
featureCache.clear()
pendingLoads.clear()
}
// ── Hook ─────────────────────────────────────────────────────────
/**
* Hook to load a feature lazily based on its handle.
*
* ALWAYS returns an object - never null or undefined. When the feature is
* loading or disabled, returns a Proxy with automatic stubs based on naming:
* - PascalCase component returning null
* - useSomething undefined (hooks not stubbed - component must not mount until ready)
* - camelCase undefined (will throw if called without checking $isReady)
* not yet ready or disabled, returns a Proxy with automatic stubs based on naming:
* - PascalCase -> component returning null
* - useSomething -> undefined (hooks not stubbed - component must not mount until ready)
* - camelCase -> undefined (will throw if called without checking $isReady)
*
* There is no intermediate "loading" state — the hook goes directly from not-ready
* to ready in a single transition, minimizing re-renders.
*
* Features are cached in a shared registry so that when multiple components use the
* same feature, only the first triggers a load. Subsequent components get the result
* synchronously on first render (1 render instead of 2).
*
* @param handle - The feature handle with name, useIsEnabled, and load function.
* @returns Feature object with meta properties ($isLoading, $isDisabled, $isReady, $error)
* @returns Feature object with meta properties ($isDisabled, $isReady, $error)
*
* @example
* ```typescript
@@ -117,53 +183,62 @@ export function useLoadFeature<T extends FeatureImplementation>(
// Check feature flag (must be called unconditionally as it's a hook)
const isEnabled = handle.useIsEnabled()
// Load feature when enabled
const [feature, error, loading] = useAsync(
() => {
if (isEnabled !== true) return
return handle.load().then(
(module) =>
({
name: handle.name,
useIsEnabled: handle.useIsEnabled,
...module.default,
}) as LoadedFeature,
)
},
[isEnabled, handle],
false,
// Single state: the loaded feature or an error. No intermediate "loading" state.
// Check the shared registry synchronously — if another component already loaded
// this feature, we get it on first render without any async work.
const [loaded, setLoaded] = useState<LoadResult<LoadedFeature>>(
() => (isEnabled === true ? getCachedResult(handle.name) : undefined) as unknown as LoadResult<LoadedFeature>,
)
// Log errors for debugging
if (error) {
logError(Errors._906, error)
}
useEffect(() => {
if (isEnabled !== true) return
// Compute meta state
const $isDisabled = isEnabled === false
const $isLoading = isEnabled === undefined || loading
const $isReady = isEnabled === true && !loading && !!feature
const $error = error
// Return feature with meta, or stub proxy
return useMemo(() => {
if ($isReady && feature) {
// Feature loaded - return real implementation with meta
return {
...feature,
$isLoading: false,
$isDisabled: false,
$isReady: true,
$error: undefined,
} as LoadedFeature & FeatureMeta
// Shared registry has this feature? Restore state with the cached reference.
// React bails out if setLoaded receives the same referential object.
const cached = getCachedResult(handle.name)
if (cached) {
setLoaded(cached as unknown as LoadResult<LoadedFeature>)
return
}
// Not ready - return stub proxy
return createStubProxy<T>({
$isLoading,
$isDisabled,
$isReady: false,
$error,
}) as LoadedFeature & FeatureMeta
}, [$isReady, $isLoading, $isDisabled, $error, feature])
let cancelled = false
getOrCreateLoadPromise(handle).then(
(result) => {
if (cancelled) return
setLoaded(result as unknown as LoadResult<LoadedFeature>)
},
(err) => {
if (cancelled) return
logError(Errors._906, toError(err))
setLoaded({ error: toError(err) })
},
)
return () => {
cancelled = true
}
}, [isEnabled, handle])
// Derive meta from current state
const feature = getFeature(loaded)
const meta: FeatureMeta = {
$isDisabled: isEnabled === false,
$isReady: !!feature,
$error: getError(loaded),
}
// Stable proxy — created once per hook instance, reads meta from ref
const metaRef = useRef<FeatureMeta>(meta)
metaRef.current = meta
const stubProxy = useMemo(() => createStableStubProxy<T>(metaRef), [])
// Return feature with meta, or the stable stub proxy
return useMemo(() => {
if (feature) {
return { ...feature, ...meta } as LoadedFeature & FeatureMeta
}
return stubProxy as unknown as LoadedFeature & FeatureMeta
}, [feature, stubProxy, meta])
}
@@ -26,7 +26,7 @@
* function MyComponentWithStates() {
* const cf = useLoadFeature(CounterfactualFeature)
*
* if (cf.$isLoading) return <Skeleton />
* if (!cf.$isReady) return <Skeleton />
* if (cf.$isDisabled) return null
*
* return <cf.CheckBalance />
+1 -1
View File
@@ -26,7 +26,7 @@
* function MyComponentWithStates() {
* const feature = useLoadFeature(PortfolioFeature)
*
* if (feature.$isLoading) return <Skeleton />
* if (!feature.$isReady) return <Skeleton />
* if (feature.$isDisabled) return null
*
* return <feature.PortfolioRefreshHint entryPoint="Dashboard" />
+1 -1
View File
@@ -23,7 +23,7 @@
* function MyComponentWithStates() {
* const feature = useLoadFeature(RecoveryFeature)
*
* if (feature.$isLoading) return <Skeleton />
* if (!feature.$isReady) return <Skeleton />
* if (feature.$isDisabled) return null
*
* return <feature.CancelRecoveryButton />
@@ -21,7 +21,6 @@ jest.mock('@/features/__core__', () => ({
...jest.requireActual('@/features/__core__'),
useLoadFeature: jest.fn(() => ({
$isReady: true,
$isLoading: false,
$isDisabled: false,
HnInfoCard: ({
hypernativeAuth,
@@ -35,7 +35,6 @@ jest.mock('@/features/__core__', () => ({
...jest.requireActual('@/features/__core__'),
useLoadFeature: jest.fn(() => ({
$isReady: true,
$isLoading: false,
$isDisabled: false,
HnAnalysisGroupCard: jest.fn(
({ children, delay, highlightedSeverity, analyticsEvent, requestId, 'data-testid': testId }) => (
+1 -1
View File
@@ -21,7 +21,7 @@
* function MyComponentWithStates() {
* const feature = useLoadFeature(SpeedupFeature)
*
* if (feature.$isLoading) return <Skeleton />
* if (!feature.$isReady) return <Skeleton />
* if (feature.$isDisabled) return null
*
* return <feature.SpeedUpModal />
@@ -168,11 +168,11 @@ This feature is controlled by the `SPENDING_LIMIT` feature flag, configured per-
import { SpendingLimitsFeature } from '@/features/spending-limits'
import { useLoadFeature } from '@/features/__core__'
const { SpendingLimitsSettings, $isDisabled, $isLoading } = useLoadFeature(SpendingLimitsFeature)
const { SpendingLimitsSettings, $isDisabled, $isReady } = useLoadFeature(SpendingLimitsFeature)
// Check feature status
if ($isDisabled) return null
if ($isLoading) return <Skeleton />
if (!$isReady) return <Skeleton />
// Use components
<SpendingLimitsSettings />
@@ -22,7 +22,7 @@
* function MyComponentWithStates() {
* const feature = useLoadFeature(TargetedOutreachFeature)
*
* if (feature.$isLoading) return <Skeleton />
* if (!feature.$isReady) return <Skeleton />
* if (feature.$isDisabled) return null
*
* return <feature.OutreachPopup />
+1 -1
View File
@@ -21,7 +21,7 @@
* function MyComponentWithStates() {
* const feature = useLoadFeature(TxNotesFeature)
*
* if (feature.$isLoading) return <Skeleton />
* if (!feature.$isReady) return <Skeleton />
* if (feature.$isDisabled) return null
*
* return <feature.TxNote />
+1 -1
View File
@@ -21,7 +21,7 @@
* function MyComponentWithStates() {
* const wc = useLoadFeature(WalletConnectFeature)
*
* if (wc.$isLoading) return <Skeleton />
* if (!wc.$isReady) return <Skeleton />
* if (wc.$isDisabled) return null
*
* return <wc.WalletConnectWidget />
+11 -13
View File
@@ -106,12 +106,11 @@ These are imported directly from `@/features/hypernative`, not via `useLoadFeatu
When using `useLoadFeature(HypernativeFeature)`, these properties are always available:
| Property | Type | Description |
| ------------- | --------------- | ---------------------------------- |
| `$isLoading` | `boolean` | True while feature code is loading |
| `$isDisabled` | `boolean` | True if feature flag is disabled |
| `$isReady` | `boolean` | True when loaded and enabled |
| `$error` | `Error \| null` | Error if loading failed |
| Property | Type | Description |
| ------------- | -------------------- | -------------------------------- |
| `$isDisabled` | `boolean` | True if feature flag is disabled |
| `$isReady` | `boolean` | True when loaded and enabled |
| `$error` | `Error \| undefined` | Error if loading failed |
## Feature Flag Mapping
@@ -168,10 +167,9 @@ Initial → Loading → Ready
↘ Error
```
| State | $isLoading | $isDisabled | $isReady | Component Behavior | Service Behavior |
| -------- | ---------- | ----------- | -------- | ------------------ | ------------------ |
| Initial | `false` | `false` | `false` | Renders `null` | `undefined` |
| Loading | `true` | `false` | `false` | Renders `null` | `undefined` |
| Ready | `false` | `false` | `true` | Renders component | Function available |
| Disabled | `false` | `true` | `false` | Renders `null` | `undefined` |
| Error | `false` | `false` | `false` | Renders `null` | `undefined` |
| State | $isDisabled | $isReady | Component Behavior | Service Behavior |
| -------- | ----------- | -------- | ------------------ | ------------------ |
| Initial | `false` | `false` | Renders `null` | `undefined` |
| Ready | `false` | `true` | Renders component | Function available |
| Disabled | `true` | `false` | Renders `null` | `undefined` |
| Error | `false` | `false` | Renders `null` | `undefined` |
+1 -1
View File
@@ -51,7 +51,7 @@
- [x] T008 [US1] Verify HypernativeFeature exports correctly from `features/hypernative/index.ts`
- [x] T009 [US1] Verify all 9 components accessible via feature handle (HnBanner, HnDashboardBanner, etc.)
- [x] T010 [US1] Verify isHypernativeGuard service accessible via feature handle
- [x] T011 [US1] Verify $isLoading, $isDisabled, $isReady meta properties work correctly
- [x] T011 [US1] Verify $isDisabled, $isReady, $error meta properties work correctly
- [x] T012 [US1] Verify stub behavior: components render null when disabled
**Checkpoint**: Feature handle works - developers can use v3 pattern
@@ -117,17 +117,15 @@
**Properties** (prefixed with `$`):
- `$isLoading: boolean` - Feature code is currently loading
- `$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: `$isLoading: true`, `$isDisabled: false`, `$isReady: false`
- Loading: `$isLoading: true`, `$isDisabled: false`, `$isReady: false`
- Ready: `$isLoading: false`, `$isDisabled: false`, `$isReady: true`
- Disabled: `$isLoading: false`, `$isDisabled: true`, `$isReady: false`
- Initial: `$isDisabled: false`, `$isReady: false`
- Ready: `$isDisabled: false`, `$isReady: true`
- Disabled: `$isDisabled: true`, `$isReady: false`
- Error: `$error` set, `$isReady: false`
## Relationships Summary
@@ -161,8 +161,8 @@ const { isEligible, remaining, limit } = useNoFeeCampaignEligibility()
```typescript
const noFeeFeature = useLoadFeature(NoFeeCampaignFeature)
if (noFeeFeature.$isLoading) return <Skeleton />
if (noFeeFeature.$isDisabled) return null
if (!noFeeFeature.$isReady) return <Skeleton />
// Destructure after state checks
const { NoFeeCampaignBanner } = noFeeFeature
@@ -130,7 +130,7 @@
**Findings**:
- **Error Handling**: `useLoadFeature` exposes `$error` meta property
- **Loading States**: `$isLoading`, `$isDisabled`, `$isReady` meta properties
- **Loading States**: `$isDisabled`, `$isReady` meta properties
- **Feature Flag Toggling**: React reactivity handles changes automatically
- **Chain Switching**: `useHasFeature` reacts to chain changes, feature reloads
- **Business Logic**: Eligibility, gas limits, blocked addresses - all unchanged
@@ -193,7 +193,7 @@ export function generateIndexTemplate(config: FeatureConfig): string {
lines.push(` * function MyComponentWithStates() {`)
lines.push(` * const feature = useLoadFeature(${featureNamePascal}Feature)`)
lines.push(` *`)
lines.push(` * if (feature.$isLoading) return <Skeleton />`)
lines.push(` * if (!feature.$isReady) return <Skeleton />`)
lines.push(` * if (feature.$isDisabled) return null`)
lines.push(` *`)
lines.push(` * return <feature.${publicAPI.components[0] || 'MyComponent'} />`)