• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eustatos / nexus-state / 24570659818

17 Apr 2026 02:35PM UTC coverage: 70.577% (-0.7%) from 71.309%
24570659818

push

github

web-flow
refactor(core): replace global atom-registry with scoped store registry (#78)

* refactor(core): replace global atom-registry with scoped store registry

* version: bump packages for store refactor and ESM-only builds

* refactor: fix build

2193 of 2575 branches covered (85.17%)

Branch coverage included in aggregate %.

467 of 618 new or added lines in 12 files covered. (75.57%)

93 existing lines in 9 files now uncovered.

8323 of 12325 relevant lines covered (67.53%)

3549.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.31
/packages/devtools/src/devtools-plugin.ts
1
/**
1✔
2
 * Full DevTools plugin implementation.
3
 * For production builds with zero overhead, use package conditional exports
4
 * ("production" condition resolves to ./devtools-noop.ts). This file is
5
 * tree-shakeable when the production entry is used.
6
 */
7

8
import type {
9
  DevToolsConfig,
10
  DevToolsConnection,
11
  DevToolsMessage,
12
  BasicAtom,
13
  DevToolsMode,
14
  DevToolsFeatureDetectionResult,
15
  ActionMetadata,
16
} from './types';
17
import type { SnapshotMapper } from './snapshot-mapper';
18
import type { SimpleTimeTravel } from '@nexus-state/time-travel';
19
import type { Store, Atom } from '@nexus-state/core';
20
import {
21
  captureStackTrace,
22
  formatStackTraceForDevTools,
23
} from './utils/stack-tracer';
24
import { createSnapshotMapper } from './snapshot-mapper';
25
import {
26
  StateSerializer,
27
  createStateSerializer,
28
  type LazySerializationOptions,
29
} from './state-serializer';
30
import {
31
  ActionNamingSystem,
32
  createActionNamingSystem,
33
  defaultActionNamingSystem,
34
  type ActionNamingStrategy,
35
  type ActionNamingStrategyType,
36
  type PatternNamingConfig,
37
} from './action-naming';
38
import { createActionMetadata } from './action-metadata';
39
import { createActionGrouper, type ActionGrouper } from './action-grouper';
40
import { createBatchUpdater, type BatchUpdater } from './batch-updater';
41

42
/**
43
 * Feature detection for DevTools extension
44
 * @returns Object containing feature detection results
45
 */
46
export function detectDevToolsFeatures(): DevToolsFeatureDetectionResult {
1✔
47
  try {
42✔
48
    // Check for SSR environment (no window object)
49
    if (typeof window === 'undefined') {
42✔
50
      return {
8✔
51
        isAvailable: false,
8✔
52
        isSSR: true,
8✔
53
        mode: 'disabled',
8✔
54
        error: null,
8✔
55
      };
8✔
56
    }
8✔
57

58
    // Check for DevTools extension
59
    const devToolsExtension = window.__REDUX_DEVTOOLS_EXTENSION__;
34✔
60
    const isAvailable = !!devToolsExtension;
34✔
61

62
    return {
34✔
63
      isAvailable,
34✔
64
      isSSR: false,
34✔
65
      mode: isAvailable ? 'active' : 'fallback',
42✔
66
      error: isAvailable
42✔
67
        ? null
33✔
68
        : new Error('Redux DevTools extension not found'),
1✔
69
    };
42✔
70
  } catch (error) {
42!
71
    return {
×
72
      isAvailable: false,
×
73
      isSSR: false,
×
74
      mode: 'disabled',
×
75
      error:
×
76
        error instanceof Error
×
77
          ? error
×
78
          : new Error('Unknown error during feature detection'),
×
79
    };
×
80
  }
×
81
}
42✔
82

83
/**
84
 * Check if current environment is SSR
85
 * @returns True if SSR environment
86
 */
87
export function isSSREnvironment(): boolean {
1✔
88
  return typeof window === 'undefined';
×
89
}
×
90

91
/**
92
 * Check if DevTools extension is available
93
 * @returns True if DevTools is available
94
 */
95
export function isDevToolsAvailable(): boolean {
1✔
96
  if (isSSREnvironment()) {
×
97
    return false;
×
98
  }
×
99
  return !!window.__REDUX_DEVTOOLS_EXTENSION__;
×
100
}
×
101

102
/**
103
 * Create a fallback connection that does nothing (no-op)
104
 * @returns DevToolsConnection implementation with no-op behavior
105
 */
106
function createFallbackConnection(): DevToolsConnection {
1✔
107
  return {
1✔
108
    send: (): void => {
1✔
109
      // No-op: silently ignore send attempts
110
    },
×
111
    subscribe: (): (() => void) => {
1✔
112
      // No-op: return no-op unsubscribe function
113
      return (): void => {};
×
114
    },
×
115
    init: (): void => {
1✔
116
      // No-op: silently ignore init attempts
117
    },
1✔
118
    unsubscribe: (): void => {
1✔
119
      // No-op: silently ignore unsubscribe attempts
120
    },
×
121
  };
1✔
122
}
1✔
123

124
/**
125
 * Determine the appropriate DevTools mode based on environment and availability
126
 * @param forceDisable - Optional flag to force disabled mode
127
 * @returns DevToolsMode enum value
128
 */
129
export function getDevToolsMode(forceDisable?: boolean): DevToolsMode {
1✔
130
  // Check for forced disabled mode
131
  if (forceDisable) {
×
132
    return 'disabled';
×
133
  }
×
134

135
  // Check for SSR environment
136
  if (isSSREnvironment()) {
×
137
    return 'disabled';
×
138
  }
×
139

140
  // Check for DevTools extension
141
  if (isDevToolsAvailable()) {
×
142
    return 'active';
×
143
  }
×
144

145
  // Fall back to fallback mode
146
  return 'fallback';
×
147
}
×
148

149
// Declare global types for Redux DevTools
150
declare global {
151
  interface Window {
152
    __REDUX_DEVTOOLS_EXTENSION__?: {
153
      connect: (options: {
154
        name?: string;
155
        trace?: boolean;
156
        latency?: number;
157
        maxAge?: number;
158
      }) => DevToolsConnection;
159
    };
160
  }
161
}
162

163
/**
164
 * DevToolsPlugin class implementing integration with enhanced store API.
165
 */
166
export class DevToolsPlugin {
1✔
167
  private config: Required<DevToolsConfig>;
168
  private connection: DevToolsConnection | null = null;
1✔
169
  private connections: DevToolsConnection[] = []; // All connections for broadcasting
1✔
170
  private mode: DevToolsMode = 'disabled';
1✔
171
  private isTracking = true;
1✔
172
  private lastState: unknown = null;
1✔
173
  private snapshotMapper: SnapshotMapper;
174
  private stateSerializer: StateSerializer;
175
  private actionNamingSystem: ActionNamingSystem;
176
  private actionGrouper: ActionGrouper;
177
  private batchUpdater: BatchUpdater;
178
  private currentBatchId: string | null = null;
1✔
179
  private currentStore: Store | null = null;
1✔
180
  /** Last lazy-serialized state (when serialization.lazy is enabled) for incremental updates */
181
  private lastLazyState: Record<string, unknown> | null = null;
1✔
182
  private timeTravel: SimpleTimeTravel | null = null;
1✔
183

184
  constructor(config: DevToolsConfig = {}) {
1✔
185
    // Extract action naming config with defaults
186
    const {
44✔
187
      actionNamingStrategy = 'auto',
44✔
188
      actionNamingPattern,
44✔
189
      actionNamingFunction,
44✔
190
      defaultNamingStrategy = 'auto',
44✔
191
    } = config;
44✔
192

193
    this.config = {
44✔
194
      name: config.name ?? 'nexus-state',
44✔
195
      trace: config.trace ?? false,
44✔
196
      traceLimit: config.traceLimit ?? 10,
44✔
197
      latency: config.latency ?? 100,
44✔
198
      maxAge: config.maxAge ?? 50,
44✔
199
      actionSanitizer: config.actionSanitizer ?? (() => true),
44✔
200
      stateSanitizer: config.stateSanitizer ?? ((state) => state),
44✔
201
      showAtomNames: config.showAtomNames ?? true,
44✔
202
      atomNameFormatter:
44✔
203
        config.atomNameFormatter ??
44✔
204
        ((_atom: BasicAtom, defaultName: string) => defaultName),
43✔
205
      actionNamingStrategy,
44✔
206
      actionNamingPattern,
44✔
207
      actionNamingFunction,
44✔
208
      defaultNamingStrategy,
44✔
209
    } as Required<DevToolsConfig>;
44✔
210
    this.snapshotMapper = createSnapshotMapper({
44✔
211
      maxMappings: config.maxAge ?? 50,
44✔
212
      autoCleanup: true,
44✔
213
    });
44✔
214
    this.stateSerializer = createStateSerializer();
44✔
215
    this.actionNamingSystem = this.createActionNamingSystem();
44✔
216
    this.actionGrouper = createActionGrouper(config.actionGroupOptions);
44✔
217
    const latency = config.latency ?? 100;
44✔
218
    const batchOpts = config.batchUpdate ?? {};
44✔
219
    this.batchUpdater = createBatchUpdater({
44✔
220
      batchLatencyMs: batchOpts.batchLatencyMs ?? latency,
44✔
221
      maxQueueSize: batchOpts.maxQueueSize ?? 100,
44✔
222
      throttleByFrame: batchOpts.throttleByFrame ?? true,
44✔
223
      maxUpdatesPerSecond: batchOpts.maxUpdatesPerSecond ?? 0,
44✔
224
      onFlush: (store, action) => {
44✔
225
        const targetStore = (store ??
3!
NEW
226
          this.currentStore) as Store | null;
×
227
        if (targetStore) {
3✔
228
          this.doSendStateUpdate(targetStore, action);
3✔
229
        }
3✔
230
      },
3✔
231
    });
44✔
232
  }
44✔
233

234
  /**
235
   * Set up time travel integration
236
   */
237
  setTimeTravel(timeTravel: SimpleTimeTravel): void {
1✔
238
    this.timeTravel = timeTravel;
×
239
  }
×
240

241
  /**
242
   * Create action naming system based on config
243
   */
244
  private createActionNamingSystem(): ActionNamingSystem {
1✔
245
    const {
44✔
246
      actionNamingStrategy,
44✔
247
      actionNamingPattern,
44✔
248
      actionNamingFunction,
44✔
249
      defaultNamingStrategy,
44✔
250
    } = this.config;
44✔
251

252
    // If strategy is already an instance, use it directly
253
    if (
44✔
254
      typeof actionNamingStrategy === 'object' &&
44✔
255
      'getName' in actionNamingStrategy
1✔
256
    ) {
44✔
257
      const system = new ActionNamingSystem();
1✔
258
      system
1✔
259
        .getRegistry()
1✔
260
        .register(actionNamingStrategy as ActionNamingStrategy, true);
1✔
261
      return system;
1✔
262
    }
1✔
263

264
    // Handle string strategy types
265
    const strategyType = actionNamingStrategy as ActionNamingStrategyType;
43✔
266

267
    // Build options based on config
268
    const options: any = {
43✔
269
      defaultStrategy: defaultNamingStrategy,
43✔
270
    };
43✔
271

272
    if (strategyType === 'pattern' && actionNamingPattern) {
44✔
273
      options.strategy = 'pattern';
2✔
274
      options.patternConfig = {
2✔
275
        pattern: actionNamingPattern,
2✔
276
        placeholders: {
2✔
277
          atomName: true,
2✔
278
          operation: true,
2✔
279
          timestamp: true,
2✔
280
          date: false,
2✔
281
          time: false,
2✔
282
        },
2✔
283
      };
2✔
284
    } else if (strategyType === 'custom' && actionNamingFunction) {
44✔
285
      options.strategy = 'custom';
1✔
286
      options.customConfig = {
1✔
287
        namingFunction: actionNamingFunction,
1✔
288
      };
1✔
289
    } else {
41✔
290
      options.strategy = strategyType;
40✔
291
    }
40✔
292

293
    return createActionNamingSystem(options);
43✔
294
  }
44✔
295

296
  /**
297
   * Apply the plugin to a store.
298
   * @param store The store to apply the plugin to
299
   */
300
  apply(store: Store): void {
1✔
301
    this.currentStore = store;
42✔
302
    // Runtime production guard: no-op when NODE_ENV is production (e.g. bundler did not use conditional exports)
303
    if (process.env.NODE_ENV === 'production') {
42!
304
      return;
×
305
    }
×
306
    // Detect DevTools features
307
    const features = detectDevToolsFeatures();
42✔
308
    this.mode = features.mode;
42✔
309

310
    // Handle SSR environment - no-op
311
    if (features.isSSR) {
42✔
312
      return;
8✔
313
    }
8✔
314

315
    // Handle disabled mode - no-op (e.g., forced disabled)
316
    if (features.mode === 'disabled') {
39!
317
      return;
×
318
    }
✔
319

320
    // Handle fallback mode - use no-op connection
321
    if (features.mode === 'fallback') {
39✔
322
      if (process.env.NODE_ENV !== 'production') {
1✔
323
        console.warn(
1✔
324
          'Redux DevTools extension is not available, using fallback mode'
1✔
325
        );
1✔
326
      }
1✔
327
      this.connection = createFallbackConnection();
1✔
328
      this.connections.push(this.connection);
1✔
329
      this.sendInitialState(store);
1✔
330
      return;
1✔
331
    }
1✔
332

333
    // Active mode - connect to real DevTools extension
334
    if (!features.isAvailable || !window.__REDUX_DEVTOOLS_EXTENSION__) {
42!
335
      if (process.env.NODE_ENV !== 'production') {
×
336
        console.warn('DevTools extension not available');
×
337
      }
×
338
      return;
×
339
    }
✔
340

341
    // Create a connection to DevTools and track all connections
342
    const extension = window.__REDUX_DEVTOOLS_EXTENSION__;
33✔
343
    this.connection = extension.connect({
33✔
344
      name: this.config.name,
33✔
345
      trace: this.config.trace,
33✔
346
      latency: this.config.latency,
33✔
347
      maxAge: this.config.maxAge,
33✔
348
    });
33✔
349

350
    // Track this connection
351
    this.connections.push(this.connection);
33✔
352

353
    // Send initial state to the main connection
354
    this.sendInitialState(store);
33✔
355

356
    // Setup message listeners for the main connection
357
    this.setupMessageListeners(store, this.connection);
33✔
358

359
    // Enhance store methods if available
360
    if (store.setWithMetadata) {
33✔
361
      this.enhanceStoreWithMetadata(store);
30✔
362
    } else {
39✔
363
      // Fallback to polling for basic stores
364
      this.setupPolling(store);
3✔
365
    }
3✔
366

367
    // Setup time travel if available
368
    this.setupTimeTravel(store);
33✔
369

370
    // Listen for new connections from DevTools (e.g., multiple DevTools windows)
371
    this.setupConnectionListener(extension, store);
33✔
372
  }
42✔
373

374
  /**
375
   * Get display name for an atom
376
   * @param atom The atom to get name for
377
   * @returns Display name for the atom
378
   */
379
  private getAtomName(atom: BasicAtom): string {
1✔
380
    try {
100✔
381
      // If showAtomNames is disabled, use atom's toString method
382
      if (!this.config.showAtomNames) {
100✔
383
        return atom.toString();
1✔
384
      }
1✔
385

386
      // Use custom formatter if provided
387
      if (this.config.atomNameFormatter) {
99✔
388
        const defaultName = this.getAtomNameFromStore(atom);
99✔
389
        return this.config.atomNameFormatter(atom, defaultName);
99✔
390
      }
99!
391

392
      // Use store's ScopedRegistry if available
NEW
393
      const storeName = this.getAtomNameFromStore(atom);
×
NEW
394
      if (storeName) {
×
NEW
395
        return storeName;
×
UNCOV
396
      }
×
397

398
      // Fallback to atom's toString method
399
      return atom.toString();
×
UNCOV
400
    } catch (error) {
×
401
      // Fallback for any errors
UNCOV
402
      return `atom-${atom.id?.toString() || 'unknown'}`;
×
UNCOV
403
    }
×
404
  }
100✔
405

406
  /**
407
   * Get atom name from the current store's ScopedRegistry
408
   */
409
  private getAtomNameFromStore(atom: BasicAtom): string {
1✔
410
    // Try via store.getRegistry()
411
    if (this.currentStore && this.currentStore.getRegistry && typeof atom.id === 'symbol') {
99✔
412
      const registry = this.currentStore.getRegistry();
9✔
413
      if (registry && registry.getMetadata) {
9✔
414
        const metadata = registry.getMetadata(atom.id);
9✔
415
        if (metadata && metadata.name) {
9✔
416
          return metadata.name;
9✔
417
        }
9✔
418
      }
9✔
419
    }
9✔
420

421
    // Fallback: try store.getAtomMetadata directly
422
    if (this.currentStore && (this.currentStore as any).getAtomMetadata && typeof atom.id === 'symbol') {
99!
NEW
423
      const metadata = (this.currentStore as any).getAtomMetadata(atom.id);
×
NEW
424
      if (metadata && metadata.name) {
×
NEW
425
        return metadata.name;
×
NEW
426
      }
×
NEW
427
    }
✔
428

429
    // Fallback: use atom.name property
430
    if ((atom as any).name && typeof (atom as any).name === 'string') {
99✔
431
      return (atom as any).name;
2✔
432
    }
2✔
433

434
    // Fallback: use atom.id.toString()
435
    if (atom.id && typeof atom.id.toString === 'function') {
99✔
436
      const idStr = atom.id.toString();
88✔
437
      // Strip 'Symbol(' prefix and ')' suffix for cleaner names
438
      if (idStr.startsWith('Symbol(') && idStr.endsWith(')')) {
88✔
439
        return idStr.substring(7, idStr.length - 1);
2✔
440
      }
2✔
441
      return idStr;
86✔
442
    }
86!
443

NEW
444
    return atom.toString();
×
445
  }
99✔
446

447
  /**
448
   * Build lazy serialization options from plugin config.
449
   */
450
  private getLazySerializationOptions(): LazySerializationOptions | null {
1✔
451
    const ser = this.config.serialization;
37✔
452
    if (!ser?.lazy) return null;
37!
453
    return {
×
454
      maxDepth: ser.maxDepth,
×
455
      maxSerializedSize: ser.maxSerializedSize,
×
456
      circularRefHandling: ser.circularRefHandling,
×
457
      placeholder: ser.placeholder,
×
458
    };
×
459
  }
37✔
460

461
  /**
462
   * Send initial state to DevTools (with optional lazy serialization).
463
   * Sends to all connections for scenarios like multiple DevTools windows.
464
   * @param store The store to get initial state from
465
   */
466
  private sendInitialState(store: Store): void {
1✔
467
    try {
34✔
468
      const state = store.serializeState?.() || store.getState();
34✔
469
      const sanitized = this.config.stateSanitizer(state) as Record<
34✔
470
        string,
471
        unknown
472
      >;
473
      this.lastState = sanitized;
34✔
474

475
      const lazyOpts = this.getLazySerializationOptions();
34✔
476
      let stateToSend: unknown = sanitized;
34✔
477
      if (lazyOpts) {
34!
478
        const result = this.stateSerializer.serializeLazy(sanitized, lazyOpts);
×
479
        this.lastLazyState = result.state as Record<string, unknown>;
×
480
        stateToSend = result.state;
×
481
      } else {
34✔
482
        this.lastLazyState = null;
34✔
483
      }
34✔
484

485
      // Send to all connections
486
      for (const conn of this.connections) {
34✔
487
        try {
34✔
488
          conn.init(stateToSend);
34✔
489
        } catch (error) {
34!
490
          if (process.env.NODE_ENV !== 'production') {
×
491
            console.warn('Failed to send initial state to connection:', error);
×
492
          }
×
493
        }
×
494
      }
34✔
495
    } catch (error) {
34!
496
      if (process.env.NODE_ENV !== 'production') {
×
497
        console.warn('Failed to send initial state to DevTools:', error);
×
498
      }
×
499
    }
×
500
  }
34✔
501

502
  /**
503
   * Setup message listeners for DevTools commands.
504
   * @param store The store to handle commands for
505
   * @param connection The connection to listen on (default: main connection)
506
   */
507
  private setupMessageListeners(
1✔
508
    store: Store,
33✔
509
    connection?: DevToolsConnection
33✔
510
  ): void {
33✔
511
    const conn = connection || this.connection;
33!
512
    const unsubscribe = conn?.subscribe((message: DevToolsMessage) => {
33✔
513
      try {
1✔
514
        this.handleDevToolsMessage(message, store);
1✔
515
      } catch (error) {
1!
516
        if (process.env.NODE_ENV !== 'production') {
×
517
          console.warn('Error handling DevTools message:', error);
×
518
        }
×
519
      }
×
520
    });
33✔
521

522
    // Clean up on window unload
523
    if (typeof window !== 'undefined' && window.addEventListener) {
33✔
524
      window.addEventListener('beforeunload', () => {
9✔
525
        unsubscribe?.();
×
526
        conn?.unsubscribe();
×
527
      });
9✔
528
    }
9✔
529
  }
33✔
530

531
  /**
532
   * Setup listener for new connections (e.g., from multiple DevTools windows).
533
   * Some DevTools extensions may create new connections dynamically.
534
   * @param extension The DevTools extension instance
535
   * @param store The store to apply to new connections
536
   */
537
  private setupConnectionListener(extension: any, store: Store): void {
1✔
538
    // Store reference for later use
539
    // eslint-disable-next-line @typescript-eslint/no-this-alias
540
    const plugin = this;
33✔
541

542
    // Override connect method to track new connections
543
    const originalConnect = extension.connect.bind(extension);
33✔
544
    extension.connect = function (options?: any) {
33✔
545
      const newConnection = originalConnect(options);
8✔
546

547
      // Track the new connection
548
      plugin.connections.push(newConnection);
8✔
549

550
      // Send initial state to the new connection
551
      try {
8✔
552
        const state = store.serializeState?.() || store.getState();
8!
553
        const sanitized = plugin.config.stateSanitizer(state) as Record<
8✔
554
          string,
555
          unknown
556
        >;
557
        newConnection.init(sanitized);
8✔
558
      } catch (error) {
8!
559
        if (process.env.NODE_ENV !== 'production') {
×
560
          console.warn(
×
561
            'Failed to send initial state to new connection:',
×
562
            error
×
563
          );
×
564
        }
×
565
      }
×
566

567
      return newConnection;
8✔
568
    };
8✔
569
  }
33✔
570

571
  /**
572
   * Handle messages from DevTools.
573
   * @param message The message from DevTools
574
   * @param store The store to apply commands to
575
   */
576
  private handleDevToolsMessage(
1✔
577
    message: DevToolsMessage,
1✔
578
    store: Store
1✔
579
  ): void {
1✔
580
    if (message.type === 'DISPATCH') {
1!
581
      const payload = message.payload as
×
582
        | { type: string; [key: string]: unknown }
583
        | undefined;
584

585
      switch (payload?.type) {
×
586
        case 'JUMP_TO_ACTION':
×
587
        case 'JUMP_TO_STATE': {
×
588
          this.handleTimeTravelCommand(payload, store);
×
589
          break;
×
590
        }
×
591

592
        case 'START':
×
593
          this.isTracking = true;
×
594
          break;
×
595

596
        case 'STOP':
×
597
          this.isTracking = false;
×
598
          break;
×
599

600
        case 'COMMIT':
×
601
          this.sendInitialState(store);
×
602
          break;
×
603

604
        case 'RESET':
×
605
          // Reset to initial state would require storing the initial state
606
          if (process.env.NODE_ENV !== 'production') {
×
607
            console.warn(
×
608
              'Reset is not fully supported without storing initial state'
×
609
            );
×
610
          }
×
611
          break;
×
612

613
        case 'IMPORT_STATE': {
×
614
          this.handleImportState(payload, store);
×
615
          break;
×
616
        }
×
617

618
        default:
×
619
          if (process.env.NODE_ENV !== 'production') {
×
620
            console.warn('Unknown DevTools dispatch type:', payload?.type);
×
621
          }
×
622
      }
×
623
    }
×
624
  }
1✔
625

626
  /**
627
   * Handle time travel commands from DevTools
628
   * @param payload The time travel command payload
629
   * @param store The store to apply commands to
630
   */
631
  private handleTimeTravelCommand(
1✔
632
    payload: { type: string; [key: string]: unknown },
×
NEW
633
    store: Store
×
634
  ): void {
×
635
    if (!this.timeTravel) {
×
636
      if (process.env.NODE_ENV !== 'production') {
×
637
        console.warn(
×
638
          '[DevToolsPlugin] Time travel not available. Ensure store has SimpleTimeTravel instance.'
×
639
        );
×
640
      }
×
641
      return;
×
642
    }
×
643

644
    try {
×
645
      switch (payload.type) {
×
646
        case 'JUMP_TO_STATE': {
×
647
          const index = (payload as any).index;
×
648
          if (typeof index === 'number' && index >= 0) {
×
649
            console.log(`[DevToolsPlugin] JUMP_TO_STATE: ${index}`);
×
650
            const success = this.timeTravel.jumpTo(index);
×
651
            if (success) {
×
652
              if (process.env.NODE_ENV !== 'production') {
×
653
                console.log(
×
654
                  `[DevToolsPlugin] Successfully jumped to state ${index}`
×
655
                );
×
656
              }
×
657
              // Send updated state to DevTools
658
              this.sendInitialState(store);
×
659
            } else {
×
660
              if (process.env.NODE_ENV !== 'production') {
×
661
                console.warn(
×
662
                  `[DevToolsPlugin] Failed to jump to state ${index}`
×
663
                );
×
664
              }
×
665
            }
×
666
          } else {
×
667
            if (process.env.NODE_ENV !== 'production') {
×
668
              console.warn(`[DevToolsPlugin] Invalid index: ${index}`);
×
669
            }
×
670
          }
×
671
          break;
×
672
        }
×
673

674
        case 'JUMP_TO_ACTION': {
×
675
          const actionName = (payload as any).action;
×
676
          if (typeof actionName === 'string') {
×
677
            console.log(`[DevToolsPlugin] JUMP_TO_ACTION: ${actionName}`);
×
678

679
            // Find the index of the action in history
680
            const history = this.timeTravel.getHistory() as { metadata: { action?: string } }[];
×
681
            let foundIndex = -1;
×
682

683
            // Search backwards from the end
684
            for (let i = history.length - 1; i >= 0; i--) {
×
685
              const snapshot = history[i];
×
686
              if (snapshot.metadata.action === actionName) {
×
687
                foundIndex = i;
×
688
                break;
×
689
              }
×
690
            }
×
691

692
            if (foundIndex >= 0) {
×
693
              const success = this.timeTravel.jumpTo(foundIndex);
×
694
              if (success) {
×
695
                if (process.env.NODE_ENV !== 'production') {
×
696
                  console.log(
×
697
                    `[DevToolsPlugin] Successfully jumped to action ${actionName} at index ${foundIndex}`
×
698
                  );
×
699
                }
×
700
                // Send updated state to DevTools
701
                this.sendInitialState(store);
×
702
              } else {
×
703
                if (process.env.NODE_ENV !== 'production') {
×
704
                  console.warn(
×
705
                    `[DevToolsPlugin] Failed to jump to action ${actionName}`
×
706
                  );
×
707
                }
×
708
              }
×
709
            } else {
×
710
              if (process.env.NODE_ENV !== 'production') {
×
711
                console.warn(
×
712
                  `[DevToolsPlugin] Action not found: ${actionName}`
×
713
                );
×
714
              }
×
715
            }
×
716
          } else {
×
717
            if (process.env.NODE_ENV !== 'production') {
×
718
              console.warn(
×
719
                `[DevToolsPlugin] Invalid action name: ${actionName}`
×
720
              );
×
721
            }
×
722
          }
×
723
          break;
×
724
        }
×
725

726
        default:
×
727
          if (process.env.NODE_ENV !== 'production') {
×
728
            console.warn(
×
729
              `[DevToolsPlugin] Unknown time travel command: ${payload.type}`
×
730
            );
×
731
          }
×
732
      }
×
733
    } catch (error) {
×
734
      if (process.env.NODE_ENV !== 'production') {
×
735
        console.error(
×
736
          '[DevToolsPlugin] Error handling time travel command:',
×
737
          error
×
738
        );
×
739
      }
×
740
    }
×
741
  }
×
742

743
  /**
744
   * Handle IMPORT_STATE command from DevTools
745
   * @param payload The IMPORT_STATE payload
746
   * @param store The store to import state into
747
   */
748
  private handleImportState(
1✔
749
    payload: { type: string; [key: string]: unknown },
×
NEW
750
    store: Store
×
751
  ): void {
×
752
    try {
×
753
      // Extract import data from payload
754
      const importData = payload.state || payload.payload;
×
755

756
      if (!importData) {
×
757
        if (process.env.NODE_ENV !== 'production') {
×
758
          console.warn('IMPORT_STATE: No state data provided');
×
759
        }
×
760
        return;
×
761
      }
×
762

763
      // Use StateSerializer to deserialize and validate
764
      const result = this.stateSerializer.importState(importData);
×
765

766
      if (!result.success) {
×
767
        if (process.env.NODE_ENV !== 'production') {
×
768
          console.warn('IMPORT_STATE: Failed to import state:', result.error);
×
769
        }
×
770
        return;
×
771
      }
×
772

773
      // Import state into store
774
      this.importStateIntoStore(result.state!, store);
×
775

776
      // Send updated state to DevTools
777
      this.sendInitialState(store);
×
778

779
      if (process.env.NODE_ENV !== 'production') {
×
780
        console.log('IMPORT_STATE: State imported successfully');
×
781
      }
×
782
    } catch (error) {
×
783
      if (process.env.NODE_ENV !== 'production') {
×
784
        console.warn('IMPORT_STATE: Error importing state:', error);
×
785
      }
×
786
    }
×
787
  }
×
788

789
  /**
790
   * Import state into store
791
   * @param state The state to import
792
   * @param store The store to import into
793
   */
794
  private importStateIntoStore(
1✔
795
    state: Record<string, unknown>,
×
NEW
796
    store: Store
×
797
  ): void {
×
798
    // Check if store has importState method (from SimpleTimeTravel)
799
    if (typeof (store as any).importState === 'function') {
×
800
      (store as any).importState(state);
×
801
      return;
×
802
    }
×
803

804
    // Fallback: use setState if available (sets atoms by name)
NEW
805
    if (store.setState) {
×
NEW
806
      store.setState(state);
×
NEW
807
      return;
×
NEW
808
    }
×
809

810
    // Last resort: manually set each atom value by name
NEW
811
    for (const [atomName, value] of Object.entries(state)) {
×
NEW
812
      try {
×
813
        // Try to get atom by name from store
NEW
814
        if (store.getByName) {
×
NEW
815
          const atom = store.getByName(atomName);
×
NEW
816
          if (atom) {
×
NEW
817
            store.set(atom, value);
×
NEW
818
          }
×
819
        } else if (process.env.NODE_ENV !== 'production') {
×
NEW
820
          console.warn(
×
NEW
821
            `IMPORT_STATE: Atom "${atomName}" not found — store lacks getByName()`
×
NEW
822
          );
×
823
        }
×
824
      } catch (error) {
×
825
        if (process.env.NODE_ENV !== 'production') {
×
NEW
826
          console.warn(
×
NEW
827
            `IMPORT_STATE: Failed to set atom "${atomName}":`,
×
NEW
828
            error
×
NEW
829
          );
×
830
        }
×
831
      }
×
832
    }
×
833
  }
×
834

835
  /**
836
   * Enhance store with metadata support.
837
   * @param store The store to enhance
838
   */
839
  private enhanceStoreWithMetadata(store: Store): void {
1✔
840
    if (!store.setWithMetadata) return;
30!
841

842
    // Override set method to capture metadata
843
    store.set = ((atom: BasicAtom, update: unknown) => {
30✔
844
      const atomName = this.getAtomName(atom);
95✔
845
      const actionName = this.getActionName(atom, atomName, 'SET');
95✔
846

847
      const builder = createActionMetadata()
95✔
848
        .type(actionName)
95✔
849
        .timestamp(Date.now())
95✔
850
        .source('DevToolsPlugin')
95✔
851
        .atomName(atomName);
95✔
852

853
      if (this.currentBatchId) {
95✔
854
        builder.groupId(this.currentBatchId);
5✔
855
      }
5✔
856

857
      if (this.config.trace) {
95✔
858
        const captured = captureStackTrace(this.config.traceLimit);
1✔
859
        if (captured) {
1✔
860
          builder.stackTrace(formatStackTraceForDevTools(captured));
1✔
861
        }
1✔
862
      }
1✔
863

864
      const metadata = builder.build() as ActionMetadata;
95✔
865

866
      store.setWithMetadata?.(atom as Atom<unknown>, update, metadata);
95✔
867

868
      if (this.currentBatchId) {
95✔
869
        this.actionGrouper.add(metadata);
5✔
870
      } else {
93✔
871
        this.sendStateUpdate(store, metadata.type);
90✔
872
      }
90✔
873
    }) as unknown as typeof store.set;
95✔
874
  }
30✔
875

876
  /**
877
   * Start a batch so that subsequent set() calls are grouped into one DevTools action.
878
   * Call endBatch with the same id to flush and send.
879
   * @param groupId Unique id for this batch
880
   */
881
  startBatch(groupId: string): void {
1✔
882
    this.currentBatchId = groupId;
3✔
883
    this.actionGrouper.startGroup(groupId);
3✔
884
  }
3✔
885

886
  /**
887
   * End a batch and send a single grouped action to DevTools.
888
   * @param groupId Must match the id passed to startBatch
889
   */
890
  endBatch(groupId: string): void {
1✔
891
    if (this.currentBatchId === groupId) {
3✔
892
      this.currentBatchId = null;
3✔
893
    }
3✔
894
    const result = this.actionGrouper.endGroup(groupId);
3✔
895
    if (result && this.currentStore) {
3✔
896
      this.sendStateUpdate(this.currentStore, result.type);
2✔
897
    }
2✔
898
  }
3✔
899

900
  /**
901
   * Flush any pending batch updates immediately.
902
   * Useful for tests to ensure updates are sent without waiting for batch latency.
903
   */
904
  flushBatch(): void {
1✔
905
    this.batchUpdater.flush();
2✔
906
  }
2✔
907

908
  /**
909
   * Get action name using naming system
910
   */
911
  private getActionName(
1✔
912
    atom: BasicAtom,
96✔
913
    atomName: string,
96✔
914
    operation: string
96✔
915
  ): string {
96✔
916
    return this.actionNamingSystem.getName({
96✔
917
      atom,
96✔
918
      atomName,
96✔
919
      operation,
96✔
920
    });
96✔
921
  }
96✔
922

923
  /**
924
   * Setup polling for state updates (fallback for basic stores).
925
   * @param store The store to poll
926
   */
927
  private setupPolling(store: Store): void {
1✔
928
    const interval = setInterval(() => {
3✔
929
      if (this.isTracking) {
1✔
930
        // Generate action name for polling updates
931
        const actionName = this.getActionName(
1✔
932
          { id: { toString: () => 'polling' } } as BasicAtom,
1✔
933
          'polling',
1✔
934
          'STATE_UPDATE'
1✔
935
        );
1✔
936
        this.sendStateUpdate(store, actionName);
1✔
937
      }
1✔
938
    }, this.config.latency);
3✔
939

940
    // Clean up interval on window unload
941
    if (typeof window !== 'undefined' && window.addEventListener) {
3✔
942
      window.addEventListener('beforeunload', () => {
1✔
943
        clearInterval(interval);
×
944
      });
1✔
945
    }
1✔
946
  }
3✔
947

948
  /**
949
   * Send state update to DevTools (called by BatchUpdater on flush).
950
   * Broadcasts to all connections for scenarios like multiple DevTools windows.
951
   * Uses lazy serialization and incremental updates when config.serialization.lazy is set.
952
   * @param store The store to get state from
953
   * @param action The action name
954
   */
955
  private doSendStateUpdate(store: Store, action: string): void {
1✔
956
    if (!this.isTracking) return;
3!
957
    try {
3✔
958
      const currentState = store.serializeState?.() || store.getState();
3!
959
      const sanitizedState = this.config.stateSanitizer(currentState) as Record<
3✔
960
        string,
961
        unknown
962
      >;
963

964
      const lazyOpts = this.getLazySerializationOptions();
3✔
965
      let stateToSend: unknown = sanitizedState;
3✔
966
      let stateChanged: boolean;
3✔
967

968
      if (lazyOpts) {
3!
969
        const prevState = this.lastState as Record<string, unknown> | null;
×
970
        const changedKeys = this.stateSerializer.getChangedKeys(
×
971
          prevState,
×
972
          sanitizedState
×
973
        );
×
974
        const result = this.stateSerializer.serializeLazy(
×
975
          sanitizedState,
×
976
          lazyOpts,
×
977
          this.lastLazyState ?? undefined,
×
978
          changedKeys.size > 0 ? changedKeys : undefined
×
979
        );
×
980
        stateToSend = result.state;
×
981
        const prevLazy = this.lastLazyState;
×
982
        this.lastLazyState = result.state as Record<string, unknown>;
×
983
        this.lastState = sanitizedState;
×
984
        stateChanged =
×
985
          prevLazy === null ||
×
986
          JSON.stringify(result.state) !== JSON.stringify(prevLazy);
×
987
      } else {
3✔
988
        stateChanged =
3✔
989
          JSON.stringify(sanitizedState) !== JSON.stringify(this.lastState);
3✔
990
        if (stateChanged) {
3✔
991
          this.lastState = sanitizedState;
3✔
992
        }
3✔
993
      }
3✔
994

995
      if (stateChanged && this.config.actionSanitizer(action, stateToSend)) {
3✔
996
        console.log('[DevTools] Sending action:', action);
3✔
997
        console.log('[DevTools] State type:', typeof stateToSend);
3✔
998
        console.log(
3✔
999
          '[DevTools] State keys:',
3✔
1000
          typeof stateToSend === 'object' && stateToSend !== null
3✔
1001
            ? Object.keys(stateToSend)
3!
1002
            : 'N/A'
×
1003
        );
3✔
1004
        console.log('[DevTools] State:', stateToSend);
3✔
1005

1006
        // Broadcast to all connections
1007
        const actionObj = { type: action };
3✔
1008
        for (const conn of this.connections) {
3✔
1009
          if (conn) {
8✔
1010
            try {
8✔
1011
              conn.send(actionObj, stateToSend);
8✔
1012
            } catch (error) {
8!
1013
              if (process.env.NODE_ENV !== 'production') {
×
1014
                console.warn('Failed to send to connection:', error);
×
1015
              }
×
1016
            }
×
1017
          }
8✔
1018
        }
8✔
1019

1020
        this.snapshotMapper.mapSnapshotToAction(
3✔
1021
          `snap-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
3✔
1022
          action
3✔
1023
        );
3✔
1024
      }
3✔
1025
    } catch (error) {
3!
1026
      if (process.env.NODE_ENV !== 'production') {
×
1027
        console.warn('Failed to send state update to DevTools:', error);
×
1028
      }
×
1029
    }
×
1030
  }
3✔
1031

1032
  /**
1033
   * Schedule a state update to be sent to DevTools (batched and throttled).
1034
   * @param store The store to get state from
1035
   * @param action The action name
1036
   */
1037
  private sendStateUpdate(store: Store, action: string): void {
1✔
1038
    this.batchUpdater.schedule(store, action);
93✔
1039
  }
93✔
1040

1041
  /**
1042
   * Export current state in DevTools-compatible format.
1043
   * Uses lazy serialization when config.serialization.lazy is set.
1044
   * @param store The store to export state from
1045
   * @param metadata Optional metadata to include
1046
   * @returns Serialized state with checksum
1047
   */
1048
  exportState(
1✔
NEW
1049
    store: Store,
×
1050
    metadata?: Record<string, unknown>
×
1051
  ): Record<string, unknown> {
×
1052
    try {
×
1053
      const state = store.serializeState?.() || store.getState();
×
1054
      const lazyOpts = this.getLazySerializationOptions();
×
1055

1056
      const exported = lazyOpts
×
1057
        ? this.stateSerializer.exportStateLazy(
×
1058
            state as Record<string, unknown>,
×
1059
            lazyOpts,
×
1060
            metadata
×
1061
          )
×
1062
        : this.stateSerializer.exportState(
×
1063
            state as Record<string, unknown>,
×
1064
            metadata
×
1065
          );
×
1066

1067
      return {
×
1068
        state: exported.state,
×
1069
        timestamp: exported.timestamp,
×
1070
        checksum: exported.checksum,
×
1071
        version: exported.version,
×
1072
        metadata: exported.metadata,
×
1073
      };
×
1074
    } catch (error) {
×
1075
      if (process.env.NODE_ENV !== 'production') {
×
1076
        console.warn('Failed to export state:', error);
×
1077
      }
×
1078

1079
      const state = store.serializeState?.() || store.getState();
×
1080
      return {
×
1081
        state,
×
1082
        timestamp: Date.now(),
×
1083
        checksum: '',
×
1084
        version: '1.0.0',
×
1085
        metadata: metadata || {},
×
1086
      };
×
1087
    }
×
1088
  }
×
1089

1090
  /**
1091
   * Get the snapshot mapper for time travel lookups
1092
   * @returns The SnapshotMapper instance
1093
   */
1094
  getSnapshotMapper(): SnapshotMapper {
1✔
1095
    return this.snapshotMapper;
×
1096
  }
×
1097

1098
  /**
1099
   * Set up time travel integration
1100
   * @param store The store to integrate with
1101
   */
1102
  private setupTimeTravel(store: Store): void {
1✔
1103
    // Check if store has timeTravel property (SimpleTimeTravel instance)
1104
    const storeWithTimeTravel = store as any;
33✔
1105

1106
    if (
33✔
1107
      storeWithTimeTravel.timeTravel &&
33!
1108
      typeof storeWithTimeTravel.timeTravel === 'object'
×
1109
    ) {
33!
1110
      console.log('[DevToolsPlugin] Found timeTravel instance:', {
×
1111
        hasJumpTo: typeof storeWithTimeTravel.timeTravel.jumpTo === 'function',
×
1112
        hasGetHistory:
×
1113
          typeof storeWithTimeTravel.timeTravel.getHistory === 'function',
×
1114
        hasUndo: typeof storeWithTimeTravel.timeTravel.undo === 'function',
×
1115
        hasRedo: typeof storeWithTimeTravel.timeTravel.redo === 'function',
×
1116
      });
×
1117

1118
      this.timeTravel = storeWithTimeTravel.timeTravel;
×
1119
    } else if (storeWithTimeTravel.jumpTo && storeWithTimeTravel.getHistory) {
33!
1120
      // Fallback: create SimpleTimeTravel wrapper for enhanced store
1121
      console.log('[DevToolsPlugin] Store has time travel methods directly');
×
1122
    }
×
1123
  }
33✔
1124
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc