sygnal 5.2.1 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "5.2.1",
3
+ "version": "5.3.1",
4
4
  "description": "An intuitive framework for building fast and small components or applications based on Cycle.js",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
package/src/component.ts CHANGED
@@ -369,6 +369,9 @@ class Component {
369
369
  this.sources.props$ = props$.map((val: any) => {
370
370
  const { sygnalFactory, sygnalOptions, ...sanitizedProps }: any = val
371
371
  this.currentProps = sanitizedProps
372
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
373
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps)
374
+ }
372
375
  return val
373
376
  })
374
377
  }
@@ -450,6 +453,9 @@ class Component {
450
453
  }
451
454
 
452
455
  dispose(): void {
456
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
457
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name)
458
+ }
453
459
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
454
460
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION))
455
461
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -870,6 +876,12 @@ class Component {
870
876
  } else {
871
877
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []))
872
878
  }
879
+ // Stamp EVENTS sink emissions with emitter component info for devtools
880
+ if (name === 'EVENTS' && acc[name]) {
881
+ const _componentNumber = this._componentNumber
882
+ const _name = this.name
883
+ acc[name] = acc[name].map((ev: any) => ({...ev, __emitterId: _componentNumber, __emitterName: _name}))
884
+ }
873
885
  return acc
874
886
  }, {} as Record<string, any>)
875
887
 
@@ -1420,6 +1432,18 @@ class Component {
1420
1432
  if (!isObj(sink$)) {
1421
1433
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
1422
1434
  }
1435
+
1436
+ // Notify devtools of collection mount
1437
+ if (typeof window !== 'undefined' && (window as any).__SYGNAL_DEVTOOLS__?.connected) {
1438
+ const itemName = typeof collectionOf === 'function'
1439
+ ? (collectionOf.componentName || collectionOf.label || collectionOf.name || 'anonymous')
1440
+ : String(collectionOf)
1441
+ ;(window as any).__SYGNAL_DEVTOOLS__.onCollectionMounted(
1442
+ this._componentNumber, this.name, itemName,
1443
+ typeof stateField === 'string' ? stateField : null
1444
+ )
1445
+ }
1446
+
1423
1447
  return sink$
1424
1448
  }
1425
1449
 
@@ -1571,6 +1595,7 @@ class Component {
1571
1595
  for (const key of Object.keys(props)) {
1572
1596
  const val = props[key]
1573
1597
  if (val && val.__sygnalCommand) {
1598
+ val._targetComponentName = componentName
1574
1599
  sources.commands$ = makeCommandSource(val as Command)
1575
1600
  break
1576
1601
  }
@@ -1620,10 +1645,15 @@ class Component {
1620
1645
  const wasReady = this._childReadyState[id]
1621
1646
  this._childReadyState[id] = !!ready
1622
1647
  // When READY state changes, trigger a re-render
1623
- if (wasReady !== !!ready && this._readyChangedListener) {
1624
- setTimeout(() => {
1625
- this._readyChangedListener?.next(null)
1626
- }, 0)
1648
+ if (wasReady !== !!ready) {
1649
+ if (this._readyChangedListener) {
1650
+ setTimeout(() => {
1651
+ this._readyChangedListener?.next(null)
1652
+ }, 0)
1653
+ }
1654
+ if (typeof window !== 'undefined' && (window as any).__SYGNAL_DEVTOOLS__?.connected) {
1655
+ (window as any).__SYGNAL_DEVTOOLS__.onReadyChanged(this._componentNumber, this.name, id, !!ready)
1656
+ }
1627
1657
  }
1628
1658
  },
1629
1659
  error: () => {},
@@ -16,6 +16,10 @@ export interface Command {
16
16
  _stream: Stream<CommandMessage>;
17
17
  /** @internal — marker for component.ts detection */
18
18
  __sygnalCommand: true;
19
+ /** @internal — stamped by component.ts when wired to a child */
20
+ _targetComponentId?: number;
21
+ /** @internal — stamped by component.ts when wired to a child */
22
+ _targetComponentName?: string;
19
23
  }
20
24
 
21
25
  export function createCommand(): Command {
@@ -26,11 +30,18 @@ export function createCommand(): Command {
26
30
  stop() { listener.next = () => {}; },
27
31
  });
28
32
 
29
- return {
30
- send: (type: string, data?: any) => listener.next({ type, data }),
33
+ const cmd: Command = {
34
+ send: (type: string, data?: any) => {
35
+ listener.next({ type, data });
36
+ if (typeof window !== 'undefined' && (window as any).__SYGNAL_DEVTOOLS__?.connected) {
37
+ (window as any).__SYGNAL_DEVTOOLS__.onCommandSent(type, data, cmd._targetComponentId, cmd._targetComponentName);
38
+ }
39
+ },
31
40
  _stream,
32
41
  __sygnalCommand: true as const,
33
42
  };
43
+
44
+ return cmd;
34
45
  }
35
46
 
36
47
  export function makeCommandSource(cmd: Command): CommandSource {
@@ -26,6 +26,12 @@ interface StateHistoryEntry {
26
26
  state: any;
27
27
  }
28
28
 
29
+ interface MviGraphData {
30
+ sources: string[];
31
+ actions: {name: string; sinks: string[]}[];
32
+ contextProvides: string[];
33
+ }
34
+
29
35
  interface ComponentMeta {
30
36
  id: number;
31
37
  name: string;
@@ -39,6 +45,7 @@ interface ComponentMeta {
39
45
  children: number[];
40
46
  debug: boolean;
41
47
  createdAt: number;
48
+ mviGraph: MviGraphData | null;
42
49
  _instanceRef: WeakRef<any>;
43
50
  }
44
51
 
@@ -94,6 +101,12 @@ class SygnalDevTools {
94
101
  case 'TIME_TRAVEL':
95
102
  this._timeTravel(msg.payload);
96
103
  break;
104
+ case 'SNAPSHOT':
105
+ this._takeSnapshot();
106
+ break;
107
+ case 'RESTORE_SNAPSHOT':
108
+ this._restoreSnapshot(msg.payload);
109
+ break;
97
110
  case 'GET_STATE':
98
111
  this._sendComponentState(msg.payload.componentId);
99
112
  break;
@@ -114,6 +127,7 @@ class SygnalDevTools {
114
127
  children: [],
115
128
  debug: instance._debug,
116
129
  createdAt: Date.now(),
130
+ mviGraph: this._extractMviGraph(instance),
117
131
  _instanceRef: new WeakRef(instance),
118
132
  };
119
133
  this._components.set(componentNumber, meta);
@@ -141,7 +155,6 @@ class SygnalDevTools {
141
155
  componentId: componentNumber,
142
156
  componentName: name,
143
157
  state: entry.state,
144
- historyIndex: this._stateHistory.length - 1,
145
158
  });
146
159
  }
147
160
 
@@ -179,6 +192,77 @@ class SygnalDevTools {
179
192
  componentId: componentNumber,
180
193
  componentName: name,
181
194
  context: this._safeClone(context),
195
+ contextTrace: this._buildContextTrace(componentNumber, context),
196
+ });
197
+ }
198
+
199
+ onPropsChanged(componentNumber: number, name: string, props: any): void {
200
+ if (!this.connected) return;
201
+ this._post('PROPS_CHANGED', {
202
+ componentId: componentNumber,
203
+ componentName: name,
204
+ props: this._safeClone(props),
205
+ });
206
+ }
207
+
208
+ onBusEvent(event: {type: string; data?: any; __emitterId?: number; __emitterName?: string}): void {
209
+ if (!this.connected) return;
210
+ this._post('BUS_EVENT', {
211
+ type: event.type,
212
+ data: this._safeClone(event.data),
213
+ componentId: event.__emitterId ?? null,
214
+ componentName: event.__emitterName ?? null,
215
+ timestamp: Date.now(),
216
+ });
217
+ }
218
+
219
+ onCommandSent(type: string, data: any, targetComponentId?: number, targetComponentName?: string): void {
220
+ if (!this.connected) return;
221
+ this._post('COMMAND_SENT', {
222
+ type,
223
+ data: this._safeClone(data),
224
+ targetComponentName: targetComponentName ?? null,
225
+ timestamp: Date.now(),
226
+ });
227
+ }
228
+
229
+ onReadyChanged(parentId: number, parentName: string, childKey: string, ready: boolean): void {
230
+ if (!this.connected) return;
231
+ this._post('READY_CHANGED', {
232
+ parentId,
233
+ parentName,
234
+ childKey,
235
+ ready,
236
+ timestamp: Date.now(),
237
+ });
238
+ }
239
+
240
+ onCollectionMounted(parentId: number, parentName: string, itemComponentName: string, stateField: string | null): void {
241
+ const meta = this._components.get(parentId);
242
+ if (meta) {
243
+ (meta as any).collection = {itemComponent: itemComponentName, stateField};
244
+ }
245
+ if (!this.connected) return;
246
+ this._post('COLLECTION_MOUNTED', {
247
+ parentId,
248
+ parentName,
249
+ itemComponent: itemComponentName,
250
+ stateField,
251
+ });
252
+ }
253
+
254
+ onComponentDisposed(componentNumber: number, name: string): void {
255
+ const meta = this._components.get(componentNumber);
256
+ if (meta) {
257
+ (meta as any).disposed = true;
258
+ (meta as any).disposedAt = Date.now();
259
+ }
260
+
261
+ if (!this.connected) return;
262
+ this._post('COMPONENT_DISPOSED', {
263
+ componentId: componentNumber,
264
+ componentName: name,
265
+ timestamp: Date.now(),
182
266
  });
183
267
  }
184
268
 
@@ -209,18 +293,78 @@ class SygnalDevTools {
209
293
  }
210
294
  }
211
295
 
212
- _timeTravel({historyIndex}: {historyIndex: number}): void {
213
- const entry = this._stateHistory[historyIndex];
214
- if (!entry) return;
296
+ _timeTravel({componentId, componentName, state}: {componentId: number; componentName: string; state: any}): void {
297
+ if (componentId == null || !state) {
298
+ console.warn('[Sygnal DevTools] _timeTravel: missing componentId or state', {componentId, hasState: !!state});
299
+ return;
300
+ }
215
301
 
216
302
  if (typeof window === 'undefined') return;
303
+
304
+ const newState = this._safeClone(state);
305
+
306
+ // Try per-component time-travel via the component's STATE sink (reducer stream)
307
+ const meta = this._components.get(componentId);
308
+ if (meta) {
309
+ const instance = meta._instanceRef?.deref();
310
+ if (!instance) {
311
+ console.warn(`[Sygnal DevTools] _timeTravel: WeakRef for component #${componentId} (${componentName}) has been GC'd`);
312
+ } else {
313
+ // sinks[stateSourceName] is the reducer stream — push a reducer that replaces state
314
+ const stateSinkName = instance.stateSourceName || 'STATE';
315
+ const stateSink = instance.sinks?.[stateSinkName];
316
+ if (stateSink?.shamefullySendNext) {
317
+ stateSink.shamefullySendNext(() => ({...newState}));
318
+ this._post('TIME_TRAVEL_APPLIED', {
319
+ componentId,
320
+ componentName,
321
+ state: newState,
322
+ });
323
+ return;
324
+ }
325
+ console.warn(`[Sygnal DevTools] _timeTravel: component #${componentId} (${componentName}) has no STATE sink with shamefullySendNext. sinkName=${stateSinkName}, hasSinks=${!!instance.sinks}, sinkKeys=${instance.sinks ? Object.keys(instance.sinks).join(',') : 'none'}`);
326
+ }
327
+ } else {
328
+ console.warn(`[Sygnal DevTools] _timeTravel: no meta for componentId ${componentId}`);
329
+ }
330
+
331
+ // Fall back to root STATE sink for root-level components
217
332
  const app = window.__SYGNAL_DEVTOOLS_APP__;
218
333
  if (app?.sinks?.STATE?.shamefullySendNext) {
219
- app.sinks.STATE.shamefullySendNext(() => ({...entry.state}));
334
+ app.sinks.STATE.shamefullySendNext(() => ({...newState}));
220
335
  this._post('TIME_TRAVEL_APPLIED', {
221
- historyIndex,
222
- state: entry.state,
336
+ componentId,
337
+ componentName,
338
+ state: newState,
223
339
  });
340
+ } else {
341
+ console.warn(`[Sygnal DevTools] _timeTravel: no fallback root STATE sink available`);
342
+ }
343
+ }
344
+
345
+ _takeSnapshot(): void {
346
+ const entries: {componentId: number; componentName: string; state: any}[] = [];
347
+ for (const [id, meta] of this._components) {
348
+ if ((meta as any).disposed) continue;
349
+ const instance = meta._instanceRef?.deref();
350
+ if (instance?.currentState != null) {
351
+ entries.push({
352
+ componentId: id,
353
+ componentName: meta.name,
354
+ state: this._safeClone(instance.currentState),
355
+ });
356
+ }
357
+ }
358
+ this._post('SNAPSHOT_TAKEN', {
359
+ entries,
360
+ timestamp: Date.now(),
361
+ });
362
+ }
363
+
364
+ _restoreSnapshot(snapshot: {entries: {componentId: number; componentName: string; state: any}[]}): void {
365
+ if (!snapshot?.entries) return;
366
+ for (const entry of snapshot.entries) {
367
+ this._timeTravel(entry);
224
368
  }
225
369
  }
226
370
 
@@ -233,12 +377,39 @@ class SygnalDevTools {
233
377
  componentId,
234
378
  state: this._safeClone(instance.currentState),
235
379
  context: this._safeClone(instance.currentContext),
380
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
236
381
  props: this._safeClone(instance.currentProps),
237
382
  });
238
383
  }
239
384
  }
240
385
  }
241
386
 
387
+ _buildContextTrace(componentId: number, context: any): {field: string; providerId: number; providerName: string}[] {
388
+ if (!context || typeof context !== 'object') return [];
389
+ const trace: {field: string; providerId: number; providerName: string}[] = [];
390
+ const fields = Object.keys(context);
391
+
392
+ for (const field of fields) {
393
+ // Walk up parent chain to find which component provides this field
394
+ let currentId: number | null = componentId;
395
+ let found = false;
396
+ while (currentId != null) {
397
+ const meta = this._components.get(currentId);
398
+ if (!meta) break;
399
+ if (meta.mviGraph?.contextProvides?.includes(field)) {
400
+ trace.push({field, providerId: meta.id, providerName: meta.name});
401
+ found = true;
402
+ break;
403
+ }
404
+ currentId = meta.parentId;
405
+ }
406
+ if (!found) {
407
+ trace.push({field, providerId: -1, providerName: 'unknown'});
408
+ }
409
+ }
410
+ return trace;
411
+ }
412
+
242
413
  _sendFullTree(): void {
243
414
  const tree: any[] = [];
244
415
  for (const [, meta] of this._components) {
@@ -276,6 +447,49 @@ class SygnalDevTools {
276
447
  }
277
448
  }
278
449
 
450
+ _extractMviGraph(instance: any): MviGraphData | null {
451
+ if (!instance.model) return null;
452
+ try {
453
+ const sources = instance.sourceNames ? [...instance.sourceNames] : [];
454
+ const actions: {name: string; sinks: string[]}[] = [];
455
+ const model = instance.model;
456
+
457
+ for (const key of Object.keys(model)) {
458
+ let actionName = key;
459
+ let entry = model[key];
460
+
461
+ // Handle shorthand 'ACTION | SINK'
462
+ if (key.includes('|')) {
463
+ const parts = key.split('|').map((s: string) => s.trim());
464
+ if (parts.length === 2 && parts[0] && parts[1]) {
465
+ actionName = parts[0];
466
+ actions.push({name: actionName, sinks: [parts[1]]});
467
+ continue;
468
+ }
469
+ }
470
+
471
+ // Function → targets STATE
472
+ if (typeof entry === 'function') {
473
+ actions.push({name: actionName, sinks: [instance.stateSourceName || 'STATE']});
474
+ continue;
475
+ }
476
+
477
+ // Object → keys are sink names
478
+ if (entry && typeof entry === 'object') {
479
+ actions.push({name: actionName, sinks: Object.keys(entry)});
480
+ continue;
481
+ }
482
+ }
483
+
484
+ const contextProvides = instance.context && typeof instance.context === 'object'
485
+ ? Object.keys(instance.context) : [];
486
+
487
+ return {sources, actions, contextProvides};
488
+ } catch (e) {
489
+ return null;
490
+ }
491
+ }
492
+
279
493
  _serializeMeta(meta: ComponentMeta): Omit<ComponentMeta, '_instanceRef'> {
280
494
  const {_instanceRef, ...rest} = meta;
281
495
  return rest;
@@ -8,19 +8,72 @@ export interface DragConfig {
8
8
  dragImage?: string;
9
9
  }
10
10
 
11
+ // ─── Enriched DND Event Streams ──────────────────────────────────────────────
12
+
13
+ export interface EnrichedDragStream<T = any> extends Stream<T> {
14
+ /** Extract a `data-*` attribute value from the payload's element/dataset.
15
+ * Optional transform fn: `.data('id', Number)` */
16
+ data(name: string): EnrichedDragStream<string | undefined>;
17
+ data<R>(name: string, fn: (val: string | undefined) => R): EnrichedDragStream<R>;
18
+
19
+ /** Extract the dragged element (dragstart) or dropZone element (drop) */
20
+ element(): EnrichedDragStream<Element | null>;
21
+ element<R>(fn: (el: Element | null) => R): EnrichedDragStream<R>;
22
+ }
23
+
24
+ /**
25
+ * Adds chainable convenience methods to a DND event stream,
26
+ * mirroring the DOM driver's `enrichEventStream` pattern.
27
+ *
28
+ * DND.dragstart('task').data('taskId')
29
+ * DND.dragstart('task').data('taskId', Number)
30
+ * DND.drop('lane').data('laneId')
31
+ * DND.dragstart('task').element()
32
+ */
33
+ function enrichDragStream(stream$: any): any {
34
+ // .data(name, fn?) — extract dataset[name] from dragstart payload,
35
+ // or dropZone.dataset[name] from drop payload
36
+ stream$.data = function data(name: string, fn?: (val: any) => any): any {
37
+ const mapped = stream$.map((e: any) => {
38
+ // dragstart payload: { element, dataset }
39
+ // drop payload: { dropZone, insertBefore }
40
+ const val = e?.dataset?.[name]
41
+ ?? (e?.dropZone as HTMLElement)?.dataset?.[name]
42
+ ?? (e?.element as HTMLElement)?.dataset?.[name]
43
+ return fn ? fn(val) : val
44
+ });
45
+ return enrichDragStream(mapped);
46
+ }
47
+
48
+ // .element(fn?) — extract the primary element from the payload
49
+ stream$.element = function element(fn?: (el: any) => any): any {
50
+ const mapped = stream$.map((e: any) => {
51
+ const el = e?.element ?? e?.dropZone ?? null
52
+ return fn ? fn(el) : el
53
+ });
54
+ return enrichDragStream(mapped);
55
+ }
56
+
57
+ return stream$
58
+ }
59
+
60
+ // ─── DND Source Interfaces ───────────────────────────────────────────────────
61
+
11
62
  export interface DragSourceEvents {
12
- events(eventType: string): Stream<any>;
63
+ events(eventType: string): EnrichedDragStream<any>;
13
64
  }
14
65
 
15
66
  export interface DragSource {
16
67
  select(category: string): DragSourceEvents;
17
- dragstart(category: string): Stream<any>;
68
+ dragstart(category: string): EnrichedDragStream<any>;
18
69
  dragend(category: string): Stream<any>;
19
- drop(category: string): Stream<any>;
70
+ drop(category: string): EnrichedDragStream<any>;
20
71
  dragover(category: string): Stream<any>;
21
72
  dispose(): void;
22
73
  }
23
74
 
75
+ // ─── Driver Factory ──────────────────────────────────────────────────────────
76
+
24
77
  export function makeDragDriver(): (sink$: Stream<any>) => DragSource {
25
78
  return function dragDriver(sink$: Stream<any>): DragSource {
26
79
  const categories = new Map<string, DragConfig>();
@@ -114,10 +167,10 @@ export function makeDragDriver(): (sink$: Stream<any>) => DragSource {
114
167
  const source: DragSource = {
115
168
  select(category: string): DragSourceEvents {
116
169
  return {
117
- events(eventType: string): Stream<any> {
170
+ events(eventType: string): EnrichedDragStream<any> {
118
171
  const busEventName = `${category}:${eventType}`;
119
172
  let handler: ((e: Event) => void) | undefined;
120
- return xs.create({
173
+ const stream$ = xs.create({
121
174
  start(listener) {
122
175
  handler = ({detail}: any) => listener.next(detail);
123
176
  bus.addEventListener(busEventName, handler);
@@ -126,6 +179,7 @@ export function makeDragDriver(): (sink$: Stream<any>) => DragSource {
126
179
  if (handler) bus.removeEventListener(busEventName, handler);
127
180
  },
128
181
  });
182
+ return enrichDragStream(stream$);
129
183
  },
130
184
  };
131
185
  },
@@ -14,8 +14,12 @@ export default function eventBusDriver(out$: Stream<BusEvent>): EventBusSource {
14
14
  const events = new EventTarget();
15
15
 
16
16
  out$.subscribe({
17
- next: (event: BusEvent) =>
18
- events.dispatchEvent(new CustomEvent('data', {detail: event})),
17
+ next: (event: BusEvent) => {
18
+ events.dispatchEvent(new CustomEvent('data', {detail: event}));
19
+ if (typeof window !== 'undefined' && (window as any).__SYGNAL_DEVTOOLS__?.connected) {
20
+ (window as any).__SYGNAL_DEVTOOLS__.onBusEvent(event);
21
+ }
22
+ },
19
23
  error: (err: any) =>
20
24
  console.error('[EVENTS driver] Error in sink stream:', err),
21
25
  });