@me2em/react 0.1.0-alpha.1 → 1.0.0-rc.1

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.
@@ -1,7 +1,10 @@
1
1
  export { Me2emProvider } from './Me2emProvider.js';
2
2
  export { useMe2emContext } from './context.js';
3
+ export type { Me2emContextValue } from './context.js';
3
4
  export { useCreateIdentity } from './useCreateIdentity.js';
5
+ export type { UseCreateIdentityResult } from './useCreateIdentity.js';
4
6
  export { useImportSeed } from './useImportSeed.js';
7
+ export type { UseImportSeedResult } from './useImportSeed.js';
5
8
  export { useHandle } from './useHandle.js';
6
9
  export { useSubHandle } from './useSubHandle.js';
7
10
  export { SeedPhraseDisplay } from './SeedPhraseDisplay.js';
@@ -6,7 +6,7 @@ import {
6
6
  } from 'react';
7
7
  import {
8
8
  generateSeedPhrase,
9
- get32ByteSeedFromMnemonic,
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
- get32ByteSeedFromMnemonic(words).then((derivedSeed) => {
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' });
@@ -10,6 +10,7 @@ export function useHandle(name: string): Handle | null {
10
10
  useEffect(() => {
11
11
  let cancelled = false;
12
12
  if (!identity) {
13
+ // 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
14
  setHandle(null);
14
15
  return;
15
16
  }
@@ -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
- get32ByteSeedFromMnemonic,
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 get32ByteSeedFromMnemonic(words, passphrase ?? '');
37
+ const seed = await get64ByteSeedFromMnemonic(words, passphrase ?? '');
38
38
  dispatch({ type: 'IMPORT', seed });
39
39
  await activateIdentity(seed);
40
40
  } catch (err) {
@@ -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, type SessionOptions, type SessionPayload } from '@me2em/core';
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
- // Update options ref without triggering re-renders
86
- optionsRef.current = options;
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
  }
@@ -12,6 +12,7 @@ export function useSubHandle(
12
12
  useEffect(() => {
13
13
  let cancelled = false;
14
14
  if (!identity) {
15
+ // 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
16
  setSubHandle(null);
16
17
  return;
17
18
  }
@@ -1,124 +0,0 @@
1
- // packages/react/src/headless/context-key-store.ts
2
- import type { IdentityContextData } from './identity-context-storage.js';
3
-
4
- const KEYS_PREFIX = '__keys:';
5
-
6
- export interface ContextKeyStore {
7
- /** Save a persisted CryptoKey for later use. */
8
- setCacheKey(key: CryptoKey): Promise<void>;
9
- /** Retrieve the persisted CryptoKey, or null if not found. */
10
- getCacheKey(): Promise<CryptoKey | null>;
11
- /** Delete the persisted CryptoKey (called on logout). */
12
- deleteCacheKey(): Promise<void>;
13
- /** Persist the CryptoKey to IndexedDB for warm return after browser restart. */
14
- persistCacheKey(): Promise<void>;
15
- /** Load a previously persisted CryptoKey from IndexedDB. */
16
- loadPersistedKey(): Promise<CryptoKey | null>;
17
- }
18
-
19
- /**
20
- * IndexedDB-backed key store. CryptoKey objects are structured-cloned
21
- * into an object store 'cryptoKeys' so they survive browser restarts.
22
- */
23
- export class InMemoryKeyStore implements ContextKeyStore {
24
- private db: IDBDatabase | null = null;
25
-
26
- private getDbName(identityId: string): string {
27
- return `${KEYS_PREFIX}${identityId}`;
28
- }
29
-
30
- private async openOrCreate(): Promise<IDBDatabase> {
31
- if (this.db) return this.db;
32
-
33
- const name = this.getDbName('default');
34
- return new Promise<IDBDatabase>((resolve, reject) => {
35
- const request = indexedDB.open(name, 1);
36
-
37
- request.onupgradeneeded = () => {
38
- const db = request.result;
39
- if (!db.objectStoreNames.contains('keys')) {
40
- db.createObjectStore('keys', { keyPath: 'key' });
41
- }
42
- if (!db.objectStoreNames.contains('cryptoKeys')) {
43
- db.createObjectStore('cryptoKeys', { keyPath: 'key' });
44
- }
45
- };
46
-
47
- request.onsuccess = () => {
48
- this.db = request.result;
49
- resolve(request.result);
50
- };
51
-
52
- request.onerror = () => reject(new Error('Keys DB open failed'));
53
- });
54
- }
55
-
56
- async setCacheKey(key: CryptoKey): Promise<void> {
57
- this.cacheKey = key;
58
- const db = await this.openOrCreate();
59
- return new Promise<void>((resolve, reject) => {
60
- const tx = db.transaction('cryptoKeys', 'readwrite');
61
- const store = tx.objectStore('cryptoKeys');
62
- store.put({ key: 'cacheKey', cryptoKey: key });
63
-
64
- tx.oncomplete = () => resolve();
65
- tx.onerror = () => reject(new Error('setCacheKey failed: ' + tx.error?.message));
66
- });
67
- }
68
-
69
- private cacheKey: CryptoKey | null = null;
70
-
71
- async getCacheKey(): Promise<CryptoKey | null> {
72
- if (this.cacheKey) return this.cacheKey;
73
- return this.loadPersistedKey();
74
- }
75
-
76
- async deleteCacheKey(): Promise<void> {
77
- this.cacheKey = null;
78
- const db = await this.openOrCreate();
79
- return new Promise<void>((resolve, reject) => {
80
- const tx = db.transaction('cryptoKeys', 'readwrite');
81
- const store = tx.objectStore('cryptoKeys');
82
- store.delete('cacheKey');
83
-
84
- tx.oncomplete = () => resolve();
85
- tx.onerror = () => reject(new Error('deleteCacheKey failed: ' + tx.error?.message));
86
- });
87
- }
88
-
89
- async persistCacheKey(): Promise<void> {
90
- const key = this.cacheKey;
91
- if (!key) {
92
- throw new Error('No cache key to persist');
93
- }
94
- const db = await this.openOrCreate();
95
- return new Promise<void>((resolve, reject) => {
96
- const tx = db.transaction('cryptoKeys', 'readwrite');
97
- const store = tx.objectStore('cryptoKeys');
98
- store.put({ key: 'cacheKey', cryptoKey: key });
99
-
100
- tx.oncomplete = () => resolve();
101
- tx.onerror = () => reject(new Error('persistCacheKey failed: ' + tx.error?.message));
102
- });
103
- }
104
-
105
- async loadPersistedKey(): Promise<CryptoKey | null> {
106
- const db = await this.openOrCreate();
107
- return new Promise<CryptoKey | null>((resolve, reject) => {
108
- const tx = db.transaction('cryptoKeys', 'readonly');
109
- const store = tx.objectStore('cryptoKeys');
110
- const request = store.get('cacheKey');
111
-
112
- request.onsuccess = () => {
113
- const record = request.result;
114
- if (!record || !record.cryptoKey) {
115
- resolve(null);
116
- return;
117
- }
118
- resolve(record.cryptoKey);
119
- };
120
-
121
- request.onerror = () => resolve(null);
122
- });
123
- }
124
- }