@open-mercato/ui 0.8.1-develop.7267.1.2e95af80a6 → 0.8.1-develop.7269.1.493e13f954

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.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 169 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- [build:ui] found 451 entry points
2
+ [build:ui] found 452 entry points
3
3
  [build:ui] built successfully
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { APP_EVENT_DOM_NAME, matchesPattern } from "./useAppEvent.js";
4
+ const DEFAULT_APP_EVENT_COALESCE_WINDOW_MS = 1e3;
5
+ function useAppEventCoalesced(eventPattern, handler, deps = [], options = {}) {
6
+ const handlerRef = useRef(handler);
7
+ handlerRef.current = handler;
8
+ const windowMs = options.windowMs ?? DEFAULT_APP_EVENT_COALESCE_WINDOW_MS;
9
+ useEffect(() => {
10
+ let windowTimer = null;
11
+ let pending = null;
12
+ const closeWindow = () => {
13
+ windowTimer = null;
14
+ if (!pending) return;
15
+ const payload = pending;
16
+ pending = null;
17
+ handlerRef.current(payload);
18
+ };
19
+ const listener = (e) => {
20
+ const detail = e.detail;
21
+ if (!detail || typeof detail.id !== "string") return;
22
+ if (!matchesPattern(eventPattern, detail.id)) return;
23
+ if (windowTimer !== null) {
24
+ pending = detail;
25
+ return;
26
+ }
27
+ windowTimer = setTimeout(closeWindow, windowMs);
28
+ handlerRef.current(detail);
29
+ };
30
+ window.addEventListener(APP_EVENT_DOM_NAME, listener);
31
+ return () => {
32
+ window.removeEventListener(APP_EVENT_DOM_NAME, listener);
33
+ if (windowTimer !== null) clearTimeout(windowTimer);
34
+ windowTimer = null;
35
+ pending = null;
36
+ };
37
+ }, [eventPattern, windowMs, ...deps]);
38
+ }
39
+ export {
40
+ DEFAULT_APP_EVENT_COALESCE_WINDOW_MS,
41
+ useAppEventCoalesced
42
+ };
43
+ //# sourceMappingURL=useAppEventCoalesced.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/injection/useAppEventCoalesced.ts"],
4
+ "sourcesContent": ["\"use client\"\nimport { useEffect, useRef } from 'react'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport { APP_EVENT_DOM_NAME, matchesPattern } from './useAppEvent'\n\n/**\n * Default coalescing window. Long enough to swallow a server-side ingest burst\n * (a mail poll emits its per-message events within a few hundred milliseconds),\n * short enough that the trailing refresh is not perceived as lag.\n */\nexport const DEFAULT_APP_EVENT_COALESCE_WINDOW_MS = 1000\n\nexport type AppEventCoalesceOptions = {\n /** Coalescing window in milliseconds. Defaults to {@link DEFAULT_APP_EVENT_COALESCE_WINDOW_MS}. */\n windowMs?: number\n}\n\n/**\n * Like {@link useAppEvent}, but collapses a burst of matching events into a\n * leading and a trailing handler call.\n *\n * Use this when the handler is expensive and idempotent \u2014 a react-query\n * invalidation that reloads a list is the canonical case: a single ingest cycle\n * emitting N events would otherwise trigger N full reloads of the same page.\n *\n * Semantics (leading + trailing, not plain debounce):\n * - The first event of a quiet period is handled synchronously, so a single\n * user action still refreshes immediately.\n * - Every further event inside the window is collapsed; when the window closes,\n * the handler runs once more with the most recent payload, so the final state\n * is never missed. A burst spanning several windows keeps collapsing at a\n * steady one call per window.\n * - Unmounting clears the pending window; the handler never runs afterwards.\n *\n * This differs from `NotificationDispatcher`'s `debounceMs` suppression, which\n * drops repeats outright because re-showing a toast has no value. Here the\n * trailing call is mandatory, because dropping it would leave the list stale.\n *\n * @param eventPattern - Pattern to match event IDs against (e.g. 'messages.message.*')\n * @param handler - Callback invoked with the most recent matching event of the window\n * @param deps - Optional dependency array for the handler (defaults to [])\n * @param options - Coalescing options\n *\n * @example\n * useAppEventCoalesced('messages.message.*', () => {\n * void queryClient.invalidateQueries({ queryKey: ['messages', 'list'] })\n * }, [queryClient])\n */\nexport function useAppEventCoalesced(\n eventPattern: string,\n handler: (payload: AppEventPayload) => void,\n deps: unknown[] = [],\n options: AppEventCoalesceOptions = {},\n): void {\n const handlerRef = useRef(handler)\n handlerRef.current = handler\n const windowMs = options.windowMs ?? DEFAULT_APP_EVENT_COALESCE_WINDOW_MS\n\n useEffect(() => {\n let windowTimer: ReturnType<typeof setTimeout> | null = null\n let pending: AppEventPayload | null = null\n\n const closeWindow = () => {\n windowTimer = null\n if (!pending) return\n const payload = pending\n pending = null\n handlerRef.current(payload)\n }\n\n const listener = (e: Event) => {\n const detail = (e as CustomEvent<AppEventPayload>).detail\n if (!detail || typeof detail.id !== 'string') return\n if (!matchesPattern(eventPattern, detail.id)) return\n if (windowTimer !== null) {\n pending = detail\n return\n }\n windowTimer = setTimeout(closeWindow, windowMs)\n handlerRef.current(detail)\n }\n\n window.addEventListener(APP_EVENT_DOM_NAME, listener)\n return () => {\n window.removeEventListener(APP_EVENT_DOM_NAME, listener)\n if (windowTimer !== null) clearTimeout(windowTimer)\n windowTimer = null\n pending = null\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [eventPattern, windowMs, ...deps])\n}\n"],
5
+ "mappings": ";AACA,SAAS,WAAW,cAAc;AAElC,SAAS,oBAAoB,sBAAsB;AAO5C,MAAM,uCAAuC;AAsC7C,SAAS,qBACd,cACA,SACA,OAAkB,CAAC,GACnB,UAAmC,CAAC,GAC9B;AACN,QAAM,aAAa,OAAO,OAAO;AACjC,aAAW,UAAU;AACrB,QAAM,WAAW,QAAQ,YAAY;AAErC,YAAU,MAAM;AACd,QAAI,cAAoD;AACxD,QAAI,UAAkC;AAEtC,UAAM,cAAc,MAAM;AACxB,oBAAc;AACd,UAAI,CAAC,QAAS;AACd,YAAM,UAAU;AAChB,gBAAU;AACV,iBAAW,QAAQ,OAAO;AAAA,IAC5B;AAEA,UAAM,WAAW,CAAC,MAAa;AAC7B,YAAM,SAAU,EAAmC;AACnD,UAAI,CAAC,UAAU,OAAO,OAAO,OAAO,SAAU;AAC9C,UAAI,CAAC,eAAe,cAAc,OAAO,EAAE,EAAG;AAC9C,UAAI,gBAAgB,MAAM;AACxB,kBAAU;AACV;AAAA,MACF;AACA,oBAAc,WAAW,aAAa,QAAQ;AAC9C,iBAAW,QAAQ,MAAM;AAAA,IAC3B;AAEA,WAAO,iBAAiB,oBAAoB,QAAQ;AACpD,WAAO,MAAM;AACX,aAAO,oBAAoB,oBAAoB,QAAQ;AACvD,UAAI,gBAAgB,KAAM,cAAa,WAAW;AAClD,oBAAc;AACd,gBAAU;AAAA,IACZ;AAAA,EAEF,GAAG,CAAC,cAAc,UAAU,GAAG,IAAI,CAAC;AACtC;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.8.1-develop.7267.1.2e95af80a6",
3
+ "version": "0.8.1-develop.7269.1.493e13f954",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.8.1-develop.7267.1.2e95af80a6",
158
+ "@open-mercato/shared": "0.8.1-develop.7269.1.493e13f954",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.8.1-develop.7267.1.2e95af80a6",
165
+ "@open-mercato/shared": "0.8.1-develop.7269.1.493e13f954",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.1",
168
168
  "@testing-library/react": "^16.3.3",
@@ -282,6 +282,7 @@ describe('CrudForm hiddenGroupIds', () => {
282
282
  // it must not block submit either.
283
283
  injectedFieldWidgets = [
284
284
  {
285
+ metadata: { id: 'injected-only-widget' },
285
286
  fields: [
286
287
  {
287
288
  id: 'injectedOnly',
@@ -0,0 +1,166 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
+ import { APP_EVENT_DOM_NAME, useAppEvent } from '../useAppEvent'
5
+ import { DEFAULT_APP_EVENT_COALESCE_WINDOW_MS, useAppEventCoalesced } from '../useAppEventCoalesced'
6
+
7
+ const MESSAGE_LIST_KEY = ['messages', 'list']
8
+
9
+ function dispatchMessageEvents(count: number, gapMs = 5) {
10
+ for (let i = 0; i < count; i++) {
11
+ const detail: AppEventPayload = {
12
+ id: 'messages.message.sent',
13
+ payload: { messageId: `message-${i}` },
14
+ } as AppEventPayload
15
+ act(() => {
16
+ window.dispatchEvent(new CustomEvent(APP_EVENT_DOM_NAME, { detail }))
17
+ jest.advanceTimersByTime(gapMs)
18
+ })
19
+ }
20
+ }
21
+
22
+ describe('useAppEventCoalesced', () => {
23
+ let invalidateQueries: jest.Mock
24
+
25
+ beforeEach(() => {
26
+ jest.useFakeTimers()
27
+ invalidateQueries = jest.fn()
28
+ })
29
+
30
+ afterEach(() => {
31
+ jest.useRealTimers()
32
+ })
33
+
34
+ const invalidateMessageList = () => {
35
+ invalidateQueries({ queryKey: MESSAGE_LIST_KEY })
36
+ }
37
+
38
+ it('characterizes the un-coalesced hook: one invalidation per ingested message', () => {
39
+ renderHook(() => useAppEvent('messages.message.*', invalidateMessageList, []))
40
+
41
+ dispatchMessageEvents(50)
42
+
43
+ expect(invalidateQueries).toHaveBeenCalledTimes(50)
44
+ expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: MESSAGE_LIST_KEY })
45
+ })
46
+
47
+ it('collapses an ingest burst into a leading and a trailing invalidation', () => {
48
+ renderHook(() => useAppEventCoalesced('messages.message.*', invalidateMessageList, []))
49
+
50
+ dispatchMessageEvents(50)
51
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
52
+
53
+ act(() => {
54
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS)
55
+ })
56
+
57
+ expect(invalidateQueries).toHaveBeenCalledTimes(2)
58
+ expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: MESSAGE_LIST_KEY })
59
+ })
60
+
61
+ it('runs the trailing invalidation even when the burst ends mid-window', () => {
62
+ const handler = jest.fn()
63
+ renderHook(() => useAppEventCoalesced('messages.message.*', handler, []))
64
+
65
+ dispatchMessageEvents(3)
66
+ expect(handler).toHaveBeenCalledTimes(1)
67
+
68
+ act(() => {
69
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS)
70
+ })
71
+
72
+ expect(handler).toHaveBeenCalledTimes(2)
73
+ expect(handler.mock.calls[1][0]).toMatchObject({ payload: { messageId: 'message-2' } })
74
+ })
75
+
76
+ it('invalidates once and immediately for a single event', () => {
77
+ renderHook(() => useAppEventCoalesced('messages.message.*', invalidateMessageList, []))
78
+
79
+ dispatchMessageEvents(1)
80
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
81
+
82
+ act(() => {
83
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS * 5)
84
+ })
85
+
86
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
87
+ })
88
+
89
+ it('keeps slow traffic at one invalidation per event', () => {
90
+ renderHook(() => useAppEventCoalesced('messages.message.*', invalidateMessageList, []))
91
+
92
+ for (let i = 0; i < 4; i++) {
93
+ dispatchMessageEvents(1)
94
+ act(() => {
95
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS * 2)
96
+ })
97
+ }
98
+
99
+ expect(invalidateQueries).toHaveBeenCalledTimes(4)
100
+ })
101
+
102
+ it('does not invalidate after unmount mid-window', () => {
103
+ const { unmount } = renderHook(() =>
104
+ useAppEventCoalesced('messages.message.*', invalidateMessageList, []),
105
+ )
106
+
107
+ dispatchMessageEvents(10)
108
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
109
+
110
+ unmount()
111
+
112
+ act(() => {
113
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS * 5)
114
+ })
115
+ dispatchMessageEvents(5)
116
+
117
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
118
+ })
119
+
120
+ it('ignores events that do not match the pattern', () => {
121
+ renderHook(() => useAppEventCoalesced('messages.message.*', invalidateMessageList, []))
122
+
123
+ act(() => {
124
+ window.dispatchEvent(
125
+ new CustomEvent(APP_EVENT_DOM_NAME, { detail: { id: 'sales.order.created' } }),
126
+ )
127
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS * 2)
128
+ })
129
+
130
+ expect(invalidateQueries).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('fires immediately for a fresh event shortly after a multi-event burst settles', () => {
134
+ renderHook(() => useAppEventCoalesced('messages.message.*', invalidateMessageList, []))
135
+
136
+ dispatchMessageEvents(2)
137
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
138
+
139
+ act(() => {
140
+ jest.advanceTimersByTime(DEFAULT_APP_EVENT_COALESCE_WINDOW_MS)
141
+ })
142
+ expect(invalidateQueries).toHaveBeenCalledTimes(2)
143
+
144
+ act(() => {
145
+ jest.advanceTimersByTime(100)
146
+ })
147
+ dispatchMessageEvents(1)
148
+
149
+ expect(invalidateQueries).toHaveBeenCalledTimes(3)
150
+ })
151
+
152
+ it('honours a custom window', () => {
153
+ renderHook(() =>
154
+ useAppEventCoalesced('messages.message.*', invalidateMessageList, [], { windowMs: 200 }),
155
+ )
156
+
157
+ dispatchMessageEvents(10, 1)
158
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
159
+
160
+ act(() => {
161
+ jest.advanceTimersByTime(200)
162
+ })
163
+
164
+ expect(invalidateQueries).toHaveBeenCalledTimes(2)
165
+ })
166
+ })
@@ -0,0 +1,92 @@
1
+ "use client"
2
+ import { useEffect, useRef } from 'react'
3
+ import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
+ import { APP_EVENT_DOM_NAME, matchesPattern } from './useAppEvent'
5
+
6
+ /**
7
+ * Default coalescing window. Long enough to swallow a server-side ingest burst
8
+ * (a mail poll emits its per-message events within a few hundred milliseconds),
9
+ * short enough that the trailing refresh is not perceived as lag.
10
+ */
11
+ export const DEFAULT_APP_EVENT_COALESCE_WINDOW_MS = 1000
12
+
13
+ export type AppEventCoalesceOptions = {
14
+ /** Coalescing window in milliseconds. Defaults to {@link DEFAULT_APP_EVENT_COALESCE_WINDOW_MS}. */
15
+ windowMs?: number
16
+ }
17
+
18
+ /**
19
+ * Like {@link useAppEvent}, but collapses a burst of matching events into a
20
+ * leading and a trailing handler call.
21
+ *
22
+ * Use this when the handler is expensive and idempotent — a react-query
23
+ * invalidation that reloads a list is the canonical case: a single ingest cycle
24
+ * emitting N events would otherwise trigger N full reloads of the same page.
25
+ *
26
+ * Semantics (leading + trailing, not plain debounce):
27
+ * - The first event of a quiet period is handled synchronously, so a single
28
+ * user action still refreshes immediately.
29
+ * - Every further event inside the window is collapsed; when the window closes,
30
+ * the handler runs once more with the most recent payload, so the final state
31
+ * is never missed. A burst spanning several windows keeps collapsing at a
32
+ * steady one call per window.
33
+ * - Unmounting clears the pending window; the handler never runs afterwards.
34
+ *
35
+ * This differs from `NotificationDispatcher`'s `debounceMs` suppression, which
36
+ * drops repeats outright because re-showing a toast has no value. Here the
37
+ * trailing call is mandatory, because dropping it would leave the list stale.
38
+ *
39
+ * @param eventPattern - Pattern to match event IDs against (e.g. 'messages.message.*')
40
+ * @param handler - Callback invoked with the most recent matching event of the window
41
+ * @param deps - Optional dependency array for the handler (defaults to [])
42
+ * @param options - Coalescing options
43
+ *
44
+ * @example
45
+ * useAppEventCoalesced('messages.message.*', () => {
46
+ * void queryClient.invalidateQueries({ queryKey: ['messages', 'list'] })
47
+ * }, [queryClient])
48
+ */
49
+ export function useAppEventCoalesced(
50
+ eventPattern: string,
51
+ handler: (payload: AppEventPayload) => void,
52
+ deps: unknown[] = [],
53
+ options: AppEventCoalesceOptions = {},
54
+ ): void {
55
+ const handlerRef = useRef(handler)
56
+ handlerRef.current = handler
57
+ const windowMs = options.windowMs ?? DEFAULT_APP_EVENT_COALESCE_WINDOW_MS
58
+
59
+ useEffect(() => {
60
+ let windowTimer: ReturnType<typeof setTimeout> | null = null
61
+ let pending: AppEventPayload | null = null
62
+
63
+ const closeWindow = () => {
64
+ windowTimer = null
65
+ if (!pending) return
66
+ const payload = pending
67
+ pending = null
68
+ handlerRef.current(payload)
69
+ }
70
+
71
+ const listener = (e: Event) => {
72
+ const detail = (e as CustomEvent<AppEventPayload>).detail
73
+ if (!detail || typeof detail.id !== 'string') return
74
+ if (!matchesPattern(eventPattern, detail.id)) return
75
+ if (windowTimer !== null) {
76
+ pending = detail
77
+ return
78
+ }
79
+ windowTimer = setTimeout(closeWindow, windowMs)
80
+ handlerRef.current(detail)
81
+ }
82
+
83
+ window.addEventListener(APP_EVENT_DOM_NAME, listener)
84
+ return () => {
85
+ window.removeEventListener(APP_EVENT_DOM_NAME, listener)
86
+ if (windowTimer !== null) clearTimeout(windowTimer)
87
+ windowTimer = null
88
+ pending = null
89
+ }
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [eventPattern, windowMs, ...deps])
92
+ }