feat: introduce core-events package with event bus, hooks, and a web showcase demo
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
# @repo/core-events
|
||||
|
||||
[← Back to Root](../../README.md)
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly typed Event Bus powered by `mitt` and React hooks.
|
||||
|
||||
By routing communication through a centralized event bus, we achieve:
|
||||
- **Zero Coupling**: Publishers and subscribers don't need to import or know about each other.
|
||||
- **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state *without* triggering massive React tree re-renders.
|
||||
- **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures.
|
||||
- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Publishers
|
||||
A[Cashier UI]
|
||||
B[Profile Settings]
|
||||
C[WebSocket Client]
|
||||
end
|
||||
|
||||
subgraph Core
|
||||
E((Event Bus<br/>mitt))
|
||||
R[[AppEvents<br/>Registry]] -.-> E
|
||||
end
|
||||
|
||||
subgraph Subscribers
|
||||
X[Electron IPC Bridge]
|
||||
Y[IndexedDB Sync]
|
||||
Z[Stock Grid Row]
|
||||
end
|
||||
|
||||
A -- "DEVICE:PRINT_RECEIPT" --> E
|
||||
B -- "AUTH:PROFILE_UPDATED" --> E
|
||||
C -- "WS:STOCK_UPDATE" --> E
|
||||
|
||||
E -.-> X
|
||||
E -.-> Y
|
||||
E -.-> Z
|
||||
|
||||
style E fill:#4263eb,color:#fff,stroke:#fff
|
||||
style R fill:#2b8a3e,color:#fff,stroke:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Defining Events
|
||||
|
||||
Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase.
|
||||
|
||||
To add a new event, simply extend the `AppEvents` type:
|
||||
|
||||
```typescript
|
||||
// packages/core-events/src/events.registry.ts
|
||||
|
||||
export interface CheckoutPayload {
|
||||
orderId: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type AppEvents = {
|
||||
// Existing events...
|
||||
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
|
||||
|
||||
// Your new event:
|
||||
'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Here are three real-world architectural patterns powered by the Event Bus.
|
||||
|
||||
### Example 1: Hardware Abstraction (Cross-Platform)
|
||||
|
||||
**Problem**: The web app needs to print receipts. If running in a browser, it should use `window.print()`. If running in the Electron wrapper, it must use the secure IPC bridge (`window.electronAPI.print()`). We don't want the UI components cluttered with platform-detection logic.
|
||||
|
||||
**Solution**: The UI publishes a blind event. A headless listener handles the platform routing.
|
||||
|
||||
**Publisher (Cashier UI)**:
|
||||
```tsx
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
|
||||
export function CashierUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const handlePrint = () => {
|
||||
// Fire and forget. Zero knowledge of how printing actually happens.
|
||||
publish('DEVICE:PRINT_RECEIPT', {
|
||||
receiptId: 'RCP-123',
|
||||
items: [...],
|
||||
total: 45.00,
|
||||
cashierName: 'Firman'
|
||||
});
|
||||
};
|
||||
|
||||
return <Button onClick={handlePrint}>Print Receipt</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Subscriber (Headless Listener)**:
|
||||
```tsx
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
|
||||
export function PrinterListener() {
|
||||
useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => {
|
||||
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||
|
||||
if (isElectron) {
|
||||
// Route via secure Electron IPC bridge
|
||||
window.electronAPI.print({ silent: true });
|
||||
} else {
|
||||
// Fallback to standard browser print dialog
|
||||
window.print();
|
||||
}
|
||||
});
|
||||
|
||||
return null; // Renders nothing
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Extreme Performance (High-Frequency Data)
|
||||
|
||||
**Problem**: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser.
|
||||
|
||||
**Solution**: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes.
|
||||
|
||||
**Parent Grid (Never re-renders)**:
|
||||
```tsx
|
||||
export function LiveStockGrid() {
|
||||
// Generates 1000 IDs once. No stock data is stored here!
|
||||
const stockIds = generateStockIds(1000);
|
||||
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{stockIds.map((id) => (
|
||||
<StockRow key={id} stockId={id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Child Row (Targeted Updates)**:
|
||||
```tsx
|
||||
import { memo, useState } from 'react';
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
|
||||
export const StockRow = memo(function StockRow({ stockId }) {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useAppEvent('WS:STOCK_UPDATE', (payload) => {
|
||||
// CRITICAL: Filter out events for other rows.
|
||||
// 999 out of 1000 rows will exit here instantly without causing a re-render.
|
||||
if (payload.id !== stockId) return;
|
||||
|
||||
// Only the targeted row updates its local state
|
||||
setData(payload);
|
||||
});
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{stockId}</td>
|
||||
<td>{data?.price}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Background Sync (Auth to IndexedDB)
|
||||
|
||||
**Problem**: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the `@repo/core-storage` package.
|
||||
|
||||
**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background.
|
||||
|
||||
**Publisher (Profile UI)**:
|
||||
```tsx
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
|
||||
export function ProfileSettingsUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const handleSave = () => {
|
||||
publish('AUTH:PROFILE_UPDATED', {
|
||||
id: 'user-1',
|
||||
name: 'Firman',
|
||||
email: 'firman@eigen.co.id',
|
||||
});
|
||||
};
|
||||
|
||||
return <Button onClick={handleSave}>Save Profile</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Subscriber (Storage Sync Listener)**:
|
||||
```tsx
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { secureIndexedDB } from '@repo/core-storage';
|
||||
|
||||
export function StorageSyncListener() {
|
||||
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
|
||||
// Automatically encrypted at rest because 'user_profile'
|
||||
// is defined in ENCRYPTED_KEYS in @repo/core-storage
|
||||
secureIndexedDB.setItem('user_profile', payload).catch(console.error);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@repo/core-events",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.ts\" \"**/*.tsx\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^8.57.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { eventBus, publish, subscribe } from './event-bus';
|
||||
import { useAppEvent, usePublishEvent } from './hooks';
|
||||
import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Clear all mitt handlers between tests to avoid cross-contamination. */
|
||||
function clearAllHandlers() {
|
||||
eventBus.all.clear();
|
||||
}
|
||||
|
||||
// ─── Test Data ──────────────────────────────────────────────────
|
||||
|
||||
const mockStockUpdate: StockUpdatePayload = {
|
||||
id: 'AAPL',
|
||||
price: 185.42,
|
||||
change: 1.23,
|
||||
volume: 50000,
|
||||
};
|
||||
|
||||
const mockProfile: ProfileUpdatedPayload = {
|
||||
id: 'user-1',
|
||||
name: 'Firman',
|
||||
email: 'firman@eigen.co.id',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// ─── Core Event Bus Tests ───────────────────────────────────────
|
||||
|
||||
describe('Event Bus (Core)', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
it('publishes and subscribes to a typed event', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
subscribe('WS:STOCK_UPDATE', handler);
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
||||
});
|
||||
|
||||
it('delivers events to multiple subscribers', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
subscribe('AUTH:PROFILE_UPDATED', handler1);
|
||||
subscribe('AUTH:PROFILE_UPDATED', handler2);
|
||||
publish('AUTH:PROFILE_UPDATED', mockProfile);
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not deliver events to unrelated subscribers', () => {
|
||||
const stockHandler = vi.fn();
|
||||
const profileHandler = vi.fn();
|
||||
|
||||
subscribe('WS:STOCK_UPDATE', stockHandler);
|
||||
subscribe('AUTH:PROFILE_UPDATED', profileHandler);
|
||||
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
|
||||
expect(stockHandler).toHaveBeenCalledTimes(1);
|
||||
expect(profileHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unsubscribes correctly via returned function', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const unsub = subscribe('WS:STOCK_UPDATE', handler);
|
||||
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Unsubscribe
|
||||
unsub();
|
||||
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
// Should still be 1, not 2
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles events with undefined payloads', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
subscribe('APP:INITIALIZED', handler);
|
||||
publish('APP:INITIALIZED', undefined);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('handles rapid-fire events without loss', () => {
|
||||
const handler = vi.fn();
|
||||
subscribe('WS:STOCK_UPDATE', handler);
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── React Hook Tests ───────────────────────────────────────────
|
||||
|
||||
describe('useAppEvent (React Hook)', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
it('subscribes on mount and receives events', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
|
||||
|
||||
act(() => {
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
||||
});
|
||||
|
||||
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
|
||||
|
||||
// Event should be received while mounted
|
||||
act(() => {
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Event should NOT be received after unmount
|
||||
act(() => {
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
});
|
||||
|
||||
// Still 1, proving the handler was properly cleaned up
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not leak handlers across mount/unmount cycles', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
// Mount and unmount 100 times
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
|
||||
unmount();
|
||||
}
|
||||
|
||||
// After 100 cycles, emit one event
|
||||
act(() => {
|
||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
||||
});
|
||||
|
||||
// Should be 0 — all handlers should have been cleaned up
|
||||
expect(handler).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Verify the handler map is empty for this event type
|
||||
const handlers = eventBus.all.get('WS:STOCK_UPDATE');
|
||||
expect(!handlers || handlers.length === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('always calls the latest handler (no stale closures)', () => {
|
||||
let capturedValue = '';
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ value }: { value: string }) =>
|
||||
useAppEvent('AUTH:PROFILE_UPDATED', () => {
|
||||
capturedValue = value;
|
||||
}),
|
||||
{ initialProps: { value: 'initial' } },
|
||||
);
|
||||
|
||||
// Update the closure value
|
||||
rerender({ value: 'updated' });
|
||||
|
||||
act(() => {
|
||||
publish('AUTH:PROFILE_UPDATED', mockProfile);
|
||||
});
|
||||
|
||||
// Should capture the LATEST value, not the stale 'initial'
|
||||
expect(capturedValue).toBe('updated');
|
||||
});
|
||||
|
||||
it('does not re-subscribe when handler reference changes', () => {
|
||||
// We spy on eventBus.on to count subscription calls
|
||||
const onSpy = vi.spyOn(eventBus, 'on');
|
||||
const offSpy = vi.spyOn(eventBus, 'off');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ handler }: { handler: () => void }) =>
|
||||
useAppEvent('APP:INITIALIZED', handler),
|
||||
{ initialProps: { handler: vi.fn() } },
|
||||
);
|
||||
|
||||
const initialOnCount = onSpy.mock.calls.length;
|
||||
const initialOffCount = offSpy.mock.calls.length;
|
||||
|
||||
// Re-render with a NEW handler function reference
|
||||
rerender({ handler: vi.fn() });
|
||||
|
||||
// on/off should NOT have been called again (ref-based pattern)
|
||||
expect(onSpy.mock.calls.length).toBe(initialOnCount);
|
||||
expect(offSpy.mock.calls.length).toBe(initialOffCount);
|
||||
|
||||
onSpy.mockRestore();
|
||||
offSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePublishEvent (React Hook)', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHandlers();
|
||||
});
|
||||
|
||||
it('returns a working publish function', () => {
|
||||
const handler = vi.fn();
|
||||
subscribe('AUTH:PROFILE_UPDATED', handler);
|
||||
|
||||
const { result } = renderHook(() => usePublishEvent());
|
||||
|
||||
act(() => {
|
||||
result.current('AUTH:PROFILE_UPDATED', mockProfile);
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(mockProfile);
|
||||
});
|
||||
|
||||
it('returns a referentially stable function across re-renders', () => {
|
||||
const { result, rerender } = renderHook(() => usePublishEvent());
|
||||
|
||||
const firstRef = result.current;
|
||||
|
||||
rerender();
|
||||
rerender();
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(firstRef);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import mitt from 'mitt';
|
||||
import type { AppEvents } from './events.registry';
|
||||
|
||||
// ─── Singleton Event Bus ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Application-wide event bus — a strictly typed `mitt` instance.
|
||||
*
|
||||
* Prefer the `publish` / `subscribe` helper functions or the React
|
||||
* hooks (`useAppEvent`, `usePublishEvent`) over using this directly.
|
||||
* Direct access is provided for edge cases like middleware or testing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { eventBus } from '@repo/core-events';
|
||||
* eventBus.on('APP:ERROR', (e) => console.error(e.message));
|
||||
* ```
|
||||
*/
|
||||
export const eventBus = mitt<AppEvents>();
|
||||
|
||||
// ─── Type-Safe Helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit (publish) a typed event to all subscribers.
|
||||
*
|
||||
* @param type - The event name from `AppEvents`.
|
||||
* @param event - The payload matching that event's type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
|
||||
* ```
|
||||
*/
|
||||
export function publish<K extends keyof AppEvents>(
|
||||
type: K,
|
||||
event: AppEvents[K],
|
||||
): void {
|
||||
eventBus.emit(type, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a typed event.
|
||||
*
|
||||
* Returns an `unsubscribe` function — call it to remove the handler.
|
||||
* For React components, prefer `useAppEvent` which handles cleanup
|
||||
* automatically on unmount.
|
||||
*
|
||||
* @param type - The event name from `AppEvents`.
|
||||
* @param handler - Callback receiving the typed payload.
|
||||
* @returns A function that removes this subscription.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const unsub = subscribe('WS:STOCK_UPDATE', (data) => {
|
||||
* console.log(data.price); // fully typed
|
||||
* });
|
||||
* // Later:
|
||||
* unsub();
|
||||
* ```
|
||||
*/
|
||||
export function subscribe<K extends keyof AppEvents>(
|
||||
type: K,
|
||||
handler: (event: AppEvents[K]) => void,
|
||||
): () => void {
|
||||
eventBus.on(type, handler);
|
||||
return () => eventBus.off(type, handler);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ─── Event Payload Types ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Receipt line item for the DEVICE:PRINT_RECEIPT event.
|
||||
*/
|
||||
export interface ReceiptItem {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the DEVICE:PRINT_RECEIPT event.
|
||||
*/
|
||||
export interface PrintReceiptPayload {
|
||||
receiptId: string;
|
||||
items: ReceiptItem[];
|
||||
total: number;
|
||||
cashierName: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the WS:STOCK_UPDATE event.
|
||||
*/
|
||||
export interface StockUpdatePayload {
|
||||
id: string;
|
||||
price: number;
|
||||
change: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the AUTH:PROFILE_UPDATED event.
|
||||
*/
|
||||
export interface ProfileUpdatedPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ─── Application Event Registry ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Central event registry for the entire application.
|
||||
*
|
||||
* Every event in the system MUST be declared here with its payload
|
||||
* type. This provides:
|
||||
*
|
||||
* 1. **Compile-time safety** — typos in event names are caught by TS.
|
||||
* 2. **Payload validation** — publishers and subscribers agree on shape.
|
||||
* 3. **Discoverability** — `Ctrl+Click` any event to find its contract.
|
||||
*
|
||||
* **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
|
||||
*
|
||||
* **Extensibility**: To add events from feature modules, extend this
|
||||
* type using intersection:
|
||||
*
|
||||
* ```ts
|
||||
* // In your feature module types:
|
||||
* type InventoryEvents = {
|
||||
* 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
|
||||
* };
|
||||
* // Then merge into AppEvents in this file.
|
||||
* ```
|
||||
*/
|
||||
export type AppEvents = {
|
||||
// ── Device / Hardware ───────────────────────────────────────────
|
||||
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
|
||||
|
||||
// ── WebSocket / Real-Time ───────────────────────────────────────
|
||||
'WS:STOCK_UPDATE': StockUpdatePayload;
|
||||
|
||||
// ── Auth / User ─────────────────────────────────────────────────
|
||||
'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
|
||||
|
||||
// ── App Lifecycle ───────────────────────────────────────────────
|
||||
'APP:INITIALIZED': undefined;
|
||||
'APP:ERROR': { message: string; code?: string };
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { AppEvents } from './events.registry';
|
||||
import { eventBus, publish as busPublish } from './event-bus';
|
||||
|
||||
// ─── useAppEvent ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Subscribe to an application event with automatic cleanup on unmount.
|
||||
*
|
||||
* The handler is stored in a ref so that:
|
||||
* 1. The subscription is stable — re-renders don't cause unsubscribe/resubscribe churn.
|
||||
* 2. The handler always sees the latest closure values (no stale closures).
|
||||
* 3. The parent component's render cycle is never triggered by the subscription itself.
|
||||
*
|
||||
* @param type - The event name from `AppEvents`.
|
||||
* @param handler - Callback receiving the typed payload. May be updated on re-render.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useAppEvent('AUTH:PROFILE_UPDATED', (profile) => {
|
||||
* console.log(profile.name); // fully typed, auto-cleaned on unmount
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useAppEvent<K extends keyof AppEvents>(
|
||||
type: K,
|
||||
handler: (event: AppEvents[K]) => void,
|
||||
): void {
|
||||
// Always keep the latest handler in a ref to avoid stale closures
|
||||
// and prevent re-subscription on every render.
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
// Create a stable delegate that forwards to the latest handler ref
|
||||
const delegate = (event: AppEvents[K]) => {
|
||||
handlerRef.current(event);
|
||||
};
|
||||
|
||||
eventBus.on(type, delegate);
|
||||
|
||||
// Cleanup: unsubscribe when the component unmounts or `type` changes.
|
||||
// This is the critical memory-leak prevention mechanism.
|
||||
return () => {
|
||||
eventBus.off(type, delegate);
|
||||
};
|
||||
}, [type]);
|
||||
}
|
||||
|
||||
// ─── usePublishEvent ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a strictly typed `publish` function.
|
||||
*
|
||||
* The returned function is referentially stable (memoized) so it
|
||||
* can be safely passed as a prop or used in dependency arrays
|
||||
* without causing unnecessary re-renders.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const publish = usePublishEvent();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* publish('DEVICE:PRINT_RECEIPT', {
|
||||
* receiptId: '001',
|
||||
* items: [{ name: 'Widget', qty: 2, price: 9.99 }],
|
||||
* total: 19.98,
|
||||
* cashierName: 'Firman',
|
||||
* timestamp: Date.now(),
|
||||
* });
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function usePublishEvent(): typeof busPublish {
|
||||
return useCallback(busPublish, []);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// ─── Event Registry ─────────────────────────────────────────────
|
||||
export type {
|
||||
AppEvents,
|
||||
PrintReceiptPayload,
|
||||
StockUpdatePayload,
|
||||
ProfileUpdatedPayload,
|
||||
ReceiptItem,
|
||||
} from './events.registry';
|
||||
|
||||
// ─── Event Bus (Core) ───────────────────────────────────────────
|
||||
export { eventBus, publish, subscribe } from './event-bus';
|
||||
|
||||
// ─── React Hooks ────────────────────────────────────────────────
|
||||
export { useAppEvent, usePublishEvent } from './hooks';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user