Skip to content

Commit 41be56a

Browse files
mCodexCopilot
andcommitted
refactor: streamline hook architecture with useMutation and useAsyncQuery integration
Co-authored-by: Copilot <copilot@github.com>
1 parent 3176ad5 commit 41be56a

6 files changed

Lines changed: 245 additions & 207 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
* **nitro 0.35:** Regenerated against `nitrogen@0.35.5` and `react-native-nitro-modules@0.35.5`.
1616
* **tooling:** Migrated linting/formatting from ESLint + Prettier to **Biome 2**. Single config at `biome.json`, faster CI runs.
1717

18+
### Refactor (KISS · DRY · SRP)
19+
20+
* Introduced `useAsyncQuery` (read-only hooks) and `useMutation` (mutation hooks) primitives. `useHasSecret`, `useSecretItem`, `useSecureOperation`, `useKeyRotation`, and `useSecureStorage` now compose the same lifecycle/abort/error-handling pipeline — no duplicated state machines.
21+
* `useSecureStorage` shrunk from ~230 LOC to ~180 LOC and reuses the shared abort + auth-cancel semantics; behaviour is unchanged.
22+
* Test fixtures consolidated in `src/__tests__/__mocks__/fixtures.ts` (`buildTestItem`, `buildTestMetadata`).
23+
* Removed redundant re-exports from `src/internal/errors.ts`.
24+
1825
### Breaking changes
1926

2027
* The default export is gone. Use named imports: `import { setItem } from 'react-native-sensitive-info'`.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ function YourComponent() {
224224

225225
For comprehensive examples and advanced patterns, see [`HOOKS.md`](./HOOKS.md).
226226

227+
### 🧱 Hook architecture (DRY · KISS · SRP)
228+
229+
Every hook in this package is a thin choreography layer over three internal primitives, so adding or auditing a hook stays a single-file change:
230+
231+
| Primitive | Responsibility |
232+
| --- | --- |
233+
| `useAsyncLifecycle` | Mount tracking + `AbortController` plumbing — _one job, no React state of its own_. |
234+
| `useAsync` / `useAsyncQuery` | The shared "stable options → strip `skip` → memoize → fetch" recipe used by every read-only hook (`useHasSecret`, `useSecretItem`, `useSecret`, `useSecureStorage`, `useSecurityAvailability`). |
235+
| `useMutation` | The imperative state machine (loading + error + auth-cancel handling) reused by every mutation-style hook (`useSecureOperation`, `useKeyRotation`, plus the `saveSecret`/`removeSecret`/`clearAll` helpers in `useSecureStorage`). |
236+
237+
Net effect: the data-fetching hooks are 25–35 lines each, mutations are ~10 lines, and the abort/cancel/error contract is identical across the surface — there is no place where a bug fix has to be repeated.
238+
227239
## ❗ Error handling
228240

229241
Every public hook returns failures as `HookError` instances. Besides `message`, each error carries:

src/hooks/useKeyRotation.ts

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type HookError,
1313
type HookMutationResult,
1414
} from './types'
15+
import useMutation from './useMutation'
1516

1617
export interface UseKeyRotationOptions extends SensitiveInfoOptions {
1718
/** When `true`, rotations eagerly re-encrypt all entries. Defaults to `false` (lazy). */
@@ -38,39 +39,38 @@ export interface UseKeyRotationResult {
3839
export function useKeyRotation(
3940
options?: UseKeyRotationOptions
4041
): UseKeyRotationResult {
41-
const [isRotating, setIsRotating] = useState(false)
42-
const [error, setError] = useState<HookError | null>(null)
4342
const [lastResult, setLastResult] = useState<RotationResult | null>(null)
43+
const [readError, setReadError] = useState<HookError | null>(null)
4444

45-
const rotate = useCallback(async () => {
46-
setIsRotating(true)
47-
setError(null)
48-
try {
49-
const request: RotateKeysRequest = {
50-
...options,
51-
reEncryptEagerly: options?.reEncryptEagerly ?? false,
52-
}
53-
const result = await rotateKeys(request)
54-
setLastResult(result)
55-
setIsRotating(false)
45+
const {
46+
error: mutateError,
47+
isLoading,
48+
mutate,
49+
} = useMutation(
50+
'useKeyRotation.rotate',
51+
'Check that the service exists and that no auth-gated entries are blocking eager rotation.'
52+
)
53+
54+
const rotate = useCallback(async (): Promise<HookMutationResult> => {
55+
const request: RotateKeysRequest = {
56+
...options,
57+
reEncryptEagerly: options?.reEncryptEagerly ?? false,
58+
}
59+
const outcome = await mutate(() => rotateKeys(request))
60+
if (outcome.success) {
61+
setLastResult(outcome.data)
5662
return createHookSuccessResult()
57-
} catch (errorLike) {
58-
const hookError = createHookError(
59-
'useKeyRotation.rotate',
60-
errorLike,
61-
'Check that the service exists and that no auth-gated entries are blocking eager rotation.'
62-
)
63-
setError(hookError)
64-
setIsRotating(false)
65-
return createHookFailureResult(hookError)
6663
}
67-
}, [options])
64+
return createHookFailureResult(outcome.error)
65+
}, [mutate, options])
6866

6967
const readVersion = useCallback(async () => {
7068
try {
71-
return await getKeyVersion(options)
69+
const version = await getKeyVersion(options)
70+
setReadError(null)
71+
return version
7272
} catch (errorLike) {
73-
setError(
73+
setReadError(
7474
createHookError(
7575
'useKeyRotation.readVersion',
7676
errorLike,
@@ -83,8 +83,8 @@ export function useKeyRotation(
8383

8484
return {
8585
lastResult,
86-
error,
87-
isRotating,
86+
error: mutateError ?? readError,
87+
isRotating: isLoading,
8888
rotate,
8989
readVersion,
9090
}

src/hooks/useMutation.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useCallback, useState } from 'react'
2+
import createHookError, { isAuthenticationCanceledError } from './error-utils'
3+
import {
4+
createHookFailureResult,
5+
type HookError,
6+
type HookFailureResult,
7+
} from './types'
8+
import useAsyncLifecycle from './useAsyncLifecycle'
9+
10+
/**
11+
* Outcome of a {@link UseMutationResult.mutate} call. Successful runs include the resolved value;
12+
* failures carry a normalized {@link HookError}.
13+
*/
14+
export type MutationOutcome<T> =
15+
| { readonly success: true; readonly data: T }
16+
| HookFailureResult
17+
18+
/**
19+
* Shared state contract surfaced by {@link useMutation}. Mirrors {@link VoidAsyncState} so that
20+
* mutation-driven hooks can spread it into their public result without translation.
21+
*/
22+
export interface UseMutationState {
23+
readonly error: HookError | null
24+
readonly isLoading: boolean
25+
readonly isPending: boolean
26+
}
27+
28+
/**
29+
* Options accepted on a per-call basis to customise error reporting.
30+
*/
31+
export interface MutateCallOptions {
32+
readonly operation?: string
33+
readonly hint?: string
34+
}
35+
36+
export interface UseMutationResult extends UseMutationState {
37+
/**
38+
* Runs an imperative async procedure and tracks loading + error state. Auth-cancel
39+
* errors are silently absorbed (no error surfaced) but still produce a failure outcome
40+
* so callers can short-circuit their own UI flow.
41+
*/
42+
readonly mutate: <T>(
43+
fn: (signal: AbortSignal) => Promise<T>,
44+
options?: MutateCallOptions
45+
) => Promise<MutationOutcome<T>>
46+
/** Clears the current error without otherwise touching state. */
47+
readonly clearError: () => void
48+
}
49+
50+
const IDLE: UseMutationState = {
51+
error: null,
52+
isLoading: false,
53+
isPending: false,
54+
}
55+
56+
/**
57+
* Generic state-machine + abort wiring shared by every mutation-style hook (`useSecureOperation`,
58+
* `useKeyRotation`, plus the `saveSecret`/`removeSecret`/`clearAll` helpers in
59+
* `useSecureStorage`). Centralises the auth-cancel / mount-guard / abort logic so each consumer
60+
* can stay a thin wrapper.
61+
*
62+
* @internal
63+
*/
64+
const useMutation = (
65+
defaultOperation: string,
66+
defaultHint: string
67+
): UseMutationResult => {
68+
const [state, setState] = useState<UseMutationState>(IDLE)
69+
const { begin, mountedRef } = useAsyncLifecycle()
70+
71+
const mutate = useCallback(
72+
async <T>(
73+
fn: (signal: AbortSignal) => Promise<T>,
74+
options?: MutateCallOptions
75+
): Promise<MutationOutcome<T>> => {
76+
const operation = options?.operation ?? defaultOperation
77+
const hint = options?.hint ?? defaultHint
78+
const controller = begin()
79+
80+
setState({ error: null, isLoading: true, isPending: true })
81+
82+
try {
83+
const data = await fn(controller.signal)
84+
if (mountedRef.current && !controller.signal.aborted) {
85+
setState(IDLE)
86+
}
87+
return { success: true, data }
88+
} catch (errorLike) {
89+
const hookError = createHookError(operation, errorLike, hint)
90+
if (!mountedRef.current || controller.signal.aborted) {
91+
return createHookFailureResult(hookError)
92+
}
93+
if (isAuthenticationCanceledError(errorLike)) {
94+
setState(IDLE)
95+
} else {
96+
setState({ error: hookError, isLoading: false, isPending: false })
97+
}
98+
return createHookFailureResult(hookError)
99+
}
100+
},
101+
[begin, mountedRef, defaultOperation, defaultHint]
102+
)
103+
104+
const clearError = useCallback(() => {
105+
setState((prev) => (prev.error ? { ...prev, error: null } : prev))
106+
}, [])
107+
108+
return { ...state, mutate, clearError }
109+
}
110+
111+
export default useMutation

src/hooks/useSecureOperation.ts

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { useCallback, useState } from 'react'
2-
import createHookError, { isAuthenticationCanceledError } from './error-utils'
1+
import { useCallback } from 'react'
32
import type { VoidAsyncState } from './types'
4-
import { createInitialVoidState } from './types'
5-
import useAsyncLifecycle from './useAsyncLifecycle'
3+
import useMutation from './useMutation'
64

75
/**
86
* Result returned by {@link useSecureOperation}.
@@ -23,56 +21,17 @@ export interface UseSecureOperationResult extends VoidAsyncState {
2321
* ```
2422
*/
2523
export function useSecureOperation(): UseSecureOperationResult {
26-
const [state, setState] = useState<VoidAsyncState>(createInitialVoidState())
27-
const { begin, mountedRef } = useAsyncLifecycle()
24+
const { error, isLoading, isPending, mutate } = useMutation(
25+
'useSecureOperation.execute',
26+
'Review the async callback passed to execute() for thrown errors.'
27+
)
2828

2929
const execute = useCallback(
3030
async (operation: () => Promise<void>) => {
31-
const controller = begin()
32-
33-
setState({
34-
error: null,
35-
isLoading: true,
36-
isPending: true,
37-
})
38-
39-
try {
40-
await operation()
41-
42-
if (mountedRef.current && !controller.signal.aborted) {
43-
setState({
44-
error: null,
45-
isLoading: false,
46-
isPending: false,
47-
})
48-
}
49-
} catch (errorLike) {
50-
if (mountedRef.current && !controller.signal.aborted) {
51-
if (isAuthenticationCanceledError(errorLike)) {
52-
setState({
53-
error: null,
54-
isLoading: false,
55-
isPending: false,
56-
})
57-
} else {
58-
setState({
59-
error: createHookError(
60-
'useSecureOperation.execute',
61-
errorLike,
62-
'Review the async callback passed to execute() for thrown errors.'
63-
),
64-
isLoading: false,
65-
isPending: false,
66-
})
67-
}
68-
}
69-
}
31+
await mutate(() => operation())
7032
},
71-
[begin, mountedRef]
33+
[mutate]
7234
)
7335

74-
return {
75-
...state,
76-
execute,
77-
}
36+
return { error, isLoading, isPending, execute }
7837
}

0 commit comments

Comments
 (0)