sygnal 5.2.0 → 5.3.0

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.
@@ -10,7 +10,7 @@
10
10
  * export default defineConfig({ plugins: [sygnal()] })
11
11
  *
12
12
  * What it does:
13
- * 1. Configures esbuild for automatic JSX transform with sygnal as the import source
13
+ * 1. Configures OXC for automatic JSX transform with sygnal as the import source
14
14
  * 2. Detects files that call `run()` from sygnal and auto-injects HMR wiring
15
15
  *
16
16
  * The HMR transform finds the pattern:
@@ -34,9 +34,11 @@ function sygnal(options = {}) {
34
34
  if (disableJsx)
35
35
  return;
36
36
  return {
37
- esbuild: {
38
- jsx: 'automatic',
39
- jsxImportSource: 'sygnal',
37
+ oxc: {
38
+ jsx: {
39
+ runtime: 'automatic',
40
+ importSource: 'sygnal',
41
+ },
40
42
  },
41
43
  };
42
44
  },
@@ -8,7 +8,7 @@
8
8
  * export default defineConfig({ plugins: [sygnal()] })
9
9
  *
10
10
  * What it does:
11
- * 1. Configures esbuild for automatic JSX transform with sygnal as the import source
11
+ * 1. Configures OXC for automatic JSX transform with sygnal as the import source
12
12
  * 2. Detects files that call `run()` from sygnal and auto-injects HMR wiring
13
13
  *
14
14
  * The HMR transform finds the pattern:
@@ -32,9 +32,11 @@ function sygnal(options = {}) {
32
32
  if (disableJsx)
33
33
  return;
34
34
  return {
35
- esbuild: {
36
- jsx: 'automatic',
37
- jsxImportSource: 'sygnal',
35
+ oxc: {
36
+ jsx: {
37
+ runtime: 'automatic',
38
+ importSource: 'sygnal',
39
+ },
38
40
  },
39
41
  };
40
42
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
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",
@@ -69,6 +69,10 @@
69
69
  "import": "./dist/vike/onRenderClient.mjs",
70
70
  "require": "./dist/vike/onRenderClient.cjs.js"
71
71
  },
72
+ "./vike/ClientOnly": {
73
+ "import": "./dist/vike/ClientOnly.mjs",
74
+ "require": "./dist/vike/ClientOnly.cjs.js"
75
+ },
72
76
  "./types": {
73
77
  "import": "./dist/index.d.ts",
74
78
  "require": "./dist/index.d.ts"
package/src/component.ts CHANGED
@@ -91,6 +91,7 @@ export interface ComponentOptions {
91
91
  stateSourceName?: string;
92
92
  requestSourceName?: string;
93
93
  isolateOpts?: string | boolean | Record<string, any>;
94
+ isolatedState?: boolean;
94
95
  onError?: (error: Error, info: { componentName: string }) => any;
95
96
  debug?: boolean;
96
97
  }
@@ -169,6 +170,7 @@ class Component {
169
170
  sourceNames: string[];
170
171
  _debug: boolean;
171
172
  onError: ((error: Error, info: { componentName: string }) => any) | undefined;
173
+ isolatedState: boolean;
172
174
  isSubComponent: boolean;
173
175
  currentState: any;
174
176
  currentProps: any;
@@ -202,7 +204,7 @@ class Component {
202
204
  _readyChanged$: any;
203
205
  _readyChangedListener: any;
204
206
 
205
- constructor({name = 'NO NAME', sources, intent, model, hmrActions, context, response, view, peers = {}, components = {}, initialState, calculated, storeCalculatedInState = true, DOMSourceName = 'DOM', stateSourceName = 'STATE', requestSourceName = 'HTTP', onError, debug = false}: ComponentOptions) {
207
+ constructor({name = 'NO NAME', sources, intent, model, hmrActions, context, response, view, peers = {}, components = {}, initialState, calculated, storeCalculatedInState = true, DOMSourceName = 'DOM', stateSourceName = 'STATE', requestSourceName = 'HTTP', isolatedState = false, onError, debug = false}: ComponentOptions) {
206
208
  if (!sources || !isObj(sources)) throw new Error(`[${name}] Missing or invalid sources`)
207
209
 
208
210
  this._componentNumber = COMPONENT_COUNT++
@@ -225,6 +227,7 @@ class Component {
225
227
  this.requestSourceName = requestSourceName
226
228
  this.sourceNames = Object.keys(sources)
227
229
  this.onError = onError
230
+ this.isolatedState = isolatedState
228
231
  this._debug = debug
229
232
 
230
233
  // Warn if calculated fields shadow base state keys
@@ -366,6 +369,9 @@ class Component {
366
369
  this.sources.props$ = props$.map((val: any) => {
367
370
  const { sygnalFactory, sygnalOptions, ...sanitizedProps }: any = val
368
371
  this.currentProps = sanitizedProps
372
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
373
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps)
374
+ }
369
375
  return val
370
376
  })
371
377
  }
@@ -447,6 +453,9 @@ class Component {
447
453
  }
448
454
 
449
455
  dispose(): void {
456
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
457
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name)
458
+ }
450
459
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
451
460
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION))
452
461
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -649,7 +658,7 @@ class Component {
649
658
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE
650
659
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState
651
660
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState }
652
- if (this.isSubComponent && this.initialState) {
661
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
653
662
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`)
654
663
  }
655
664
  const hasInitialState = (typeof effectiveInitialState !== 'undefined')
@@ -851,6 +860,7 @@ class Component {
851
860
  .map((vdom: any) => processLazy(vdom, this))
852
861
  .map(processPortals)
853
862
  .map(processTransitions)
863
+ .map(processClientOnly)
854
864
  .compose(this.instantiateSubComponents.bind(this))
855
865
  .filter((val: any) => val !== undefined)
856
866
  .compose(this.renderVdom.bind(this))
@@ -866,6 +876,12 @@ class Component {
866
876
  } else {
867
877
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []))
868
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
+ }
869
885
  return acc
870
886
  }, {} as Record<string, any>)
871
887
 
@@ -1416,6 +1432,18 @@ class Component {
1416
1432
  if (!isObj(sink$)) {
1417
1433
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
1418
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
+
1419
1447
  return sink$
1420
1448
  }
1421
1449
 
@@ -1567,6 +1595,7 @@ class Component {
1567
1595
  for (const key of Object.keys(props)) {
1568
1596
  const val = props[key]
1569
1597
  if (val && val.__sygnalCommand) {
1598
+ val._targetComponentName = componentName
1570
1599
  sources.commands$ = makeCommandSource(val as Command)
1571
1600
  break
1572
1601
  }
@@ -1616,10 +1645,15 @@ class Component {
1616
1645
  const wasReady = this._childReadyState[id]
1617
1646
  this._childReadyState[id] = !!ready
1618
1647
  // When READY state changes, trigger a re-render
1619
- if (wasReady !== !!ready && this._readyChangedListener) {
1620
- setTimeout(() => {
1621
- this._readyChangedListener?.next(null)
1622
- }, 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
+ }
1623
1657
  }
1624
1658
  },
1625
1659
  error: () => {},
@@ -1876,6 +1910,29 @@ function processTransitions(vnode: any): any {
1876
1910
  return vnode
1877
1911
  }
1878
1912
 
1913
+ function processClientOnly(vnode: any): any {
1914
+ if (!vnode || !vnode.sel) return vnode
1915
+ if (vnode.sel === 'clientonly') {
1916
+ // On the client, unwrap to children (render them normally)
1917
+ const children = vnode.children || []
1918
+ if (children.length === 0) return { sel: 'div', data: {}, children: [] }
1919
+ if (children.length === 1) return processClientOnly(children[0])
1920
+ // Multiple children: wrap in a div
1921
+ return {
1922
+ sel: 'div',
1923
+ data: {},
1924
+ children: children.map(processClientOnly),
1925
+ text: undefined,
1926
+ elm: undefined,
1927
+ key: undefined,
1928
+ }
1929
+ }
1930
+ if (vnode.children && vnode.children.length > 0) {
1931
+ vnode.children = vnode.children.map(processClientOnly)
1932
+ }
1933
+ return vnode
1934
+ }
1935
+
1879
1936
  function applyTransitionHooks(vnode: any, name: string, duration?: number): any {
1880
1937
  const existingInsert = vnode.data?.hook?.insert
1881
1938
  const existingRemove = vnode.data?.hook?.remove
@@ -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
  });