@dxos/react-ui-feed 0.11.1

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 (230) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/chunk-src.mjs +2228 -0
  3. package/dist/lib/chunk-src.mjs.map +1 -0
  4. package/dist/lib/index.mjs +2 -0
  5. package/dist/lib/testing.mjs +1923 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
  8. package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
  9. package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
  10. package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
  11. package/dist/types/src/components/Block/block.stories.d.ts +21 -0
  12. package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Block/construction.d.ts +36 -0
  14. package/dist/types/src/components/Block/construction.d.ts.map +1 -0
  15. package/dist/types/src/components/Block/extensions.d.ts +23 -0
  16. package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
  17. package/dist/types/src/components/Block/highlight.d.ts +14 -0
  18. package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
  19. package/dist/types/src/components/Block/index.d.ts +6 -0
  20. package/dist/types/src/components/Block/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Block/selection-group.d.ts +21 -0
  22. package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
  23. package/dist/types/src/components/Block/widget-state.d.ts +33 -0
  24. package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
  25. package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
  26. package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
  27. package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
  28. package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
  29. package/dist/types/src/components/MessageList/index.d.ts +3 -0
  30. package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
  31. package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
  32. package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
  33. package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
  34. package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
  35. package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
  36. package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Minimap/index.d.ts +2 -0
  38. package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Outline/Outline.d.ts +46 -0
  40. package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
  41. package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
  42. package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/Outline/index.d.ts +2 -0
  44. package/dist/types/src/components/Outline/index.d.ts.map +1 -0
  45. package/dist/types/src/components/index.d.ts +5 -0
  46. package/dist/types/src/components/index.d.ts.map +1 -0
  47. package/dist/types/src/debug/Debug.d.ts +17 -0
  48. package/dist/types/src/debug/Debug.d.ts.map +1 -0
  49. package/dist/types/src/debug/Stats.d.ts +30 -0
  50. package/dist/types/src/debug/Stats.d.ts.map +1 -0
  51. package/dist/types/src/debug/debug-model.d.ts +32 -0
  52. package/dist/types/src/debug/debug-model.d.ts.map +1 -0
  53. package/dist/types/src/debug/debug-model.test.d.ts +2 -0
  54. package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
  55. package/dist/types/src/debug/frame-meter.d.ts +48 -0
  56. package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
  57. package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
  58. package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
  59. package/dist/types/src/debug/index.d.ts +6 -0
  60. package/dist/types/src/debug/index.d.ts.map +1 -0
  61. package/dist/types/src/debug/use-debug.d.ts +19 -0
  62. package/dist/types/src/debug/use-debug.d.ts.map +1 -0
  63. package/dist/types/src/hooks/follow.d.ts +89 -0
  64. package/dist/types/src/hooks/follow.d.ts.map +1 -0
  65. package/dist/types/src/hooks/follow.stories.d.ts +25 -0
  66. package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
  67. package/dist/types/src/hooks/follow.test.d.ts +2 -0
  68. package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
  69. package/dist/types/src/hooks/index.d.ts +6 -0
  70. package/dist/types/src/hooks/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useDecorations.d.ts +31 -0
  72. package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
  74. package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useFollow.d.ts +46 -0
  76. package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
  78. package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/model/feed-model.d.ts +67 -0
  82. package/dist/types/src/model/feed-model.d.ts.map +1 -0
  83. package/dist/types/src/model/feed-model.test.d.ts +2 -0
  84. package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
  85. package/dist/types/src/model/feed.d.ts +66 -0
  86. package/dist/types/src/model/feed.d.ts.map +1 -0
  87. package/dist/types/src/model/feed.test.d.ts +2 -0
  88. package/dist/types/src/model/feed.test.d.ts.map +1 -0
  89. package/dist/types/src/model/index.d.ts +5 -0
  90. package/dist/types/src/model/index.d.ts.map +1 -0
  91. package/dist/types/src/model/list-model.d.ts +61 -0
  92. package/dist/types/src/model/list-model.d.ts.map +1 -0
  93. package/dist/types/src/model/list-model.test.d.ts +2 -0
  94. package/dist/types/src/model/list-model.test.d.ts.map +1 -0
  95. package/dist/types/src/model/useListModel.d.ts +22 -0
  96. package/dist/types/src/model/useListModel.d.ts.map +1 -0
  97. package/dist/types/src/stories/assistant.stories.d.ts +38 -0
  98. package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
  99. package/dist/types/src/stories/baseline.stories.d.ts +32 -0
  100. package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
  101. package/dist/types/src/stories/bridge.stories.d.ts +72 -0
  102. package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
  103. package/dist/types/src/stories/construction.stories.d.ts +17 -0
  104. package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
  105. package/dist/types/src/stories/fill.stories.d.ts +51 -0
  106. package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
  107. package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
  108. package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
  109. package/dist/types/src/stories/mount.stories.d.ts +19 -0
  110. package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
  111. package/dist/types/src/stories/navigation.stories.d.ts +33 -0
  112. package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
  113. package/dist/types/src/stories/streaming.stories.d.ts +17 -0
  114. package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
  115. package/dist/types/src/stories/tail.stories.d.ts +47 -0
  116. package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
  117. package/dist/types/src/stories/widget.stories.d.ts +16 -0
  118. package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
  119. package/dist/types/src/testing/FeedStory.d.ts +30 -0
  120. package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
  121. package/dist/types/src/testing/MessageWindow.d.ts +48 -0
  122. package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
  123. package/dist/types/src/testing/controls.d.ts +46 -0
  124. package/dist/types/src/testing/controls.d.ts.map +1 -0
  125. package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
  126. package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
  127. package/dist/types/src/testing/debug/index.d.ts +4 -0
  128. package/dist/types/src/testing/debug/index.d.ts.map +1 -0
  129. package/dist/types/src/testing/debug/sweep.d.ts +18 -0
  130. package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
  131. package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
  132. package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
  133. package/dist/types/src/testing/generator.d.ts +16 -0
  134. package/dist/types/src/testing/generator.d.ts.map +1 -0
  135. package/dist/types/src/testing/index.d.ts +10 -0
  136. package/dist/types/src/testing/index.d.ts.map +1 -0
  137. package/dist/types/src/testing/scenarios.d.ts +58 -0
  138. package/dist/types/src/testing/scenarios.d.ts.map +1 -0
  139. package/dist/types/src/testing/stream.d.ts +22 -0
  140. package/dist/types/src/testing/stream.d.ts.map +1 -0
  141. package/dist/types/src/testing/turn.d.ts +21 -0
  142. package/dist/types/src/testing/turn.d.ts.map +1 -0
  143. package/dist/types/src/testing/widgets.d.ts +3 -0
  144. package/dist/types/src/testing/widgets.d.ts.map +1 -0
  145. package/dist/types/src/virtualizer/Window.d.ts +137 -0
  146. package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
  147. package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
  148. package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
  149. package/dist/types/src/virtualizer/index.d.ts +3 -0
  150. package/dist/types/src/virtualizer/index.d.ts.map +1 -0
  151. package/dist/types/src/virtualizer/placement.d.ts +188 -0
  152. package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
  153. package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
  154. package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
  155. package/dist/types/tsconfig.tsbuildinfo +1 -0
  156. package/package.json +72 -0
  157. package/src/components/Block/HtmlBlock.tsx +26 -0
  158. package/src/components/Block/MarkdownBlock.tsx +157 -0
  159. package/src/components/Block/block.stories.tsx +140 -0
  160. package/src/components/Block/construction.ts +77 -0
  161. package/src/components/Block/extensions.ts +95 -0
  162. package/src/components/Block/highlight.ts +53 -0
  163. package/src/components/Block/index.ts +10 -0
  164. package/src/components/Block/selection-group.ts +53 -0
  165. package/src/components/Block/widget-state.tsx +75 -0
  166. package/src/components/MessageList/MessageList.stories.tsx +111 -0
  167. package/src/components/MessageList/MessageList.tsx +647 -0
  168. package/src/components/MessageList/index.ts +6 -0
  169. package/src/components/MessageList/position-log.ts +167 -0
  170. package/src/components/Minimap/Minimap.stories.tsx +129 -0
  171. package/src/components/Minimap/Minimap.tsx +84 -0
  172. package/src/components/Minimap/index.ts +5 -0
  173. package/src/components/Outline/Outline.stories.tsx +196 -0
  174. package/src/components/Outline/Outline.tsx +383 -0
  175. package/src/components/Outline/index.ts +5 -0
  176. package/src/components/index.ts +8 -0
  177. package/src/debug/Debug.tsx +90 -0
  178. package/src/debug/Stats.tsx +57 -0
  179. package/src/debug/debug-model.test.ts +36 -0
  180. package/src/debug/debug-model.ts +66 -0
  181. package/src/debug/frame-meter.test.ts +49 -0
  182. package/src/debug/frame-meter.ts +188 -0
  183. package/src/debug/index.ts +9 -0
  184. package/src/debug/use-debug.tsx +41 -0
  185. package/src/hooks/follow.stories.tsx +200 -0
  186. package/src/hooks/follow.test.ts +105 -0
  187. package/src/hooks/follow.ts +241 -0
  188. package/src/hooks/index.ts +9 -0
  189. package/src/hooks/useDecorations.tsx +54 -0
  190. package/src/hooks/useFeedNavigation.ts +90 -0
  191. package/src/hooks/useFollow.ts +258 -0
  192. package/src/hooks/useItemSelection.tsx +53 -0
  193. package/src/index.ts +7 -0
  194. package/src/model/feed-model.test.ts +84 -0
  195. package/src/model/feed-model.ts +214 -0
  196. package/src/model/feed.test.ts +65 -0
  197. package/src/model/feed.ts +157 -0
  198. package/src/model/index.ts +8 -0
  199. package/src/model/list-model.test.ts +71 -0
  200. package/src/model/list-model.ts +152 -0
  201. package/src/model/useListModel.ts +53 -0
  202. package/src/stories/assistant.stories.tsx +425 -0
  203. package/src/stories/baseline.stories.tsx +78 -0
  204. package/src/stories/bridge.stories.tsx +376 -0
  205. package/src/stories/construction.stories.tsx +147 -0
  206. package/src/stories/fill.stories.tsx +271 -0
  207. package/src/stories/horizontal.stories.tsx +129 -0
  208. package/src/stories/mount.stories.tsx +233 -0
  209. package/src/stories/navigation.stories.tsx +145 -0
  210. package/src/stories/streaming.stories.tsx +132 -0
  211. package/src/stories/tail.stories.tsx +149 -0
  212. package/src/stories/widget.stories.tsx +107 -0
  213. package/src/testing/FeedStory.tsx +452 -0
  214. package/src/testing/MessageWindow.tsx +139 -0
  215. package/src/testing/controls.tsx +96 -0
  216. package/src/testing/debug/FeedStats.tsx +182 -0
  217. package/src/testing/debug/index.ts +16 -0
  218. package/src/testing/debug/sweep.ts +85 -0
  219. package/src/testing/debug/use-feed-debug.ts +91 -0
  220. package/src/testing/generator.ts +90 -0
  221. package/src/testing/index.ts +13 -0
  222. package/src/testing/scenarios.tsx +552 -0
  223. package/src/testing/stream.ts +70 -0
  224. package/src/testing/turn.ts +91 -0
  225. package/src/testing/widgets.tsx +183 -0
  226. package/src/virtualizer/Window.stories.tsx +683 -0
  227. package/src/virtualizer/Window.tsx +435 -0
  228. package/src/virtualizer/index.ts +6 -0
  229. package/src/virtualizer/placement.test.ts +312 -0
  230. package/src/virtualizer/placement.ts +403 -0
@@ -0,0 +1,271 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import { expect } from 'storybook/test';
7
+
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
+
10
+ import { FeedStory, type FeedStoryProps } from '../testing';
11
+
12
+ /**
13
+ * How long the first fill takes, and what it spends the time on.
14
+ *
15
+ * `baseline/Uniform` visibly fills from the bottom up over more than a second, which construction
16
+ * cost does not explain — `stories/construction` measures one item at ~0.4ms, so a viewport of them
17
+ * is ~8ms. The remaining explanation is the number of *passes*: every measured row changes the
18
+ * offsets below it, and each correction the virtualizer makes costs a frame. This samples the DOM
19
+ * once per animation frame and reports how many frames the list took to stop changing.
20
+ */
21
+ const meta: Meta<FeedStoryProps> = {
22
+ title: 'ui/react-ui-feed/stories/fill',
23
+ render: FeedStory,
24
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
25
+ parameters: { layout: 'fullscreen' },
26
+ };
27
+
28
+ export default meta;
29
+
30
+ type Story = StoryObj<FeedStoryProps>;
31
+
32
+ type Sample = {
33
+ /** Milliseconds since the play function started sampling. */
34
+ at: number;
35
+ /** Rows the virtualizer has mounted. */
36
+ rows: number;
37
+ scrollTop: number;
38
+ scrollHeight: number;
39
+ /**
40
+ * Viewport-relative top of every mounted row, by index.
41
+ *
42
+ * Keyed, not positional. "The first mounted row" is not the same row from one frame to the next —
43
+ * a rebuild changes which rows are mounted — so its position moving says nothing about whether
44
+ * anything the reader is looking at moved. Comparing a row against itself does.
45
+ */
46
+ tops: Map<number, number>;
47
+ };
48
+
49
+ /**
50
+ * Sampled for a fixed window rather than until it holds still.
51
+ *
52
+ * Stopping at the first quiet patch reports the fill and hides everything after it: the layout can
53
+ * be rebuilt from the top hundreds of milliseconds later — after the quiet period that gates the
54
+ * re-base — and a reader sees the whole page move. Two seconds covers that.
55
+ */
56
+ const WATCH_FRAMES = 150;
57
+
58
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
59
+
60
+ /**
61
+ * Samples the scroll container once per frame until it stops changing.
62
+ *
63
+ * Read in the same frame as the scroll, deliberately: a row's position and the scroll offset taken
64
+ * from different frames differ by whatever happened between them, which reads as movement.
65
+ */
66
+ const sampleFill = async (viewport: HTMLElement): Promise<Sample[]> => {
67
+ const samples: Sample[] = [];
68
+ const start = performance.now();
69
+
70
+ for (let frame = 0; frame < WATCH_FRAMES; frame++) {
71
+ await nextFrame();
72
+ const rows = [...viewport.querySelectorAll<HTMLElement>('[data-index]')];
73
+ const top = viewport.getBoundingClientRect().top;
74
+ const sample: Sample = {
75
+ at: Math.round(performance.now() - start),
76
+ rows: rows.length,
77
+ scrollTop: Math.round(viewport.scrollTop),
78
+ scrollHeight: Math.round(viewport.scrollHeight),
79
+ tops: new Map(rows.map((row) => [Number(row.dataset.index), Math.round(row.getBoundingClientRect().top - top)])),
80
+ };
81
+
82
+ samples.push(sample);
83
+ }
84
+
85
+ return samples;
86
+ };
87
+
88
+ const unchanged = (a: Sample, b: Sample) =>
89
+ a.rows === b.rows && a.scrollTop === b.scrollTop && a.scrollHeight === b.scrollHeight && !shifted(a, b);
90
+
91
+ /** How far each row present in both frames has moved, by index; empty when nothing did. */
92
+ const deltas = (a: Sample, b: Sample): Map<number, number> =>
93
+ new Map(
94
+ [...b.tops]
95
+ .filter(([index, top]) => a.tops.has(index) && Math.abs(a.tops.get(index)! - top) > 1)
96
+ .map(([index, top]) => [index, top - a.tops.get(index)!]),
97
+ );
98
+
99
+ /** Rows present in both frames that are not where they were. */
100
+ const shifted = (a: Sample, b: Sample): number => deltas(a, b).size;
101
+
102
+ /**
103
+ * What a shift looks like, which is what says where it comes from.
104
+ *
105
+ * Every row moving by the same amount is the scroll and the offsets disagreeing — one number wrong.
106
+ * Rows moving by different amounts is the spacing between them changing, which is a measurement
107
+ * being discarded rather than a position being mis-set.
108
+ */
109
+ const describe = (a: Sample, b: Sample): string => {
110
+ const moved = [...deltas(a, b)];
111
+ if (!moved.length) {
112
+ return '';
113
+ }
114
+
115
+ const values = moved.map(([, delta]) => delta);
116
+ const min = Math.min(...values);
117
+ const max = Math.max(...values);
118
+ const indexes = moved.map(([index]) => index);
119
+ return ` moved ${moved.length} rows ${min === max ? `all by ${min}` : `by ${min}..${max}`}, indexes ${Math.min(...indexes)}..${Math.max(...indexes)}`;
120
+ };
121
+
122
+ /** Only the frames where something moved: a run of identical frames says nothing. */
123
+ const changes = (samples: Sample[]) =>
124
+ samples.filter((sample, index) => index === 0 || !unchanged(samples[index - 1], sample));
125
+
126
+ const report = (name: string, samples: Sample[]) => {
127
+ const moved = changes(samples);
128
+ const settled = samples.indexOf(moved.at(-1)!) + 1;
129
+ const header = ['at ms', 'rows', 'scrollTop', 'scrollHeight', 'shifted'];
130
+ const body = moved.map((sample) => {
131
+ const previous = samples[samples.indexOf(sample) - 1];
132
+ return [
133
+ sample.at,
134
+ sample.rows,
135
+ sample.scrollTop,
136
+ sample.scrollHeight,
137
+ previous ? `${shifted(previous, sample)}${describe(previous, sample)}` : '0',
138
+ ].map(String);
139
+ });
140
+ const widths = header.map((_, column) => Math.max(...[header, ...body].map((row) => row[column].length)));
141
+ const table = [header, ...body]
142
+ .map((row) => row.map((cell, column) => cell.padStart(widths[column])).join(' '))
143
+ .join('\n');
144
+
145
+ // eslint-disable-next-line no-console
146
+ console.log(
147
+ `[fill: ${name}] last change at frame ${settled} (${samples[settled - 1]?.at ?? 0}ms), ` +
148
+ `${moved.length} of ${samples.length} frames changed something\n${table}`,
149
+ );
150
+ };
151
+
152
+ /**
153
+ * Main-thread time the mount cost, which the frame sampler cannot see.
154
+ *
155
+ * A play function does not start until the story has rendered, so everything expensive has already
156
+ * happened by the time it can look. The observer is installed when this module is evaluated —
157
+ * before any story renders — and simply keeps the long tasks it is handed.
158
+ */
159
+ const longTasks: PerformanceEntry[] = [];
160
+ if (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
161
+ new PerformanceObserver((list) => longTasks.push(...list.getEntries())).observe({ entryTypes: ['longtask'] });
162
+ }
163
+
164
+ const playFill = (name: string, allowed = 0) =>
165
+ async function play({ canvasElement }: { canvasElement: HTMLElement }) {
166
+ const viewport = await waitForViewport(canvasElement);
167
+ const samples = await sampleFill(viewport);
168
+ report(name, samples);
169
+ // Entries arrive through the observer's callback, so one more turn is needed before the last
170
+ // task of the fill is in the buffer.
171
+ await nextFrame();
172
+ const blocking = longTasks.splice(0).map(({ duration }) => Math.round(duration));
173
+ // eslint-disable-next-line no-console
174
+ console.log(
175
+ `[fill: ${name}] blocking ${blocking.reduce((sum, ms) => sum + ms, 0)}ms in ${blocking.length} long tasks ` +
176
+ `[${blocking.join(', ')}]`,
177
+ );
178
+ // Nothing here scrolls the feed, so a mounted row that moves is a defect — counted in rows, over
179
+ // every frame. The document *is* allowed to change: the layout is rebuilt once the measured
180
+ // average leaves the estimate behind, and that is correct. What the rebuild may not do is move
181
+ // the offsets and the scroll position in different frames, because the rows travel in between.
182
+ const jumped = samples.reduce(
183
+ (total, sample, index) => total + (index ? shifted(samples[index - 1], sample) : 0),
184
+ 0,
185
+ );
186
+ await expect({ name, jumped }).toEqual({ name, jumped: allowed });
187
+ };
188
+
189
+ const waitForViewport = async (canvasElement: HTMLElement): Promise<HTMLElement> => {
190
+ for (let frame = 0; frame < 120; frame++) {
191
+ const viewport = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]');
192
+ if (viewport?.querySelector('[data-index]')) {
193
+ return viewport;
194
+ }
195
+ await nextFrame();
196
+ }
197
+
198
+ throw new Error('feed viewport never mounted a row');
199
+ };
200
+
201
+ /** Passive: the first fill, watched rather than measured. */
202
+ export const Default: Story = {
203
+ args: { scenario: 'uniform', count: 500 },
204
+ };
205
+
206
+ /** No editor at all: the floor, and what every other case is compared against. */
207
+ export const Plain: Story = {
208
+ args: { scenario: 'plain', count: 500 },
209
+ play: playFill('plain 500'),
210
+ };
211
+
212
+ /**
213
+ * The same feed, a tenth the length.
214
+ *
215
+ * A virtualized list mounts a viewport's worth of rows either way, so a cost that follows the count
216
+ * belongs to the model — building the messages, projecting them — and a cost that does not belongs
217
+ * to the rows on screen. Which one it is decides where the fix goes.
218
+ */
219
+ export const PlainShort: Story = {
220
+ args: { scenario: 'plain', count: 50 },
221
+ play: playFill('plain 50'),
222
+ };
223
+
224
+ /** One editor per row, all identical — construction, and nothing else, added to `Plain`. */
225
+ export const Uniform: Story = {
226
+ args: { scenario: 'uniform', count: 500 },
227
+ play: playFill('uniform 500'),
228
+ };
229
+
230
+ /**
231
+ * The same feed with space reserved past the end.
232
+ *
233
+ * The reserved space is part of the document, so everything that reads the element's own maximum —
234
+ * the tail test, the follow's target, the opening offset — has to stop short of it instead. This is
235
+ * where that is checked.
236
+ */
237
+ /** Tall uneven rows and space past the end: the case where the opening jump is most wrong. */
238
+ export const PlainPastEnd: Story = {
239
+ args: { scenario: 'plain', count: 500, scrollPastEnd: true },
240
+ play: playFill('plain past-end'),
241
+ };
242
+
243
+ export const UniformPastEnd: Story = {
244
+ args: { scenario: 'uniform', count: 500, scrollPastEnd: true },
245
+ // This was the one rung that moved: eighteen rows travelled on the frame the layout was rebuilt,
246
+ // pinned at what was measured so a regression past it would fail. There is no rebuild any more —
247
+ // it existed because the old engine could only revise the rows below the last one it had measured,
248
+ // so the whole document had to be re-based from the top to correct an opening guess, and that
249
+ // moved every offset at once. Zero now, and held there.
250
+ play: playFill('uniform past-end'),
251
+ };
252
+
253
+ export const UniformShort: Story = {
254
+ args: { scenario: 'uniform', count: 50 },
255
+ play: playFill('uniform 50'),
256
+ };
257
+
258
+ /**
259
+ * Contents of different lengths, so the estimate is wrong for most rows.
260
+ *
261
+ * The one rung where the rebuild is still not atomic: the reader is put back by index, and with rows
262
+ * of every height the offsets around the landing point are mostly estimates, so the virtualizer
263
+ * retries across a frame and the page is seen to move in it. Restoring by offset instead lands in
264
+ * one frame and then spends a second correcting itself — measured at 69–96 of 150 frames changing,
265
+ * against one here. Recorded in `chat-ui/TASKS.md`; the allowance is what is measured today, so a
266
+ * regression past it still fails.
267
+ */
268
+ export const Varied: Story = {
269
+ args: { count: 500 },
270
+ play: playFill('varied 500'),
271
+ };
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo, useRef } from 'react';
7
+ import { expect } from 'storybook/test';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { MarkdownBlock } from '../components';
12
+ import { ListModel } from '../model';
13
+ import { Window, type WindowController } from '../virtualizer/Window';
14
+
15
+ /**
16
+ * The inline axis against real content — editors, not boxes.
17
+ *
18
+ * `virtualizer/Horizontal` proves the arithmetic with rows that cannot lie about their size; this
19
+ * is the acceptance the axis claim needs (SPEC F-1.7): a horizontal strip of real CodeMirror
20
+ * documents, measured on the inline axis, where the *cross* axis (each card's height) is the hard
21
+ * constraint of the layout and the extent is what varies. The axis is in scope for the
22
+ * virtualizer only — the feed list stays vertical — so this story is the axis's whole acceptance
23
+ * suite, and it deliberately reuses the invariants the block axis is held to.
24
+ */
25
+ const CARDS = 200;
26
+
27
+ const documents = Array.from({ length: CARDS }, (_, index) => {
28
+ const words = 3 + ((index * 7) % 24);
29
+ return `### Card ${index}\n\n${Array.from({ length: words }, (_, word) => `word${word}`).join(' ')}`;
30
+ });
31
+
32
+ /** Declared widths, deliberately wrong for most cards so measurement is exercised. */
33
+ const extent = (index: number) => 240 + (index % 5) * 40;
34
+
35
+ const DefaultStory = () => {
36
+ const controller = useRef<WindowController>(null);
37
+ const extents = useMemo(() => ({ of: extent }), []);
38
+ const model = useMemo(
39
+ () =>
40
+ new ListModel({
41
+ items: Array.from({ length: CARDS }, (_, index) => ({ id: `card-${index}` })),
42
+ getId: (item) => item.id,
43
+ }),
44
+ [],
45
+ );
46
+
47
+ return (
48
+ <Window classNames='grow min-h-0' axis='inline' model={model} extents={extents} controllerRef={controller}>
49
+ {(index) => (
50
+ // Padding, never margin: a child's margin is outside its parent's offsetWidth, so the
51
+ // measured extent would disagree with the rendered pitch by exactly the gap.
52
+ <div className='h-full px-1 py-1'>
53
+ <div className='h-full w-[20rem] border border-separator rounded-md p-2 overflow-hidden'>
54
+ <MarkdownBlock text={documents[index]} />
55
+ </div>
56
+ </div>
57
+ )}
58
+ </Window>
59
+ );
60
+ };
61
+
62
+ const meta: Meta<typeof DefaultStory> = {
63
+ title: 'ui/react-ui-feed/stories/horizontal',
64
+ render: DefaultStory,
65
+ decorators: [withLayout({ layout: 'fullscreen' }), withTheme()],
66
+ parameters: { layout: 'fullscreen' },
67
+ };
68
+
69
+ export default meta;
70
+
71
+ type Story = StoryObj<typeof DefaultStory>;
72
+
73
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
74
+
75
+ const settle = async (frames = 30) => {
76
+ for (let frame = 0; frame < frames; frame++) {
77
+ await nextFrame();
78
+ }
79
+ };
80
+
81
+ /** The horizontal strip, passively. No play. */
82
+ export const Default: Story = {};
83
+
84
+ /**
85
+ * The block-axis invariants, on the inline axis, with editors.
86
+ *
87
+ * Nothing here names a direction the arithmetic could hide behind: rows are tracked by index, a
88
+ * scroll must move them by what was scrolled, and the far end must be reachable — the same three
89
+ * readings `bridge/*` takes vertically.
90
+ */
91
+ export const InlineContent: Story = {
92
+ play: async ({ canvasElement }) => {
93
+ await settle();
94
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
95
+ const left = () => scroller.getBoundingClientRect().left;
96
+ const read = () =>
97
+ new Map(
98
+ [...scroller.querySelectorAll<HTMLElement>('[data-index]')].map((row) => [
99
+ Number(row.dataset.index),
100
+ Math.round(row.getBoundingClientRect().left - left()),
101
+ ]),
102
+ );
103
+
104
+ // A scroll moves the content by what was scrolled.
105
+ const before = read();
106
+ const from = scroller.scrollLeft;
107
+ scroller.scrollLeft = from + 480;
108
+ await settle(10);
109
+ const scrolled = scroller.scrollLeft - from;
110
+ const after = read();
111
+ // A row's viewport position after the scroll is its old position minus the distance scrolled.
112
+ const jumps = [...after]
113
+ .filter(([index, position]) => before.has(index) && Math.abs(before.get(index)! - position - scrolled) > 2)
114
+ .map(([index, position]) => ({ index, was: before.get(index)!, now: position, scrolled }));
115
+
116
+ // And the end is reachable, which is what says the measured extents reached the sizer.
117
+ let previous = -1;
118
+ for (let attempt = 0; attempt < 8 && scroller.scrollLeft !== previous; attempt++) {
119
+ previous = scroller.scrollLeft;
120
+ scroller.scrollLeft = scroller.scrollWidth;
121
+ await settle(10);
122
+ }
123
+
124
+ const last = scroller.querySelector<HTMLElement>(`[data-index="${CARDS - 1}"]`);
125
+ const rests = !!last && Math.abs(last.getBoundingClientRect().right - scroller.getBoundingClientRect().right) <= 2;
126
+
127
+ await expect({ mounted: after.size > 0, jumps, rests }).toEqual({ mounted: true, jumps: [], rests: true });
128
+ },
129
+ };
@@ -0,0 +1,233 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
7
+ import { expect, waitFor, within } from 'storybook/test';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { MessageList } from '../components';
12
+ import { useFeedModel } from '../model';
13
+ import { type FeedScenario, type ScenarioDefinition, createScenario } from '../testing';
14
+
15
+ /**
16
+ * What one mounted row costs, with the fixtures already built.
17
+ *
18
+ * `baseline/fill` can only watch from the first frame a play function gets, and its numbers carry
19
+ * whatever the story did before that — generating five hundred messages, most of all. Here the
20
+ * scenarios are built once, before anything is timed, and each feed is then mounted and torn down
21
+ * repeatedly. What is left is the cost of putting rows on screen.
22
+ */
23
+ const SCENARIOS: FeedScenario[] = [
24
+ 'plain',
25
+ 'uniform-text',
26
+ 'uniform-bare',
27
+ 'uniform-themed',
28
+ 'uniform-markdown',
29
+ 'uniform-decorated',
30
+ 'uniform-item',
31
+ 'uniform',
32
+ 'assistant',
33
+ ];
34
+
35
+ /** Warm-up mounts, discarded: the first feed of a run pays for style sheets the rest reuse. */
36
+ const WARMUP = 1;
37
+
38
+ /** Frames of an unchanged row count before a mount counts as complete. */
39
+ const STABLE_FRAMES = 3;
40
+
41
+ const MAX_FRAMES = 240;
42
+
43
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
44
+
45
+ type Result = {
46
+ scenario: FeedScenario;
47
+ /** Rows the virtualizer mounted to cover the viewport. */
48
+ rows: number;
49
+ /** Mean milliseconds from mount until the row count stops changing. */
50
+ mean: number;
51
+ worst: number;
52
+ /**
53
+ * Mean animation frames over the same interval.
54
+ *
55
+ * The number that separates the two explanations: a fill that takes as many frames as it has rows
56
+ * is mounting them one per frame and the fix is to stop doing that, where a fill that takes a
57
+ * handful of very long frames is doing too much work per row.
58
+ */
59
+ frames: number;
60
+ };
61
+
62
+ type MountProfileProps = {
63
+ count?: number;
64
+ runs?: number;
65
+ };
66
+
67
+ const MountProfile = ({ count = 200, runs = 3 }: MountProfileProps) => {
68
+ // Built before anything is timed. Generating the fixtures is the story's cost, not the list's, and
69
+ // it is the larger number of the two at this length.
70
+ const scenarios = useMemo(
71
+ () => Object.fromEntries(SCENARIOS.map((scenario) => [scenario, createScenario({ scenario, count })])),
72
+ [count],
73
+ );
74
+
75
+ const [shown, setShown] = useState<FeedScenario | null>(null);
76
+ const [results, setResults] = useState<Result[]>([]);
77
+ const containerRef = useRef<HTMLDivElement>(null);
78
+
79
+ useEffect(() => {
80
+ let cancelled = false;
81
+
82
+ // Measured to the last frame that changed anything, not to the end of the loop: the trailing
83
+ // stable frames are the sampler waiting, and charging them to the mount reports its patience.
84
+ const settle = async (): Promise<{ elapsed: number; frames: number; rows: number; fill: number[] }> => {
85
+ const start = performance.now();
86
+ // Rows mounted on each frame until the count holds. A feed that fills in one commit reads as a
87
+ // single number; anything else is the reader watching it arrive in pieces.
88
+ const fill: number[] = [];
89
+ let rows = 0;
90
+ let stable = 0;
91
+ let elapsed = 0;
92
+ let frames = 0;
93
+ for (let frame = 0; frame < MAX_FRAMES && stable < STABLE_FRAMES; frame++) {
94
+ await nextFrame();
95
+ const mounted = containerRef.current?.querySelectorAll('[data-index]').length ?? 0;
96
+ if (mounted > 0 && mounted === rows) {
97
+ stable += 1;
98
+ } else {
99
+ stable = 0;
100
+ elapsed = performance.now() - start;
101
+ frames = frame + 1;
102
+ fill.push(mounted);
103
+ }
104
+
105
+ rows = mounted;
106
+ }
107
+
108
+ return { elapsed, frames, rows, fill };
109
+ };
110
+
111
+ void (async () => {
112
+ const measured: Result[] = [];
113
+ for (const scenario of SCENARIOS) {
114
+ const times: number[] = [];
115
+ const frames: number[] = [];
116
+ let rows = 0;
117
+ for (let run = 0; run < runs + WARMUP && !cancelled; run++) {
118
+ setShown(scenario);
119
+ const settled = await settle();
120
+ rows = settled.rows;
121
+ if (run >= WARMUP) {
122
+ times.push(settled.elapsed);
123
+ frames.push(settled.frames);
124
+ // eslint-disable-next-line no-console
125
+ console.log(`[fill: ${scenario}]`, JSON.stringify(settled.fill));
126
+ }
127
+
128
+ setShown(null);
129
+ await nextFrame();
130
+ }
131
+
132
+ if (cancelled) {
133
+ return;
134
+ }
135
+
136
+ measured.push({
137
+ scenario,
138
+ rows,
139
+ mean: mean(times),
140
+ worst: Math.max(...times),
141
+ frames: mean(frames),
142
+ });
143
+ }
144
+
145
+ setResults(measured);
146
+ // eslint-disable-next-line no-console
147
+ console.log(`[mount: ${count} messages]\n` + format(measured));
148
+ })();
149
+
150
+ return () => {
151
+ cancelled = true;
152
+ };
153
+ }, [scenarios, runs, count]);
154
+
155
+ const definition = shown ? scenarios[shown] : undefined;
156
+
157
+ return (
158
+ <div className='flex flex-col h-full'>
159
+ <pre className='p-4 text-xs whitespace-pre' data-testid='mount.results'>
160
+ {results.length ? format(results) : 'measuring…'}
161
+ </pre>
162
+ <div ref={containerRef} className='relative grow min-h-0'>
163
+ {definition && <Mounted key={shown} definition={definition} />}
164
+ </div>
165
+ </div>
166
+ );
167
+ };
168
+
169
+ const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
170
+
171
+ const format = (results: Result[]): string => {
172
+ const header = ['scenario', 'rows', 'mean ms', 'worst ms', 'ms/row', 'frames', 'ms/frame'];
173
+ const body = results.map(({ scenario, rows, mean, worst, frames }) => [
174
+ scenario,
175
+ String(rows),
176
+ mean.toFixed(1),
177
+ worst.toFixed(1),
178
+ (mean / Math.max(1, rows)).toFixed(2),
179
+ frames.toFixed(1),
180
+ (mean / Math.max(1, frames)).toFixed(1),
181
+ ]);
182
+ const widths = header.map((_, column) => Math.max(...[header, ...body].map((row) => row[column].length)));
183
+
184
+ return [header, ...body].map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ')).join('\n');
185
+ };
186
+
187
+ /** One scenario mounted through the real Root; a component so the model hook has somewhere to live. */
188
+ const Mounted = ({ definition }: { definition: ScenarioDefinition }) => {
189
+ const model = useFeedModel(definition.messages);
190
+ return (
191
+ <MessageList.Root
192
+ model={model}
193
+ renderer={definition.renderer}
194
+ registry={definition.registry}
195
+ Chrome={definition.Chrome}
196
+ Custom={definition.Custom}
197
+ estimateSize={definition.estimateSize}
198
+ stickyBottom={definition.stickyBottom}
199
+ >
200
+ <MessageList.Viewport classNames='absolute inset-0' padding />
201
+ </MessageList.Root>
202
+ );
203
+ };
204
+
205
+ const meta: Meta<MountProfileProps> = {
206
+ title: 'ui/react-ui-feed/stories/mount',
207
+ component: MountProfile,
208
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
209
+ parameters: { layout: 'fullscreen' },
210
+ };
211
+
212
+ export default meta;
213
+
214
+ type Story = StoryObj<MountProfileProps>;
215
+
216
+ /**
217
+ * Per-row mount cost up the ladder.
218
+ *
219
+ * `plain` has no editor at all, `uniform` adds one identical editor per row, `assistant` adds
220
+ * per-message renderers and block widgets. `ms/row` is the number that decides whether the first
221
+ * fill is worth optimizing in the item or in the list.
222
+ */
223
+ export const Default: Story = { args: { count: 200 }, play: waitForResults };
224
+
225
+ /** The same rows in a shorter feed: anything that changes here scales with the model, not the view. */
226
+ export const Short: Story = { args: { count: 50 }, play: waitForResults };
227
+
228
+ // The measurement runs in the component, so the test's only job is to still be there when it
229
+ // finishes — the numbers are printed, not asserted: a threshold on a shared CI machine is noise.
230
+ async function waitForResults({ canvasElement }: { canvasElement: HTMLElement }) {
231
+ const results = within(canvasElement).getByTestId('mount.results');
232
+ await waitFor(() => expect(results.textContent).toContain('ms/row'), { timeout: 60_000 });
233
+ }