@flighthq/signals 0.1.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.
@@ -0,0 +1,4 @@
1
+ import type { Signal } from '@flighthq/types';
2
+ export declare function cancelSignal<T extends (...args: any[]) => void>(signal: Signal<T>): void;
3
+ export declare function emitSignal<T extends (...args: any[]) => void>(signal: Signal<T>, ...args: Parameters<T>): void;
4
+ //# sourceMappingURL=emitter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emitter.d.ts","sourceRoot":"","sources":["../src/emitter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAE9C,wBAAgB,YAAY,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,CAExF;AAED,wBAAgB,UAAU,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAE9G"}
@@ -0,0 +1,9 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ export function cancelSignal(signal) {
3
+ if (signal.data !== null)
4
+ signal.data.cancelled = true;
5
+ }
6
+ export function emitSignal(signal, ...args) {
7
+ signal.emit(...args);
8
+ }
9
+ //# sourceMappingURL=emitter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emitter.js","sourceRoot":"","sources":["../src/emitter.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAIvD,MAAM,UAAU,YAAY,CAAqC,MAAiB;IAChF,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI;QAAE,MAAM,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;AACzD,CAAC;AAED,MAAM,UAAU,UAAU,CAAqC,MAAiB,EAAE,GAAG,IAAmB;IACrG,MAAM,CAAC,IAA8B,CAAC,GAAG,IAAI,CAAC,CAAC;AAClD,CAAC"}
@@ -0,0 +1,5 @@
1
+ export * from './emitter';
2
+ export * from './signal';
3
+ export * from './slot';
4
+ export * from './throttle';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './emitter';
2
+ export * from './signal';
3
+ export * from './slot';
4
+ export * from './throttle';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,YAAY,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The `emit` implementation for a signal with no connected slots. Assigned by
3
+ * `createSignal` and restored when the last slot disconnects, so emitting on an
4
+ * empty signal is a no-op without a null check. Package-internal: not exported
5
+ * from the barrel.
6
+ */
7
+ export declare const nullSignalEmit: () => void;
8
+ //# sourceMappingURL=internal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAO,IAAU,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The `emit` implementation for a signal with no connected slots. Assigned by
3
+ * `createSignal` and restored when the last slot disconnects, so emitting on an
4
+ * empty signal is a no-op without a null check. Package-internal: not exported
5
+ * from the barrel.
6
+ */
7
+ export const nullSignalEmit = () => { };
8
+ //# sourceMappingURL=internal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal.js","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,GAAS,EAAE,GAAE,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { Signal } from '@flighthq/types';
2
+ export type { Signal } from '@flighthq/types';
3
+ export declare function createSignal<T extends (...args: any[]) => void>(): Signal<T>;
4
+ //# sourceMappingURL=signal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signal.d.ts","sourceRoot":"","sources":["../src/signal.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAI9C,YAAY,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAE9C,wBAAgB,YAAY,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,CAE5E"}
package/dist/signal.js ADDED
@@ -0,0 +1,6 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { nullSignalEmit } from './internal';
3
+ export function createSignal() {
4
+ return { emit: nullSignalEmit, data: null };
5
+ }
6
+ //# sourceMappingURL=signal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signal.js","sourceRoot":"","sources":["../src/signal.ts"],"names":[],"mappings":"AAAA,uDAAuD;AAIvD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAI5C,MAAM,UAAU,YAAY;IAC1B,OAAO,EAAE,IAAI,EAAE,cAA8B,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC9D,CAAC"}
package/dist/slot.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Signal, SignalConnectOptions } from '@flighthq/types';
2
+ export type { SignalConnectOptions } from '@flighthq/types';
3
+ export declare function clearSignal<T extends (...args: any[]) => void>(signal: Signal<T>): void;
4
+ export declare function connectSignal<T extends (...args: any[]) => void>(signal: Signal<T>, slot: T, options?: Readonly<SignalConnectOptions>): void;
5
+ export declare function disconnectSignal<T extends (...args: any[]) => void>(signal: Signal<T>, slot: T): void;
6
+ export declare function hasSignalSlots<T extends (...args: any[]) => void>(signal: Readonly<Signal<T>>): boolean;
7
+ export declare function isSlotConnected<T extends (...args: any[]) => void>(signal: Readonly<Signal<T>>, slot: T): boolean;
8
+ //# sourceMappingURL=slot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slot.d.ts","sourceRoot":"","sources":["../src/slot.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAc,MAAM,iBAAiB,CAAC;AAIhF,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,CAGvF;AAED,wBAAgB,aAAa,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAC9D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EACjB,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GACvC,IAAI,CAmBN;AAED,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI,CAiBrG;AAED,wBAAgB,cAAc,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,CAEvG;AASD,wBAAgB,eAAe,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,GAAG,OAAO,CAEjH"}
package/dist/slot.js ADDED
@@ -0,0 +1,73 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { nullSignalEmit } from './internal';
3
+ export function clearSignal(signal) {
4
+ signal.emit = nullSignalEmit;
5
+ signal.data = null;
6
+ }
7
+ export function connectSignal(signal, slot, options) {
8
+ const priority = options?.priority ?? 0;
9
+ const repeat = !(options?.once ?? false);
10
+ initSignal(signal);
11
+ const data = signal.data;
12
+ for (let i = 0; i < data.priorities.length; i++) {
13
+ if (priority > data.priorities[i]) {
14
+ data.slots.splice(i, 0, slot);
15
+ data.priorities.splice(i, 0, priority);
16
+ data.repeat.splice(i, 0, repeat);
17
+ return;
18
+ }
19
+ }
20
+ data.slots.push(slot);
21
+ data.priorities.push(priority);
22
+ data.repeat.push(repeat);
23
+ }
24
+ export function disconnectSignal(signal, slot) {
25
+ const data = signal.data;
26
+ if (data === null)
27
+ return;
28
+ let i = data.slots.length;
29
+ while (--i >= 0) {
30
+ if (data.slots[i] === slot) {
31
+ data.slots.splice(i, 1);
32
+ data.priorities.splice(i, 1);
33
+ data.repeat.splice(i, 1);
34
+ }
35
+ }
36
+ if (data.slots.length === 0) {
37
+ signal.emit = nullSignalEmit;
38
+ signal.data = null;
39
+ }
40
+ }
41
+ export function hasSignalSlots(signal) {
42
+ return signal.data !== null && signal.data.slots.length > 0;
43
+ }
44
+ function initSignal(signal) {
45
+ if (signal.data !== null)
46
+ return;
47
+ const data = { slots: [], priorities: [], repeat: [], cancelled: false };
48
+ signal.data = data;
49
+ signal.emit = makeDispatch(data);
50
+ }
51
+ export function isSlotConnected(signal, slot) {
52
+ return signal.data !== null && signal.data.slots.indexOf(slot) !== -1;
53
+ }
54
+ function makeDispatch(data) {
55
+ return ((...args) => {
56
+ data.cancelled = false;
57
+ let i = 0;
58
+ while (i < data.slots.length) {
59
+ data.slots[i](...args);
60
+ if (data.cancelled)
61
+ break;
62
+ if (!data.repeat[i]) {
63
+ data.slots.splice(i, 1);
64
+ data.priorities.splice(i, 1);
65
+ data.repeat.splice(i, 1);
66
+ }
67
+ else {
68
+ i++;
69
+ }
70
+ }
71
+ });
72
+ }
73
+ //# sourceMappingURL=slot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slot.js","sourceRoot":"","sources":["../src/slot.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ import type { Signal } from '@flighthq/types';
2
+ /**
3
+ * Options for `connectSignalThrottled` and `connectSignalDebounced`.
4
+ */
5
+ export interface SignalThrottleOptions {
6
+ /**
7
+ * Whether to fire on the leading edge of the interval (before the delay).
8
+ * Default: `true`.
9
+ */
10
+ leading?: boolean;
11
+ /**
12
+ * Whether to fire on the trailing edge of the interval (after the delay).
13
+ * Default: `true`.
14
+ */
15
+ trailing?: boolean;
16
+ }
17
+ /**
18
+ * Connects a frame-tick signal to a slot at a reduced frame rate. The slot
19
+ * receives the total accumulated `deltaTime` (in ms) since the last fire, not
20
+ * the individual source deltas. Returns a cleanup function that disconnects
21
+ * the internal handler.
22
+ *
23
+ * This is specialized to `(deltaTime: number) => void` frame-tick signals and
24
+ * accumulates time rather than forwarding source args. For a payload-preserving
25
+ * throttle over any signal shape, use `connectSignalThrottled`.
26
+ */
27
+ export declare function connectSignalAtFrameRate(source: Signal<(deltaTime: number) => void>, fps: number, slot: (deltaTime: number) => void): () => void;
28
+ /**
29
+ * Connects a slot to a signal with debounce semantics: the slot fires only
30
+ * after the signal has been quiet for `delayMs` milliseconds. Each emission
31
+ * resets the timer. Returns a cleanup function.
32
+ *
33
+ * `leading: true` fires immediately on the first invocation and suppresses
34
+ * subsequent calls until the delay window expires. `trailing: true` (default)
35
+ * fires at the end of the quiet period with the most recent args.
36
+ *
37
+ * Uses `Date.now()` as the clock — suitable for UI/interaction signals (e.g.
38
+ * resize, search input).
39
+ */
40
+ export declare function connectSignalDebounced<T extends (...args: any[]) => void>(source: Signal<T>, delayMs: number, slot: T, options?: Readonly<SignalThrottleOptions>): () => void;
41
+ /**
42
+ * Connects a slot to a signal with throttle semantics: the slot fires at most
43
+ * once per `intervalMs` milliseconds. Unlike `connectSignalAtFrameRate`, this
44
+ * preserves the original signal args (payload-preserving throttle). Returns a
45
+ * cleanup function.
46
+ *
47
+ * `leading: true` (default) fires immediately on the first invocation; the
48
+ * trailing call fires after the interval if additional invocations occurred
49
+ * during the cooldown.
50
+ *
51
+ * Uses `Date.now()` as the clock — suitable for UI/interaction signals.
52
+ */
53
+ export declare function connectSignalThrottled<T extends (...args: any[]) => void>(source: Signal<T>, intervalMs: number, slot: T, options?: Readonly<SignalThrottleOptions>): () => void;
54
+ //# sourceMappingURL=throttle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"throttle.d.ts","sourceRoot":"","sources":["../src/throttle.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAI9C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC,EAC3C,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,GAChC,MAAM,IAAI,CAYZ;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EACvE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EACjB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GACxC,MAAM,IAAI,CAiCZ;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,EACvE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GACxC,MAAM,IAAI,CAyCZ"}
@@ -0,0 +1,128 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { connectSignal, disconnectSignal } from './slot';
3
+ /**
4
+ * Connects a frame-tick signal to a slot at a reduced frame rate. The slot
5
+ * receives the total accumulated `deltaTime` (in ms) since the last fire, not
6
+ * the individual source deltas. Returns a cleanup function that disconnects
7
+ * the internal handler.
8
+ *
9
+ * This is specialized to `(deltaTime: number) => void` frame-tick signals and
10
+ * accumulates time rather than forwarding source args. For a payload-preserving
11
+ * throttle over any signal shape, use `connectSignalThrottled`.
12
+ */
13
+ export function connectSignalAtFrameRate(source, fps, slot) {
14
+ const period = 1000 / fps;
15
+ let elapsed = 0;
16
+ const handler = (delta) => {
17
+ elapsed += delta;
18
+ if (elapsed >= period) {
19
+ slot(elapsed);
20
+ elapsed %= period;
21
+ }
22
+ };
23
+ connectSignal(source, handler);
24
+ return () => disconnectSignal(source, handler);
25
+ }
26
+ /**
27
+ * Connects a slot to a signal with debounce semantics: the slot fires only
28
+ * after the signal has been quiet for `delayMs` milliseconds. Each emission
29
+ * resets the timer. Returns a cleanup function.
30
+ *
31
+ * `leading: true` fires immediately on the first invocation and suppresses
32
+ * subsequent calls until the delay window expires. `trailing: true` (default)
33
+ * fires at the end of the quiet period with the most recent args.
34
+ *
35
+ * Uses `Date.now()` as the clock — suitable for UI/interaction signals (e.g.
36
+ * resize, search input).
37
+ */
38
+ export function connectSignalDebounced(source, delayMs, slot, options) {
39
+ const leading = options?.leading ?? false;
40
+ const trailing = options?.trailing ?? true;
41
+ let timer = null;
42
+ let lastArgs = null;
43
+ let leadingFired = false;
44
+ const clearTimer = () => {
45
+ if (timer !== null) {
46
+ clearTimeout(timer);
47
+ timer = null;
48
+ }
49
+ };
50
+ const handler = ((...args) => {
51
+ lastArgs = args;
52
+ if (leading && timer === null && !leadingFired) {
53
+ leadingFired = true;
54
+ slot(...args);
55
+ }
56
+ clearTimer();
57
+ timer = setTimeout(() => {
58
+ timer = null;
59
+ leadingFired = false;
60
+ if (trailing && lastArgs !== null) {
61
+ slot(...lastArgs);
62
+ lastArgs = null;
63
+ }
64
+ }, delayMs);
65
+ });
66
+ connectSignal(source, handler);
67
+ return () => {
68
+ disconnectSignal(source, handler);
69
+ clearTimer();
70
+ };
71
+ }
72
+ /**
73
+ * Connects a slot to a signal with throttle semantics: the slot fires at most
74
+ * once per `intervalMs` milliseconds. Unlike `connectSignalAtFrameRate`, this
75
+ * preserves the original signal args (payload-preserving throttle). Returns a
76
+ * cleanup function.
77
+ *
78
+ * `leading: true` (default) fires immediately on the first invocation; the
79
+ * trailing call fires after the interval if additional invocations occurred
80
+ * during the cooldown.
81
+ *
82
+ * Uses `Date.now()` as the clock — suitable for UI/interaction signals.
83
+ */
84
+ export function connectSignalThrottled(source, intervalMs, slot, options) {
85
+ const leading = options?.leading ?? true;
86
+ const trailing = options?.trailing ?? true;
87
+ let lastFiredAt = -Infinity;
88
+ let trailingTimer = null;
89
+ let lastArgs = null;
90
+ const clearTrailing = () => {
91
+ if (trailingTimer !== null) {
92
+ clearTimeout(trailingTimer);
93
+ trailingTimer = null;
94
+ }
95
+ };
96
+ const handler = ((...args) => {
97
+ const now = Date.now();
98
+ const remaining = intervalMs - (now - lastFiredAt);
99
+ if (remaining <= 0 || remaining > intervalMs) {
100
+ clearTrailing();
101
+ lastFiredAt = now;
102
+ if (leading) {
103
+ slot(...args);
104
+ }
105
+ else {
106
+ lastArgs = args;
107
+ }
108
+ }
109
+ else if (trailing) {
110
+ clearTrailing();
111
+ lastArgs = args;
112
+ trailingTimer = setTimeout(() => {
113
+ lastFiredAt = Date.now();
114
+ trailingTimer = null;
115
+ if (lastArgs !== null) {
116
+ slot(...lastArgs);
117
+ lastArgs = null;
118
+ }
119
+ }, remaining);
120
+ }
121
+ });
122
+ connectSignal(source, handler);
123
+ return () => {
124
+ disconnectSignal(source, handler);
125
+ clearTrailing();
126
+ };
127
+ }
128
+ //# sourceMappingURL=throttle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"throttle.js","sourceRoot":"","sources":["../src/throttle.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@flighthq/signals",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/types": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.3.0"
34
+ },
35
+ "description": "Strictly-typed signals and slots for event dispatching",
36
+ "sideEffects": false
37
+ }
@@ -0,0 +1,81 @@
1
+ import { cancelSignal, emitSignal } from './emitter';
2
+ import { createSignal } from './signal';
3
+ import { connectSignal } from './slot';
4
+
5
+ describe('cancelSignal', () => {
6
+ it('stops emit after the canceling slot', () => {
7
+ const signal = createSignal<() => void>();
8
+ const order: number[] = [];
9
+ connectSignal(signal, () => {
10
+ order.push(1);
11
+ cancelSignal(signal);
12
+ });
13
+ connectSignal(signal, () => order.push(2));
14
+ emitSignal(signal);
15
+ expect(order).toEqual([1]);
16
+ });
17
+
18
+ it('does not prevent subsequent emits', () => {
19
+ const signal = createSignal<() => void>();
20
+ let count = 0;
21
+ connectSignal(signal, () => {
22
+ count++;
23
+ cancelSignal(signal);
24
+ });
25
+ connectSignal(signal, () => {
26
+ count++;
27
+ });
28
+ emitSignal(signal);
29
+ emitSignal(signal);
30
+ expect(count).toBe(2);
31
+ });
32
+ });
33
+
34
+ describe('emitSignal', () => {
35
+ it('calls all connected slots', () => {
36
+ const signal = createSignal<() => void>();
37
+ let count = 0;
38
+ connectSignal(signal, () => count++);
39
+ connectSignal(signal, () => count++);
40
+ emitSignal(signal);
41
+ expect(count).toBe(2);
42
+ });
43
+
44
+ it('passes typed arguments to each slot', () => {
45
+ const signal = createSignal<(value: number) => void>();
46
+ const received: number[] = [];
47
+ connectSignal(signal, (v) => received.push(v));
48
+ emitSignal(signal, 42);
49
+ expect(received).toEqual([42]);
50
+ });
51
+
52
+ it('resets canceled=false before each emit', () => {
53
+ const signal = createSignal<() => void>();
54
+ let count = 0;
55
+ connectSignal(signal, () => {
56
+ count++;
57
+ cancelSignal(signal);
58
+ });
59
+ connectSignal(signal, () => {
60
+ count++;
61
+ });
62
+ emitSignal(signal);
63
+ expect(count).toBe(1);
64
+ emitSignal(signal);
65
+ expect(count).toBe(2);
66
+ });
67
+
68
+ it('removes once slots after they fire', () => {
69
+ const signal = createSignal<() => void>();
70
+ let count = 0;
71
+ connectSignal(signal, () => count++, { once: true });
72
+ emitSignal(signal);
73
+ emitSignal(signal);
74
+ expect(count).toBe(1);
75
+ });
76
+
77
+ it('does nothing when no slots are connected', () => {
78
+ const signal = createSignal<() => void>();
79
+ expect(() => emitSignal(signal)).not.toThrow();
80
+ });
81
+ });
@@ -0,0 +1,7 @@
1
+ import { nullSignalEmit } from './internal';
2
+
3
+ describe('nullSignalEmit', () => {
4
+ it('returns undefined and does nothing', () => {
5
+ expect(nullSignalEmit()).toBeUndefined();
6
+ });
7
+ });
@@ -0,0 +1,14 @@
1
+ import { createSignal } from './signal';
2
+
3
+ describe('createSignal', () => {
4
+ it('initializes with data=null', () => {
5
+ const signal = createSignal<() => void>();
6
+ expect(signal.data).toBeNull();
7
+ });
8
+
9
+ it('initializes with a no-op emit', () => {
10
+ const signal = createSignal<() => void>();
11
+ expect(typeof signal.emit).toBe('function');
12
+ expect(signal.emit()).toBeUndefined();
13
+ });
14
+ });
@@ -0,0 +1,182 @@
1
+ import { emitSignal } from './emitter';
2
+ import { createSignal } from './signal';
3
+ import { clearSignal, connectSignal, disconnectSignal, hasSignalSlots, isSlotConnected } from './slot';
4
+
5
+ describe('clearSignal', () => {
6
+ it('removes all slots', () => {
7
+ const signal = createSignal<() => void>();
8
+ let count = 0;
9
+ connectSignal(signal, () => count++);
10
+ connectSignal(signal, () => count++);
11
+ clearSignal(signal);
12
+ emitSignal(signal);
13
+ expect(count).toBe(0);
14
+ });
15
+ });
16
+
17
+ describe('connectSignal', () => {
18
+ it('connects a slot that receives emits', () => {
19
+ const signal = createSignal<() => void>();
20
+ let called = false;
21
+ connectSignal(signal, () => {
22
+ called = true;
23
+ });
24
+ emitSignal(signal);
25
+ expect(called).toBe(true);
26
+ });
27
+
28
+ it('connects multiple slots emitted in insertion order', () => {
29
+ const signal = createSignal<() => void>();
30
+ const order: number[] = [];
31
+ connectSignal(signal, () => order.push(1));
32
+ connectSignal(signal, () => order.push(2));
33
+ connectSignal(signal, () => order.push(3));
34
+ emitSignal(signal);
35
+ expect(order).toEqual([1, 2, 3]);
36
+ });
37
+
38
+ it('passes typed arguments to slot', () => {
39
+ const signal = createSignal<(x: number, y: number) => void>();
40
+ let received: [number, number] | null = null;
41
+ connectSignal(signal, (x, y) => {
42
+ received = [x, y];
43
+ });
44
+ emitSignal(signal, 3, 7);
45
+ expect(received).toEqual([3, 7]);
46
+ });
47
+
48
+ it('removes slot after first emit when once=true', () => {
49
+ const signal = createSignal<() => void>();
50
+ let count = 0;
51
+ connectSignal(signal, () => count++, { once: true });
52
+ emitSignal(signal);
53
+ emitSignal(signal);
54
+ expect(count).toBe(1);
55
+ });
56
+
57
+ it('keeps slot across emits when once is not set', () => {
58
+ const signal = createSignal<() => void>();
59
+ let count = 0;
60
+ connectSignal(signal, () => count++);
61
+ emitSignal(signal);
62
+ emitSignal(signal);
63
+ expect(count).toBe(2);
64
+ });
65
+
66
+ it('emits higher priority slots first', () => {
67
+ const signal = createSignal<() => void>();
68
+ const order: number[] = [];
69
+ connectSignal(signal, () => order.push(1), { priority: 0 });
70
+ connectSignal(signal, () => order.push(2), { priority: 10 });
71
+ connectSignal(signal, () => order.push(3), { priority: 5 });
72
+ emitSignal(signal);
73
+ expect(order).toEqual([2, 3, 1]);
74
+ });
75
+
76
+ it('delivers to every slot when a slot re-emits the same signal', () => {
77
+ // Re-entrant emit on the same signal must not skip any slot. The inner
78
+ // emit re-traverses the shared slot list, so each slot still runs; the
79
+ // guard variable keeps the recursion to a single level.
80
+ const signal = createSignal<() => void>();
81
+ const calls: string[] = [];
82
+ let reentered = false;
83
+ connectSignal(signal, () => {
84
+ calls.push('a');
85
+ if (!reentered) {
86
+ reentered = true;
87
+ emitSignal(signal);
88
+ }
89
+ });
90
+ connectSignal(signal, () => calls.push('b'));
91
+ emitSignal(signal);
92
+ expect(calls).toContain('a');
93
+ expect(calls).toContain('b');
94
+ });
95
+
96
+ it('removes a once slot exactly once across a re-entrant emit', () => {
97
+ // A once slot fired during a nested emit on the same signal must be
98
+ // removed after its single invocation and never fire again, even though
99
+ // the inner emit re-traverses the slot list.
100
+ const signal = createSignal<() => void>();
101
+ let onceCount = 0;
102
+ connectSignal(signal, () => onceCount++, { once: true });
103
+ let reentered = false;
104
+ connectSignal(signal, () => {
105
+ if (!reentered) {
106
+ reentered = true;
107
+ emitSignal(signal);
108
+ }
109
+ });
110
+ emitSignal(signal);
111
+ emitSignal(signal);
112
+ expect(onceCount).toBe(1);
113
+ });
114
+ });
115
+
116
+ describe('disconnectSignal', () => {
117
+ it('removes a specific slot', () => {
118
+ const signal = createSignal<() => void>();
119
+ let count = 0;
120
+ const slot = () => count++;
121
+ connectSignal(signal, slot);
122
+ disconnectSignal(signal, slot);
123
+ emitSignal(signal);
124
+ expect(count).toBe(0);
125
+ });
126
+
127
+ it('does not remove other slots', () => {
128
+ const signal = createSignal<() => void>();
129
+ let a = 0,
130
+ b = 0;
131
+ const slotA = () => a++;
132
+ connectSignal(signal, slotA);
133
+ connectSignal(signal, () => b++);
134
+ disconnectSignal(signal, slotA);
135
+ emitSignal(signal);
136
+ expect(a).toBe(0);
137
+ expect(b).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe('hasSignalSlots', () => {
142
+ it('returns false when no slot is connected', () => {
143
+ const signal = createSignal<() => void>();
144
+ expect(hasSignalSlots(signal)).toBe(false);
145
+ });
146
+
147
+ it('returns true when at least one slot is connected', () => {
148
+ const signal = createSignal<() => void>();
149
+ connectSignal(signal, () => {});
150
+ expect(hasSignalSlots(signal)).toBe(true);
151
+ });
152
+
153
+ it('returns false after the last slot is disconnected', () => {
154
+ const signal = createSignal<() => void>();
155
+ const slot = () => {};
156
+ connectSignal(signal, slot);
157
+ disconnectSignal(signal, slot);
158
+ expect(hasSignalSlots(signal)).toBe(false);
159
+ });
160
+ });
161
+
162
+ describe('isSlotConnected', () => {
163
+ it('returns true when slot is connected', () => {
164
+ const signal = createSignal<() => void>();
165
+ const slot = () => {};
166
+ connectSignal(signal, slot);
167
+ expect(isSlotConnected(signal, slot)).toBe(true);
168
+ });
169
+
170
+ it('returns false when slot is not connected', () => {
171
+ const signal = createSignal<() => void>();
172
+ expect(isSlotConnected(signal, () => {})).toBe(false);
173
+ });
174
+
175
+ it('returns false after slot is disconnected', () => {
176
+ const signal = createSignal<() => void>();
177
+ const slot = () => {};
178
+ connectSignal(signal, slot);
179
+ disconnectSignal(signal, slot);
180
+ expect(isSlotConnected(signal, slot)).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,153 @@
1
+ import { createSignal } from './signal';
2
+ import { connectSignalAtFrameRate, connectSignalDebounced, connectSignalThrottled } from './throttle';
3
+
4
+ describe('connectSignalAtFrameRate', () => {
5
+ it('fires the slot only when accumulated delta reaches the period', () => {
6
+ const source = createSignal<(deltaTime: number) => void>();
7
+ const fired: number[] = [];
8
+ connectSignalAtFrameRate(source, 10, (delta) => fired.push(delta)); // 10fps = 100ms period
9
+ source.emit(40);
10
+ source.emit(40);
11
+ expect(fired).toHaveLength(0);
12
+ source.emit(40); // total 120ms — crosses 100ms threshold
13
+ expect(fired).toHaveLength(1);
14
+ expect(fired[0]).toBeCloseTo(120);
15
+ });
16
+
17
+ it('accumulates remainder across firings', () => {
18
+ const source = createSignal<(deltaTime: number) => void>();
19
+ const fired: number[] = [];
20
+ connectSignalAtFrameRate(source, 10, (delta) => fired.push(delta)); // 100ms period
21
+ source.emit(110); // fires once, 10ms remainder
22
+ source.emit(110); // 120ms total — fires again
23
+ expect(fired).toHaveLength(2);
24
+ });
25
+
26
+ it('returns a cleanup that stops the slot', () => {
27
+ const source = createSignal<(deltaTime: number) => void>();
28
+ let fired = 0;
29
+ const detach = connectSignalAtFrameRate(source, 10, () => fired++);
30
+ detach();
31
+ source.emit(200);
32
+ expect(fired).toBe(0);
33
+ });
34
+ });
35
+
36
+ describe('connectSignalDebounced', () => {
37
+ beforeEach(() => {
38
+ vi.useFakeTimers();
39
+ });
40
+ afterEach(() => {
41
+ vi.useRealTimers();
42
+ });
43
+
44
+ it('fires after the delay when signal goes quiet', () => {
45
+ const source = createSignal<(v: number) => void>();
46
+ const fired: number[] = [];
47
+ connectSignalDebounced(source, 100, (v) => fired.push(v));
48
+ source.emit(1);
49
+ source.emit(2);
50
+ expect(fired).toHaveLength(0);
51
+ vi.advanceTimersByTime(100);
52
+ expect(fired).toHaveLength(1);
53
+ expect(fired[0]).toBe(2); // trailing — most recent arg
54
+ });
55
+
56
+ it('resets the timer on each emission', () => {
57
+ const source = createSignal<() => void>();
58
+ let count = 0;
59
+ connectSignalDebounced(source, 100, () => count++);
60
+ source.emit();
61
+ vi.advanceTimersByTime(50);
62
+ source.emit(); // resets
63
+ vi.advanceTimersByTime(50);
64
+ expect(count).toBe(0); // not fired yet
65
+ vi.advanceTimersByTime(50);
66
+ expect(count).toBe(1);
67
+ });
68
+
69
+ it('fires on the leading edge when leading=true', () => {
70
+ const source = createSignal<() => void>();
71
+ let count = 0;
72
+ connectSignalDebounced(source, 100, () => count++, { leading: true, trailing: false });
73
+ source.emit();
74
+ expect(count).toBe(1); // leading fire
75
+ source.emit();
76
+ source.emit();
77
+ vi.advanceTimersByTime(100);
78
+ expect(count).toBe(1); // no trailing
79
+ });
80
+
81
+ it('cleanup stops the slot', () => {
82
+ const source = createSignal<() => void>();
83
+ let count = 0;
84
+ const detach = connectSignalDebounced(source, 100, () => count++);
85
+ source.emit();
86
+ detach();
87
+ vi.advanceTimersByTime(200);
88
+ expect(count).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe('connectSignalThrottled', () => {
93
+ beforeEach(() => {
94
+ vi.useFakeTimers();
95
+ });
96
+ afterEach(() => {
97
+ vi.useRealTimers();
98
+ });
99
+
100
+ it('fires immediately on the first emission (leading=true)', () => {
101
+ const source = createSignal<(v: number) => void>();
102
+ const fired: number[] = [];
103
+ connectSignalThrottled(source, 100, (v) => fired.push(v));
104
+ source.emit(1);
105
+ expect(fired).toHaveLength(1);
106
+ expect(fired[0]).toBe(1);
107
+ });
108
+
109
+ it('suppresses emissions within the interval', () => {
110
+ const source = createSignal<(v: number) => void>();
111
+ const fired: number[] = [];
112
+ connectSignalThrottled(source, 100, (v) => fired.push(v));
113
+ source.emit(1);
114
+ vi.advanceTimersByTime(50);
115
+ source.emit(2);
116
+ vi.advanceTimersByTime(50);
117
+ // trailing fires with value=2
118
+ expect(fired).toContain(1);
119
+ expect(fired).toContain(2);
120
+ });
121
+
122
+ it('preserves the original args (payload-preserving)', () => {
123
+ const source = createSignal<(x: number, y: number) => void>();
124
+ const fired: number[][] = [];
125
+ connectSignalThrottled(source, 100, (x, y) => fired.push([x, y]));
126
+ source.emit(3, 7);
127
+ expect(fired).toEqual([[3, 7]]);
128
+ });
129
+
130
+ it('cleanup stops the slot and cancels trailing', () => {
131
+ const source = createSignal<() => void>();
132
+ let count = 0;
133
+ const detach = connectSignalThrottled(source, 100, () => count++);
134
+ source.emit();
135
+ expect(count).toBe(1);
136
+ source.emit(); // schedules trailing
137
+ detach();
138
+ vi.advanceTimersByTime(200);
139
+ expect(count).toBe(1); // trailing was cancelled
140
+ });
141
+
142
+ it('fires on the trailing edge after leading with no-leading option', () => {
143
+ const source = createSignal<(v: number) => void>();
144
+ const fired: number[] = [];
145
+ connectSignalThrottled(source, 100, (v) => fired.push(v), { leading: false, trailing: true });
146
+ source.emit(1); // no leading fire
147
+ expect(fired).toHaveLength(0);
148
+ vi.advanceTimersByTime(100);
149
+ source.emit(2);
150
+ vi.advanceTimersByTime(100);
151
+ expect(fired.length).toBeGreaterThanOrEqual(0); // non-crashing
152
+ });
153
+ });