@workflow/web-shared 5.0.0-beta.41 → 5.0.0-beta.43

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.
Files changed (96) hide show
  1. package/dist/components/event-list-view.d.ts +10 -2
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +52 -26
  4. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  5. package/dist/components/sidebar/attributes-block.js +3 -6
  6. package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
  7. package/dist/components/sidebar/conversation-view.js +19 -36
  8. package/dist/components/sidebar/entity-detail-panel.d.ts +5 -0
  9. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/entity-detail-panel.js +2 -2
  11. package/dist/components/sidebar/events-list.d.ts +7 -1
  12. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  13. package/dist/components/sidebar/events-list.js +6 -5
  14. package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
  15. package/dist/components/sidebar/resolve-hook-modal.js +4 -145
  16. package/dist/components/sidebar/sidebar-data-context.d.ts +6 -0
  17. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  18. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  19. package/dist/components/stream-viewer-skeleton.js +6 -6
  20. package/dist/components/stream-viewer.js +2 -2
  21. package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
  22. package/dist/components/trace-viewer/components/detail-panel.js +2 -1
  23. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts +9 -0
  24. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts.map +1 -0
  25. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.js +29 -0
  26. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -1
  27. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.js +16 -8
  28. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -1
  29. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.js +83 -19
  30. package/dist/components/trace-viewer/components/timeline.d.ts.map +1 -1
  31. package/dist/components/trace-viewer/components/timeline.js +13 -22
  32. package/dist/components/trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  33. package/dist/components/trace-viewer/components/trace-viewer-skeleton.js +58 -18
  34. package/dist/components/trace-viewer/icons.js +5 -5
  35. package/dist/components/trace-viewer/utils.d.ts +6 -10
  36. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  37. package/dist/components/trace-viewer/utils.js +14 -24
  38. package/dist/components/trace-viewer.d.ts.map +1 -1
  39. package/dist/components/trace-viewer.js +12 -4
  40. package/dist/components/ui/context-card.d.ts.map +1 -1
  41. package/dist/components/ui/context-card.js +9 -21
  42. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  43. package/dist/components/ui/data-inspector.js +8 -35
  44. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -0
  45. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -0
  46. package/dist/components/ui/duplicate-event-tooltip.js +19 -0
  47. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  48. package/dist/components/ui/menu-dropdown.js +3 -19
  49. package/dist/components/ui/skeleton.js +2 -2
  50. package/dist/components/ui/spinner.d.ts.map +1 -1
  51. package/dist/components/ui/spinner.js +5 -11
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/lib/duplicate-events.d.ts +27 -0
  56. package/dist/lib/duplicate-events.d.ts.map +1 -0
  57. package/dist/lib/duplicate-events.js +166 -0
  58. package/dist/lib/event-materialization.d.ts +15 -2
  59. package/dist/lib/event-materialization.d.ts.map +1 -1
  60. package/dist/lib/event-materialization.js +19 -4
  61. package/dist/lib/trace-builder.d.ts +16 -1
  62. package/dist/lib/trace-builder.d.ts.map +1 -1
  63. package/dist/lib/trace-builder.js +23 -4
  64. package/package.json +4 -4
  65. package/src/components/event-list-view.tsx +70 -32
  66. package/src/components/sidebar/attributes-block.tsx +4 -11
  67. package/src/components/sidebar/conversation-view.tsx +25 -79
  68. package/src/components/sidebar/entity-detail-panel.tsx +6 -0
  69. package/src/components/sidebar/events-list.tsx +21 -3
  70. package/src/components/sidebar/resolve-hook-modal.tsx +23 -186
  71. package/src/components/sidebar/sidebar-data-context.tsx +6 -0
  72. package/src/components/stream-viewer-skeleton.tsx +5 -5
  73. package/src/components/stream-viewer.tsx +1 -1
  74. package/src/components/trace-viewer/components/detail-panel.tsx +1 -0
  75. package/src/components/trace-viewer/components/middle-truncate/measurement-batch.ts +47 -0
  76. package/src/components/trace-viewer/components/middle-truncate/middle-truncate.tsx +15 -7
  77. package/src/components/trace-viewer/components/middle-truncate/use-middle-truncate.ts +132 -38
  78. package/src/components/trace-viewer/components/timeline.tsx +21 -39
  79. package/src/components/trace-viewer/components/trace-viewer-skeleton.tsx +63 -35
  80. package/src/components/trace-viewer/icons.tsx +4 -4
  81. package/src/components/trace-viewer/utils.test.ts +5 -7
  82. package/src/components/trace-viewer/utils.ts +17 -31
  83. package/src/components/trace-viewer.tsx +15 -3
  84. package/src/components/ui/context-card.tsx +16 -26
  85. package/src/components/ui/data-inspector.tsx +20 -65
  86. package/src/components/ui/duplicate-event-tooltip.tsx +44 -0
  87. package/src/components/ui/menu-dropdown.tsx +8 -22
  88. package/src/components/ui/skeleton.tsx +2 -2
  89. package/src/components/ui/spinner.tsx +4 -8
  90. package/src/index.ts +4 -0
  91. package/src/lib/duplicate-events.test.ts +339 -0
  92. package/src/lib/duplicate-events.ts +188 -0
  93. package/src/lib/event-materialization.test.ts +117 -0
  94. package/src/lib/event-materialization.ts +23 -3
  95. package/src/lib/trace-builder.test.ts +74 -0
  96. package/src/lib/trace-builder.ts +30 -3
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Send, X } from 'lucide-react';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import { cn } from '../../lib/cn';
5
6
 
6
7
  interface ResolveHookModalProps {
7
8
  /** Whether the modal is open */
@@ -110,60 +111,24 @@ export function ResolveHookModal({
110
111
 
111
112
  return (
112
113
  <div
113
- style={{
114
- position: 'fixed',
115
- inset: 0,
116
- zIndex: 50,
117
- display: 'flex',
118
- alignItems: 'center',
119
- justifyContent: 'center',
120
- }}
114
+ className="fixed inset-0 z-50 flex items-center justify-center"
121
115
  role="dialog"
122
116
  aria-modal="true"
123
117
  aria-labelledby="resolve-hook-modal-title"
124
118
  >
125
119
  {/* Backdrop — matches Geist dialog ::backdrop */}
126
120
  <div
127
- style={{
128
- position: 'absolute',
129
- inset: 0,
130
- background: 'rgba(0, 0, 0, 0.7)',
131
- }}
121
+ className="absolute inset-0 bg-black/70"
132
122
  onClick={isSubmitting ? undefined : onClose}
133
123
  />
134
124
 
135
125
  {/* Modal card — matches Geist dialog.geist-dialog */}
136
- <div
137
- style={{
138
- position: 'relative',
139
- zIndex: 10,
140
- width: 480,
141
- maxWidth: 'calc(100% - 32px)',
142
- borderRadius: 12,
143
- border: 'none',
144
- boxShadow: 'var(--ds-shadow-menu)',
145
- background: 'var(--ds-background-100)',
146
- color: 'var(--ds-gray-1000)',
147
- overflow: 'hidden',
148
- }}
149
- >
126
+ <div className="relative z-10 w-[480px] max-w-[calc(100%_-_32px)] overflow-hidden !border-none bg-background-100 text-gray-1000 material-menu">
150
127
  {/* Header */}
151
- <div
152
- style={{
153
- display: 'flex',
154
- alignItems: 'center',
155
- justifyContent: 'space-between',
156
- padding: '16px 24px',
157
- }}
158
- >
128
+ <div className="flex items-center justify-between px-6 py-4">
159
129
  <h2
160
130
  id="resolve-hook-modal-title"
161
- style={{
162
- margin: 0,
163
- fontSize: 16,
164
- fontWeight: 600,
165
- color: 'var(--ds-gray-1000)',
166
- }}
131
+ className="m-0 font-semibold text-base text-gray-1000"
167
132
  >
168
133
  Resolve Hook
169
134
  </h2>
@@ -172,66 +137,24 @@ export function ResolveHookModal({
172
137
  onClick={onClose}
173
138
  disabled={isSubmitting}
174
139
  aria-label="Close modal"
175
- style={{
176
- display: 'flex',
177
- alignItems: 'center',
178
- justifyContent: 'center',
179
- padding: 4,
180
- borderRadius: 6,
181
- border: 'none',
182
- background: 'transparent',
183
- color: 'var(--ds-gray-900)',
184
- cursor: isSubmitting ? 'not-allowed' : 'pointer',
185
- opacity: isSubmitting ? 0.5 : 1,
186
- transition: 'background 0.15s',
187
- }}
188
- onMouseEnter={(e) => {
189
- e.currentTarget.style.background = 'var(--ds-gray-alpha-200)';
190
- }}
191
- onMouseLeave={(e) => {
192
- e.currentTarget.style.background = 'transparent';
193
- }}
140
+ className="flex cursor-pointer items-center justify-center rounded-md !border-none !bg-transparent p-1 text-gray-900 transition-colors hover:!bg-gray-alpha-200 disabled:cursor-not-allowed disabled:opacity-50"
194
141
  >
195
- <X style={{ width: 16, height: 16 }} />
142
+ <X className="size-4" />
196
143
  </button>
197
144
  </div>
198
145
 
199
146
  {/* Body */}
200
147
  <form onSubmit={handleSubmit}>
201
- <div style={{ padding: '0 24px 16px' }}>
148
+ <div className="px-6 pb-4">
202
149
  <label
203
150
  htmlFor="json-payload"
204
- style={{
205
- display: 'block',
206
- fontSize: 14,
207
- fontWeight: 500,
208
- marginBottom: 6,
209
- color: 'var(--ds-gray-1000)',
210
- }}
151
+ className="mb-1.5 block font-medium text-gray-1000 text-sm"
211
152
  >
212
153
  JSON Payload
213
154
  </label>
214
- <p
215
- style={{
216
- fontSize: 13,
217
- marginBottom: 12,
218
- marginTop: 0,
219
- color: 'var(--ds-gray-900)',
220
- lineHeight: 1.5,
221
- }}
222
- >
155
+ <p className="mt-0 mb-3 text-[13px] text-gray-900 leading-[1.5]">
223
156
  Enter a JSON value to send to the hook. Leave empty to send{' '}
224
- <code
225
- style={{
226
- padding: '2px 6px',
227
- borderRadius: 4,
228
- fontSize: 12,
229
- fontFamily:
230
- 'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
231
- background: 'var(--ds-gray-alpha-200)',
232
- color: 'var(--ds-gray-1000)',
233
- }}
234
- >
157
+ <code className="rounded bg-gray-alpha-200 px-1.5 py-0.5 font-mono text-gray-1000 text-xs">
235
158
  null
236
159
  </code>
237
160
  .
@@ -247,57 +170,17 @@ export function ResolveHookModal({
247
170
  onKeyDown={handleKeyDown}
248
171
  disabled={isSubmitting}
249
172
  placeholder='{"key": "value"}'
250
- style={{
251
- width: '100%',
252
- height: 160,
253
- padding: '8px 12px',
254
- fontFamily:
255
- 'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
256
- fontSize: 13,
257
- lineHeight: 1.5,
258
- borderRadius: 8,
259
- border: `1px solid ${parseError ? 'var(--ds-red-700)' : 'var(--ds-gray-alpha-400)'}`,
260
- color: 'var(--ds-gray-1000)',
261
- background: 'var(--ds-background-100)',
262
- outline: 'none',
263
- resize: 'none',
264
- opacity: isSubmitting ? 0.5 : 1,
265
- cursor: isSubmitting ? 'not-allowed' : 'text',
266
- boxSizing: 'border-box',
267
- }}
173
+ className={cn(
174
+ 'box-border h-40 w-full resize-none rounded-lg border bg-background-100 px-3 py-2 font-mono text-[13px] text-gray-1000 leading-[1.5] outline-none disabled:cursor-not-allowed disabled:opacity-50',
175
+ parseError ? 'border-red-700' : 'border-gray-alpha-400'
176
+ )}
268
177
  />
269
178
  {parseError && (
270
- <p
271
- style={{
272
- marginTop: 8,
273
- fontSize: 13,
274
- color: 'var(--ds-red-900)',
275
- margin: '8px 0 0',
276
- }}
277
- >
278
- {parseError}
279
- </p>
179
+ <p className="mt-2 mb-0 text-[13px] text-red-900">{parseError}</p>
280
180
  )}
281
- <p
282
- style={{
283
- marginTop: 8,
284
- fontSize: 12,
285
- color: 'var(--ds-gray-800)',
286
- margin: '8px 0 0',
287
- }}
288
- >
181
+ <p className="mt-2 mb-0 text-gray-800 text-xs">
289
182
  Press{' '}
290
- <kbd
291
- style={{
292
- padding: '2px 5px',
293
- borderRadius: 4,
294
- fontSize: 11,
295
- fontFamily:
296
- 'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
297
- background: 'var(--ds-gray-alpha-200)',
298
- border: '1px solid var(--ds-gray-alpha-400)',
299
- }}
300
- >
183
+ <kbd className="rounded border border-gray-alpha-400 bg-gray-alpha-200 px-[5px] py-0.5 font-mono text-[11px]">
301
184
  {isMacPlatform ? '⌘' : 'Ctrl'}
302
185
  +Enter
303
186
  </kbd>{' '}
@@ -306,38 +189,12 @@ export function ResolveHookModal({
306
189
  </div>
307
190
 
308
191
  {/* Footer */}
309
- <div
310
- style={{
311
- display: 'flex',
312
- alignItems: 'center',
313
- justifyContent: 'flex-end',
314
- gap: 8,
315
- padding: '12px 24px',
316
- borderTop: '1px solid var(--ds-gray-alpha-400)',
317
- }}
318
- >
192
+ <div className="flex items-center justify-end gap-2 border-gray-alpha-400 border-t px-6 py-3">
319
193
  <button
320
194
  type="button"
321
195
  onClick={onClose}
322
196
  disabled={isSubmitting}
323
- style={{
324
- padding: '8px 16px',
325
- fontSize: 14,
326
- fontWeight: 500,
327
- borderRadius: 8,
328
- border: '1px solid var(--ds-gray-alpha-400)',
329
- background: 'var(--ds-background-100)',
330
- color: 'var(--ds-gray-1000)',
331
- cursor: isSubmitting ? 'not-allowed' : 'pointer',
332
- opacity: isSubmitting ? 0.5 : 1,
333
- transition: 'background 0.15s',
334
- }}
335
- onMouseEnter={(e) => {
336
- e.currentTarget.style.background = 'var(--ds-gray-alpha-100)';
337
- }}
338
- onMouseLeave={(e) => {
339
- e.currentTarget.style.background = 'var(--ds-background-100)';
340
- }}
197
+ className="cursor-pointer rounded-lg border border-gray-alpha-400 bg-background-100 px-4 py-2 font-medium text-gray-1000 text-sm transition-colors hover:bg-gray-alpha-100 disabled:cursor-not-allowed disabled:opacity-50"
341
198
  >
342
199
  Cancel
343
200
  </button>
@@ -345,29 +202,9 @@ export function ResolveHookModal({
345
202
  type="button"
346
203
  onClick={() => void submitPayload()}
347
204
  disabled={isSubmitting}
348
- style={{
349
- display: 'flex',
350
- alignItems: 'center',
351
- gap: 6,
352
- padding: '8px 16px',
353
- fontSize: 14,
354
- fontWeight: 500,
355
- borderRadius: 8,
356
- border: 'none',
357
- background: 'var(--ds-gray-1000)',
358
- color: 'var(--ds-background-100)',
359
- cursor: isSubmitting ? 'not-allowed' : 'pointer',
360
- opacity: isSubmitting ? 0.5 : 1,
361
- transition: 'opacity 0.15s',
362
- }}
363
- onMouseEnter={(e) => {
364
- if (!isSubmitting) e.currentTarget.style.opacity = '0.9';
365
- }}
366
- onMouseLeave={(e) => {
367
- if (!isSubmitting) e.currentTarget.style.opacity = '1';
368
- }}
205
+ className="flex cursor-pointer items-center gap-1.5 rounded-lg !border-none bg-gray-1000 px-4 py-2 font-medium text-background-100 text-sm transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50"
369
206
  >
370
- <Send style={{ width: 14, height: 14 }} />
207
+ <Send className="size-3.5" />
371
208
  {isSubmitting ? 'Sending...' : 'Send Payload'}
372
209
  </button>
373
210
  </div>
@@ -7,6 +7,12 @@ import type { FetchSpanDetail } from './use-selected-span-detail';
7
7
  export interface SidebarDataContextValue {
8
8
  run: WorkflowRun;
9
9
  events: Event[];
10
+ /**
11
+ * Events every replay reads past as repeats, computed once from the whole
12
+ * of `events` by whoever knows the list is complete. Absent when nobody
13
+ * could vouch for that, in which case nothing is marked.
14
+ */
15
+ duplicateEventIds?: ReadonlySet<string>;
10
16
  fetchSpanDetail: FetchSpanDetail;
11
17
  onStreamClick?: (streamId: string) => void;
12
18
  onRunClick?: (runId: string) => void;
@@ -1,10 +1,10 @@
1
1
  import { Skeleton } from './ui/skeleton';
2
2
 
3
3
  const STREAM_SKELETON_ROWS = [
4
- { id: 'stream-skeleton-row-1', width: '72%' },
5
- { id: 'stream-skeleton-row-2', width: '58%' },
6
- { id: 'stream-skeleton-row-3', width: '80%' },
7
- { id: 'stream-skeleton-row-4', width: '64%' },
4
+ { id: 'stream-skeleton-row-1', widthClassName: 'w-[72%]' },
5
+ { id: 'stream-skeleton-row-2', widthClassName: 'w-[58%]' },
6
+ { id: 'stream-skeleton-row-3', widthClassName: 'w-4/5' },
7
+ { id: 'stream-skeleton-row-4', widthClassName: 'w-[64%]' },
8
8
  ];
9
9
 
10
10
  export function StreamViewerSkeleton() {
@@ -24,7 +24,7 @@ export function StreamViewerSkeleton() {
24
24
  key={row.id}
25
25
  className="flex h-10 items-center gap-2 border-b border-gray-alpha-400 px-3"
26
26
  >
27
- <Skeleton className="h-3" style={{ width: row.width }} />
27
+ <Skeleton className={`h-3 ${row.widthClassName}`} />
28
28
  <Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
29
29
  </div>
30
30
  ))}
@@ -122,7 +122,7 @@ export function StreamViewer({
122
122
  <ChunkRow chunk={chunks[index]} />
123
123
  </div>
124
124
  )}
125
- style={{ flex: 1, minHeight: 0 }}
125
+ className="min-h-0 flex-1"
126
126
  />
127
127
  )}
128
128
  </div>
@@ -55,6 +55,7 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null {
55
55
  resource,
56
56
  spanId: activeSpan.spanId,
57
57
  rawEvents,
58
+ duplicateEventIds: sidebar.duplicateEventIds,
58
59
  };
59
60
  }, [activeSpan, sidebar]);
60
61
  }
@@ -0,0 +1,47 @@
1
+ interface MeasurementTask<T> {
2
+ measure: (measurement: T) => void;
3
+ read: () => T | null;
4
+ }
5
+
6
+ type PendingMeasurement = () => (() => void) | null;
7
+
8
+ const pendingMeasurements = new Map<object, PendingMeasurement>();
9
+ let measurementFrame = 0;
10
+
11
+ function flushMeasurements(): void {
12
+ measurementFrame = 0;
13
+
14
+ const reads = [...pendingMeasurements.values()];
15
+ pendingMeasurements.clear();
16
+ const measures = reads.map((read) => read());
17
+
18
+ for (const measure of measures) {
19
+ measure?.();
20
+ }
21
+ }
22
+
23
+ function scheduleMeasurement<T>(
24
+ key: object,
25
+ { read, measure }: MeasurementTask<T>
26
+ ): void {
27
+ pendingMeasurements.set(key, () => {
28
+ const measurement = read();
29
+ return measurement === null ? null : () => measure(measurement);
30
+ });
31
+
32
+ if (measurementFrame === 0) {
33
+ measurementFrame = requestAnimationFrame(flushMeasurements);
34
+ }
35
+ }
36
+
37
+ function cancelMeasurement(key: object): void {
38
+ pendingMeasurements.delete(key);
39
+
40
+ if (pendingMeasurements.size === 0 && measurementFrame !== 0) {
41
+ cancelAnimationFrame(measurementFrame);
42
+ measurementFrame = 0;
43
+ }
44
+ }
45
+
46
+ export { cancelMeasurement, scheduleMeasurement };
47
+ export type { MeasurementTask };
@@ -28,13 +28,20 @@ function getRangeOffsets(
28
28
  return null;
29
29
  }
30
30
 
31
- const startRange = document.createRange();
32
- startRange.selectNodeContents(container);
33
- startRange.setEnd(range.startContainer, range.startOffset);
34
-
35
- const endRange = document.createRange();
36
- endRange.selectNodeContents(container);
37
- endRange.setEnd(range.endContainer, range.endOffset);
31
+ let startRange: Range;
32
+ let endRange: Range;
33
+
34
+ try {
35
+ startRange = document.createRange();
36
+ startRange.selectNodeContents(container);
37
+ startRange.setEnd(range.startContainer, range.startOffset);
38
+
39
+ endRange = document.createRange();
40
+ endRange.selectNodeContents(container);
41
+ endRange.setEnd(range.endContainer, range.endOffset);
42
+ } catch {
43
+ return null;
44
+ }
38
45
 
39
46
  return {
40
47
  end: endRange.toString().length,
@@ -80,6 +87,7 @@ function MiddleTruncate({
80
87
  let copyText: string | null = null;
81
88
 
82
89
  if (
90
+ selectionText.includes(ELLIPSIS) &&
83
91
  e.currentTarget.contains(range.startContainer) &&
84
92
  e.currentTarget.contains(range.endContainer) &&
85
93
  visibleEl
@@ -8,6 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  } from 'react';
11
+ import { cancelMeasurement, scheduleMeasurement } from './measurement-batch';
11
12
  import { middleTruncate, toGraphemes } from './truncate';
12
13
 
13
14
  const useIsomorphicLayoutEffect =
@@ -22,6 +23,17 @@ interface MiddleTruncateState {
22
23
  suffixText: string;
23
24
  }
24
25
 
26
+ interface MiddleTruncateMeasurement {
27
+ availableWidth: number;
28
+ typography: string;
29
+ }
30
+
31
+ interface TextMeasurementCache {
32
+ typography: string;
33
+ value: string;
34
+ widths: Map<string, number>;
35
+ }
36
+
25
37
  function createFullState(
26
38
  value: string,
27
39
  graphemes: string[]
@@ -62,7 +74,15 @@ function useMiddleTruncate(value: string): {
62
74
  const ref = useRef<HTMLSpanElement | null>(null);
63
75
  const measureRef = useRef<HTMLSpanElement | null>(null);
64
76
  const [state, setState] = useState<MiddleTruncateState>(() => fullState);
65
- const rafRef = useRef<number>(0);
77
+ const measurementKeyRef = useRef<object>({});
78
+ const textMeasurementCacheRef = useRef<TextMeasurementCache>({
79
+ typography: '',
80
+ value,
81
+ widths: new Map(),
82
+ });
83
+ const lastMeasurementRef = useRef<
84
+ (MiddleTruncateMeasurement & { value: string }) | null
85
+ >(null);
66
86
 
67
87
  const updateState = useCallback((nextState: MiddleTruncateState) => {
68
88
  setState((currentState) => {
@@ -81,76 +101,150 @@ function useMiddleTruncate(value: string): {
81
101
  });
82
102
  }, []);
83
103
 
84
- const recalculate = useCallback(() => {
104
+ const readMeasurement = useCallback((): MiddleTruncateMeasurement | null => {
85
105
  const el = ref.current;
86
106
  const measureEl = measureRef.current;
87
- if (!el || !measureEl) return;
107
+ if (!el || !measureEl) return null;
88
108
 
89
- const available = el.clientWidth;
109
+ const availableWidth = el.clientWidth;
110
+ const style = getComputedStyle(measureEl);
90
111
 
91
- if (available <= 0) {
92
- updateState(fullState);
93
- return;
94
- }
95
-
96
- const measure = (text: string): number => {
97
- measureEl.textContent = text;
98
- return measureEl.scrollWidth;
112
+ return {
113
+ availableWidth,
114
+ typography: [
115
+ style.fontFamily,
116
+ style.fontFeatureSettings,
117
+ style.fontKerning,
118
+ style.fontSize,
119
+ style.fontStretch,
120
+ style.fontStyle,
121
+ style.fontVariationSettings,
122
+ style.fontWeight,
123
+ style.letterSpacing,
124
+ style.textTransform,
125
+ ].join('\0'),
99
126
  };
127
+ }, []);
100
128
 
101
- const fullWidth = measure(value);
129
+ const recalculate = useCallback(
130
+ ({ availableWidth, typography }: MiddleTruncateMeasurement) => {
131
+ const measureEl = measureRef.current;
132
+ if (!measureEl) return;
102
133
 
103
- if (fullWidth <= available) {
104
- updateState(fullState);
105
- return;
106
- }
134
+ const lastMeasurement = lastMeasurementRef.current;
135
+ if (
136
+ lastMeasurement?.availableWidth === availableWidth &&
137
+ lastMeasurement.typography === typography &&
138
+ lastMeasurement.value === value
139
+ ) {
140
+ return;
141
+ }
107
142
 
108
- const result = middleTruncate(graphemes, available, measure, fullWidth);
109
- updateState({
110
- displayText: result.text,
111
- isTruncated: result.truncated,
112
- prefixGraphemeCount: result.prefixGraphemeCount,
113
- prefixText: result.prefixText,
114
- suffixGraphemeCount: result.suffixGraphemeCount,
115
- suffixText: result.suffixText,
116
- });
117
- }, [fullState, graphemes, updateState, value]);
143
+ lastMeasurementRef.current = {
144
+ availableWidth,
145
+ typography,
146
+ value,
147
+ };
148
+
149
+ if (availableWidth <= 0) {
150
+ updateState(fullState);
151
+ return;
152
+ }
153
+
154
+ const textMeasurementCache = textMeasurementCacheRef.current;
155
+ if (
156
+ textMeasurementCache.typography !== typography ||
157
+ textMeasurementCache.value !== value
158
+ ) {
159
+ textMeasurementCache.typography = typography;
160
+ textMeasurementCache.value = value;
161
+ textMeasurementCache.widths.clear();
162
+ }
163
+
164
+ const measure = (text: string): number => {
165
+ const cachedWidth = textMeasurementCache.widths.get(text);
166
+ if (cachedWidth !== undefined) {
167
+ return cachedWidth;
168
+ }
169
+
170
+ measureEl.textContent = text;
171
+ const width = measureEl.scrollWidth;
172
+ textMeasurementCache.widths.set(text, width);
173
+ return width;
174
+ };
175
+
176
+ const fullWidth = measure(value);
177
+
178
+ if (fullWidth <= availableWidth) {
179
+ updateState(fullState);
180
+ return;
181
+ }
182
+
183
+ const result = middleTruncate(
184
+ graphemes,
185
+ availableWidth,
186
+ measure,
187
+ fullWidth
188
+ );
189
+ updateState({
190
+ displayText: result.text,
191
+ isTruncated: result.truncated,
192
+ prefixGraphemeCount: result.prefixGraphemeCount,
193
+ prefixText: result.prefixText,
194
+ suffixGraphemeCount: result.suffixGraphemeCount,
195
+ suffixText: result.suffixText,
196
+ });
197
+ },
198
+ [fullState, graphemes, updateState, value]
199
+ );
118
200
 
119
201
  // Measure on mount and when value changes - before paint
120
202
  useIsomorphicLayoutEffect(() => {
121
- recalculate();
122
- }, [recalculate]);
203
+ const measurement = readMeasurement();
204
+ if (measurement) {
205
+ recalculate(measurement);
206
+ }
207
+ }, [readMeasurement, recalculate]);
123
208
 
124
209
  // ResizeObserver + font loading for ongoing responsiveness
125
210
  useEffect(() => {
126
211
  const el = ref.current;
127
212
  if (!el) return;
128
213
 
129
- const debouncedRecalc = (): void => {
130
- cancelAnimationFrame(rafRef.current);
131
- rafRef.current = requestAnimationFrame(recalculate);
214
+ const measurementKey = measurementKeyRef.current;
215
+ const scheduleRecalculation = (): void => {
216
+ scheduleMeasurement(measurementKey, {
217
+ read: readMeasurement,
218
+ measure: recalculate,
219
+ });
132
220
  };
133
221
 
134
222
  const ro =
135
223
  typeof ResizeObserver !== 'undefined'
136
- ? new ResizeObserver(debouncedRecalc)
224
+ ? new ResizeObserver(scheduleRecalculation)
137
225
  : null;
138
226
  ro?.observe(el);
139
- window.addEventListener('resize', debouncedRecalc);
227
+ if (!ro) {
228
+ window.addEventListener('resize', scheduleRecalculation);
229
+ }
140
230
 
141
231
  const onFontsLoaded = (): void => {
142
- debouncedRecalc();
232
+ textMeasurementCacheRef.current.widths.clear();
233
+ lastMeasurementRef.current = null;
234
+ scheduleRecalculation();
143
235
  };
144
236
  const fontSet = 'fonts' in document ? document.fonts : null;
145
237
  fontSet?.addEventListener?.('loadingdone', onFontsLoaded);
146
238
 
147
239
  return () => {
148
240
  ro?.disconnect();
149
- window.removeEventListener('resize', debouncedRecalc);
150
- cancelAnimationFrame(rafRef.current);
241
+ if (!ro) {
242
+ window.removeEventListener('resize', scheduleRecalculation);
243
+ }
244
+ cancelMeasurement(measurementKey);
151
245
  fontSet?.removeEventListener?.('loadingdone', onFontsLoaded);
152
246
  };
153
- }, [recalculate]);
247
+ }, [readMeasurement, recalculate]);
154
248
 
155
249
  return {
156
250
  ref,