@fitzzero/quickdraw-core 3.3.1 → 3.5.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.
package/README.md CHANGED
@@ -76,10 +76,10 @@ export default function RootLayout({ children }) {
76
76
  }
77
77
 
78
78
  // app/chat/page.tsx
79
- import { useService, useSubscription } from '@fitzzero/quickdraw-core/client';
79
+ import { useService, useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
80
80
 
81
81
  function ChatPage({ chatId }: { chatId: string }) {
82
- // Subscribe to real-time updates
82
+ // Subscribe to real-time entity updates
83
83
  const { data: chat, isLoading } = useSubscription('chatService', chatId);
84
84
 
85
85
  // Mutation hook
@@ -87,6 +87,14 @@ function ChatPage({ chatId }: { chatId: string }) {
87
87
  onSuccess: () => console.log('Title updated!'),
88
88
  });
89
89
 
90
+ // Listen for custom events broadcast to the chat room
91
+ const [typing, setTyping] = useState(false);
92
+ useRoomEvents({
93
+ 'chat:message': (msg) => appendMessage(msg),
94
+ 'agent_typing_start': () => setTyping(true),
95
+ 'agent_typing_stop': () => setTyping(false),
96
+ });
97
+
90
98
  if (isLoading) return <div>Loading...</div>;
91
99
 
92
100
  return (
@@ -119,6 +127,46 @@ function ChatTitleEditor({ chat, updateChat }) {
119
127
  }
120
128
  ```
121
129
 
130
+ ### Custom Room Events
131
+
132
+ When your server broadcasts custom events to subscription rooms via `emitToRoom`, use `useRoomEvents` on the client to listen with proper lifecycle management:
133
+
134
+ ```tsx
135
+ import { useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
136
+
137
+ function ProjectBoard({ projectId }: { projectId: string }) {
138
+ const { data: project } = useSubscription('projectService', projectId);
139
+ const [tasks, setTasks] = useState<Task[]>([]);
140
+
141
+ // Lifecycle-managed event listeners — cleanup handled automatically
142
+ useRoomEvents({
143
+ 'task:created': (task: Task) => setTasks(prev => [...prev, task]),
144
+ 'task:deleted': ({ id }: { id: string }) => setTasks(prev => prev.filter(t => t.id !== id)),
145
+ });
146
+
147
+ return <Board tasks={tasks} />;
148
+ }
149
+ ```
150
+
151
+ ### Auto-Invalidating Queries
152
+
153
+ For list queries that should refresh when related events fire, use `invalidateOn`:
154
+
155
+ ```tsx
156
+ import { useServiceQuery } from '@fitzzero/quickdraw-core/client';
157
+
158
+ function TaskList({ projectId }: { projectId: string }) {
159
+ // Auto-refetches when tasks are created, deleted, or change status
160
+ const { data: tasks } = useServiceQuery('taskService', 'listTasks', { projectId }, {
161
+ invalidateOn: ['task:created', 'task:deleted', 'task:statusUpdate'],
162
+ });
163
+
164
+ return <ul>{tasks?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
165
+ }
166
+ ```
167
+
168
+ Rapid-fire events within 100ms are debounced into a single refetch.
169
+
122
170
  ## Package Exports
123
171
 
124
172
  ```typescript
@@ -144,7 +192,9 @@ import {
144
192
  QuickdrawProvider,
145
193
  useQuickdrawSocket,
146
194
  useService,
195
+ useServiceQuery,
147
196
  useSubscription,
197
+ useRoomEvents,
148
198
  SocketCheckbox,
149
199
  SocketTextField,
150
200
  SocketSelect,
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
- import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult } from '../types-Cr8YC7Kg.js';
3
- export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-Cr8YC7Kg.js';
2
+ import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult, g as UseRoomEventsOptions } from '../types-BJj94_XW.js';
3
+ export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-BJj94_XW.js';
4
4
  import 'socket.io-client';
5
5
  import '@tanstack/react-query';
6
6
  import '../shared/index.js';
@@ -171,6 +171,42 @@ declare function useSubscription<TData extends {
171
171
  id: string;
172
172
  }>(serviceName: string, entryId: string | null, options?: UseSubscriptionOptions<TData>): UseSubscriptionResult<TData>;
173
173
 
174
+ /**
175
+ * Hook for listening to custom socket events with managed lifecycle.
176
+ *
177
+ * Handles `socket.on`/`socket.off` cleanup, reconnection re-attachment,
178
+ * and connection-awareness. Each component gets its own listeners (no dedup needed).
179
+ * Room scoping is handled server-side via `emitToRoom`.
180
+ *
181
+ * Pair with `useSubscription` to ensure the socket is in the correct room:
182
+ * `useSubscription` manages room membership, `useRoomEvents` manages event listeners.
183
+ *
184
+ * @param handlers - Map of event names to handler functions. Event names should be
185
+ * stable (don't change between renders). Handler functions can change freely.
186
+ * @param options - Optional configuration
187
+ *
188
+ * @example
189
+ * ```tsx
190
+ * function ChatView({ chatId }: { chatId: string }) {
191
+ * const [messages, setMessages] = useState<Message[]>([]);
192
+ * const [typing, setTyping] = useState(false);
193
+ *
194
+ * // Subscribe to the chat room (manages room membership)
195
+ * const { data: chat } = useSubscription('chatService', chatId);
196
+ *
197
+ * // Listen for custom events broadcast to the chat room
198
+ * useRoomEvents({
199
+ * 'chat:message': (msg: Message) => setMessages(prev => [...prev, msg]),
200
+ * 'agent_typing_start': () => setTyping(true),
201
+ * 'agent_typing_stop': () => setTyping(false),
202
+ * });
203
+ *
204
+ * return <div>{chat?.title}</div>;
205
+ * }
206
+ * ```
207
+ */
208
+ declare function useRoomEvents(handlers: Record<string, (data: never) => void>, options?: UseRoomEventsOptions): void;
209
+
174
210
  type CommitMode = "change" | "blur" | "debounce";
175
211
  interface UseSocketInputOptions<TEntry, TAllowedUpdate, TKey extends keyof TAllowedUpdate> {
176
212
  /**
@@ -481,4 +517,4 @@ declare function buildBreadcrumbs(pathname: string, navigation: NavItem[]): Brea
481
517
  */
482
518
  declare function routeRequiresAuth(pathname: string, navigation: NavItem[], userMenuItems?: NavItem[]): boolean;
483
519
 
484
- export { type BreadcrumbItem, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawSocketContextValue, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
520
+ export { type BreadcrumbItem, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawSocketContextValue, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseRoomEventsOptions, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
@@ -386,7 +386,8 @@ function useServiceQuery(serviceName, methodName, payload, options) {
386
386
  skipCache = false,
387
387
  timeout = 1e4,
388
388
  retry = 1,
389
- retryDelay
389
+ retryDelay,
390
+ invalidateOn
390
391
  } = options ?? {};
391
392
  const queryKey = React.useMemo(
392
393
  () => [serviceName, methodName, JSON.stringify(payload)],
@@ -420,6 +421,34 @@ function useServiceQuery(serviceName, methodName, payload, options) {
420
421
  queryClient.invalidateQueries({ queryKey });
421
422
  }
422
423
  }, [skipCache, enabled, queryClient, queryKey]);
424
+ const invalidateOnKey = invalidateOn?.join("\0");
425
+ const stableInvalidateOn = React.useMemo(
426
+ () => invalidateOn,
427
+ // eslint-disable-next-line react-hooks/exhaustive-deps
428
+ [invalidateOnKey]
429
+ );
430
+ React.useEffect(() => {
431
+ if (!socket || !isConnected || !stableInvalidateOn?.length || !enabled) {
432
+ return;
433
+ }
434
+ let debounceTimer = null;
435
+ const handleInvalidate = () => {
436
+ if (debounceTimer) return;
437
+ debounceTimer = setTimeout(() => {
438
+ debounceTimer = null;
439
+ queryClient.invalidateQueries({ queryKey });
440
+ }, 100);
441
+ };
442
+ for (const eventName of stableInvalidateOn) {
443
+ socket.on(eventName, handleInvalidate);
444
+ }
445
+ return () => {
446
+ if (debounceTimer) clearTimeout(debounceTimer);
447
+ for (const eventName of stableInvalidateOn) {
448
+ socket.off(eventName, handleInvalidate);
449
+ }
450
+ };
451
+ }, [socket, isConnected, stableInvalidateOn, enabled, queryClient, queryKey]);
423
452
  const query = useQuery({
424
453
  queryKey,
425
454
  queryFn,
@@ -607,6 +636,39 @@ function useSubscription(serviceName, entryId, options = {}) {
607
636
  unsubscribe
608
637
  };
609
638
  }
639
+ function useRoomEvents(handlers, options) {
640
+ const { socket, isConnected } = useQuickdrawSocket();
641
+ const { enabled = true } = options ?? {};
642
+ const handlersRef = React.useRef(handlers);
643
+ React.useEffect(() => {
644
+ handlersRef.current = handlers;
645
+ }, [handlers]);
646
+ const eventNames = Object.keys(handlers);
647
+ const eventNamesKey = eventNames.join("\0");
648
+ const stableEventNames = React.useMemo(
649
+ () => eventNames,
650
+ // eslint-disable-next-line react-hooks/exhaustive-deps
651
+ [eventNamesKey]
652
+ );
653
+ React.useEffect(() => {
654
+ if (!socket || !isConnected || !enabled || stableEventNames.length === 0) {
655
+ return;
656
+ }
657
+ const wrappers = /* @__PURE__ */ new Map();
658
+ for (const eventName of stableEventNames) {
659
+ const wrapper = (...args) => {
660
+ handlersRef.current[eventName]?.(...args);
661
+ };
662
+ wrappers.set(eventName, wrapper);
663
+ socket.on(eventName, wrapper);
664
+ }
665
+ return () => {
666
+ for (const [eventName, wrapper] of wrappers) {
667
+ socket.off(eventName, wrapper);
668
+ }
669
+ };
670
+ }, [socket, isConnected, enabled, stableEventNames]);
671
+ }
610
672
  function useSocketInput(options) {
611
673
  const {
612
674
  state,
@@ -1137,6 +1199,6 @@ function routeRequiresAuth(pathname, navigation, userMenuItems) {
1137
1199
  return false;
1138
1200
  }
1139
1201
 
1140
- export { QuickdrawProvider, SocketCheckbox, SocketSelect, SocketSlider, SocketSwitch, SocketTextField, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
1202
+ export { QuickdrawProvider, SocketCheckbox, SocketSelect, SocketSlider, SocketSwitch, SocketTextField, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
1141
1203
  //# sourceMappingURL=index.js.map
1142
1204
  //# sourceMappingURL=index.js.map