@usefy/use-signal 0.0.31
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/README.md +587 -0
- package/dist/index.d.mts +115 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +161 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">@usefy/use-signal</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>A lightweight React hook for event-driven communication between components</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@usefy/use-signal">
|
|
13
|
+
<img src="https://img.shields.io/npm/v/@usefy/use-signal.svg?style=flat-square&color=007acc" alt="npm version" />
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/@usefy/use-signal">
|
|
16
|
+
<img src="https://img.shields.io/npm/dm/@usefy/use-signal.svg?style=flat-square&color=007acc" alt="npm downloads" />
|
|
17
|
+
</a>
|
|
18
|
+
<a href="https://bundlephobia.com/package/@usefy/use-signal">
|
|
19
|
+
<img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-signal?style=flat-square&color=007acc" alt="bundle size" />
|
|
20
|
+
</a>
|
|
21
|
+
<a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE">
|
|
22
|
+
<img src="https://img.shields.io/npm/l/@usefy/use-signal.svg?style=flat-square&color=007acc" alt="license" />
|
|
23
|
+
</a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<a href="#installation">Installation</a> •
|
|
28
|
+
<a href="#quick-start">Quick Start</a> •
|
|
29
|
+
<a href="#api-reference">API Reference</a> •
|
|
30
|
+
<a href="#examples">Examples</a> •
|
|
31
|
+
<a href="#license">License</a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-usesignal--docs" target="_blank" rel="noopener noreferrer">
|
|
36
|
+
<strong>📚 View Storybook Demo</strong>
|
|
37
|
+
</a>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Overview
|
|
43
|
+
|
|
44
|
+
`@usefy/use-signal` enables event-driven communication between React components without prop drilling or complex state management setup. Components subscribe to a shared "signal" by name, and when any component emits that signal, all subscribers receive an update.
|
|
45
|
+
|
|
46
|
+
**Part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem** — a collection of production-ready React hooks designed for modern applications.
|
|
47
|
+
|
|
48
|
+
### Why use-signal?
|
|
49
|
+
|
|
50
|
+
- **Zero Dependencies** — Pure React implementation with no external dependencies
|
|
51
|
+
- **TypeScript First** — Full type safety with exported interfaces
|
|
52
|
+
- **Stable References** — `emit` and `info` maintain stable references across re-renders
|
|
53
|
+
- **SSR Compatible** — Works seamlessly with Next.js, Remix, and other SSR frameworks
|
|
54
|
+
- **Lightweight** — Minimal bundle footprint (~1KB minified + gzipped)
|
|
55
|
+
- **Well Tested** — Comprehensive test coverage with Vitest
|
|
56
|
+
- **React 18+ Optimized** — Uses `useSyncExternalStore` for concurrent mode compatibility
|
|
57
|
+
|
|
58
|
+
### Use Cases
|
|
59
|
+
|
|
60
|
+
- **Dashboard Refresh** — Refresh multiple widgets with a single button click
|
|
61
|
+
- **Form Reset** — Reset multiple form sections simultaneously
|
|
62
|
+
- **Cache Invalidation** — Invalidate and reload data across components
|
|
63
|
+
- **Multi-step Flows** — Coordinate state across wizard steps
|
|
64
|
+
- **Event Broadcasting** — Notify multiple listeners about system events
|
|
65
|
+
|
|
66
|
+
### ⚠️ What This Hook Is NOT
|
|
67
|
+
|
|
68
|
+
**`useSignal` is NOT a global state management solution.**
|
|
69
|
+
|
|
70
|
+
This hook is designed for lightweight event-driven communication—sharing simple "signals" between components without the overhead of complex state management setup.
|
|
71
|
+
|
|
72
|
+
If you need:
|
|
73
|
+
- Complex shared state with derived values
|
|
74
|
+
- Persistent state across page navigation
|
|
75
|
+
- State that drives business logic
|
|
76
|
+
- Fine-grained state updates with selectors
|
|
77
|
+
|
|
78
|
+
→ Use dedicated state management tools like **React Context**, **Zustand**, **Jotai**, **Recoil**, or **Redux**.
|
|
79
|
+
|
|
80
|
+
**About `info.data`:** The data payload feature exists for cases where you need to pass contextual information along with a signal (e.g., which item was clicked, what action was performed). It's meant for event metadata, not as a global state container.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// ✅ Good: Signal with contextual data
|
|
84
|
+
emit({ itemId: "123", action: "refresh" });
|
|
85
|
+
|
|
86
|
+
// ❌ Bad: Using as global state
|
|
87
|
+
emit({ user: userData, cart: cartItems, settings: appSettings });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Installation
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# npm
|
|
96
|
+
npm install @usefy/use-signal
|
|
97
|
+
|
|
98
|
+
# yarn
|
|
99
|
+
yarn add @usefy/use-signal
|
|
100
|
+
|
|
101
|
+
# pnpm
|
|
102
|
+
pnpm add @usefy/use-signal
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Peer Dependencies
|
|
106
|
+
|
|
107
|
+
This package requires React 18 or 19:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"peerDependencies": {
|
|
112
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Quick Start
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { useSignal } from "@usefy/use-signal";
|
|
123
|
+
import { useEffect } from "react";
|
|
124
|
+
|
|
125
|
+
// Emitter Component
|
|
126
|
+
function RefreshButton() {
|
|
127
|
+
const { emit } = useSignal("dashboard-refresh");
|
|
128
|
+
|
|
129
|
+
return <button onClick={emit}>Refresh Dashboard</button>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Subscriber Component
|
|
133
|
+
function DataWidget() {
|
|
134
|
+
const { signal } = useSignal("dashboard-refresh");
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
fetchData(); // Refetch when signal changes
|
|
138
|
+
}, [signal]);
|
|
139
|
+
|
|
140
|
+
return <div>Widget Content</div>;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## API Reference
|
|
147
|
+
|
|
148
|
+
### `useSignal(name, options?)`
|
|
149
|
+
|
|
150
|
+
A hook that subscribes to a named signal and provides emit functionality.
|
|
151
|
+
|
|
152
|
+
#### Parameters
|
|
153
|
+
|
|
154
|
+
| Parameter | Type | Description |
|
|
155
|
+
| --------- | --------------- | ---------------------------------- |
|
|
156
|
+
| `name` | `string` | Unique identifier for the signal |
|
|
157
|
+
| `options` | `SignalOptions` | Optional configuration (see below) |
|
|
158
|
+
|
|
159
|
+
#### Options
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
interface SignalOptions {
|
|
163
|
+
emitOnMount?: boolean; // Emit when component mounts (default: false)
|
|
164
|
+
onEmit?: () => void; // Callback executed on emit
|
|
165
|
+
enabled?: boolean; // Enable/disable subscription (default: true)
|
|
166
|
+
debounce?: number; // Debounce emit calls in milliseconds
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### Returns `UseSignalReturn<T>`
|
|
171
|
+
|
|
172
|
+
| Property | Type | Description |
|
|
173
|
+
| -------- | ----------------- | ------------------------------------------- |
|
|
174
|
+
| `signal` | `number` | Current version number (use in deps arrays) |
|
|
175
|
+
| `emit` | `(data?: T) => void` | Function to emit the signal with optional data |
|
|
176
|
+
| `info` | `SignalInfo<T>` | Metadata object (see below) |
|
|
177
|
+
|
|
178
|
+
#### SignalInfo
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
interface SignalInfo<T = unknown> {
|
|
182
|
+
name: string; // Signal name
|
|
183
|
+
subscriberCount: number; // Active subscribers
|
|
184
|
+
timestamp: number; // Last emit timestamp
|
|
185
|
+
emitCount: number; // Total emit count
|
|
186
|
+
data: T | undefined; // Data passed with last emit
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
> **Note:** `info` is a stable reference (ref-based) that doesn't trigger re-renders. Use `signal` in dependency arrays to react to changes, and access the latest `info.data` inside `useEffect`.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Examples
|
|
195
|
+
|
|
196
|
+
### Dashboard Refresh Pattern
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { useSignal } from "@usefy/use-signal";
|
|
200
|
+
import { useEffect, useState } from "react";
|
|
201
|
+
|
|
202
|
+
function RefreshButton() {
|
|
203
|
+
const { emit, info } = useSignal("dashboard-refresh");
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<button onClick={emit}>
|
|
207
|
+
Refresh All ({info.subscriberCount} widgets)
|
|
208
|
+
</button>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function SalesChart() {
|
|
213
|
+
const { signal } = useSignal("dashboard-refresh");
|
|
214
|
+
const [data, setData] = useState([]);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
fetchSalesData().then(setData);
|
|
218
|
+
}, [signal]);
|
|
219
|
+
|
|
220
|
+
return <Chart data={data} />;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function UserStats() {
|
|
224
|
+
const { signal } = useSignal("dashboard-refresh");
|
|
225
|
+
const [stats, setStats] = useState(null);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
fetchUserStats().then(setStats);
|
|
229
|
+
}, [signal]);
|
|
230
|
+
|
|
231
|
+
return <Stats data={stats} />;
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Form Reset
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
import { useSignal } from "@usefy/use-signal";
|
|
239
|
+
|
|
240
|
+
function ResetButton() {
|
|
241
|
+
const { emit } = useSignal("form-reset");
|
|
242
|
+
return <button onClick={emit}>Reset All Fields</button>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function PersonalInfoSection() {
|
|
246
|
+
const { signal } = useSignal("form-reset");
|
|
247
|
+
const [name, setName] = useState("");
|
|
248
|
+
const [email, setEmail] = useState("");
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (signal > 0) {
|
|
252
|
+
setName("");
|
|
253
|
+
setEmail("");
|
|
254
|
+
}
|
|
255
|
+
}, [signal]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<section>
|
|
259
|
+
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
260
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
261
|
+
</section>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### With Data Payload
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
import { useSignal } from "@usefy/use-signal";
|
|
270
|
+
import { useEffect } from "react";
|
|
271
|
+
|
|
272
|
+
interface NotificationData {
|
|
273
|
+
type: "success" | "error" | "info";
|
|
274
|
+
message: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function NotificationEmitter() {
|
|
278
|
+
const { emit } = useSignal<NotificationData>("notification");
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<button
|
|
282
|
+
onClick={() =>
|
|
283
|
+
emit({ type: "success", message: "Operation completed!" })
|
|
284
|
+
}
|
|
285
|
+
>
|
|
286
|
+
Send Notification
|
|
287
|
+
</button>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function NotificationReceiver() {
|
|
292
|
+
const { signal, info } = useSignal<NotificationData>("notification");
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (signal > 0 && info.data) {
|
|
296
|
+
// info.data is guaranteed to contain the latest data
|
|
297
|
+
console.log(`[${info.data.type}] ${info.data.message}`);
|
|
298
|
+
}
|
|
299
|
+
}, [signal]);
|
|
300
|
+
|
|
301
|
+
return <div>Last: {info.data?.message ?? "No notifications"}</div>;
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### With Debounce
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { useSignal } from "@usefy/use-signal";
|
|
309
|
+
|
|
310
|
+
function SearchInput() {
|
|
311
|
+
const { emit } = useSignal<string>("search-update", { debounce: 300 });
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<input
|
|
315
|
+
type="text"
|
|
316
|
+
onChange={(e) => {
|
|
317
|
+
emit(e.target.value); // Debounced - uses latest value after 300ms
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Conditional Subscription
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
import { useSignal } from "@usefy/use-signal";
|
|
328
|
+
|
|
329
|
+
function ConditionalWidget({ visible }: { visible: boolean }) {
|
|
330
|
+
// Only subscribe when visible
|
|
331
|
+
const { signal } = useSignal("updates", { enabled: visible });
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (signal > 0) {
|
|
335
|
+
refreshData();
|
|
336
|
+
}
|
|
337
|
+
}, [signal]);
|
|
338
|
+
|
|
339
|
+
if (!visible) return null;
|
|
340
|
+
return <div>Widget Content</div>;
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### With onEmit Callback
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
import { useSignal } from "@usefy/use-signal";
|
|
348
|
+
|
|
349
|
+
function LoggingEmitter() {
|
|
350
|
+
const { emit } = useSignal("analytics-event", {
|
|
351
|
+
onEmit: () => {
|
|
352
|
+
console.log("Event emitted at", new Date().toISOString());
|
|
353
|
+
trackAnalytics("signal_emitted");
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return <button onClick={emit}>Track Event</button>;
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Emit on Mount
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
import { useSignal } from "@usefy/use-signal";
|
|
365
|
+
|
|
366
|
+
function AutoRefreshComponent() {
|
|
367
|
+
const { signal } = useSignal("data-refresh", { emitOnMount: true });
|
|
368
|
+
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
// This runs immediately on mount due to emitOnMount
|
|
371
|
+
fetchLatestData();
|
|
372
|
+
}, [signal]);
|
|
373
|
+
|
|
374
|
+
return <div>Auto-refreshed content</div>;
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Using Info for Conditional Logic
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
import { useSignal } from "@usefy/use-signal";
|
|
382
|
+
|
|
383
|
+
function SmartEmitter() {
|
|
384
|
+
const { emit, info } = useSignal("notification");
|
|
385
|
+
|
|
386
|
+
const handleClick = () => {
|
|
387
|
+
// Only emit if there are subscribers
|
|
388
|
+
if (info.subscriberCount > 0) {
|
|
389
|
+
emit();
|
|
390
|
+
} else {
|
|
391
|
+
console.log("No subscribers, skipping emit");
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<button onClick={handleClick}>
|
|
397
|
+
Notify ({info.subscriberCount} listeners)
|
|
398
|
+
</button>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## TypeScript
|
|
406
|
+
|
|
407
|
+
This hook is written in TypeScript and exports all interfaces. Use generics to type the data payload.
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
import {
|
|
411
|
+
useSignal,
|
|
412
|
+
type UseSignalReturn,
|
|
413
|
+
type SignalOptions,
|
|
414
|
+
type SignalInfo,
|
|
415
|
+
} from "@usefy/use-signal";
|
|
416
|
+
|
|
417
|
+
// With typed data payload
|
|
418
|
+
interface MyEventData {
|
|
419
|
+
action: string;
|
|
420
|
+
payload: Record<string, unknown>;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const { signal, emit, info }: UseSignalReturn<MyEventData> = useSignal<MyEventData>(
|
|
424
|
+
"my-signal",
|
|
425
|
+
{
|
|
426
|
+
emitOnMount: true,
|
|
427
|
+
onEmit: () => console.log("emitted"),
|
|
428
|
+
enabled: true,
|
|
429
|
+
debounce: 100,
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// signal: number
|
|
434
|
+
// emit: (data?: MyEventData) => void
|
|
435
|
+
// info: SignalInfo<MyEventData>
|
|
436
|
+
// info.data: MyEventData | undefined
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## Performance
|
|
442
|
+
|
|
443
|
+
### Stable References
|
|
444
|
+
|
|
445
|
+
The `emit` function and `info` object maintain stable references across re-renders:
|
|
446
|
+
|
|
447
|
+
```tsx
|
|
448
|
+
const { emit, info } = useSignal<MyData>("my-signal");
|
|
449
|
+
|
|
450
|
+
// emit reference remains stable
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
// Safe to use as dependencies
|
|
453
|
+
}, [emit]);
|
|
454
|
+
|
|
455
|
+
// info is a ref-based object - stable reference, live values via getters
|
|
456
|
+
console.log(info.subscriberCount); // Always current
|
|
457
|
+
console.log(info.data); // Latest data from last emit
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Data Ordering Guarantee
|
|
461
|
+
|
|
462
|
+
When `emit(data)` is called, the data is stored **before** the signal version increments:
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
const { signal, info } = useSignal<string>("my-signal");
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
// This is guaranteed: info.data contains the data passed to emit()
|
|
469
|
+
// that triggered this signal change
|
|
470
|
+
console.log(info.data); // Always the latest data
|
|
471
|
+
}, [signal]);
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Minimal Re-renders
|
|
475
|
+
|
|
476
|
+
- Only subscribed components re-render when signal is emitted
|
|
477
|
+
- The signal value is a primitive number, optimized for dependency arrays
|
|
478
|
+
- Uses React 18's `useSyncExternalStore` for optimal concurrent mode support
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## How It Works
|
|
483
|
+
|
|
484
|
+
1. **Global Store**: A singleton Map stores signal data (version, subscribers, metadata, payload data)
|
|
485
|
+
2. **Subscription**: Components subscribe via `useSyncExternalStore`
|
|
486
|
+
3. **Emit**: Sets data → Increments version → Updates timestamp → Notifies all subscribers
|
|
487
|
+
4. **Cleanup**: Automatic unsubscription on unmount
|
|
488
|
+
|
|
489
|
+
> **Key Design**: Data is set **before** version increment to ensure `useEffect` callbacks always see the latest `info.data`.
|
|
490
|
+
|
|
491
|
+
```
|
|
492
|
+
┌─────────────┐ emit() ┌─────────────┐
|
|
493
|
+
│ Component │ ───────────────▶│ Signal │
|
|
494
|
+
│ Emitter │ │ Store │
|
|
495
|
+
└─────────────┘ └──────┬──────┘
|
|
496
|
+
│
|
|
497
|
+
notify all subscribers
|
|
498
|
+
│
|
|
499
|
+
┌────────────────────────┼────────────────────────┐
|
|
500
|
+
▼ ▼ ▼
|
|
501
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
502
|
+
│ Subscriber │ │ Subscriber │ │ Subscriber │
|
|
503
|
+
│ A │ │ B │ │ C │
|
|
504
|
+
└─────────────┘ └─────────────┘ └─────────────┘
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Testing
|
|
510
|
+
|
|
511
|
+
This package maintains comprehensive test coverage to ensure reliability and stability.
|
|
512
|
+
|
|
513
|
+
### Test Categories
|
|
514
|
+
|
|
515
|
+
<details>
|
|
516
|
+
<summary><strong>Initialization Tests</strong></summary>
|
|
517
|
+
|
|
518
|
+
- Initial signal value is 0
|
|
519
|
+
- Returns all required properties (signal, emit, info)
|
|
520
|
+
- Info object contains correct properties
|
|
521
|
+
- Subscriber count tracking
|
|
522
|
+
|
|
523
|
+
</details>
|
|
524
|
+
|
|
525
|
+
<details>
|
|
526
|
+
<summary><strong>Emit Tests</strong></summary>
|
|
527
|
+
|
|
528
|
+
- Signal increments on emit
|
|
529
|
+
- Multiple emits increment correctly
|
|
530
|
+
- Emit count and timestamp update
|
|
531
|
+
- Rapid successive emits
|
|
532
|
+
|
|
533
|
+
</details>
|
|
534
|
+
|
|
535
|
+
<details>
|
|
536
|
+
<summary><strong>Data Payload Tests</strong></summary>
|
|
537
|
+
|
|
538
|
+
- Data stored in info.data on emit
|
|
539
|
+
- Data updates on each emit
|
|
540
|
+
- Data shared across subscribers
|
|
541
|
+
- Data ordering guarantee (available before signal changes)
|
|
542
|
+
- Complex object data support
|
|
543
|
+
- Debounced emit uses latest data
|
|
544
|
+
|
|
545
|
+
</details>
|
|
546
|
+
|
|
547
|
+
<details>
|
|
548
|
+
<summary><strong>Multi-Subscriber Tests</strong></summary>
|
|
549
|
+
|
|
550
|
+
- All subscribers receive signal updates
|
|
551
|
+
- Subscriber count accuracy
|
|
552
|
+
- Cleanup on unmount
|
|
553
|
+
|
|
554
|
+
</details>
|
|
555
|
+
|
|
556
|
+
<details>
|
|
557
|
+
<summary><strong>Options Tests</strong></summary>
|
|
558
|
+
|
|
559
|
+
- emitOnMount behavior
|
|
560
|
+
- onEmit callback execution
|
|
561
|
+
- enabled option subscription control
|
|
562
|
+
- debounce timing
|
|
563
|
+
|
|
564
|
+
</details>
|
|
565
|
+
|
|
566
|
+
<details>
|
|
567
|
+
<summary><strong>Stable Reference Tests</strong></summary>
|
|
568
|
+
|
|
569
|
+
- Emit function stability
|
|
570
|
+
- Info object stability
|
|
571
|
+
- Values update with stable reference
|
|
572
|
+
|
|
573
|
+
</details>
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## License
|
|
578
|
+
|
|
579
|
+
MIT © [mirunamu](https://github.com/mirunamu00)
|
|
580
|
+
|
|
581
|
+
This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
<p align="center">
|
|
586
|
+
<sub>Built with care by the usefy team</sub>
|
|
587
|
+
</p>
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal metadata object for debugging and monitoring
|
|
3
|
+
*/
|
|
4
|
+
interface SignalInfo<T = unknown> {
|
|
5
|
+
/** Signal subscription name */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Current number of active subscribers */
|
|
8
|
+
subscriberCount: number;
|
|
9
|
+
/** Timestamp of last emit (Date.now()) */
|
|
10
|
+
timestamp: number;
|
|
11
|
+
/** Total number of times this signal has been emitted */
|
|
12
|
+
emitCount: number;
|
|
13
|
+
/** Data passed with the last emit */
|
|
14
|
+
data: T | undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Options for useSignal hook
|
|
18
|
+
*/
|
|
19
|
+
interface SignalOptions {
|
|
20
|
+
/** Automatically emit when component mounts */
|
|
21
|
+
emitOnMount?: boolean;
|
|
22
|
+
/** Callback executed when emit is called */
|
|
23
|
+
onEmit?: () => void;
|
|
24
|
+
/** Conditionally enable/disable subscription (default: true) */
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
/** Debounce emit calls in milliseconds */
|
|
27
|
+
debounce?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return type for useSignal hook
|
|
31
|
+
*/
|
|
32
|
+
interface UseSignalReturn<T = unknown> {
|
|
33
|
+
/** Current signal version number - use in dependency arrays */
|
|
34
|
+
signal: number;
|
|
35
|
+
/** Function to emit the signal and notify all subscribers, optionally with data */
|
|
36
|
+
emit: (data?: T) => void;
|
|
37
|
+
/** Stable metadata object for debugging and monitoring */
|
|
38
|
+
info: SignalInfo<T>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A hook for event-driven communication between components without prop drilling.
|
|
42
|
+
* Components subscribe to a shared signal by name. When any component emits,
|
|
43
|
+
* all subscribers receive a new version number.
|
|
44
|
+
*
|
|
45
|
+
* @param name - Unique identifier string for the signal channel
|
|
46
|
+
* @param options - Configuration options
|
|
47
|
+
* @returns Object containing signal value, emit function, and info metadata
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* // Parent Component - emits signal
|
|
52
|
+
* function ParentComponent() {
|
|
53
|
+
* const { emit } = useSignal("Dashboard Refresh");
|
|
54
|
+
*
|
|
55
|
+
* return <button onClick={emit}>Refresh All</button>;
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* // Child Component - subscribes to signal
|
|
59
|
+
* function DataTable() {
|
|
60
|
+
* const { signal } = useSignal("Dashboard Refresh");
|
|
61
|
+
*
|
|
62
|
+
* useEffect(() => {
|
|
63
|
+
* fetchTableData();
|
|
64
|
+
* }, [signal]);
|
|
65
|
+
*
|
|
66
|
+
* return <table>...</table>;
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* // With debugging info
|
|
73
|
+
* function MonitoredComponent() {
|
|
74
|
+
* const { signal, emit, info } = useSignal("API Sync", {
|
|
75
|
+
* onEmit: () => console.log("Syncing...")
|
|
76
|
+
* });
|
|
77
|
+
*
|
|
78
|
+
* useEffect(() => {
|
|
79
|
+
* syncData();
|
|
80
|
+
* console.log(`Sync #${info.emitCount} with ${info.subscriberCount} listeners`);
|
|
81
|
+
* }, [signal]);
|
|
82
|
+
*
|
|
83
|
+
* return <button onClick={emit}>Sync</button>;
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```tsx
|
|
89
|
+
* // With data payload
|
|
90
|
+
* function DataEmitter() {
|
|
91
|
+
* const { emit } = useSignal<{ userId: string }>("user-action");
|
|
92
|
+
*
|
|
93
|
+
* const handleClick = (userId: string) => {
|
|
94
|
+
* emit({ userId });
|
|
95
|
+
* };
|
|
96
|
+
*
|
|
97
|
+
* return <button onClick={() => handleClick("123")}>Action</button>;
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* function DataReceiver() {
|
|
101
|
+
* const { signal, info } = useSignal<{ userId: string }>("user-action");
|
|
102
|
+
*
|
|
103
|
+
* useEffect(() => {
|
|
104
|
+
* if (info.data) {
|
|
105
|
+
* console.log("User action for:", info.data.userId);
|
|
106
|
+
* }
|
|
107
|
+
* }, [signal]);
|
|
108
|
+
*
|
|
109
|
+
* return <div>Listening...</div>;
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function useSignal<T = unknown>(name: string, options?: SignalOptions): UseSignalReturn<T>;
|
|
114
|
+
|
|
115
|
+
export { type SignalInfo, type SignalOptions, type UseSignalReturn, useSignal };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal metadata object for debugging and monitoring
|
|
3
|
+
*/
|
|
4
|
+
interface SignalInfo<T = unknown> {
|
|
5
|
+
/** Signal subscription name */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Current number of active subscribers */
|
|
8
|
+
subscriberCount: number;
|
|
9
|
+
/** Timestamp of last emit (Date.now()) */
|
|
10
|
+
timestamp: number;
|
|
11
|
+
/** Total number of times this signal has been emitted */
|
|
12
|
+
emitCount: number;
|
|
13
|
+
/** Data passed with the last emit */
|
|
14
|
+
data: T | undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Options for useSignal hook
|
|
18
|
+
*/
|
|
19
|
+
interface SignalOptions {
|
|
20
|
+
/** Automatically emit when component mounts */
|
|
21
|
+
emitOnMount?: boolean;
|
|
22
|
+
/** Callback executed when emit is called */
|
|
23
|
+
onEmit?: () => void;
|
|
24
|
+
/** Conditionally enable/disable subscription (default: true) */
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
/** Debounce emit calls in milliseconds */
|
|
27
|
+
debounce?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return type for useSignal hook
|
|
31
|
+
*/
|
|
32
|
+
interface UseSignalReturn<T = unknown> {
|
|
33
|
+
/** Current signal version number - use in dependency arrays */
|
|
34
|
+
signal: number;
|
|
35
|
+
/** Function to emit the signal and notify all subscribers, optionally with data */
|
|
36
|
+
emit: (data?: T) => void;
|
|
37
|
+
/** Stable metadata object for debugging and monitoring */
|
|
38
|
+
info: SignalInfo<T>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A hook for event-driven communication between components without prop drilling.
|
|
42
|
+
* Components subscribe to a shared signal by name. When any component emits,
|
|
43
|
+
* all subscribers receive a new version number.
|
|
44
|
+
*
|
|
45
|
+
* @param name - Unique identifier string for the signal channel
|
|
46
|
+
* @param options - Configuration options
|
|
47
|
+
* @returns Object containing signal value, emit function, and info metadata
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* // Parent Component - emits signal
|
|
52
|
+
* function ParentComponent() {
|
|
53
|
+
* const { emit } = useSignal("Dashboard Refresh");
|
|
54
|
+
*
|
|
55
|
+
* return <button onClick={emit}>Refresh All</button>;
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* // Child Component - subscribes to signal
|
|
59
|
+
* function DataTable() {
|
|
60
|
+
* const { signal } = useSignal("Dashboard Refresh");
|
|
61
|
+
*
|
|
62
|
+
* useEffect(() => {
|
|
63
|
+
* fetchTableData();
|
|
64
|
+
* }, [signal]);
|
|
65
|
+
*
|
|
66
|
+
* return <table>...</table>;
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* // With debugging info
|
|
73
|
+
* function MonitoredComponent() {
|
|
74
|
+
* const { signal, emit, info } = useSignal("API Sync", {
|
|
75
|
+
* onEmit: () => console.log("Syncing...")
|
|
76
|
+
* });
|
|
77
|
+
*
|
|
78
|
+
* useEffect(() => {
|
|
79
|
+
* syncData();
|
|
80
|
+
* console.log(`Sync #${info.emitCount} with ${info.subscriberCount} listeners`);
|
|
81
|
+
* }, [signal]);
|
|
82
|
+
*
|
|
83
|
+
* return <button onClick={emit}>Sync</button>;
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```tsx
|
|
89
|
+
* // With data payload
|
|
90
|
+
* function DataEmitter() {
|
|
91
|
+
* const { emit } = useSignal<{ userId: string }>("user-action");
|
|
92
|
+
*
|
|
93
|
+
* const handleClick = (userId: string) => {
|
|
94
|
+
* emit({ userId });
|
|
95
|
+
* };
|
|
96
|
+
*
|
|
97
|
+
* return <button onClick={() => handleClick("123")}>Action</button>;
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* function DataReceiver() {
|
|
101
|
+
* const { signal, info } = useSignal<{ userId: string }>("user-action");
|
|
102
|
+
*
|
|
103
|
+
* useEffect(() => {
|
|
104
|
+
* if (info.data) {
|
|
105
|
+
* console.log("User action for:", info.data.userId);
|
|
106
|
+
* }
|
|
107
|
+
* }, [signal]);
|
|
108
|
+
*
|
|
109
|
+
* return <div>Listening...</div>;
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function useSignal<T = unknown>(name: string, options?: SignalOptions): UseSignalReturn<T>;
|
|
114
|
+
|
|
115
|
+
export { type SignalInfo, type SignalOptions, type UseSignalReturn, useSignal };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
useSignal: () => useSignal
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/useSignal.ts
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
|
|
30
|
+
// src/store.ts
|
|
31
|
+
var signalStore = /* @__PURE__ */ new Map();
|
|
32
|
+
function getOrCreateSignal(name) {
|
|
33
|
+
if (!signalStore.has(name)) {
|
|
34
|
+
signalStore.set(name, {
|
|
35
|
+
version: 0,
|
|
36
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
37
|
+
emitCount: 0,
|
|
38
|
+
timestamp: 0,
|
|
39
|
+
data: void 0
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return signalStore.get(name);
|
|
43
|
+
}
|
|
44
|
+
function subscribe(name, listener) {
|
|
45
|
+
const signal = getOrCreateSignal(name);
|
|
46
|
+
signal.subscribers.add(listener);
|
|
47
|
+
return () => {
|
|
48
|
+
signal.subscribers.delete(listener);
|
|
49
|
+
if (signal.subscribers.size === 0 && signal.emitCount === 0) {
|
|
50
|
+
signalStore.delete(name);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function getSnapshot(name) {
|
|
55
|
+
const signal = signalStore.get(name);
|
|
56
|
+
return signal?.version ?? 0;
|
|
57
|
+
}
|
|
58
|
+
function emit(name, data) {
|
|
59
|
+
const signal = getOrCreateSignal(name);
|
|
60
|
+
signal.data = data;
|
|
61
|
+
signal.version += 1;
|
|
62
|
+
signal.emitCount += 1;
|
|
63
|
+
signal.timestamp = Date.now();
|
|
64
|
+
signal.subscribers.forEach((listener) => listener());
|
|
65
|
+
}
|
|
66
|
+
function getSubscriberCount(name) {
|
|
67
|
+
return signalStore.get(name)?.subscribers.size ?? 0;
|
|
68
|
+
}
|
|
69
|
+
function getEmitCount(name) {
|
|
70
|
+
return signalStore.get(name)?.emitCount ?? 0;
|
|
71
|
+
}
|
|
72
|
+
function getTimestamp(name) {
|
|
73
|
+
return signalStore.get(name)?.timestamp ?? 0;
|
|
74
|
+
}
|
|
75
|
+
function getData(name) {
|
|
76
|
+
return signalStore.get(name)?.data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/useSignal.ts
|
|
80
|
+
function useSignal(name, options = {}) {
|
|
81
|
+
const {
|
|
82
|
+
emitOnMount = false,
|
|
83
|
+
onEmit,
|
|
84
|
+
enabled = true,
|
|
85
|
+
debounce
|
|
86
|
+
} = options;
|
|
87
|
+
const onEmitRef = (0, import_react.useRef)(onEmit);
|
|
88
|
+
onEmitRef.current = onEmit;
|
|
89
|
+
const nameRef = (0, import_react.useRef)(name);
|
|
90
|
+
nameRef.current = name;
|
|
91
|
+
const infoRef = (0, import_react.useRef)(null);
|
|
92
|
+
if (!infoRef.current) {
|
|
93
|
+
infoRef.current = {
|
|
94
|
+
get name() {
|
|
95
|
+
return nameRef.current;
|
|
96
|
+
},
|
|
97
|
+
get subscriberCount() {
|
|
98
|
+
return getSubscriberCount(nameRef.current);
|
|
99
|
+
},
|
|
100
|
+
get timestamp() {
|
|
101
|
+
return getTimestamp(nameRef.current);
|
|
102
|
+
},
|
|
103
|
+
get emitCount() {
|
|
104
|
+
return getEmitCount(nameRef.current);
|
|
105
|
+
},
|
|
106
|
+
get data() {
|
|
107
|
+
return getData(nameRef.current);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const subscribeToStore = (0, import_react.useCallback)(
|
|
112
|
+
(onStoreChange) => {
|
|
113
|
+
if (!enabled) {
|
|
114
|
+
return () => {
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return subscribe(name, onStoreChange);
|
|
118
|
+
},
|
|
119
|
+
[name, enabled]
|
|
120
|
+
);
|
|
121
|
+
const getSnapshot2 = (0, import_react.useCallback)(() => {
|
|
122
|
+
if (!enabled) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
return getSnapshot(name);
|
|
126
|
+
}, [name, enabled]);
|
|
127
|
+
const getServerSnapshot = (0, import_react.useCallback)(() => {
|
|
128
|
+
return 0;
|
|
129
|
+
}, []);
|
|
130
|
+
const signal = (0, import_react.useSyncExternalStore)(
|
|
131
|
+
subscribeToStore,
|
|
132
|
+
getSnapshot2,
|
|
133
|
+
getServerSnapshot
|
|
134
|
+
);
|
|
135
|
+
const baseEmit = (0, import_react.useCallback)(
|
|
136
|
+
(data) => {
|
|
137
|
+
emit(name, data);
|
|
138
|
+
onEmitRef.current?.();
|
|
139
|
+
},
|
|
140
|
+
[name]
|
|
141
|
+
);
|
|
142
|
+
const debounceTimerRef = (0, import_react.useRef)(null);
|
|
143
|
+
const pendingDataRef = (0, import_react.useRef)(void 0);
|
|
144
|
+
const emit2 = (0, import_react.useMemo)(() => {
|
|
145
|
+
if (!debounce || debounce <= 0) {
|
|
146
|
+
return baseEmit;
|
|
147
|
+
}
|
|
148
|
+
return (data) => {
|
|
149
|
+
pendingDataRef.current = data;
|
|
150
|
+
if (debounceTimerRef.current) {
|
|
151
|
+
clearTimeout(debounceTimerRef.current);
|
|
152
|
+
}
|
|
153
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
154
|
+
baseEmit(pendingDataRef.current);
|
|
155
|
+
pendingDataRef.current = void 0;
|
|
156
|
+
debounceTimerRef.current = null;
|
|
157
|
+
}, debounce);
|
|
158
|
+
};
|
|
159
|
+
}, [baseEmit, debounce]);
|
|
160
|
+
(0, import_react.useEffect)(() => {
|
|
161
|
+
return () => {
|
|
162
|
+
if (debounceTimerRef.current) {
|
|
163
|
+
clearTimeout(debounceTimerRef.current);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}, []);
|
|
167
|
+
(0, import_react.useEffect)(() => {
|
|
168
|
+
if (emitOnMount) {
|
|
169
|
+
baseEmit();
|
|
170
|
+
}
|
|
171
|
+
}, [emitOnMount, baseEmit]);
|
|
172
|
+
return {
|
|
173
|
+
signal,
|
|
174
|
+
emit: emit2,
|
|
175
|
+
info: infoRef.current
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
179
|
+
0 && (module.exports = {
|
|
180
|
+
useSignal
|
|
181
|
+
});
|
|
182
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/useSignal.ts","../src/store.ts"],"sourcesContent":["export {\n useSignal,\n type UseSignalReturn,\n type SignalOptions,\n type SignalInfo,\n} from \"./useSignal\";\n","import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n} from \"react\";\nimport {\n subscribe,\n getSnapshot as getStoreSnapshot,\n emit as storeEmit,\n getSubscriberCount,\n getEmitCount,\n getTimestamp,\n getData,\n} from \"./store\";\n\n/**\n * Signal metadata object for debugging and monitoring\n */\nexport interface SignalInfo<T = unknown> {\n /** Signal subscription name */\n name: string;\n /** Current number of active subscribers */\n subscriberCount: number;\n /** Timestamp of last emit (Date.now()) */\n timestamp: number;\n /** Total number of times this signal has been emitted */\n emitCount: number;\n /** Data passed with the last emit */\n data: T | undefined;\n}\n\n/**\n * Options for useSignal hook\n */\nexport interface SignalOptions {\n /** Automatically emit when component mounts */\n emitOnMount?: boolean;\n /** Callback executed when emit is called */\n onEmit?: () => void;\n /** Conditionally enable/disable subscription (default: true) */\n enabled?: boolean;\n /** Debounce emit calls in milliseconds */\n debounce?: number;\n}\n\n/**\n * Return type for useSignal hook\n */\nexport interface UseSignalReturn<T = unknown> {\n /** Current signal version number - use in dependency arrays */\n signal: number;\n /** Function to emit the signal and notify all subscribers, optionally with data */\n emit: (data?: T) => void;\n /** Stable metadata object for debugging and monitoring */\n info: SignalInfo<T>;\n}\n\n/**\n * A hook for event-driven communication between components without prop drilling.\n * Components subscribe to a shared signal by name. When any component emits,\n * all subscribers receive a new version number.\n *\n * @param name - Unique identifier string for the signal channel\n * @param options - Configuration options\n * @returns Object containing signal value, emit function, and info metadata\n *\n * @example\n * ```tsx\n * // Parent Component - emits signal\n * function ParentComponent() {\n * const { emit } = useSignal(\"Dashboard Refresh\");\n *\n * return <button onClick={emit}>Refresh All</button>;\n * }\n *\n * // Child Component - subscribes to signal\n * function DataTable() {\n * const { signal } = useSignal(\"Dashboard Refresh\");\n *\n * useEffect(() => {\n * fetchTableData();\n * }, [signal]);\n *\n * return <table>...</table>;\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With debugging info\n * function MonitoredComponent() {\n * const { signal, emit, info } = useSignal(\"API Sync\", {\n * onEmit: () => console.log(\"Syncing...\")\n * });\n *\n * useEffect(() => {\n * syncData();\n * console.log(`Sync #${info.emitCount} with ${info.subscriberCount} listeners`);\n * }, [signal]);\n *\n * return <button onClick={emit}>Sync</button>;\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With data payload\n * function DataEmitter() {\n * const { emit } = useSignal<{ userId: string }>(\"user-action\");\n *\n * const handleClick = (userId: string) => {\n * emit({ userId });\n * };\n *\n * return <button onClick={() => handleClick(\"123\")}>Action</button>;\n * }\n *\n * function DataReceiver() {\n * const { signal, info } = useSignal<{ userId: string }>(\"user-action\");\n *\n * useEffect(() => {\n * if (info.data) {\n * console.log(\"User action for:\", info.data.userId);\n * }\n * }, [signal]);\n *\n * return <div>Listening...</div>;\n * }\n * ```\n */\nexport function useSignal<T = unknown>(\n name: string,\n options: SignalOptions = {}\n): UseSignalReturn<T> {\n const {\n emitOnMount = false,\n onEmit,\n enabled = true,\n debounce,\n } = options;\n\n // Store options in refs for stable references\n const onEmitRef = useRef(onEmit);\n onEmitRef.current = onEmit;\n\n // Stable name ref for info object\n const nameRef = useRef(name);\n nameRef.current = name;\n\n // Info object with stable reference using getters for live data\n const infoRef = useRef<SignalInfo<T> | null>(null);\n if (!infoRef.current) {\n infoRef.current = {\n get name() {\n return nameRef.current;\n },\n get subscriberCount() {\n return getSubscriberCount(nameRef.current);\n },\n get timestamp() {\n return getTimestamp(nameRef.current);\n },\n get emitCount() {\n return getEmitCount(nameRef.current);\n },\n get data() {\n return getData(nameRef.current) as T | undefined;\n },\n } as SignalInfo<T>;\n }\n\n // Subscribe function for useSyncExternalStore\n const subscribeToStore = useCallback(\n (onStoreChange: () => void) => {\n if (!enabled) {\n return () => {};\n }\n return subscribe(name, onStoreChange);\n },\n [name, enabled]\n );\n\n // Get snapshot function\n const getSnapshot = useCallback((): number => {\n if (!enabled) {\n return 0;\n }\n return getStoreSnapshot(name);\n }, [name, enabled]);\n\n // Server snapshot (always 0 for SSR)\n const getServerSnapshot = useCallback((): number => {\n return 0;\n }, []);\n\n // Use useSyncExternalStore for synchronized state\n const signal = useSyncExternalStore(\n subscribeToStore,\n getSnapshot,\n getServerSnapshot\n );\n\n // Base emit function\n const baseEmit = useCallback(\n (data?: T) => {\n storeEmit(name, data);\n onEmitRef.current?.();\n },\n [name]\n );\n\n // Debounce timer ref\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n // Store the latest data for debounced emit\n const pendingDataRef = useRef<T | undefined>(undefined);\n\n // Debounced or regular emit\n const emit = useMemo(() => {\n if (!debounce || debounce <= 0) {\n return baseEmit;\n }\n\n return (data?: T) => {\n // Store the latest data\n pendingDataRef.current = data;\n\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => {\n baseEmit(pendingDataRef.current);\n pendingDataRef.current = undefined;\n debounceTimerRef.current = null;\n }, debounce);\n };\n }, [baseEmit, debounce]);\n\n // Cleanup debounce timer on unmount\n useEffect(() => {\n return () => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, []);\n\n // Emit on mount if option is set\n useEffect(() => {\n if (emitOnMount) {\n baseEmit();\n }\n }, [emitOnMount, baseEmit]);\n\n return {\n signal,\n emit,\n info: infoRef.current!,\n };\n}\n","/**\n * Internal Signal Store for cross-component communication\n * This module manages signal versions and subscribers for the useSignal hook.\n *\n * @internal This module is not exported publicly\n */\n\n/** Signal data structure for each named signal */\ninterface SignalData {\n version: number;\n subscribers: Set<() => void>;\n emitCount: number;\n timestamp: number;\n data: unknown;\n}\n\n/** Map of signal name -> SignalData */\nconst signalStore = new Map<string, SignalData>();\n\n/**\n * Get or create signal data for a given name\n * @param name - The signal name\n * @returns SignalData object\n */\nfunction getOrCreateSignal(name: string): SignalData {\n if (!signalStore.has(name)) {\n signalStore.set(name, {\n version: 0,\n subscribers: new Set(),\n emitCount: 0,\n timestamp: 0,\n data: undefined,\n });\n }\n return signalStore.get(name)!;\n}\n\n/**\n * Subscribe a listener to changes for a specific signal name\n * @param name - The signal name to subscribe to\n * @param listener - Callback to invoke when the signal is emitted\n * @returns Unsubscribe function\n */\nexport function subscribe(name: string, listener: () => void): () => void {\n const signal = getOrCreateSignal(name);\n signal.subscribers.add(listener);\n\n return () => {\n signal.subscribers.delete(listener);\n\n // Cleanup: remove the signal entry if no more subscribers and never emitted\n if (signal.subscribers.size === 0 && signal.emitCount === 0) {\n signalStore.delete(name);\n }\n };\n}\n\n/**\n * Get the current version number for a signal\n * @param name - The signal name\n * @returns Current version number (0 if signal doesn't exist)\n */\nexport function getSnapshot(name: string): number {\n const signal = signalStore.get(name);\n return signal?.version ?? 0;\n}\n\n/**\n * Emit a signal - update data, increment version, update metadata, and notify all subscribers\n * Data is set BEFORE version increment to ensure useEffect callbacks see the latest data.\n * @param name - The signal name to emit\n * @param data - Optional data to pass with the signal\n */\nexport function emit(name: string, data?: unknown): void {\n const signal = getOrCreateSignal(name);\n\n // Set data FIRST before incrementing version\n // This ensures that when useEffect runs due to signal change,\n // info.data already contains the latest value\n signal.data = data;\n\n signal.version += 1;\n signal.emitCount += 1;\n signal.timestamp = Date.now();\n\n // Notify all subscribers\n signal.subscribers.forEach((listener) => listener());\n}\n\n/**\n * Get the current subscriber count for a signal\n * @param name - The signal name\n * @returns Number of active subscribers\n */\nexport function getSubscriberCount(name: string): number {\n return signalStore.get(name)?.subscribers.size ?? 0;\n}\n\n/**\n * Get the total emit count for a signal\n * @param name - The signal name\n * @returns Total number of times the signal has been emitted\n */\nexport function getEmitCount(name: string): number {\n return signalStore.get(name)?.emitCount ?? 0;\n}\n\n/**\n * Get the last emit timestamp for a signal\n * @param name - The signal name\n * @returns Timestamp of last emit (0 if never emitted)\n */\nexport function getTimestamp(name: string): number {\n return signalStore.get(name)?.timestamp ?? 0;\n}\n\n/**\n * Get the data passed with the last emit for a signal\n * @param name - The signal name\n * @returns Data from last emit (undefined if never emitted or no data)\n */\nexport function getData(name: string): unknown {\n return signalStore.get(name)?.data;\n}\n\n/**\n * Clear all signals (for testing purposes)\n * @internal\n */\nexport function clearAllSignals(): void {\n signalStore.clear();\n}\n"],"mappings":"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","names":["getSnapshot","emit"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/useSignal.ts
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useSyncExternalStore
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
// src/store.ts
|
|
11
|
+
var signalStore = /* @__PURE__ */ new Map();
|
|
12
|
+
function getOrCreateSignal(name) {
|
|
13
|
+
if (!signalStore.has(name)) {
|
|
14
|
+
signalStore.set(name, {
|
|
15
|
+
version: 0,
|
|
16
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
17
|
+
emitCount: 0,
|
|
18
|
+
timestamp: 0,
|
|
19
|
+
data: void 0
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return signalStore.get(name);
|
|
23
|
+
}
|
|
24
|
+
function subscribe(name, listener) {
|
|
25
|
+
const signal = getOrCreateSignal(name);
|
|
26
|
+
signal.subscribers.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
signal.subscribers.delete(listener);
|
|
29
|
+
if (signal.subscribers.size === 0 && signal.emitCount === 0) {
|
|
30
|
+
signalStore.delete(name);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function getSnapshot(name) {
|
|
35
|
+
const signal = signalStore.get(name);
|
|
36
|
+
return signal?.version ?? 0;
|
|
37
|
+
}
|
|
38
|
+
function emit(name, data) {
|
|
39
|
+
const signal = getOrCreateSignal(name);
|
|
40
|
+
signal.data = data;
|
|
41
|
+
signal.version += 1;
|
|
42
|
+
signal.emitCount += 1;
|
|
43
|
+
signal.timestamp = Date.now();
|
|
44
|
+
signal.subscribers.forEach((listener) => listener());
|
|
45
|
+
}
|
|
46
|
+
function getSubscriberCount(name) {
|
|
47
|
+
return signalStore.get(name)?.subscribers.size ?? 0;
|
|
48
|
+
}
|
|
49
|
+
function getEmitCount(name) {
|
|
50
|
+
return signalStore.get(name)?.emitCount ?? 0;
|
|
51
|
+
}
|
|
52
|
+
function getTimestamp(name) {
|
|
53
|
+
return signalStore.get(name)?.timestamp ?? 0;
|
|
54
|
+
}
|
|
55
|
+
function getData(name) {
|
|
56
|
+
return signalStore.get(name)?.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/useSignal.ts
|
|
60
|
+
function useSignal(name, options = {}) {
|
|
61
|
+
const {
|
|
62
|
+
emitOnMount = false,
|
|
63
|
+
onEmit,
|
|
64
|
+
enabled = true,
|
|
65
|
+
debounce
|
|
66
|
+
} = options;
|
|
67
|
+
const onEmitRef = useRef(onEmit);
|
|
68
|
+
onEmitRef.current = onEmit;
|
|
69
|
+
const nameRef = useRef(name);
|
|
70
|
+
nameRef.current = name;
|
|
71
|
+
const infoRef = useRef(null);
|
|
72
|
+
if (!infoRef.current) {
|
|
73
|
+
infoRef.current = {
|
|
74
|
+
get name() {
|
|
75
|
+
return nameRef.current;
|
|
76
|
+
},
|
|
77
|
+
get subscriberCount() {
|
|
78
|
+
return getSubscriberCount(nameRef.current);
|
|
79
|
+
},
|
|
80
|
+
get timestamp() {
|
|
81
|
+
return getTimestamp(nameRef.current);
|
|
82
|
+
},
|
|
83
|
+
get emitCount() {
|
|
84
|
+
return getEmitCount(nameRef.current);
|
|
85
|
+
},
|
|
86
|
+
get data() {
|
|
87
|
+
return getData(nameRef.current);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const subscribeToStore = useCallback(
|
|
92
|
+
(onStoreChange) => {
|
|
93
|
+
if (!enabled) {
|
|
94
|
+
return () => {
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return subscribe(name, onStoreChange);
|
|
98
|
+
},
|
|
99
|
+
[name, enabled]
|
|
100
|
+
);
|
|
101
|
+
const getSnapshot2 = useCallback(() => {
|
|
102
|
+
if (!enabled) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
return getSnapshot(name);
|
|
106
|
+
}, [name, enabled]);
|
|
107
|
+
const getServerSnapshot = useCallback(() => {
|
|
108
|
+
return 0;
|
|
109
|
+
}, []);
|
|
110
|
+
const signal = useSyncExternalStore(
|
|
111
|
+
subscribeToStore,
|
|
112
|
+
getSnapshot2,
|
|
113
|
+
getServerSnapshot
|
|
114
|
+
);
|
|
115
|
+
const baseEmit = useCallback(
|
|
116
|
+
(data) => {
|
|
117
|
+
emit(name, data);
|
|
118
|
+
onEmitRef.current?.();
|
|
119
|
+
},
|
|
120
|
+
[name]
|
|
121
|
+
);
|
|
122
|
+
const debounceTimerRef = useRef(null);
|
|
123
|
+
const pendingDataRef = useRef(void 0);
|
|
124
|
+
const emit2 = useMemo(() => {
|
|
125
|
+
if (!debounce || debounce <= 0) {
|
|
126
|
+
return baseEmit;
|
|
127
|
+
}
|
|
128
|
+
return (data) => {
|
|
129
|
+
pendingDataRef.current = data;
|
|
130
|
+
if (debounceTimerRef.current) {
|
|
131
|
+
clearTimeout(debounceTimerRef.current);
|
|
132
|
+
}
|
|
133
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
134
|
+
baseEmit(pendingDataRef.current);
|
|
135
|
+
pendingDataRef.current = void 0;
|
|
136
|
+
debounceTimerRef.current = null;
|
|
137
|
+
}, debounce);
|
|
138
|
+
};
|
|
139
|
+
}, [baseEmit, debounce]);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
return () => {
|
|
142
|
+
if (debounceTimerRef.current) {
|
|
143
|
+
clearTimeout(debounceTimerRef.current);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}, []);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (emitOnMount) {
|
|
149
|
+
baseEmit();
|
|
150
|
+
}
|
|
151
|
+
}, [emitOnMount, baseEmit]);
|
|
152
|
+
return {
|
|
153
|
+
signal,
|
|
154
|
+
emit: emit2,
|
|
155
|
+
info: infoRef.current
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
useSignal
|
|
160
|
+
};
|
|
161
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useSignal.ts","../src/store.ts"],"sourcesContent":["import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n} from \"react\";\nimport {\n subscribe,\n getSnapshot as getStoreSnapshot,\n emit as storeEmit,\n getSubscriberCount,\n getEmitCount,\n getTimestamp,\n getData,\n} from \"./store\";\n\n/**\n * Signal metadata object for debugging and monitoring\n */\nexport interface SignalInfo<T = unknown> {\n /** Signal subscription name */\n name: string;\n /** Current number of active subscribers */\n subscriberCount: number;\n /** Timestamp of last emit (Date.now()) */\n timestamp: number;\n /** Total number of times this signal has been emitted */\n emitCount: number;\n /** Data passed with the last emit */\n data: T | undefined;\n}\n\n/**\n * Options for useSignal hook\n */\nexport interface SignalOptions {\n /** Automatically emit when component mounts */\n emitOnMount?: boolean;\n /** Callback executed when emit is called */\n onEmit?: () => void;\n /** Conditionally enable/disable subscription (default: true) */\n enabled?: boolean;\n /** Debounce emit calls in milliseconds */\n debounce?: number;\n}\n\n/**\n * Return type for useSignal hook\n */\nexport interface UseSignalReturn<T = unknown> {\n /** Current signal version number - use in dependency arrays */\n signal: number;\n /** Function to emit the signal and notify all subscribers, optionally with data */\n emit: (data?: T) => void;\n /** Stable metadata object for debugging and monitoring */\n info: SignalInfo<T>;\n}\n\n/**\n * A hook for event-driven communication between components without prop drilling.\n * Components subscribe to a shared signal by name. When any component emits,\n * all subscribers receive a new version number.\n *\n * @param name - Unique identifier string for the signal channel\n * @param options - Configuration options\n * @returns Object containing signal value, emit function, and info metadata\n *\n * @example\n * ```tsx\n * // Parent Component - emits signal\n * function ParentComponent() {\n * const { emit } = useSignal(\"Dashboard Refresh\");\n *\n * return <button onClick={emit}>Refresh All</button>;\n * }\n *\n * // Child Component - subscribes to signal\n * function DataTable() {\n * const { signal } = useSignal(\"Dashboard Refresh\");\n *\n * useEffect(() => {\n * fetchTableData();\n * }, [signal]);\n *\n * return <table>...</table>;\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With debugging info\n * function MonitoredComponent() {\n * const { signal, emit, info } = useSignal(\"API Sync\", {\n * onEmit: () => console.log(\"Syncing...\")\n * });\n *\n * useEffect(() => {\n * syncData();\n * console.log(`Sync #${info.emitCount} with ${info.subscriberCount} listeners`);\n * }, [signal]);\n *\n * return <button onClick={emit}>Sync</button>;\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With data payload\n * function DataEmitter() {\n * const { emit } = useSignal<{ userId: string }>(\"user-action\");\n *\n * const handleClick = (userId: string) => {\n * emit({ userId });\n * };\n *\n * return <button onClick={() => handleClick(\"123\")}>Action</button>;\n * }\n *\n * function DataReceiver() {\n * const { signal, info } = useSignal<{ userId: string }>(\"user-action\");\n *\n * useEffect(() => {\n * if (info.data) {\n * console.log(\"User action for:\", info.data.userId);\n * }\n * }, [signal]);\n *\n * return <div>Listening...</div>;\n * }\n * ```\n */\nexport function useSignal<T = unknown>(\n name: string,\n options: SignalOptions = {}\n): UseSignalReturn<T> {\n const {\n emitOnMount = false,\n onEmit,\n enabled = true,\n debounce,\n } = options;\n\n // Store options in refs for stable references\n const onEmitRef = useRef(onEmit);\n onEmitRef.current = onEmit;\n\n // Stable name ref for info object\n const nameRef = useRef(name);\n nameRef.current = name;\n\n // Info object with stable reference using getters for live data\n const infoRef = useRef<SignalInfo<T> | null>(null);\n if (!infoRef.current) {\n infoRef.current = {\n get name() {\n return nameRef.current;\n },\n get subscriberCount() {\n return getSubscriberCount(nameRef.current);\n },\n get timestamp() {\n return getTimestamp(nameRef.current);\n },\n get emitCount() {\n return getEmitCount(nameRef.current);\n },\n get data() {\n return getData(nameRef.current) as T | undefined;\n },\n } as SignalInfo<T>;\n }\n\n // Subscribe function for useSyncExternalStore\n const subscribeToStore = useCallback(\n (onStoreChange: () => void) => {\n if (!enabled) {\n return () => {};\n }\n return subscribe(name, onStoreChange);\n },\n [name, enabled]\n );\n\n // Get snapshot function\n const getSnapshot = useCallback((): number => {\n if (!enabled) {\n return 0;\n }\n return getStoreSnapshot(name);\n }, [name, enabled]);\n\n // Server snapshot (always 0 for SSR)\n const getServerSnapshot = useCallback((): number => {\n return 0;\n }, []);\n\n // Use useSyncExternalStore for synchronized state\n const signal = useSyncExternalStore(\n subscribeToStore,\n getSnapshot,\n getServerSnapshot\n );\n\n // Base emit function\n const baseEmit = useCallback(\n (data?: T) => {\n storeEmit(name, data);\n onEmitRef.current?.();\n },\n [name]\n );\n\n // Debounce timer ref\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n // Store the latest data for debounced emit\n const pendingDataRef = useRef<T | undefined>(undefined);\n\n // Debounced or regular emit\n const emit = useMemo(() => {\n if (!debounce || debounce <= 0) {\n return baseEmit;\n }\n\n return (data?: T) => {\n // Store the latest data\n pendingDataRef.current = data;\n\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => {\n baseEmit(pendingDataRef.current);\n pendingDataRef.current = undefined;\n debounceTimerRef.current = null;\n }, debounce);\n };\n }, [baseEmit, debounce]);\n\n // Cleanup debounce timer on unmount\n useEffect(() => {\n return () => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, []);\n\n // Emit on mount if option is set\n useEffect(() => {\n if (emitOnMount) {\n baseEmit();\n }\n }, [emitOnMount, baseEmit]);\n\n return {\n signal,\n emit,\n info: infoRef.current!,\n };\n}\n","/**\n * Internal Signal Store for cross-component communication\n * This module manages signal versions and subscribers for the useSignal hook.\n *\n * @internal This module is not exported publicly\n */\n\n/** Signal data structure for each named signal */\ninterface SignalData {\n version: number;\n subscribers: Set<() => void>;\n emitCount: number;\n timestamp: number;\n data: unknown;\n}\n\n/** Map of signal name -> SignalData */\nconst signalStore = new Map<string, SignalData>();\n\n/**\n * Get or create signal data for a given name\n * @param name - The signal name\n * @returns SignalData object\n */\nfunction getOrCreateSignal(name: string): SignalData {\n if (!signalStore.has(name)) {\n signalStore.set(name, {\n version: 0,\n subscribers: new Set(),\n emitCount: 0,\n timestamp: 0,\n data: undefined,\n });\n }\n return signalStore.get(name)!;\n}\n\n/**\n * Subscribe a listener to changes for a specific signal name\n * @param name - The signal name to subscribe to\n * @param listener - Callback to invoke when the signal is emitted\n * @returns Unsubscribe function\n */\nexport function subscribe(name: string, listener: () => void): () => void {\n const signal = getOrCreateSignal(name);\n signal.subscribers.add(listener);\n\n return () => {\n signal.subscribers.delete(listener);\n\n // Cleanup: remove the signal entry if no more subscribers and never emitted\n if (signal.subscribers.size === 0 && signal.emitCount === 0) {\n signalStore.delete(name);\n }\n };\n}\n\n/**\n * Get the current version number for a signal\n * @param name - The signal name\n * @returns Current version number (0 if signal doesn't exist)\n */\nexport function getSnapshot(name: string): number {\n const signal = signalStore.get(name);\n return signal?.version ?? 0;\n}\n\n/**\n * Emit a signal - update data, increment version, update metadata, and notify all subscribers\n * Data is set BEFORE version increment to ensure useEffect callbacks see the latest data.\n * @param name - The signal name to emit\n * @param data - Optional data to pass with the signal\n */\nexport function emit(name: string, data?: unknown): void {\n const signal = getOrCreateSignal(name);\n\n // Set data FIRST before incrementing version\n // This ensures that when useEffect runs due to signal change,\n // info.data already contains the latest value\n signal.data = data;\n\n signal.version += 1;\n signal.emitCount += 1;\n signal.timestamp = Date.now();\n\n // Notify all subscribers\n signal.subscribers.forEach((listener) => listener());\n}\n\n/**\n * Get the current subscriber count for a signal\n * @param name - The signal name\n * @returns Number of active subscribers\n */\nexport function getSubscriberCount(name: string): number {\n return signalStore.get(name)?.subscribers.size ?? 0;\n}\n\n/**\n * Get the total emit count for a signal\n * @param name - The signal name\n * @returns Total number of times the signal has been emitted\n */\nexport function getEmitCount(name: string): number {\n return signalStore.get(name)?.emitCount ?? 0;\n}\n\n/**\n * Get the last emit timestamp for a signal\n * @param name - The signal name\n * @returns Timestamp of last emit (0 if never emitted)\n */\nexport function getTimestamp(name: string): number {\n return signalStore.get(name)?.timestamp ?? 0;\n}\n\n/**\n * Get the data passed with the last emit for a signal\n * @param name - The signal name\n * @returns Data from last emit (undefined if never emitted or no data)\n */\nexport function getData(name: string): unknown {\n return signalStore.get(name)?.data;\n}\n\n/**\n * Clear all signals (for testing purposes)\n * @internal\n */\nexport function clearAllSignals(): void {\n signalStore.clear();\n}\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;;;ACWP,IAAM,cAAc,oBAAI,IAAwB;AAOhD,SAAS,kBAAkB,MAA0B;AACnD,MAAI,CAAC,YAAY,IAAI,IAAI,GAAG;AAC1B,gBAAY,IAAI,MAAM;AAAA,MACpB,SAAS;AAAA,MACT,aAAa,oBAAI,IAAI;AAAA,MACrB,WAAW;AAAA,MACX,WAAW;AAAA,MACX,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AACA,SAAO,YAAY,IAAI,IAAI;AAC7B;AAQO,SAAS,UAAU,MAAc,UAAkC;AACxE,QAAM,SAAS,kBAAkB,IAAI;AACrC,SAAO,YAAY,IAAI,QAAQ;AAE/B,SAAO,MAAM;AACX,WAAO,YAAY,OAAO,QAAQ;AAGlC,QAAI,OAAO,YAAY,SAAS,KAAK,OAAO,cAAc,GAAG;AAC3D,kBAAY,OAAO,IAAI;AAAA,IACzB;AAAA,EACF;AACF;AAOO,SAAS,YAAY,MAAsB;AAChD,QAAM,SAAS,YAAY,IAAI,IAAI;AACnC,SAAO,QAAQ,WAAW;AAC5B;AAQO,SAAS,KAAK,MAAc,MAAsB;AACvD,QAAM,SAAS,kBAAkB,IAAI;AAKrC,SAAO,OAAO;AAEd,SAAO,WAAW;AAClB,SAAO,aAAa;AACpB,SAAO,YAAY,KAAK,IAAI;AAG5B,SAAO,YAAY,QAAQ,CAAC,aAAa,SAAS,CAAC;AACrD;AAOO,SAAS,mBAAmB,MAAsB;AACvD,SAAO,YAAY,IAAI,IAAI,GAAG,YAAY,QAAQ;AACpD;AAOO,SAAS,aAAa,MAAsB;AACjD,SAAO,YAAY,IAAI,IAAI,GAAG,aAAa;AAC7C;AAOO,SAAS,aAAa,MAAsB;AACjD,SAAO,YAAY,IAAI,IAAI,GAAG,aAAa;AAC7C;AAOO,SAAS,QAAQ,MAAuB;AAC7C,SAAO,YAAY,IAAI,IAAI,GAAG;AAChC;;;ADSO,SAAS,UACd,MACA,UAAyB,CAAC,GACN;AACpB,QAAM;AAAA,IACJ,cAAc;AAAA,IACd;AAAA,IACA,UAAU;AAAA,IACV;AAAA,EACF,IAAI;AAGJ,QAAM,YAAY,OAAO,MAAM;AAC/B,YAAU,UAAU;AAGpB,QAAM,UAAU,OAAO,IAAI;AAC3B,UAAQ,UAAU;AAGlB,QAAM,UAAU,OAA6B,IAAI;AACjD,MAAI,CAAC,QAAQ,SAAS;AACpB,YAAQ,UAAU;AAAA,MAChB,IAAI,OAAO;AACT,eAAO,QAAQ;AAAA,MACjB;AAAA,MACA,IAAI,kBAAkB;AACpB,eAAO,mBAAmB,QAAQ,OAAO;AAAA,MAC3C;AAAA,MACA,IAAI,YAAY;AACd,eAAO,aAAa,QAAQ,OAAO;AAAA,MACrC;AAAA,MACA,IAAI,YAAY;AACd,eAAO,aAAa,QAAQ,OAAO;AAAA,MACrC;AAAA,MACA,IAAI,OAAO;AACT,eAAO,QAAQ,QAAQ,OAAO;AAAA,MAChC;AAAA,IACF;AAAA,EACF;AAGA,QAAM,mBAAmB;AAAA,IACvB,CAAC,kBAA8B;AAC7B,UAAI,CAAC,SAAS;AACZ,eAAO,MAAM;AAAA,QAAC;AAAA,MAChB;AACA,aAAO,UAAU,MAAM,aAAa;AAAA,IACtC;AAAA,IACA,CAAC,MAAM,OAAO;AAAA,EAChB;AAGA,QAAMA,eAAc,YAAY,MAAc;AAC5C,QAAI,CAAC,SAAS;AACZ,aAAO;AAAA,IACT;AACA,WAAO,YAAiB,IAAI;AAAA,EAC9B,GAAG,CAAC,MAAM,OAAO,CAAC;AAGlB,QAAM,oBAAoB,YAAY,MAAc;AAClD,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAGL,QAAM,SAAS;AAAA,IACb;AAAA,IACAA;AAAA,IACA;AAAA,EACF;AAGA,QAAM,WAAW;AAAA,IACf,CAAC,SAAa;AACZ,WAAU,MAAM,IAAI;AACpB,gBAAU,UAAU;AAAA,IACtB;AAAA,IACA,CAAC,IAAI;AAAA,EACP;AAGA,QAAM,mBAAmB,OAA6C,IAAI;AAE1E,QAAM,iBAAiB,OAAsB,MAAS;AAGtD,QAAMC,QAAO,QAAQ,MAAM;AACzB,QAAI,CAAC,YAAY,YAAY,GAAG;AAC9B,aAAO;AAAA,IACT;AAEA,WAAO,CAAC,SAAa;AAEnB,qBAAe,UAAU;AAEzB,UAAI,iBAAiB,SAAS;AAC5B,qBAAa,iBAAiB,OAAO;AAAA,MACvC;AACA,uBAAiB,UAAU,WAAW,MAAM;AAC1C,iBAAS,eAAe,OAAO;AAC/B,uBAAe,UAAU;AACzB,yBAAiB,UAAU;AAAA,MAC7B,GAAG,QAAQ;AAAA,IACb;AAAA,EACF,GAAG,CAAC,UAAU,QAAQ,CAAC;AAGvB,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,iBAAiB,SAAS;AAC5B,qBAAa,iBAAiB,OAAO;AAAA,MACvC;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAGL,YAAU,MAAM;AACd,QAAI,aAAa;AACf,eAAS;AAAA,IACX;AAAA,EACF,GAAG,CAAC,aAAa,QAAQ,CAAC;AAE1B,SAAO;AAAA,IACL;AAAA,IACA,MAAAA;AAAA,IACA,MAAM,QAAQ;AAAA,EAChB;AACF;","names":["getSnapshot","emit"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usefy/use-signal",
|
|
3
|
+
"version": "0.0.31",
|
|
4
|
+
"description": "A React hook for event-driven communication between components",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
24
|
+
"@testing-library/react": "^16.3.1",
|
|
25
|
+
"@testing-library/user-event": "^14.6.1",
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"jsdom": "^27.3.0",
|
|
28
|
+
"react": "^19.0.0",
|
|
29
|
+
"rimraf": "^6.0.1",
|
|
30
|
+
"tsup": "^8.0.0",
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"vitest": "^4.0.16"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/mirunamu00/usefy.git",
|
|
40
|
+
"directory": "packages/use-signal"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"keywords": [
|
|
44
|
+
"react",
|
|
45
|
+
"hooks",
|
|
46
|
+
"signal",
|
|
47
|
+
"event",
|
|
48
|
+
"broadcast",
|
|
49
|
+
"pubsub"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"clean": "rimraf dist"
|
|
58
|
+
}
|
|
59
|
+
}
|