• 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

44.42
/packages/devtools/src/devtools-plugin-refactored.ts
1
/**
1✔
2
 * Refactored DevToolsPlugin with decomposed responsibilities
3
 *
4
 * This version uses separate services for different responsibilities:
5
 * 1. DevToolsConnector - Manages connection to DevTools
6
 * 2. StateSerializer - Serializes state
7
 * 3. ActionNamingSystem - Names actions
8
 * 4. MessageHandler - Handles DevTools messages
9
 * 5. StackTraceService - Captures stack traces
10
 * 6. AtomNameResolver - Resolves atom names
11
 * 7. PollingService - Fallback polling
12
 * 8. BatchUpdater - Batches updates
13
 * 9. ActionGrouper - Groups actions
14
 */
15

16
import type {
17
  DevToolsConfig,
18
  BasicAtom,
19
  DevToolsMode,
20
  ActionMetadata,
21
} from "./types";
22
import type { SimpleTimeTravel } from "@nexus-state/time-travel";
23
import type { Store, Atom } from "@nexus-state/core";
24

25
// Import decomposed services
26
import { DevToolsConnector, createDevToolsConnector } from "./devtools-connector";
27
import { StateSerializer, createStateSerializer } from "./state-serializer";
28
import { ActionNamingSystem, createActionNamingSystem } from "./action-naming";
29
import { MessageHandler, createMessageHandler } from "./message-handler";
30
import {
31
  StackTraceService,
32
  createStackTraceService,
33
} from "./stack-trace-service";
34
import { AtomNameResolver, createAtomNameResolver } from "./atom-name-resolver";
35
import { PollingService, createPollingService } from "./polling-service";
36
import { createBatchUpdater, type BatchUpdater } from "./batch-updater";
37
import { createActionGrouper, type ActionGrouper } from "./action-grouper";
38
import { createActionMetadata } from "./action-metadata";
39
import { createSnapshotMapper, type SnapshotMapper } from "./snapshot-mapper";
40

41
/**
42
 * Refactored DevToolsPlugin class with decomposed responsibilities
43
 */
44
export class DevToolsPluginRefactored {
1✔
45
  // Core services
46
  private connector: DevToolsConnector;
47
  private stateSerializer: StateSerializer;
48
  private actionNamingSystem: ActionNamingSystem;
49
  private messageHandler: MessageHandler;
50
  private stackTraceService: StackTraceService;
51
  private atomNameResolver: AtomNameResolver;
52
  private pollingService: PollingService;
53
  private batchUpdater: BatchUpdater;
54
  private actionGrouper: ActionGrouper;
55
  private snapshotMapper: SnapshotMapper;
56

57
  // Configuration
58
  private config: Required<DevToolsConfig>;
59

60
  // State
61
  private currentStore: Store | null = null;
1✔
62
  private currentBatchId: string | null = null;
1✔
63
  private lastState: unknown = null;
1✔
64
  private lastLazyState: Record<string, unknown> | null = null;
1✔
65
  private isTracking = true;
1✔
66
  private timeTravel: SimpleTimeTravel | null = null;
1✔
67

68
  constructor(config: DevToolsConfig = {}) {
1✔
69
    // Store configuration
70
    this.config = this.createConfig(config);
11✔
71

72
    // Initialize services
73
    this.connector = createDevToolsConnector();
11✔
74
    this.stateSerializer = createStateSerializer();
11✔
75
    this.actionNamingSystem = this.createActionNamingSystem();
11✔
76
    this.messageHandler = createMessageHandler({
11✔
77
      enableTimeTravel: true,
11✔
78
      enableImportExport: true,
11✔
79
      debug: process.env.NODE_ENV !== "production",
11✔
80
      onStateUpdate: (state) => this.onStateUpdateFromTimeTravel(state),
11✔
81
    });
11✔
82
    this.stackTraceService = createStackTraceService();
11✔
83
    this.atomNameResolver = createAtomNameResolver({
11✔
84
      showAtomNames: this.config.showAtomNames,
11✔
85
      formatter: this.config.atomNameFormatter,
11✔
86
    });
11✔
87
    this.pollingService = createPollingService({
11✔
88
      interval: this.config.latency,
11✔
89
      debug: process.env.NODE_ENV !== "production",
11✔
90
    });
11✔
91
    this.snapshotMapper = createSnapshotMapper({
11✔
92
      maxMappings: this.config.maxAge,
11✔
93
      autoCleanup: true,
11✔
94
    });
11✔
95
    this.actionGrouper = createActionGrouper(this.config.actionGroupOptions);
11✔
96

97
    // Initialize batch updater
98
    const batchOpts = this.config.batchUpdate ?? {};
11✔
99
    this.batchUpdater = createBatchUpdater({
11✔
100
      batchLatencyMs: batchOpts.batchLatencyMs ?? this.config.latency,
11✔
101
      maxQueueSize: batchOpts.maxQueueSize ?? 100,
11✔
102
      throttleByFrame: batchOpts.throttleByFrame ?? true,
11✔
103
      maxUpdatesPerSecond: batchOpts.maxUpdatesPerSecond ?? 0,
11✔
104
      onFlush: (store, action) => {
11✔
105
        const targetStore = (store ??
×
NEW
106
          this.currentStore) as Store | null;
×
107
        if (targetStore) {
×
108
          this.doSendStateUpdate(targetStore, action);
×
109
        }
×
110
      },
×
111
    });
11✔
112
  }
11✔
113

114
  /**
115
   * Create configuration with defaults
116
   */
117
  private createConfig(config: DevToolsConfig): Required<DevToolsConfig> {
1✔
118
    const {
11✔
119
      actionNamingStrategy = "auto",
11✔
120
      actionNamingPattern,
11✔
121
      actionNamingFunction,
11✔
122
      defaultNamingStrategy = "auto",
11✔
123
    } = config;
11✔
124

125
    return {
11✔
126
      name: config.name ?? "nexus-state",
11✔
127
      trace: config.trace ?? false,
11✔
128
      traceLimit: config.traceLimit ?? 10,
11✔
129
      latency: config.latency ?? 100,
11✔
130
      maxAge: config.maxAge ?? 50,
11✔
131
      actionSanitizer: config.actionSanitizer ?? (() => true),
11✔
132
      stateSanitizer: config.stateSanitizer ?? ((state) => state),
11✔
133
      showAtomNames: config.showAtomNames ?? true,
11✔
134
      atomNameFormatter:
11✔
135
        config.atomNameFormatter ??
11✔
136
        ((_atom: BasicAtom, defaultName: string) => defaultName),
10✔
137
      actionNamingStrategy,
11✔
138
      actionNamingPattern,
11✔
139
      actionNamingFunction,
11✔
140
      defaultNamingStrategy,
11✔
141
      serialization: config.serialization,
11✔
142
      batchUpdate: config.batchUpdate,
11✔
143
      actionGroupOptions: config.actionGroupOptions,
11✔
144
    } as Required<DevToolsConfig>;
11✔
145
  }
11✔
146

147
  /**
148
   * Create action naming system based on config
149
   */
150
  private createActionNamingSystem(): ActionNamingSystem {
1✔
151
    const {
11✔
152
      actionNamingStrategy,
11✔
153
      actionNamingPattern,
11✔
154
      actionNamingFunction,
11✔
155
      defaultNamingStrategy,
11✔
156
    } = this.config;
11✔
157

158
    // If strategy is already an instance, use it directly
159
    if (
11✔
160
      typeof actionNamingStrategy === "object" &&
11!
161
      "getName" in actionNamingStrategy
×
162
    ) {
11!
163
      const system = new ActionNamingSystem();
×
164
      system.getRegistry().register(actionNamingStrategy as any, true);
×
165
      return system;
×
166
    }
×
167

168
    // Handle string strategy types
169
    const strategyType = actionNamingStrategy as string;
11✔
170

171
    // Build options based on config
172
    const options: any = {
11✔
173
      defaultStrategy: defaultNamingStrategy,
11✔
174
    };
11✔
175

176
    if (strategyType === "pattern" && actionNamingPattern) {
11!
177
      options.strategy = "pattern";
×
178
      options.patternConfig = {
×
179
        pattern: actionNamingPattern,
×
180
        placeholders: {
×
181
          atomName: true,
×
182
          operation: true,
×
183
          timestamp: true,
×
184
          date: false,
×
185
          time: false,
×
186
        },
×
187
      };
×
188
    } else if (strategyType === "custom" && actionNamingFunction) {
11!
189
      options.strategy = "custom";
×
190
      options.customConfig = {
×
191
        namingFunction: actionNamingFunction,
×
192
      };
×
193
    } else {
11✔
194
      options.strategy = strategyType;
11✔
195
    }
11✔
196

197
    return createActionNamingSystem(options);
11✔
198
  }
11✔
199

200
  /**
201
   * Set the SimpleTimeTravel instance for time travel debugging
202
   * @param timeTravel The SimpleTimeTravel instance
203
   */
204
  setTimeTravel(timeTravel: SimpleTimeTravel): void {
1✔
205
    this.timeTravel = timeTravel;
×
206
    console.log('[DevToolsPlugin] TimeTravel set:', {
×
207
      hasJumpTo: typeof timeTravel.jumpTo === 'function',
×
208
      hasGetHistory: typeof timeTravel.getHistory === 'function',
×
209
    });
×
210
  }
×
211

212
  /**
213
   * Handle state update from time-travel command
214
   */
215
  private onStateUpdateFromTimeTravel(state: Record<string, unknown>): void {
1✔
216
    console.log('[DevToolsPlugin] State updated from time-travel:', {
×
217
      hasState: !!state,
×
218
      stateKeys: Object.keys(state),
×
219
    });
×
220
    
221
    // Send updated state to DevTools
222
    if (this.currentStore && this.connector.isConnectedToDevTools()) {
×
223
      const actionName = this.actionNamingSystem.getName({
×
224
        atom: { id: { toString: (): string => "time-travel" } } as BasicAtom,
×
225
        atomName: "time-travel",
×
226
        operation: "JUMP",
×
227
      });
×
228
      
229
      this.doSendStateUpdate(this.currentStore, actionName);
×
230
    }
×
231
  }
×
232

233
  /**
234
   * Apply the plugin to a store
235
   */
236
  apply(store: Store): void {
1✔
237
    this.currentStore = store;
2✔
238

239
    // Update atom name resolver with store reference
240
    this.atomNameResolver.setStore(store);
2✔
241

242
    // Runtime production guard
243
    if (process.env.NODE_ENV === "production") {
2!
244
      return;
×
245
    }
×
246

247
    // Connect to DevTools
248
    const connection = this.connector.connect({
2✔
249
      name: this.config.name,
2✔
250
      trace: this.config.trace,
2✔
251
      latency: this.config.latency,
2✔
252
      maxAge: this.config.maxAge,
2✔
253
    });
2✔
254

255
    if (!connection) {
2✔
256
      return;
2✔
257
    }
2!
258

259
    // Send initial state
260
    this.sendInitialState(store);
×
261

262
    // Setup message handler
263
    this.messageHandler.setStore(store);
×
264
    this.messageHandler.setSnapshotMapper(this.snapshotMapper);
×
265

266
    // Setup time travel if available
267
    this.setupTimeTravel(store);
×
268

269
    // Subscribe to DevTools messages
270
    this.connector.subscribe((message) => {
×
271
      this.messageHandler.handle(message, store);
×
272
    });
×
273

274
    // Enhance store methods if available
275
    if (store.setWithMetadata) {
×
276
      this.enhanceStoreWithMetadata(store);
×
277
    } else {
×
278
      // Fallback to polling for basic stores
279
      this.setupPolling(store);
×
280
    }
×
281
  }
2✔
282

283
  /**
284
   * Send initial state to DevTools
285
   */
286
  private sendInitialState(store: Store): void {
1✔
287
    try {
×
288
      const state = store.serializeState?.() || store.getState();
×
289
      const sanitized = this.config.stateSanitizer(state) as Record<
×
290
        string,
291
        unknown
292
      >;
293
      this.lastState = sanitized;
×
294

295
      const lazyOpts = this.getLazySerializationOptions();
×
296
      let stateToSend: unknown = sanitized;
×
297

298
      if (lazyOpts) {
×
299
        const result = this.stateSerializer.serializeLazy(sanitized, lazyOpts);
×
300
        this.lastLazyState = result.state as Record<string, unknown>;
×
301
        stateToSend = result.state;
×
302
      } else {
×
303
        this.lastLazyState = null;
×
304
      }
×
305

306
      this.connector.init(stateToSend);
×
307
    } catch (error) {
×
308
      if (process.env.NODE_ENV !== "production") {
×
309
        console.warn("Failed to send initial state to DevTools:", error);
×
310
      }
×
311
    }
×
312
  }
×
313

314
  /**
315
   * Get lazy serialization options from config
316
   */
317
  private getLazySerializationOptions(): any {
1✔
318
    const ser = this.config.serialization;
2✔
319
    if (!ser?.lazy) return null;
2!
320

321
    return {
×
322
      maxDepth: ser.maxDepth,
×
323
      maxSerializedSize: ser.maxSerializedSize,
×
324
      circularRefHandling: ser.circularRefHandling,
×
325
      placeholder: ser.placeholder,
×
326
    };
×
327
  }
2✔
328

329
  /**
330
   * Enhance store with metadata support
331
   */
332
  private enhanceStoreWithMetadata(store: Store): void {
1✔
333
    if (!store.setWithMetadata) return;
×
334

335
    // Override set method to capture metadata
336
    store.set = ((atom: BasicAtom, update: unknown) => {
×
337
      const atomName = this.atomNameResolver.getName(atom);
×
338
      const actionName = this.actionNamingSystem.getName({
×
339
        atom,
×
340
        atomName,
×
341
        operation: "SET",
×
342
      });
×
343

344
      const builder = createActionMetadata()
×
345
        .type(actionName)
×
346
        .timestamp(Date.now())
×
347
        .source("DevToolsPlugin")
×
348
        .atomName(atomName);
×
349

350
      if (this.currentBatchId) {
×
351
        builder.groupId(this.currentBatchId);
×
352
      }
×
353

354
      if (this.config.trace) {
×
355
        const captured = this.stackTraceService.capture({
×
356
          limit: this.config.traceLimit,
×
357
        });
×
358
        if (captured) {
×
359
          builder.stackTrace(
×
360
            this.stackTraceService.formatForDevTools(captured),
×
361
          );
×
362
        }
×
363
      }
×
364

365
      const metadata = builder.build() as ActionMetadata;
×
366

NEW
367
      store.setWithMetadata?.(atom as Atom<unknown>, update, metadata);
×
368

369
      if (this.currentBatchId) {
×
370
        this.actionGrouper.add(metadata);
×
371
      } else {
×
372
        this.sendStateUpdate(store, metadata.type);
×
373
      }
×
374
    }) as unknown as typeof store.set;
×
375
  }
×
376

377
  /**
378
   * Setup polling for state updates (fallback for basic stores)
379
   */
380
  private setupPolling(store: Store): void {
1✔
381
    this.pollingService.start(this.config.latency, () => {
×
382
      if (this.isTracking) {
×
383
        const actionName = this.actionNamingSystem.getName({
×
384
          atom: { id: { toString: () => "polling" } } as BasicAtom,
×
385
          atomName: "polling",
×
386
          operation: "STATE_UPDATE",
×
387
        });
×
388
        this.sendStateUpdate(store, actionName);
×
389
      }
×
390
    });
×
391
  }
×
392

393
  /**
394
   * Send state update to DevTools
395
   */
396
  private doSendStateUpdate(store: Store, action: string): void {
1✔
397
    if (!this.isTracking || !this.connector.isConnectedToDevTools()) return;
×
398

399
    try {
×
400
      const currentState = store.serializeState?.() || store.getState();
×
401
      const sanitizedState = this.config.stateSanitizer(currentState) as Record<
×
402
        string,
403
        unknown
404
      >;
405

406
      const lazyOpts = this.getLazySerializationOptions();
×
407
      let stateToSend: unknown = sanitizedState;
×
408
      let stateChanged: boolean;
×
409

410
      if (lazyOpts) {
×
411
        const prevState = this.lastState as Record<string, unknown> | null;
×
412
        const changedKeys = this.stateSerializer.getChangedKeys(
×
413
          prevState,
×
414
          sanitizedState,
×
415
        );
×
416
        const result = this.stateSerializer.serializeLazy(
×
417
          sanitizedState,
×
418
          lazyOpts,
×
419
          this.lastLazyState ?? undefined,
×
420
          changedKeys.size > 0 ? changedKeys : undefined,
×
421
        );
×
422
        stateToSend = result.state;
×
423
        const prevLazy = this.lastLazyState;
×
424
        this.lastLazyState = result.state as Record<string, unknown>;
×
425
        this.lastState = sanitizedState;
×
426
        stateChanged =
×
427
          prevLazy === null ||
×
428
          JSON.stringify(result.state) !== JSON.stringify(prevLazy);
×
429
      } else {
×
430
        stateChanged =
×
431
          JSON.stringify(sanitizedState) !== JSON.stringify(this.lastState);
×
432
        if (stateChanged) {
×
433
          this.lastState = sanitizedState;
×
434
        }
×
435
      }
×
436

437
      if (stateChanged && this.config.actionSanitizer(action, stateToSend)) {
×
438
        this.connector.send(action, stateToSend);
×
439
        this.snapshotMapper.mapSnapshotToAction(
×
440
          `snap-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`,
×
441
          action,
×
442
        );
×
443
      }
×
444
    } catch (error) {
×
445
      if (process.env.NODE_ENV !== "production") {
×
446
        console.warn("Failed to send state update to DevTools:", error);
×
447
      }
×
448
    }
×
449
  }
×
450

451
  /**
452
   * Schedule a state update to be sent to DevTools
453
   */
454
  private sendStateUpdate(store: Store, action: string): void {
1✔
455
    this.batchUpdater.schedule(store, action);
×
456
  }
×
457

458
  /**
459
   * Start a batch for grouping actions
460
   */
461
  startBatch(groupId: string): void {
1✔
462
    this.currentBatchId = groupId;
1✔
463
    this.actionGrouper.startGroup(groupId);
1✔
464
  }
1✔
465

466
  /**
467
   * End a batch and send grouped action
468
   */
469
  endBatch(groupId: string): void {
1✔
470
    if (this.currentBatchId === groupId) {
1✔
471
      this.currentBatchId = null;
1✔
472
    }
1✔
473
    const result = this.actionGrouper.endGroup(groupId);
1✔
474
    if (result && this.currentStore) {
1!
475
      this.sendStateUpdate(this.currentStore, result.type);
×
476
    }
×
477
  }
1✔
478

479
  /**
480
   * Export current state in DevTools-compatible format
481
   */
482
  exportState(
1✔
483
    store: Store,
2✔
484
    metadata?: Record<string, unknown>,
2✔
485
  ): Record<string, unknown> {
2✔
486
    try {
2✔
487
      const state = store.serializeState?.() || store.getState();
2!
488
      const lazyOpts = this.getLazySerializationOptions();
2✔
489

490
      const exported = lazyOpts
2!
491
        ? this.stateSerializer.exportStateLazy(
×
492
            state as Record<string, unknown>,
×
493
            lazyOpts,
×
494
            metadata,
×
495
          )
×
496
        : this.stateSerializer.exportState(
2✔
497
            state as Record<string, unknown>,
2✔
498
            metadata,
2✔
499
          );
2✔
500

501
      return {
2✔
502
        state: exported.state,
2✔
503
        timestamp: exported.timestamp,
2✔
504
        checksum: exported.checksum,
2✔
505
        version: exported.version,
2✔
506
        metadata: exported.metadata,
2✔
507
      };
2✔
508
    } catch (error) {
2!
509
      if (process.env.NODE_ENV !== "production") {
×
510
        console.warn("Failed to export state:", error);
×
511
      }
×
512

513
      const state = store.serializeState?.() || store.getState();
×
514
      return {
×
515
        state,
×
516
        timestamp: Date.now(),
×
517
        checksum: "",
×
518
        version: "1.0.0",
×
519
        metadata: metadata || {},
×
520
      };
×
521
    }
×
522
  }
2✔
523

524
  /**
525
   * Get the snapshot mapper
526
   */
527
  getSnapshotMapper(): SnapshotMapper {
1✔
528
    return this.snapshotMapper;
×
529
  }
×
530

531
  /**
532
   * Get the message handler
533
   */
534
  getMessageHandler(): MessageHandler {
1✔
535
    return this.messageHandler;
2✔
536
  }
2✔
537

538
  /**
539
   * Get the atom name resolver
540
   */
541
  getAtomNameResolver(): AtomNameResolver {
1✔
542
    return this.atomNameResolver;
2✔
543
  }
2✔
544

545
  /**
546
   * Get the stack trace service
547
   */
548
  getStackTraceService(): StackTraceService {
1✔
549
    return this.stackTraceService;
1✔
550
  }
1✔
551

552
  /**
553
   * Get the polling service
554
   */
555
  getPollingService(): PollingService {
1✔
556
    return this.pollingService;
1✔
557
  }
1✔
558

559
  /**
560
   * Get the DevTools connector (for testing purposes)
561
   */
562
  getConnector(): DevToolsConnector {
1✔
563
    return this.connector;
×
564
  }
×
565

566
  /**
567
   * Clean up resources
568
   */
569
  dispose(): void {
1✔
570
    this.connector.disconnect();
12✔
571
    this.pollingService.dispose();
12✔
572
    this.currentStore = null;
12✔
573
    this.currentBatchId = null;
12✔
574
    this.lastState = null;
12✔
575
    this.lastLazyState = null;
12✔
576
    this.timeTravel = null;
12✔
577
    this.isTracking = false;
12✔
578
  }
12✔
579

580
  /**
581
   * Set up time travel integration
582
   * @param store The store to integrate with
583
   */
584
  private setupTimeTravel(store: Store): void {
1✔
585
    const storeWithTimeTravel = store as any;
×
586
    
587
    // First check if setTimeTravel was called explicitly
588
    if (this.timeTravel) {
×
589
      console.log('[DevToolsPlugin] Using explicitly set timeTravel');
×
590
      this.messageHandler.setTimeTravel(this.timeTravel);
×
591
      return;
×
592
    }
×
593
    
594
    // Then check if store has timeTravel property
595
    if (storeWithTimeTravel.timeTravel && typeof storeWithTimeTravel.timeTravel === 'object') {
×
596
      console.log('[DevToolsPlugin] Found timeTravel on store:', {
×
597
        hasJumpTo: typeof storeWithTimeTravel.timeTravel.jumpTo === 'function',
×
598
        hasGetHistory: typeof storeWithTimeTravel.timeTravel.getHistory === 'function',
×
599
        hasUndo: typeof storeWithTimeTravel.timeTravel.undo === 'function',
×
600
        hasRedo: typeof storeWithTimeTravel.timeTravel.redo === 'function',
×
601
      });
×
602
      
603
      this.messageHandler.setTimeTravel(storeWithTimeTravel.timeTravel);
×
604
      return;
×
605
    }
×
606
    
607
    // Check if store has time travel methods directly
608
    if (storeWithTimeTravel.jumpTo && storeWithTimeTravel.getHistory) {
×
609
      console.log('[DevToolsPlugin] Store has time travel methods directly');
×
610
      // Create a wrapper
611
      this.messageHandler.setTimeTravel(storeWithTimeTravel);
×
612
      return;
×
613
    }
×
614
    
615
    console.log('[DevToolsPlugin] No timeTravel found on store');
×
616
  }
×
617
}
1✔
618

619
/**
620
 * Create a new refactored DevToolsPlugin instance
621
 */
622
export function createDevToolsPluginRefactored(
1✔
623
  config: DevToolsConfig = {},
×
624
): DevToolsPluginRefactored {
×
625
  return new DevToolsPluginRefactored(config);
×
626
}
×
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