@me2em/react 0.1.0-alpha.2 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/dist/index.d.ts +61 -1
- package/dist/index.js +96 -72
- package/dist/index.js.map +1 -1
- package/package.json +11 -6
- package/src/headless/context-crypto.ts +13 -30
- package/src/headless/identity-context-storage.ts +2 -2
- package/src/headless/identity-context-types.ts +9 -0
- package/src/headless/import-flow.ts +1 -1
- package/src/headless/types.ts +7 -0
- package/src/react/Me2emProvider.tsx +40 -0
- package/src/react/PassphraseInput.tsx +1 -1
- package/src/react/SeedPhraseDisplay.tsx +29 -8
- package/src/react/SeedPhraseImport.tsx +1 -1
- package/src/react/SeedPhraseVerify.tsx +9 -1
- package/src/react/context.ts +16 -0
- package/src/react/useCreateIdentity.ts +2 -2
- package/src/react/useHandle.ts +9 -0
- package/src/react/useIdentityContext.ts +37 -1
- package/src/react/useImportSeed.ts +2 -2
- package/src/react/useSession.ts +7 -5
- package/src/react/useSubHandle.ts +9 -0
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
useState,
|
|
3
3
|
useCallback,
|
|
4
4
|
useMemo,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
5
7
|
type ReactNode,
|
|
6
8
|
} from 'react';
|
|
7
9
|
import { Identity } from '@me2em/core';
|
|
@@ -10,9 +12,32 @@ import { Me2emContext, type Me2emContextValue } from './context.js';
|
|
|
10
12
|
export function Me2emProvider({ children }: { children: ReactNode }) {
|
|
11
13
|
const [identity, setIdentity] = useState<Identity | null>(null);
|
|
12
14
|
|
|
15
|
+
// BL-47 dispose parity: the ref is mutated only in event handlers and
|
|
16
|
+
// cleanup effects, always alongside setIdentity — never inside a state
|
|
17
|
+
// updater (StrictMode double-invokes updaters; dispose is a side
|
|
18
|
+
// effect). A replaced/cleared handle is disposed at the swap, so
|
|
19
|
+
// stale references fail loudly with DisposedError (core contract)
|
|
20
|
+
// instead of silently operating on zeroed key material.
|
|
21
|
+
const identityRef = useRef<Identity | null>(null);
|
|
22
|
+
// Generation ticket for in-flight Identity.fromSeed results — the
|
|
23
|
+
// same load-race class fixed for useIdentityContext in rc.1: a
|
|
24
|
+
// result landing after a newer activation, clearIdentity() or
|
|
25
|
+
// unmount disposes its own handle instead of overwriting newer
|
|
26
|
+
// state.
|
|
27
|
+
const genRef = useRef(0);
|
|
28
|
+
|
|
13
29
|
const activateIdentity = useCallback(async (seed: Uint8Array): Promise<boolean> => {
|
|
30
|
+
const gen = ++genRef.current;
|
|
14
31
|
try {
|
|
15
32
|
const id = await Identity.fromSeed(seed);
|
|
33
|
+
if (gen !== genRef.current) {
|
|
34
|
+
// Superseded while in flight: discard this handle loudly
|
|
35
|
+
// rather than leaking it into state.
|
|
36
|
+
id.dispose();
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
identityRef.current?.dispose(); // zero the replaced handle
|
|
40
|
+
identityRef.current = id;
|
|
16
41
|
setIdentity(id);
|
|
17
42
|
return true;
|
|
18
43
|
} catch {
|
|
@@ -21,9 +46,24 @@ export function Me2emProvider({ children }: { children: ReactNode }) {
|
|
|
21
46
|
}, []);
|
|
22
47
|
|
|
23
48
|
const clearIdentity = useCallback(() => {
|
|
49
|
+
++genRef.current; // invalidate in-flight activations
|
|
50
|
+
identityRef.current?.dispose();
|
|
51
|
+
identityRef.current = null;
|
|
24
52
|
setIdentity(null);
|
|
25
53
|
}, []);
|
|
26
54
|
|
|
55
|
+
// Unmount: cleanup-only dispose — no side effects in render or
|
|
56
|
+
// updaters (the identity was provider state, so it is unreachable
|
|
57
|
+
// after unmount either way; dispose just zeroizes it eagerly).
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
return () => {
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reading the LIVE ticket at teardown is the point: invalidate whatever activation is in flight at unmount; snapshotting it would defeat the guard
|
|
61
|
+
++genRef.current;
|
|
62
|
+
identityRef.current?.dispose();
|
|
63
|
+
identityRef.current = null;
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
27
67
|
const value = useMemo<Me2emContextValue>(
|
|
28
68
|
() => ({ identity, hasIdentity: identity !== null, activateIdentity, clearIdentity }),
|
|
29
69
|
[identity, activateIdentity, clearIdentity]
|
|
@@ -6,18 +6,37 @@ import {
|
|
|
6
6
|
} from '../headless/display-flow.js';
|
|
7
7
|
|
|
8
8
|
export interface SeedPhraseDisplayProps {
|
|
9
|
+
/** Seed material is reachable via React DevTools in this window; by design. */
|
|
9
10
|
words: string[];
|
|
10
11
|
onDismiss?: () => void;
|
|
11
12
|
onCopyAllowed?: () => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* R8: the permanent/transient line for a failed clipboard write is
|
|
17
|
+
* "can a retry in this window ever succeed?". Without the async
|
|
18
|
+
* Clipboard API (non-secure context, unsupported browser) it cannot —
|
|
19
|
+
* the failure is permanent, the copy-all button is disabled and the
|
|
20
|
+
* user is pointed at manual word selection. With the API present any
|
|
21
|
+
* rejection (lost focus, dismissed permission prompt, OS hiccup)
|
|
22
|
+
* stays transient: the button remains enabled and a fresh attempt
|
|
23
|
+
* may go through.
|
|
24
|
+
*/
|
|
25
|
+
function clipboardWriteAvailable(): boolean {
|
|
26
|
+
return (
|
|
27
|
+
typeof navigator !== 'undefined' &&
|
|
28
|
+
!!navigator.clipboard &&
|
|
29
|
+
typeof navigator.clipboard.writeText === 'function'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
export function SeedPhraseDisplay({
|
|
15
34
|
words,
|
|
16
35
|
onDismiss,
|
|
17
36
|
onCopyAllowed,
|
|
18
37
|
}: SeedPhraseDisplayProps) {
|
|
19
38
|
const [state, dispatch] = useReducer(displayFlowReducer, initialDisplayState);
|
|
20
|
-
const [
|
|
39
|
+
const [copyError, setCopyError] = useState<'transient' | 'permanent' | null>(null);
|
|
21
40
|
|
|
22
41
|
const { status, copyCount } = state;
|
|
23
42
|
|
|
@@ -28,12 +47,12 @@ export function SeedPhraseDisplay({
|
|
|
28
47
|
const handleCopy = async () => {
|
|
29
48
|
if (status === 'revealed' && copyCount === 0) {
|
|
30
49
|
try {
|
|
31
|
-
await
|
|
50
|
+
await navigator.clipboard.writeText(words.join(' '));
|
|
32
51
|
dispatch({ type: 'COPY', at: Date.now() });
|
|
33
|
-
|
|
52
|
+
setCopyError(null);
|
|
34
53
|
onCopyAllowed?.();
|
|
35
54
|
} catch {
|
|
36
|
-
|
|
55
|
+
setCopyError(clipboardWriteAvailable() ? 'transient' : 'permanent');
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
58
|
};
|
|
@@ -72,15 +91,17 @@ export function SeedPhraseDisplay({
|
|
|
72
91
|
</li>
|
|
73
92
|
))}
|
|
74
93
|
</ol>
|
|
75
|
-
{
|
|
76
|
-
<div data-testid="copy-error">
|
|
77
|
-
|
|
94
|
+
{copyError && (
|
|
95
|
+
<div data-testid="copy-error" data-kind={copyError}>
|
|
96
|
+
{copyError === 'permanent'
|
|
97
|
+
? 'Copy unavailable in this context — select the words manually'
|
|
98
|
+
: 'Copy failed — try again'}
|
|
78
99
|
</div>
|
|
79
100
|
)}
|
|
80
101
|
<button
|
|
81
102
|
data-testid="copy-all"
|
|
82
103
|
onClick={handleCopy}
|
|
83
|
-
disabled={copyCount === 1}
|
|
104
|
+
disabled={copyCount === 1 || copyError === 'permanent'}
|
|
84
105
|
>
|
|
85
106
|
{copyCount === 1 ? 'Copied' : 'Copy all'}
|
|
86
107
|
</button>
|
|
@@ -3,7 +3,6 @@ import { useReducer, useCallback, useState, useEffect, useRef } from 'react';
|
|
|
3
3
|
import {
|
|
4
4
|
verifyFlowReducer,
|
|
5
5
|
initVerifyState,
|
|
6
|
-
type VerifyState,
|
|
7
6
|
} from '../headless/verify-flow.js';
|
|
8
7
|
import { wordlist } from '@me2em/core';
|
|
9
8
|
|
|
@@ -17,6 +16,15 @@ export interface SeedPhraseVerifyProps {
|
|
|
17
16
|
|
|
18
17
|
const DEFAULT_GRID_SIZE_OFFSET = 8;
|
|
19
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Confirmation grid for a generated seed phrase.
|
|
21
|
+
*
|
|
22
|
+
* The grid shuffle is deterministic by design (fixed base seed 42;
|
|
23
|
+
* each retry shifts it by `+retryCount + 1`) for testability and SSR
|
|
24
|
+
* stability — it is NOT a security control: decoy words come from the
|
|
25
|
+
* public wordlist, so the layout leaks nothing (BL-49). A
|
|
26
|
+
* `randomSeed` prop is deliberately not offered in the frozen 1.0 API.
|
|
27
|
+
*/
|
|
20
28
|
export function SeedPhraseVerify({
|
|
21
29
|
realWords,
|
|
22
30
|
gridSize,
|
package/src/react/context.ts
CHANGED
|
@@ -2,9 +2,25 @@ import { createContext, useContext } from 'react';
|
|
|
2
2
|
import type { Identity } from '@me2em/core';
|
|
3
3
|
|
|
4
4
|
export interface Me2emContextValue {
|
|
5
|
+
/**
|
|
6
|
+
* Active identity, or null. The previously active identity is
|
|
7
|
+
* disposed (key material zeroed) the moment it is replaced by a new
|
|
8
|
+
* activation or by {@link clearIdentity} — stale references fail
|
|
9
|
+
* loudly with `DisposedError` per the core contract (BL-47).
|
|
10
|
+
*/
|
|
5
11
|
identity: Identity | null;
|
|
6
12
|
hasIdentity: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Activates an identity from raw seed bytes, replacing (and
|
|
15
|
+
* disposing) any previously active identity. Resolves `false` if
|
|
16
|
+
* derivation failed or the activation was superseded by a newer
|
|
17
|
+
* activation / `clearIdentity()` while in flight.
|
|
18
|
+
*
|
|
19
|
+
* Does not consume the seed — the caller owns the buffer and must
|
|
20
|
+
* zero it when done (BL-48).
|
|
21
|
+
*/
|
|
7
22
|
activateIdentity: (seed: Uint8Array) => Promise<boolean>;
|
|
23
|
+
/** Disposes the active identity (if any) and clears it. */
|
|
8
24
|
clearIdentity: () => void;
|
|
9
25
|
}
|
|
10
26
|
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from 'react';
|
|
7
7
|
import {
|
|
8
8
|
generateSeedPhrase,
|
|
9
|
-
|
|
9
|
+
get64ByteSeedFromMnemonic,
|
|
10
10
|
type SeedStrength,
|
|
11
11
|
type Identity,
|
|
12
12
|
} from '@me2em/core';
|
|
@@ -37,7 +37,7 @@ export function useCreateIdentity(): UseCreateIdentityResult {
|
|
|
37
37
|
const generate = useCallback(
|
|
38
38
|
(opts?: { strength?: SeedStrength }) => {
|
|
39
39
|
const words = generateSeedPhrase(opts?.strength ?? 128);
|
|
40
|
-
|
|
40
|
+
get64ByteSeedFromMnemonic(words).then((derivedSeed) => {
|
|
41
41
|
dispatch({ type: 'GENERATE', words, seed: derivedSeed });
|
|
42
42
|
}).catch(() => {
|
|
43
43
|
dispatch({ type: 'FAIL', message: 'Failed to generate seed' });
|
package/src/react/useHandle.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { useState, useEffect } from 'react';
|
|
|
3
3
|
import { type Handle } from '@me2em/core';
|
|
4
4
|
import { useMe2emContext } from './context.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Derives the named Handle for the active identity.
|
|
8
|
+
*
|
|
9
|
+
* Silent-failure contract (R7): on derivation failure the hook logs
|
|
10
|
+
* via console.error and keeps returning null — there is no thrown or
|
|
11
|
+
* returned error signal. The structured `{ handle, error }` shape is
|
|
12
|
+
* a post-freeze v1.1 candidate (BL-50).
|
|
13
|
+
*/
|
|
6
14
|
export function useHandle(name: string): Handle | null {
|
|
7
15
|
const { identity } = useMe2emContext();
|
|
8
16
|
const [handle, setHandle] = useState<Handle | null>(null);
|
|
@@ -10,6 +18,7 @@ export function useHandle(name: string): Handle | null {
|
|
|
10
18
|
useEffect(() => {
|
|
11
19
|
let cancelled = false;
|
|
12
20
|
if (!identity) {
|
|
21
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async result when identity is gone; set-during-render refactor is a tracked post-rc.2 follow-up
|
|
13
22
|
setHandle(null);
|
|
14
23
|
return;
|
|
15
24
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// packages/react/src/react/useIdentityContext.ts
|
|
2
2
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import type { Identity } from '@me2em/core';
|
|
3
4
|
import type { IdentityContextStorage, IdentityContextData } from '../headless/identity-context-types.js';
|
|
4
5
|
import { useMe2emContext } from './context.js';
|
|
5
6
|
|
|
@@ -9,6 +10,10 @@ function publicKeyToId(publicKey: Uint8Array): string {
|
|
|
9
10
|
.join('');
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
function identityToId(identity: Identity | null): string | null {
|
|
14
|
+
return identity ? publicKeyToId(identity.getPublicKey()) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
export interface UseIdentityContextResult {
|
|
13
18
|
context: IdentityContextData | null;
|
|
14
19
|
isLoaded: boolean;
|
|
@@ -28,11 +33,23 @@ export function useIdentityContext(
|
|
|
28
33
|
const [error, setError] = useState<string | null>(null);
|
|
29
34
|
|
|
30
35
|
const prevIdentityIdRef = useRef<string | null>(null);
|
|
36
|
+
// Generation ticket for async state transitions. Load (identity
|
|
37
|
+
// change / `refresh()`), `save()` and `clear()` each take a ticket;
|
|
38
|
+
// a resolution may only commit state while its ticket is the newest
|
|
39
|
+
// one. Without this, an in-flight load started before a save could
|
|
40
|
+
// resolve after it and clobber the just-saved state with pre-save
|
|
41
|
+
// data (out-of-order async update — observed as a rare CI flake in
|
|
42
|
+
// the rerender spec; pinned there by a gated-storage test).
|
|
43
|
+
const opRef = useRef(0);
|
|
44
|
+
const identityRef = useRef<Identity | null>(null);
|
|
45
|
+
// eslint-disable-next-line react-hooks/refs -- render-time mirror of the latest identity: save()/clear() are stable callbacks (storage-only deps) and must verify at commit time, after their await, that the identity they started for is still the active one
|
|
46
|
+
identityRef.current = identity;
|
|
31
47
|
|
|
32
48
|
useEffect(() => {
|
|
33
49
|
let cancelled = false;
|
|
34
50
|
|
|
35
51
|
if (!identity) {
|
|
52
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async state when identity is gone (generation-ticket effect from the load-race fix); set-during-render refactor is a tracked post-rc.2 follow-up
|
|
36
53
|
setContext(null);
|
|
37
54
|
setIsLoaded(true);
|
|
38
55
|
setError(null);
|
|
@@ -49,9 +66,10 @@ export function useIdentityContext(
|
|
|
49
66
|
prevIdentityIdRef.current = identityId;
|
|
50
67
|
|
|
51
68
|
// Open storage for this identity and load cached context (atomic)
|
|
69
|
+
const op = ++opRef.current;
|
|
52
70
|
storage.openAndLoad(identityId)
|
|
53
71
|
.then((data) => {
|
|
54
|
-
if (cancelled) return;
|
|
72
|
+
if (cancelled || op !== opRef.current) return;
|
|
55
73
|
setContext(data);
|
|
56
74
|
setIsLoaded(true);
|
|
57
75
|
setError(null);
|
|
@@ -68,19 +86,37 @@ export function useIdentityContext(
|
|
|
68
86
|
}, [identity, storage]);
|
|
69
87
|
|
|
70
88
|
const save = useCallback(async (data: IdentityContextData): Promise<void> => {
|
|
89
|
+
const idAtStart = identityToId(identityRef.current);
|
|
90
|
+
opRef.current += 1; // invalidate any in-flight load/refresh
|
|
71
91
|
await storage.save(data);
|
|
92
|
+
// If the identity switched while the write was in flight, state
|
|
93
|
+
// ownership has moved to the new identity's load effect — committing
|
|
94
|
+
// here would surface identity A's context under identity B. The
|
|
95
|
+
// generation ticket above only orders this op against in-flight
|
|
96
|
+
// loads; it does not know about identity switches.
|
|
97
|
+
if (identityToId(identityRef.current) !== idAtStart) return;
|
|
72
98
|
setContext(data);
|
|
99
|
+
setError(null);
|
|
73
100
|
}, [storage]);
|
|
74
101
|
|
|
75
102
|
const clear = useCallback(async (): Promise<void> => {
|
|
103
|
+
const idAtStart = identityToId(identityRef.current);
|
|
104
|
+
opRef.current += 1; // invalidate any in-flight load/refresh
|
|
76
105
|
await storage.clear();
|
|
106
|
+
// Same ownership rule as save(): a clear() resolving after an
|
|
107
|
+
// identity switch must not blank the new identity's freshly
|
|
108
|
+
// loaded context.
|
|
109
|
+
if (identityToId(identityRef.current) !== idAtStart) return;
|
|
77
110
|
setContext(null);
|
|
111
|
+
setError(null);
|
|
78
112
|
}, [storage]);
|
|
79
113
|
|
|
80
114
|
const refresh = useCallback(async (): Promise<void> => {
|
|
81
115
|
if (!identity) return;
|
|
82
116
|
const identityId = publicKeyToId(identity.getPublicKey());
|
|
117
|
+
const op = ++opRef.current;
|
|
83
118
|
const data = await storage.openAndLoad(identityId);
|
|
119
|
+
if (op !== opRef.current) return;
|
|
84
120
|
setContext(data);
|
|
85
121
|
}, [identity, storage]);
|
|
86
122
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useReducer, useCallback, type Reducer } from 'react';
|
|
3
3
|
import {
|
|
4
4
|
validateSeedPhrase,
|
|
5
|
-
|
|
5
|
+
get64ByteSeedFromMnemonic,
|
|
6
6
|
type Identity,
|
|
7
7
|
} from '@me2em/core';
|
|
8
8
|
import {
|
|
@@ -34,7 +34,7 @@ export function useImportSeed(): UseImportSeedResult {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
try {
|
|
37
|
-
const seed = await
|
|
37
|
+
const seed = await get64ByteSeedFromMnemonic(words, passphrase ?? '');
|
|
38
38
|
dispatch({ type: 'IMPORT', seed });
|
|
39
39
|
await activateIdentity(seed);
|
|
40
40
|
} catch (err) {
|
package/src/react/useSession.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// packages/react/src/react/useSession.ts
|
|
2
2
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
3
|
import type { Handle } from '@me2em/core';
|
|
4
|
-
import { Session
|
|
5
|
-
import { useMe2emContext } from './context.js';
|
|
4
|
+
import { Session } from '@me2em/core';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Options for useSession, memoized via deep comparison of
|
|
@@ -74,7 +73,6 @@ export function useSession(
|
|
|
74
73
|
options: UseSessionOptions,
|
|
75
74
|
autoRenew: boolean = true
|
|
76
75
|
): UseSessionResult {
|
|
77
|
-
const { identity } = useMe2emContext();
|
|
78
76
|
const [session, setSession] = useState<Session | null>(null);
|
|
79
77
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
80
78
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -82,8 +80,11 @@ export function useSession(
|
|
|
82
80
|
const optionsRef = useRef(options);
|
|
83
81
|
const mountedRef = useRef(true);
|
|
84
82
|
|
|
85
|
-
//
|
|
86
|
-
|
|
83
|
+
// Mirror the latest options into the ref after every commit —
|
|
84
|
+
// ref writes belong in effects, not during render (react-hooks/refs).
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
optionsRef.current = options;
|
|
87
|
+
});
|
|
87
88
|
|
|
88
89
|
const clearTimer = useCallback(() => {
|
|
89
90
|
if (timerRef.current) {
|
|
@@ -128,6 +129,7 @@ export function useSession(
|
|
|
128
129
|
const halfLife = Math.floor((s.expiresAt * 1000 - Date.now()) / 2);
|
|
129
130
|
if (halfLife > 0) {
|
|
130
131
|
timerRef.current = setTimeout(() => {
|
|
132
|
+
// eslint-disable-next-line react-hooks/immutability -- self-rescheduling renewal timer: the timer is cleared and re-created whenever renew itself changes (deps + clearTimer), so the captured binding is always the current one
|
|
131
133
|
renew();
|
|
132
134
|
}, Math.max(halfLife, 1000));
|
|
133
135
|
}
|
|
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
|
|
|
2
2
|
import { type SubHandle } from '@me2em/core';
|
|
3
3
|
import { useMe2emContext } from './context.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Derives the named SubHandle under the given Handle.
|
|
7
|
+
*
|
|
8
|
+
* Silent-failure contract (R7): on derivation failure the hook logs
|
|
9
|
+
* via console.error and keeps returning null — there is no thrown or
|
|
10
|
+
* returned error signal. The structured `{ handle, error }` shape is
|
|
11
|
+
* a post-freeze v1.1 candidate (BL-50).
|
|
12
|
+
*/
|
|
5
13
|
export function useSubHandle(
|
|
6
14
|
handleName: string,
|
|
7
15
|
subName: string
|
|
@@ -12,6 +20,7 @@ export function useSubHandle(
|
|
|
12
20
|
useEffect(() => {
|
|
13
21
|
let cancelled = false;
|
|
14
22
|
if (!identity) {
|
|
23
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async result when identity is gone; set-during-render refactor is a tracked post-rc.2 follow-up
|
|
15
24
|
setSubHandle(null);
|
|
16
25
|
return;
|
|
17
26
|
}
|