mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-07-26 22:53:37 +00:00
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:
committed by
GitHub
co-authored by
Hanzo Dev
parent
a938689393
commit
39e37998f9
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
-1
@@ -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 }) => (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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'} />`)
|
||||
|
||||
Reference in New Issue
Block a user