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

eustatos / nexus-state / 23151573359

16 Mar 2026 03:27PM UTC coverage: 72.999% (-2.4%) from 75.353%
23151573359

push

github

web-flow
feat(form): add features and refactor

2058 of 2422 branches covered (84.97%)

Branch coverage included in aggregate %.

681 of 720 new or added lines in 11 files covered. (94.58%)

19 existing lines in 1 file now uncovered.

7910 of 11233 relevant lines covered (70.42%)

164.16 hits per line

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

54.99
/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
  EnhancedStore,
13
  BasicAtom,
14
  DevToolsMode,
15
  DevToolsFeatureDetectionResult,
16
  ActionMetadata,
17
} from './types';
18
import type { SnapshotMapper } from './snapshot-mapper';
19
import type { SimpleTimeTravel } from '@nexus-state/time-travel';
20
import {
21
  captureStackTrace,
22
  formatStackTraceForDevTools,
23
} from './utils/stack-tracer';
24
import { atomRegistry } from '@nexus-state/core';
25
import { createSnapshotMapper } from './snapshot-mapper';
26
import {
27
  StateSerializer,
28
  createStateSerializer,
29
  type LazySerializationOptions,
30
} from './state-serializer';
31
import {
32
  ActionNamingSystem,
33
  createActionNamingSystem,
34
  defaultActionNamingSystem,
35
  type ActionNamingStrategy,
36
  type ActionNamingStrategyType,
37
  type PatternNamingConfig,
38
} from './action-naming';
39
import { createActionMetadata } from './action-metadata';
40
import { createActionGrouper, type ActionGrouper } from './action-grouper';
41
import { createBatchUpdater, type BatchUpdater } from './batch-updater';
42

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

311
    // Handle SSR environment - no-op
312
    if (features.isSSR) {
38✔
313
      return;
4✔
314
    }
4✔
315

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

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

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

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

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

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

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

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

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

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

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

387
      // Use custom formatter if provided
388
      if (this.config.atomNameFormatter) {
99✔
389
        const defaultName = atomRegistry.getName(atom as { id: symbol });
99✔
390
        return this.config.atomNameFormatter(atom, defaultName);
99✔
391
      }
99!
392

393
      // Use registry name if available
394
      const registryName = atomRegistry.getName(atom as { id: symbol });
×
395
      if (registryName) {
×
396
        return registryName;
×
397
      }
×
398

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

407
  /**
408
   * Build lazy serialization options from plugin config.
409
   */
410
  private getLazySerializationOptions(): LazySerializationOptions | null {
1✔
411
    const ser = this.config.serialization;
40✔
412
    if (!ser?.lazy) return null;
40!
413
    return {
×
414
      maxDepth: ser.maxDepth,
×
415
      maxSerializedSize: ser.maxSerializedSize,
×
416
      circularRefHandling: ser.circularRefHandling,
×
417
      placeholder: ser.placeholder,
×
418
    };
×
419
  }
40✔
420

421
  /**
422
   * Send initial state to DevTools (with optional lazy serialization).
423
   * Sends to all connections for scenarios like multiple DevTools windows.
424
   * @param store The store to get initial state from
425
   */
426
  private sendInitialState(store: EnhancedStore): void {
1✔
427
    try {
34✔
428
      const state = store.serializeState?.() || store.getState();
34✔
429
      const sanitized = this.config.stateSanitizer(state) as Record<
34✔
430
        string,
431
        unknown
432
      >;
433
      this.lastState = sanitized;
34✔
434

435
      const lazyOpts = this.getLazySerializationOptions();
34✔
436
      let stateToSend: unknown = sanitized;
34✔
437
      if (lazyOpts) {
34!
438
        const result = this.stateSerializer.serializeLazy(sanitized, lazyOpts);
×
439
        this.lastLazyState = result.state as Record<string, unknown>;
×
440
        stateToSend = result.state;
×
441
      } else {
34✔
442
        this.lastLazyState = null;
34✔
443
      }
34✔
444

445
      // Send to all connections
446
      for (const conn of this.connections) {
34✔
447
        try {
34✔
448
          conn.init(stateToSend);
34✔
449
        } catch (error) {
34!
450
          if (process.env.NODE_ENV !== 'production') {
×
451
            console.warn('Failed to send initial state to connection:', error);
×
452
          }
×
453
        }
×
454
      }
34✔
455
    } catch (error) {
34!
456
      if (process.env.NODE_ENV !== 'production') {
×
457
        console.warn('Failed to send initial state to DevTools:', error);
×
458
      }
×
459
    }
×
460
  }
34✔
461

462
  /**
463
   * Setup message listeners for DevTools commands.
464
   * @param store The store to handle commands for
465
   * @param connection The connection to listen on (default: main connection)
466
   */
467
  private setupMessageListeners(
1✔
468
    store: EnhancedStore,
33✔
469
    connection?: DevToolsConnection
33✔
470
  ): void {
33✔
471
    const conn = connection || this.connection;
33!
472
    const unsubscribe = conn?.subscribe((message: DevToolsMessage) => {
33✔
473
      try {
4✔
474
        this.handleDevToolsMessage(message, store);
4✔
475
      } catch (error) {
4!
476
        if (process.env.NODE_ENV !== 'production') {
×
477
          console.warn('Error handling DevTools message:', error);
×
478
        }
×
479
      }
×
480
    });
33✔
481

482
    // Clean up on window unload
483
    if (typeof window !== 'undefined' && window.addEventListener) {
33✔
484
      window.addEventListener('beforeunload', () => {
9✔
485
        unsubscribe?.();
×
486
        conn?.unsubscribe();
×
487
      });
9✔
488
    }
9✔
489
  }
33✔
490

491
  /**
492
   * Setup listener for new connections (e.g., from multiple DevTools windows).
493
   * Some DevTools extensions may create new connections dynamically.
494
   * @param extension The DevTools extension instance
495
   * @param store The store to apply to new connections
496
   */
497
  private setupConnectionListener(extension: any, store: EnhancedStore): void {
1✔
498
    // Store reference for later use
499
    // eslint-disable-next-line @typescript-eslint/no-this-alias
500
    const plugin = this;
33✔
501

502
    // Override connect method to track new connections
503
    const originalConnect = extension.connect.bind(extension);
33✔
504
    extension.connect = function (options?: any) {
33✔
505
      const newConnection = originalConnect(options);
8✔
506

507
      // Track the new connection
508
      plugin.connections.push(newConnection);
8✔
509

510
      // Send initial state to the new connection
511
      try {
8✔
512
        const state = store.serializeState?.() || store.getState();
8!
513
        const sanitized = plugin.config.stateSanitizer(state) as Record<
8✔
514
          string,
515
          unknown
516
        >;
517
        newConnection.init(sanitized);
8✔
518
      } catch (error) {
8!
519
        if (process.env.NODE_ENV !== 'production') {
×
520
          console.warn(
×
521
            'Failed to send initial state to new connection:',
×
522
            error
×
523
          );
×
524
        }
×
525
      }
×
526

527
      return newConnection;
8✔
528
    };
8✔
529
  }
33✔
530

531
  /**
532
   * Handle messages from DevTools.
533
   * @param message The message from DevTools
534
   * @param store The store to apply commands to
535
   */
536
  private handleDevToolsMessage(
1✔
537
    message: DevToolsMessage,
4✔
538
    store: EnhancedStore
4✔
539
  ): void {
4✔
540
    if (message.type === 'DISPATCH') {
4!
541
      const payload = message.payload as
×
542
        | { type: string; [key: string]: unknown }
543
        | undefined;
544

545
      switch (payload?.type) {
×
546
        case 'JUMP_TO_ACTION':
×
547
        case 'JUMP_TO_STATE': {
×
548
          this.handleTimeTravelCommand(payload, store);
×
549
          break;
×
550
        }
×
551

552
        case 'START':
×
553
          this.isTracking = true;
×
554
          break;
×
555

556
        case 'STOP':
×
557
          this.isTracking = false;
×
558
          break;
×
559

560
        case 'COMMIT':
×
561
          this.sendInitialState(store);
×
562
          break;
×
563

564
        case 'RESET':
×
565
          // Reset to initial state would require storing the initial state
566
          if (process.env.NODE_ENV !== 'production') {
×
567
            console.warn(
×
568
              'Reset is not fully supported without storing initial state'
×
569
            );
×
570
          }
×
571
          break;
×
572

573
        case 'IMPORT_STATE': {
×
574
          this.handleImportState(payload, store);
×
575
          break;
×
576
        }
×
577

578
        default:
×
579
          if (process.env.NODE_ENV !== 'production') {
×
580
            console.warn('Unknown DevTools dispatch type:', payload?.type);
×
581
          }
×
582
      }
×
583
    }
×
584
  }
4✔
585

586
  /**
587
   * Handle time travel commands from DevTools
588
   * @param payload The time travel command payload
589
   * @param store The store to apply commands to
590
   */
591
  private handleTimeTravelCommand(
1✔
592
    payload: { type: string; [key: string]: unknown },
×
593
    store: EnhancedStore
×
594
  ): void {
×
595
    if (!this.timeTravel) {
×
596
      if (process.env.NODE_ENV !== 'production') {
×
597
        console.warn(
×
598
          '[DevToolsPlugin] Time travel not available. Ensure store has SimpleTimeTravel instance.'
×
599
        );
×
600
      }
×
601
      return;
×
602
    }
×
603

604
    try {
×
605
      switch (payload.type) {
×
606
        case 'JUMP_TO_STATE': {
×
607
          const index = (payload as any).index;
×
608
          if (typeof index === 'number' && index >= 0) {
×
609
            console.log(`[DevToolsPlugin] JUMP_TO_STATE: ${index}`);
×
610
            const success = this.timeTravel.jumpTo(index);
×
611
            if (success) {
×
612
              if (process.env.NODE_ENV !== 'production') {
×
613
                console.log(
×
614
                  `[DevToolsPlugin] Successfully jumped to state ${index}`
×
615
                );
×
616
              }
×
617
              // Send updated state to DevTools
618
              this.sendInitialState(store);
×
619
            } else {
×
620
              if (process.env.NODE_ENV !== 'production') {
×
621
                console.warn(
×
622
                  `[DevToolsPlugin] Failed to jump to state ${index}`
×
623
                );
×
624
              }
×
625
            }
×
626
          } else {
×
627
            if (process.env.NODE_ENV !== 'production') {
×
628
              console.warn(`[DevToolsPlugin] Invalid index: ${index}`);
×
629
            }
×
630
          }
×
631
          break;
×
632
        }
×
633

634
        case 'JUMP_TO_ACTION': {
×
635
          const actionName = (payload as any).action;
×
636
          if (typeof actionName === 'string') {
×
637
            console.log(`[DevToolsPlugin] JUMP_TO_ACTION: ${actionName}`);
×
638

639
            // Find the index of the action in history
NEW
640
            const history = this.timeTravel.getHistory() as { metadata: { action?: string } }[];
×
641
            let foundIndex = -1;
×
642

643
            // Search backwards from the end
644
            for (let i = history.length - 1; i >= 0; i--) {
×
645
              const snapshot = history[i];
×
646
              if (snapshot.metadata.action === actionName) {
×
647
                foundIndex = i;
×
648
                break;
×
649
              }
×
650
            }
×
651

652
            if (foundIndex >= 0) {
×
653
              const success = this.timeTravel.jumpTo(foundIndex);
×
654
              if (success) {
×
655
                if (process.env.NODE_ENV !== 'production') {
×
656
                  console.log(
×
657
                    `[DevToolsPlugin] Successfully jumped to action ${actionName} at index ${foundIndex}`
×
658
                  );
×
659
                }
×
660
                // Send updated state to DevTools
661
                this.sendInitialState(store);
×
662
              } else {
×
663
                if (process.env.NODE_ENV !== 'production') {
×
664
                  console.warn(
×
665
                    `[DevToolsPlugin] Failed to jump to action ${actionName}`
×
666
                  );
×
667
                }
×
668
              }
×
669
            } else {
×
670
              if (process.env.NODE_ENV !== 'production') {
×
671
                console.warn(
×
672
                  `[DevToolsPlugin] Action not found: ${actionName}`
×
673
                );
×
674
              }
×
675
            }
×
676
          } else {
×
677
            if (process.env.NODE_ENV !== 'production') {
×
678
              console.warn(
×
679
                `[DevToolsPlugin] Invalid action name: ${actionName}`
×
680
              );
×
681
            }
×
682
          }
×
683
          break;
×
684
        }
×
685

686
        default:
×
687
          if (process.env.NODE_ENV !== 'production') {
×
688
            console.warn(
×
689
              `[DevToolsPlugin] Unknown time travel command: ${payload.type}`
×
690
            );
×
691
          }
×
692
      }
×
693
    } catch (error) {
×
694
      if (process.env.NODE_ENV !== 'production') {
×
695
        console.error(
×
696
          '[DevToolsPlugin] Error handling time travel command:',
×
697
          error
×
698
        );
×
699
      }
×
700
    }
×
701
  }
×
702

703
  /**
704
   * Handle IMPORT_STATE command from DevTools
705
   * @param payload The IMPORT_STATE payload
706
   * @param store The store to import state into
707
   */
708
  private handleImportState(
1✔
709
    payload: { type: string; [key: string]: unknown },
×
710
    store: EnhancedStore
×
711
  ): void {
×
712
    try {
×
713
      // Extract import data from payload
714
      const importData = payload.state || payload.payload;
×
715

716
      if (!importData) {
×
717
        if (process.env.NODE_ENV !== 'production') {
×
718
          console.warn('IMPORT_STATE: No state data provided');
×
719
        }
×
720
        return;
×
721
      }
×
722

723
      // Use StateSerializer to deserialize and validate
724
      const result = this.stateSerializer.importState(importData);
×
725

726
      if (!result.success) {
×
727
        if (process.env.NODE_ENV !== 'production') {
×
728
          console.warn('IMPORT_STATE: Failed to import state:', result.error);
×
729
        }
×
730
        return;
×
731
      }
×
732

733
      // Import state into store
734
      this.importStateIntoStore(result.state!, store);
×
735

736
      // Send updated state to DevTools
737
      this.sendInitialState(store);
×
738

739
      if (process.env.NODE_ENV !== 'production') {
×
740
        console.log('IMPORT_STATE: State imported successfully');
×
741
      }
×
742
    } catch (error) {
×
743
      if (process.env.NODE_ENV !== 'production') {
×
744
        console.warn('IMPORT_STATE: Error importing state:', error);
×
745
      }
×
746
    }
×
747
  }
×
748

749
  /**
750
   * Import state into store
751
   * @param state The state to import
752
   * @param store The store to import into
753
   */
754
  private importStateIntoStore(
1✔
755
    state: Record<string, unknown>,
×
756
    store: EnhancedStore
×
757
  ): void {
×
758
    // Check if store has importState method (from SimpleTimeTravel)
759
    if (typeof (store as any).importState === 'function') {
×
760
      (store as any).importState(state);
×
761
      return;
×
762
    }
×
763

764
    // Fallback: manually set each atom value
765
    for (const [atomIdStr, value] of Object.entries(state)) {
×
766
      try {
×
767
        // Convert string atom ID to symbol
768
        const atomId = Symbol.for(atomIdStr);
×
769
        const atom = atomRegistry.get(atomId) as BasicAtom;
×
770

771
        if (atom) {
×
772
          store.set(atom, value);
×
773
        } else if (process.env.NODE_ENV !== 'production') {
×
774
          console.warn(`IMPORT_STATE: Atom ${atomIdStr} not found in registry`);
×
775
        }
×
776
      } catch (error) {
×
777
        if (process.env.NODE_ENV !== 'production') {
×
778
          console.warn(`IMPORT_STATE: Failed to set atom ${atomIdStr}:`, error);
×
779
        }
×
780
      }
×
781
    }
×
782
  }
×
783

784
  /**
785
   * Enhance store with metadata support.
786
   * @param store The store to enhance
787
   */
788
  private enhanceStoreWithMetadata(store: EnhancedStore): void {
1✔
789
    if (!store.setWithMetadata) return;
30!
790

791
    // Override set method to capture metadata
792
    store.set = ((atom: BasicAtom, update: unknown) => {
30✔
793
      const atomName = this.getAtomName(atom);
95✔
794
      const actionName = this.getActionName(atom, atomName, 'SET');
95✔
795

796
      const builder = createActionMetadata()
95✔
797
        .type(actionName)
95✔
798
        .timestamp(Date.now())
95✔
799
        .source('DevToolsPlugin')
95✔
800
        .atomName(atomName);
95✔
801

802
      if (this.currentBatchId) {
95✔
803
        builder.groupId(this.currentBatchId);
5✔
804
      }
5✔
805

806
      if (this.config.trace) {
95✔
807
        const captured = captureStackTrace(this.config.traceLimit);
1✔
808
        if (captured) {
1✔
809
          builder.stackTrace(formatStackTraceForDevTools(captured));
1✔
810
        }
1✔
811
      }
1✔
812

813
      const metadata = builder.build() as ActionMetadata;
95✔
814

815
      store.setWithMetadata?.(atom, update, metadata);
95✔
816

817
      if (this.currentBatchId) {
95✔
818
        this.actionGrouper.add(metadata);
5✔
819
      } else {
93✔
820
        this.sendStateUpdate(store, metadata.type);
90✔
821
      }
90✔
822
    }) as unknown as typeof store.set;
95✔
823
  }
30✔
824

825
  /**
826
   * Start a batch so that subsequent set() calls are grouped into one DevTools action.
827
   * Call endBatch with the same id to flush and send.
828
   * @param groupId Unique id for this batch
829
   */
830
  startBatch(groupId: string): void {
1✔
831
    this.currentBatchId = groupId;
3✔
832
    this.actionGrouper.startGroup(groupId);
3✔
833
  }
3✔
834

835
  /**
836
   * End a batch and send a single grouped action to DevTools.
837
   * @param groupId Must match the id passed to startBatch
838
   */
839
  endBatch(groupId: string): void {
1✔
840
    if (this.currentBatchId === groupId) {
3✔
841
      this.currentBatchId = null;
3✔
842
    }
3✔
843
    const result = this.actionGrouper.endGroup(groupId);
3✔
844
    if (result && this.currentStore) {
3✔
845
      this.sendStateUpdate(this.currentStore, result.type);
2✔
846
    }
2✔
847
  }
3✔
848

849
  /**
850
   * Flush any pending batch updates immediately.
851
   * Useful for tests to ensure updates are sent without waiting for batch latency.
852
   */
853
  flushBatch(): void {
1✔
854
    this.batchUpdater.flush();
2✔
855
  }
2✔
856

857
  /**
858
   * Get action name using naming system
859
   */
860
  private getActionName(
1✔
861
    atom: BasicAtom,
96✔
862
    atomName: string,
96✔
863
    operation: string
96✔
864
  ): string {
96✔
865
    return this.actionNamingSystem.getName({
96✔
866
      atom,
96✔
867
      atomName,
96✔
868
      operation,
96✔
869
    });
96✔
870
  }
96✔
871

872
  /**
873
   * Setup polling for state updates (fallback for basic stores).
874
   * @param store The store to poll
875
   */
876
  private setupPolling(store: EnhancedStore): void {
1✔
877
    const interval = setInterval(() => {
3✔
878
      if (this.isTracking) {
1✔
879
        // Generate action name for polling updates
880
        const actionName = this.getActionName(
1✔
881
          { id: { toString: () => 'polling' } } as BasicAtom,
1✔
882
          'polling',
1✔
883
          'STATE_UPDATE'
1✔
884
        );
1✔
885
        this.sendStateUpdate(store, actionName);
1✔
886
      }
1✔
887
    }, this.config.latency);
3✔
888

889
    // Clean up interval on window unload
890
    if (typeof window !== 'undefined' && window.addEventListener) {
3✔
891
      window.addEventListener('beforeunload', () => {
1✔
892
        clearInterval(interval);
×
893
      });
1✔
894
    }
1✔
895
  }
3✔
896

897
  /**
898
   * Send state update to DevTools (called by BatchUpdater on flush).
899
   * Broadcasts to all connections for scenarios like multiple DevTools windows.
900
   * Uses lazy serialization and incremental updates when config.serialization.lazy is set.
901
   * @param store The store to get state from
902
   * @param action The action name
903
   */
904
  private doSendStateUpdate(store: EnhancedStore, action: string): void {
1✔
905
    if (!this.isTracking) return;
6!
906
    try {
6✔
907
      const currentState = store.serializeState?.() || store.getState();
6!
908
      const sanitizedState = this.config.stateSanitizer(currentState) as Record<
6✔
909
        string,
910
        unknown
911
      >;
912

913
      const lazyOpts = this.getLazySerializationOptions();
6✔
914
      let stateToSend: unknown = sanitizedState;
6✔
915
      let stateChanged: boolean;
6✔
916

917
      if (lazyOpts) {
6!
918
        const prevState = this.lastState as Record<string, unknown> | null;
×
919
        const changedKeys = this.stateSerializer.getChangedKeys(
×
920
          prevState,
×
921
          sanitizedState
×
922
        );
×
923
        const result = this.stateSerializer.serializeLazy(
×
924
          sanitizedState,
×
925
          lazyOpts,
×
926
          this.lastLazyState ?? undefined,
×
927
          changedKeys.size > 0 ? changedKeys : undefined
×
928
        );
×
929
        stateToSend = result.state;
×
930
        const prevLazy = this.lastLazyState;
×
931
        this.lastLazyState = result.state as Record<string, unknown>;
×
932
        this.lastState = sanitizedState;
×
933
        stateChanged =
×
934
          prevLazy === null ||
×
935
          JSON.stringify(result.state) !== JSON.stringify(prevLazy);
×
936
      } else {
6✔
937
        stateChanged =
6✔
938
          JSON.stringify(sanitizedState) !== JSON.stringify(this.lastState);
6✔
939
        if (stateChanged) {
6✔
940
          this.lastState = sanitizedState;
6✔
941
        }
6✔
942
      }
6✔
943

944
      if (stateChanged && this.config.actionSanitizer(action, stateToSend)) {
6✔
945
        console.log('[DevTools] Sending action:', action);
6✔
946
        console.log('[DevTools] State type:', typeof stateToSend);
6✔
947
        console.log(
6✔
948
          '[DevTools] State keys:',
6✔
949
          typeof stateToSend === 'object' && stateToSend !== null
6✔
950
            ? Object.keys(stateToSend)
6!
951
            : 'N/A'
×
952
        );
6✔
953
        console.log('[DevTools] State:', stateToSend);
6✔
954

955
        // Broadcast to all connections
956
        const actionObj = { type: action };
6✔
957
        for (const conn of this.connections) {
6✔
958
          if (conn) {
11✔
959
            try {
11✔
960
              conn.send(actionObj, stateToSend);
11✔
961
            } catch (error) {
11!
962
              if (process.env.NODE_ENV !== 'production') {
×
963
                console.warn('Failed to send to connection:', error);
×
964
              }
×
965
            }
×
966
          }
11✔
967
        }
11✔
968

969
        this.snapshotMapper.mapSnapshotToAction(
6✔
970
          `snap-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
6✔
971
          action
6✔
972
        );
6✔
973
      }
6✔
974
    } catch (error) {
6!
975
      if (process.env.NODE_ENV !== 'production') {
×
976
        console.warn('Failed to send state update to DevTools:', error);
×
977
      }
×
978
    }
×
979
  }
6✔
980

981
  /**
982
   * Schedule a state update to be sent to DevTools (batched and throttled).
983
   * @param store The store to get state from
984
   * @param action The action name
985
   */
986
  private sendStateUpdate(store: EnhancedStore, action: string): void {
1✔
987
    this.batchUpdater.schedule(store, action);
93✔
988
  }
93✔
989

990
  /**
991
   * Export current state in DevTools-compatible format.
992
   * Uses lazy serialization when config.serialization.lazy is set.
993
   * @param store The store to export state from
994
   * @param metadata Optional metadata to include
995
   * @returns Serialized state with checksum
996
   */
997
  exportState(
1✔
998
    store: EnhancedStore,
×
999
    metadata?: Record<string, unknown>
×
1000
  ): Record<string, unknown> {
×
1001
    try {
×
1002
      const state = store.serializeState?.() || store.getState();
×
1003
      const lazyOpts = this.getLazySerializationOptions();
×
1004

1005
      const exported = lazyOpts
×
1006
        ? this.stateSerializer.exportStateLazy(
×
1007
            state as Record<string, unknown>,
×
1008
            lazyOpts,
×
1009
            metadata
×
1010
          )
×
1011
        : this.stateSerializer.exportState(
×
1012
            state as Record<string, unknown>,
×
1013
            metadata
×
1014
          );
×
1015

1016
      return {
×
1017
        state: exported.state,
×
1018
        timestamp: exported.timestamp,
×
1019
        checksum: exported.checksum,
×
1020
        version: exported.version,
×
1021
        metadata: exported.metadata,
×
1022
      };
×
1023
    } catch (error) {
×
1024
      if (process.env.NODE_ENV !== 'production') {
×
1025
        console.warn('Failed to export state:', error);
×
1026
      }
×
1027

1028
      const state = store.serializeState?.() || store.getState();
×
1029
      return {
×
1030
        state,
×
1031
        timestamp: Date.now(),
×
1032
        checksum: '',
×
1033
        version: '1.0.0',
×
1034
        metadata: metadata || {},
×
1035
      };
×
1036
    }
×
1037
  }
×
1038

1039
  /**
1040
   * Get the snapshot mapper for time travel lookups
1041
   * @returns The SnapshotMapper instance
1042
   */
1043
  getSnapshotMapper(): SnapshotMapper {
1✔
1044
    return this.snapshotMapper;
×
1045
  }
×
1046

1047
  /**
1048
   * Set up time travel integration
1049
   * @param store The store to integrate with
1050
   */
1051
  private setupTimeTravel(store: EnhancedStore): void {
1✔
1052
    // Check if store has timeTravel property (SimpleTimeTravel instance)
1053
    const storeWithTimeTravel = store as any;
33✔
1054

1055
    if (
33✔
1056
      storeWithTimeTravel.timeTravel &&
33!
1057
      typeof storeWithTimeTravel.timeTravel === 'object'
×
1058
    ) {
33!
1059
      console.log('[DevToolsPlugin] Found timeTravel instance:', {
×
1060
        hasJumpTo: typeof storeWithTimeTravel.timeTravel.jumpTo === 'function',
×
1061
        hasGetHistory:
×
1062
          typeof storeWithTimeTravel.timeTravel.getHistory === 'function',
×
1063
        hasUndo: typeof storeWithTimeTravel.timeTravel.undo === 'function',
×
1064
        hasRedo: typeof storeWithTimeTravel.timeTravel.redo === 'function',
×
1065
      });
×
1066

1067
      this.timeTravel = storeWithTimeTravel.timeTravel;
×
1068
    } else if (storeWithTimeTravel.jumpTo && storeWithTimeTravel.getHistory) {
33!
1069
      // Fallback: create SimpleTimeTravel wrapper for enhanced store
1070
      console.log('[DevToolsPlugin] Store has time travel methods directly');
×
1071
    }
×
1072
  }
33✔
1073
}
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