basalt-win32 0.0.0-stage → 0.2.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runWindows.js +35 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +429 -0
  5. package/native/tests/test_win32_accessibility.cpp +585 -0
  6. package/native/tests/test_win32_animated_image.cpp +351 -0
  7. package/native/tests/test_win32_clipboard.cpp +121 -0
  8. package/native/tests/test_win32_focus.cpp +195 -0
  9. package/native/tests/test_win32_hittest.cpp +750 -0
  10. package/native/tests/test_win32_image.cpp +370 -0
  11. package/native/tests/test_win32_imageloader.cpp +208 -0
  12. package/native/tests/test_win32_input.cpp +511 -0
  13. package/native/tests/test_win32_key_events.cpp +84 -0
  14. package/native/tests/test_win32_mounting.cpp +757 -0
  15. package/native/tests/test_win32_paint.cpp +1799 -0
  16. package/native/tests/test_win32_scrollview.cpp +647 -0
  17. package/native/tests/test_win32_selection.cpp +215 -0
  18. package/native/tests/test_win32_text.cpp +1456 -0
  19. package/native/tests/test_win32_textinput.cpp +1594 -0
  20. package/native/tests/test_win32_titlebar.cpp +111 -0
  21. package/native/tests/test_win32_titlebar_actions.cpp +62 -0
  22. package/native/tests/test_win32_view.cpp +222 -0
  23. package/native/win32/ColorSchemeWin32.cpp +56 -0
  24. package/native/win32/ComponentRegistryWin32.cpp +156 -0
  25. package/native/win32/DirectWriteLayout.cpp +485 -0
  26. package/native/win32/DirectWriteLayout.h +77 -0
  27. package/native/win32/DirectWriteLayoutManager.cpp +154 -0
  28. package/native/win32/FontRegistryDirectWrite.cpp +117 -0
  29. package/native/win32/PlatformServicesWin32.cpp +744 -0
  30. package/native/win32/RnWin32Accessible.cpp +343 -0
  31. package/native/win32/RnWin32Accessible.h +130 -0
  32. package/native/win32/RnWin32Image.cpp +588 -0
  33. package/native/win32/RnWin32Image.h +139 -0
  34. package/native/win32/RnWin32TextLayout.cpp +1379 -0
  35. package/native/win32/RnWin32TextLayout.h +390 -0
  36. package/native/win32/RnWin32View.cpp +2956 -0
  37. package/native/win32/RnWin32View.h +926 -0
  38. package/native/win32/Win32AnimationChoreographer.cpp +76 -0
  39. package/native/win32/Win32AnimationChoreographer.h +61 -0
  40. package/native/win32/Win32Clip.h +209 -0
  41. package/native/win32/Win32Cursors.cpp +309 -0
  42. package/native/win32/Win32Cursors.h +121 -0
  43. package/native/win32/Win32DropTarget.cpp +567 -0
  44. package/native/win32/Win32DropTarget.h +42 -0
  45. package/native/win32/Win32Focus.cpp +262 -0
  46. package/native/win32/Win32Focus.h +109 -0
  47. package/native/win32/Win32ImageLoader.cpp +199 -0
  48. package/native/win32/Win32ImageLoader.h +133 -0
  49. package/native/win32/Win32InputScopes.h +92 -0
  50. package/native/win32/Win32KeyEvents.cpp +126 -0
  51. package/native/win32/Win32KeyEvents.h +20 -0
  52. package/native/win32/Win32MenuBar.cpp +382 -0
  53. package/native/win32/Win32MenuBar.h +16 -0
  54. package/native/win32/Win32MountingManager.cpp +1282 -0
  55. package/native/win32/Win32MountingManager.h +243 -0
  56. package/native/win32/Win32Offscreen.h +53 -0
  57. package/native/win32/Win32Packaging.cpp +161 -0
  58. package/native/win32/Win32Packaging.h +56 -0
  59. package/native/win32/Win32RunLoopObserver.cpp +48 -0
  60. package/native/win32/Win32RunLoopObserver.h +42 -0
  61. package/native/win32/Win32ScrollView.cpp +718 -0
  62. package/native/win32/Win32ScrollView.h +256 -0
  63. package/native/win32/Win32SkiaContext.cpp +221 -0
  64. package/native/win32/Win32SkiaContext.h +53 -0
  65. package/native/win32/Win32SkiaModule.cpp +80 -0
  66. package/native/win32/Win32SkiaModule.h +50 -0
  67. package/native/win32/Win32SkiaPeer.cpp +358 -0
  68. package/native/win32/Win32SkiaPeer.h +66 -0
  69. package/native/win32/Win32SkiaUnicode.cpp +42 -0
  70. package/native/win32/Win32Snapshot.cpp +201 -0
  71. package/native/win32/Win32Snapshot.h +66 -0
  72. package/native/win32/Win32Strings.h +54 -0
  73. package/native/win32/Win32TextInput.cpp +1296 -0
  74. package/native/win32/Win32TextInput.h +341 -0
  75. package/native/win32/Win32TitleBar.cpp +562 -0
  76. package/native/win32/Win32TitleBar.h +146 -0
  77. package/native/win32/Win32TitleBarLayout.cpp +46 -0
  78. package/native/win32/Win32TitleBarLayout.h +58 -0
  79. package/native/win32/Win32TouchDispatcher.cpp +577 -0
  80. package/native/win32/Win32TouchDispatcher.h +201 -0
  81. package/native/win32/Win32UiThread.h +27 -0
  82. package/native/win32/Win32WindowControl.cpp +329 -0
  83. package/native/win32/Win32WindowModule.cpp +209 -0
  84. package/native/win32/Win32WindowModule.h +72 -0
  85. package/native/win32/demo_layout_win32.cpp +232 -0
  86. package/native/win32/main_win32.cpp +2722 -0
  87. package/native/win32/mount_harness_win32.cpp +316 -0
  88. package/package.json +25 -4
  89. package/react-native.config.js +22 -0
  90. package/README.md +0 -3
@@ -0,0 +1,647 @@
1
+ // Tests for <ScrollView> on Windows: the offset, the clamp, the wheel's
2
+ // routing, and the commands.
3
+ //
4
+ // The first tests any of the three desktops has for a scroll manager. GTK's and
5
+ // AppKit's are exercised end to end by `scripts/compare_hosts.sh` running
6
+ // `e2e/scroll.js` on both, which is a better test in every way except that it
7
+ // needs two working desktops and this machine has one -- WSL2 will not start
8
+ // here, so nothing else can check Windows against another host. So the
9
+ // arithmetic gets tested directly instead.
10
+ //
11
+ // What is observable from here is narrower than what the manager does, and the
12
+ // two limits are worth naming rather than working around. A ShadowView built by
13
+ // hand has no ShadowNodeFamily, so `State::updateState` finds nothing to commit
14
+ // into and the unthrottled state write-back cannot be asserted on. And an
15
+ // EventEmitter built by hand has no EventDispatcher, so `onScroll` goes
16
+ // nowhere. What is left is the part that decides where the content actually
17
+ // sits: the clamp, the adoption, the routing and the commands.
18
+
19
+ #include "TestHarness.h"
20
+
21
+ #include "Win32MountingManager.h"
22
+ #include "Win32ScrollView.h"
23
+ // For hitTestTag: what a press would find, which is the other half of "the
24
+ // scroll moved" and the half a scroll manager can get silently wrong.
25
+ #include "Win32TouchDispatcher.h"
26
+
27
+ #include <react/renderer/components/scrollview/ScrollViewProps.h>
28
+ #include <react/renderer/components/scrollview/ScrollViewShadowNode.h>
29
+ #include <react/renderer/components/scrollview/ScrollViewState.h>
30
+ #include <react/renderer/components/view/ViewProps.h>
31
+
32
+ #include <memory>
33
+ #include <optional>
34
+ #include <string>
35
+
36
+ using basalt::Win32MountingManager;
37
+ using basalt::Win32ScrollViewManager;
38
+ using basalt::win32::RnWin32View;
39
+ using facebook::react::LayoutMetrics;
40
+ using facebook::react::MountingTransaction;
41
+ using facebook::react::Point;
42
+ using facebook::react::Rect;
43
+ using facebook::react::ScrollViewProps;
44
+ using facebook::react::ScrollViewShadowNode;
45
+ using facebook::react::ScrollViewState;
46
+ using facebook::react::ShadowNodeFamily;
47
+ using facebook::react::ShadowView;
48
+ using facebook::react::ShadowViewMutation;
49
+ using facebook::react::ShadowViewMutationList;
50
+ using facebook::react::Size;
51
+ using facebook::react::SurfaceId;
52
+ using facebook::react::Tag;
53
+ using facebook::react::TransactionTelemetry;
54
+ using facebook::react::ViewProps;
55
+
56
+ namespace {
57
+
58
+ constexpr SurfaceId kSurfaceId = 1;
59
+
60
+ ShadowView makeView(Tag tag, float x, float y, float width, float height) {
61
+ LayoutMetrics metrics;
62
+ metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
63
+
64
+ ShadowView view;
65
+ view.componentName = "View";
66
+ view.surfaceId = kSurfaceId;
67
+ view.tag = tag;
68
+ view.props = std::make_shared<ViewProps>();
69
+ view.layoutMetrics = metrics;
70
+ return view;
71
+ }
72
+
73
+ // A ScrollView whose viewport is `width`x`height` and whose content is
74
+ // `contentWidth`x`contentHeight` -- which is the whole of what the manager
75
+ // needs, because Yoga has already laid the content out at its full size by the
76
+ // time a mutation arrives.
77
+ ShadowView makeScrollView(Tag tag,
78
+ float x,
79
+ float y,
80
+ float width,
81
+ float height,
82
+ float contentWidth,
83
+ float contentHeight,
84
+ bool scrollEnabled = true,
85
+ Point initialOffset = Point{0, 0},
86
+ bool centerContent = false,
87
+ facebook::react::ScrollViewIndicatorStyle indicatorStyle =
88
+ facebook::react::ScrollViewIndicatorStyle::Default,
89
+ std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition>
90
+ maintainVisible = std::nullopt) {
91
+ LayoutMetrics metrics;
92
+ metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
93
+
94
+ auto props = std::make_shared<ScrollViewProps>();
95
+ props->scrollEnabled = scrollEnabled;
96
+ // The prop as well as the state, because that is how the two arrive for real:
97
+ // `ScrollViewShadowNode::initialStateData` seeds the state from this prop, so
98
+ // a test that set only one of them would be testing a shape React Native does
99
+ // not produce.
100
+ props->contentOffset = initialOffset;
101
+ props->centerContent = centerContent;
102
+ props->indicatorStyle = indicatorStyle;
103
+ props->maintainVisibleContentPosition = maintainVisible;
104
+
105
+ ScrollViewState data;
106
+ data.contentOffset = initialOffset;
107
+ data.contentBoundingRect = Rect{.origin = {.x = 0, .y = 0},
108
+ .size = Size{.width = contentWidth, .height = contentHeight}};
109
+
110
+ ShadowView view;
111
+ view.componentName = "ScrollView";
112
+ view.surfaceId = kSurfaceId;
113
+ view.tag = tag;
114
+ view.props = props;
115
+ view.layoutMetrics = metrics;
116
+ // An empty family: nothing can be committed back through it, which is what
117
+ // makes the state write-back unobservable here. See the file header.
118
+ view.state = std::make_shared<const ScrollViewShadowNode::ConcreteState>(
119
+ std::make_shared<const ScrollViewState>(data), ShadowNodeFamily::Weak{});
120
+ return view;
121
+ }
122
+
123
+ void apply(Win32MountingManager &manager, ShadowViewMutationList &&mutations) {
124
+ manager.applyTransaction(
125
+ kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
126
+ }
127
+
128
+ // Creates and inserts one shadow view under `parent`, in a single transaction.
129
+ void mount(Win32MountingManager &manager, Tag parent, const ShadowView &shadowView) {
130
+ ShadowViewMutationList mutations;
131
+ mutations.push_back(ShadowViewMutation::CreateMutation(shadowView));
132
+ mutations.push_back(ShadowViewMutation::InsertMutation(parent, shadowView, 0));
133
+ apply(manager, std::move(mutations));
134
+ }
135
+
136
+ // One notch's worth of pixels, in the direction a contentOffset grows.
137
+ constexpr double kNotch = Win32ScrollViewManager::kWheelStepPixels;
138
+
139
+ } // namespace
140
+
141
+ // The offset is applied to the view, which is where painting and hit testing
142
+ // both read it -- so this is the one assertion that covers all three.
143
+ TEST(win32_a_wheel_scrolls_the_view_under_it) {
144
+ Win32MountingManager manager;
145
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
146
+ root->setFrame(0, 0, 400, 300);
147
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
148
+
149
+ EXPECT(manager.scrollAt(root, 200, 150, 0, 5 * kNotch));
150
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 5 * kNotch, 0.01);
151
+ EXPECT_NEAR(manager.viewForTag(10)->scrollX(), 0.0, 0.01);
152
+
153
+ manager.destroySurfaceRoot(kSurfaceId);
154
+ }
155
+
156
+ // The clamp, at both ends. Content 1000 tall in a 300 viewport can scroll 700
157
+ // and no further, and nothing can scroll above the top.
158
+ TEST(win32_the_offset_is_clamped_to_the_content) {
159
+ Win32MountingManager manager;
160
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
161
+ root->setFrame(0, 0, 400, 300);
162
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
163
+
164
+ manager.scrollAt(root, 200, 150, 0, 100 * kNotch);
165
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 700.0, 0.01);
166
+
167
+ manager.scrollAt(root, 200, 150, 0, -100 * kNotch);
168
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
169
+
170
+ manager.destroySurfaceRoot(kSurfaceId);
171
+ }
172
+
173
+ // Content that fits does not scroll at all, but the wheel is still consumed --
174
+ // matching AppKit, and matching what every desktop does: a list at its end must
175
+ // not hand the wheel to whatever is behind it.
176
+ TEST(win32_content_that_fits_consumes_the_wheel_and_does_not_move) {
177
+ Win32MountingManager manager;
178
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
179
+ root->setFrame(0, 0, 400, 300);
180
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 200));
181
+
182
+ EXPECT(manager.scrollAt(root, 200, 150, 0, 5 * kNotch));
183
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
184
+
185
+ manager.destroySurfaceRoot(kSurfaceId);
186
+ }
187
+
188
+ // scrollEnabled: false does not consume, so the wheel keeps going up the tree.
189
+ TEST(win32_a_disabled_scroll_view_passes_the_wheel_on) {
190
+ Win32MountingManager manager;
191
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
192
+ root->setFrame(0, 0, 400, 300);
193
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000, false));
194
+
195
+ EXPECT(!manager.scrollAt(root, 200, 150, 0, 5 * kNotch));
196
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
197
+
198
+ manager.destroySurfaceRoot(kSurfaceId);
199
+ }
200
+
201
+ // A wheel over a plain row scrolls the list containing it. GTK gets this from
202
+ // attaching a controller per widget and AppKit from the responder chain; here
203
+ // it is the parent walk in scrollAt, so it is worth an assertion.
204
+ TEST(win32_a_wheel_over_a_child_scrolls_its_scroll_view) {
205
+ Win32MountingManager manager;
206
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
207
+ root->setFrame(0, 0, 400, 300);
208
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
209
+ mount(manager, 10, makeView(11, 0, 0, 400, 80));
210
+
211
+ // The point lands on the row, not on the ScrollView.
212
+ EXPECT_EQ(static_cast<long>(basalt::hitTestTag(root, 200, 40)), 11L);
213
+
214
+ EXPECT(manager.scrollAt(root, 200, 40, 0, 2 * kNotch));
215
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 2 * kNotch, 0.01);
216
+
217
+ manager.destroySurfaceRoot(kSurfaceId);
218
+ }
219
+
220
+ // A list inside a list scrolls the inner one. Innermost-first is the whole
221
+ // reason the walk starts at the hit view rather than at the root.
222
+ TEST(win32_a_nested_scroll_view_takes_the_wheel_first) {
223
+ Win32MountingManager manager;
224
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
225
+ root->setFrame(0, 0, 400, 300);
226
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
227
+ mount(manager, 10, makeScrollView(11, 0, 0, 400, 100, 400, 500));
228
+
229
+ EXPECT(manager.scrollAt(root, 200, 50, 0, 1 * kNotch));
230
+ EXPECT_NEAR(manager.viewForTag(11)->scrollY(), kNotch, 0.01);
231
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
232
+
233
+ manager.destroySurfaceRoot(kSurfaceId);
234
+ }
235
+
236
+ // Scrolling moves what a press lands on. `hitTest` adds the scroll offset back
237
+ // on the way down, so this needs nothing from the scroll manager -- which is
238
+ // exactly the claim being checked, because a list that scrolls correctly and
239
+ // answers presses meant for the row above is the bug this prevents.
240
+ TEST(win32_hit_testing_follows_the_scroll) {
241
+ Win32MountingManager manager;
242
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
243
+ root->setFrame(0, 0, 400, 300);
244
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
245
+ // Two rows, the second below the fold at y=400.
246
+ mount(manager, 10, makeView(12, 0, 400, 400, 100));
247
+ mount(manager, 10, makeView(11, 0, 0, 400, 100));
248
+
249
+ EXPECT_EQ(static_cast<long>(basalt::hitTestTag(root, 200, 50)), 11L);
250
+
251
+ manager.scrollAt(root, 200, 150, 0, 400);
252
+ EXPECT_EQ(static_cast<long>(basalt::hitTestTag(root, 200, 50)), 12L);
253
+
254
+ manager.destroySurfaceRoot(kSurfaceId);
255
+ }
256
+
257
+ // A ScrollView must clip whatever its props say. Its content is deliberately
258
+ // larger than its frame, and without this it paints over its siblings.
259
+ TEST(win32_a_scroll_view_clips_regardless_of_overflow) {
260
+ Win32MountingManager manager;
261
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
262
+ root->setFrame(0, 0, 400, 300);
263
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
264
+
265
+ EXPECT(manager.viewForTag(10)->clipsChildren());
266
+
267
+ manager.destroySurfaceRoot(kSurfaceId);
268
+ }
269
+
270
+ // An offset already in the state is adopted rather than reset. That is what a
271
+ // `contentOffset` prop looks like by the time it reaches here, and what a
272
+ // surface that was suspended and remounted carries.
273
+ TEST(win32_an_initial_offset_in_the_state_is_adopted) {
274
+ Win32MountingManager manager;
275
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
276
+ root->setFrame(0, 0, 400, 300);
277
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000, true, Point{0, 120}));
278
+
279
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 120.0, 0.01);
280
+
281
+ manager.destroySurfaceRoot(kSurfaceId);
282
+ }
283
+
284
+ // Content can shrink under a scrolled offset -- a list whose items were
285
+ // filtered away. The re-clamp on every mutation is what stops the view from
286
+ // being left showing empty space below its own content.
287
+ TEST(win32_shrinking_content_pulls_the_offset_back) {
288
+ Win32MountingManager manager;
289
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
290
+ root->setFrame(0, 0, 400, 300);
291
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
292
+
293
+ manager.scrollAt(root, 200, 150, 0, 700);
294
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 700.0, 0.01);
295
+
296
+ ShadowViewMutationList mutations;
297
+ mutations.push_back(
298
+ ShadowViewMutation::UpdateMutation(makeScrollView(10, 0, 0, 400, 300, 400, 1000),
299
+ makeScrollView(10, 0, 0, 400, 300, 400, 500),
300
+ kSurfaceId));
301
+ apply(manager, std::move(mutations));
302
+
303
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 200.0, 0.01);
304
+
305
+ manager.destroySurfaceRoot(kSurfaceId);
306
+ }
307
+
308
+ // scrollTo and scrollToEnd, which arrive through dispatchCommand. e2e/scroll.js
309
+ // drives itself through a ref, so this is the path that file exercises and the
310
+ // only one that needs no wheel.
311
+ TEST(win32_scroll_to_and_scroll_to_end_move_the_offset) {
312
+ Win32MountingManager manager;
313
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
314
+ root->setFrame(0, 0, 400, 300);
315
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
316
+
317
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 265, false));
318
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 265.0, 0.01);
319
+
320
+ manager.applyCommand(10, "scrollToEnd", folly::dynamic::array(false));
321
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 700.0, 0.01);
322
+
323
+ // scrollTo past the end is clamped like any other offset.
324
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 5000, false));
325
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 700.0, 0.01);
326
+
327
+ manager.destroySurfaceRoot(kSurfaceId);
328
+ }
329
+
330
+ // A command for a tag that is not a ScrollView, and one for a tag that is gone.
331
+ // Both are ordinary: a scrollTo can arrive after the surface that owned it was
332
+ // torn down.
333
+ TEST(win32_a_command_for_an_unknown_tag_is_survivable) {
334
+ Win32MountingManager manager;
335
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
336
+ root->setFrame(0, 0, 400, 300);
337
+ mount(manager, kSurfaceId, makeView(10, 0, 0, 100, 100));
338
+
339
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 100, false));
340
+ manager.applyCommand(999, "scrollTo", folly::dynamic::array(0, 100, false));
341
+ manager.applyCommand(10, "somethingElse", folly::dynamic::array());
342
+
343
+ manager.destroySurfaceRoot(kSurfaceId);
344
+ }
345
+
346
+ // The entry has to go with the view. A wheel arriving after the ScrollView was
347
+ // deleted must find nothing rather than a dangling RnWin32View pointer.
348
+ TEST(win32_deleting_a_scroll_view_forgets_its_entry) {
349
+ Win32MountingManager manager;
350
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
351
+ root->setFrame(0, 0, 400, 300);
352
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000));
353
+ EXPECT(manager.scrollAt(root, 200, 150, 0, kNotch));
354
+
355
+ ShadowViewMutationList mutations;
356
+ mutations.push_back(ShadowViewMutation::RemoveMutation(
357
+ kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 1000), 0));
358
+ mutations.push_back(
359
+ ShadowViewMutation::DeleteMutation(makeScrollView(10, 0, 0, 400, 300, 400, 1000)));
360
+ apply(manager, std::move(mutations));
361
+
362
+ EXPECT(manager.viewForTag(10) == nullptr);
363
+ EXPECT(!manager.scrollAt(root, 200, 150, 0, kNotch));
364
+ manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 100, false));
365
+
366
+ manager.destroySurfaceRoot(kSurfaceId);
367
+ }
368
+
369
+ // Nothing under the pointer, and no surface at all. The host reads the return
370
+ // value to decide whether to leave the message to DefWindowProc, so "false"
371
+ // here is the difference between a wheel over a non-scrolling app doing nothing
372
+ // and doing nothing *and* being swallowed.
373
+ TEST(win32_a_wheel_over_nothing_is_not_consumed) {
374
+ Win32MountingManager manager;
375
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
376
+ root->setFrame(0, 0, 400, 300);
377
+ mount(manager, kSurfaceId, makeView(10, 0, 0, 100, 100));
378
+
379
+ EXPECT(!manager.scrollAt(root, 50, 50, 0, kNotch));
380
+ EXPECT(!manager.scrollAt(root, 5000, 5000, 0, kNotch));
381
+ EXPECT(!manager.scrollAt(nullptr, 50, 50, 0, kNotch));
382
+
383
+ manager.destroySurfaceRoot(kSurfaceId);
384
+ }
385
+
386
+ // Horizontal, which travels the same path with the other axis and is easy to
387
+ // leave half-wired: WM_MOUSEHWHEEL is a separate message with the opposite
388
+ // sign convention to WM_MOUSEWHEEL.
389
+ TEST(win32_a_horizontal_wheel_scrolls_the_other_axis) {
390
+ Win32MountingManager manager;
391
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
392
+ root->setFrame(0, 0, 400, 300);
393
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 1200, 300));
394
+
395
+ EXPECT(manager.scrollAt(root, 200, 150, 3 * kNotch, 0));
396
+ EXPECT_NEAR(manager.viewForTag(10)->scrollX(), 3 * kNotch, 0.01);
397
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
398
+
399
+ manager.destroySurfaceRoot(kSurfaceId);
400
+ }
401
+
402
+ // A `contentOffset` that *changes* moves the list, which no host did until
403
+ // 2026-10-10: the first value arrives through the state and is adopted above,
404
+ // and a later one arrives as an ordinary prop update.
405
+ TEST(win32_a_changed_content_offset_moves_the_list) {
406
+ Win32MountingManager manager;
407
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
408
+ root->setFrame(0, 0, 400, 300);
409
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 120}));
410
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 120.0, 0.01);
411
+
412
+ ShadowViewMutationList mutations;
413
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
414
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 120}),
415
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 700}),
416
+ kSurfaceId));
417
+ apply(manager, std::move(mutations));
418
+
419
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 700.0, 0.01);
420
+
421
+ manager.destroySurfaceRoot(kSurfaceId);
422
+ }
423
+
424
+ // And one that has not changed leaves the list alone, which is the rule rather
425
+ // than an optimisation: React Native re-renders for all sorts of reasons and
426
+ // every one of them carries the same `contentOffset` the app wrote once, so a
427
+ // host that applied it whenever it differed would drag the list back under the
428
+ // person reading it.
429
+ TEST(win32_an_unchanged_content_offset_leaves_the_list_where_it_is) {
430
+ Win32MountingManager manager;
431
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
432
+ root->setFrame(0, 0, 400, 300);
433
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 120}));
434
+
435
+ manager.scrollAt(root, 200, 150, 0, 900);
436
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 1020.0, 0.01);
437
+
438
+ ShadowViewMutationList mutations;
439
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
440
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 120}),
441
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 120}),
442
+ kSurfaceId));
443
+ apply(manager, std::move(mutations));
444
+
445
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 1020.0, 0.01);
446
+
447
+ manager.destroySurfaceRoot(kSurfaceId);
448
+ }
449
+
450
+ // `centerContent`: content smaller than the container sits in the middle of it.
451
+ // The arithmetic is core/ScrollBounds.h's -- an inset of half the slack at each
452
+ // end, leaving one offset to rest at -- so this asserts the prop reaches it and
453
+ // that the clamp this host already ran puts the content there.
454
+ TEST(win32_center_content_centres_content_that_fits) {
455
+ Win32MountingManager manager;
456
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
457
+ root->setFrame(0, 0, 400, 300);
458
+ // 200 of content in a 300 container: 100 of slack, so an offset of -50.
459
+ mount(manager,
460
+ kSurfaceId,
461
+ makeScrollView(10, 0, 0, 400, 300, 400, 200, true, Point{0, 0}, /*centerContent=*/true));
462
+
463
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), -50.0, 0.01);
464
+
465
+ manager.destroySurfaceRoot(kSurfaceId);
466
+ }
467
+
468
+ TEST(win32_content_that_fits_is_not_centred_unless_asked) {
469
+ Win32MountingManager manager;
470
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
471
+ root->setFrame(0, 0, 400, 300);
472
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 200));
473
+
474
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
475
+
476
+ manager.destroySurfaceRoot(kSurfaceId);
477
+ }
478
+
479
+ TEST(win32_center_content_does_nothing_to_a_list_that_scrolls) {
480
+ Win32MountingManager manager;
481
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
482
+ root->setFrame(0, 0, 400, 300);
483
+ mount(manager,
484
+ kSurfaceId,
485
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000, true, Point{0, 0}, /*centerContent=*/true));
486
+
487
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
488
+ // And the list cannot be pulled above its first row, which an inset here
489
+ // would have allowed.
490
+ manager.scrollAt(root, 200, 150, 0, -50);
491
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
492
+
493
+ manager.destroySurfaceRoot(kSurfaceId);
494
+ }
495
+
496
+ // `indicatorStyle`: the prop reaching the thumb's colour, and the dump line
497
+ // that is the only part of it a cross-host diff can see. What the colour draws
498
+ // as is a pixel question, which test_win32_paint.cpp answers.
499
+ TEST(win32_indicator_style_reaches_the_thumb) {
500
+ Win32MountingManager manager;
501
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
502
+ root->setFrame(0, 0, 400, 300);
503
+ mount(manager, kSurfaceId, makeScrollView(10, 0, 0, 400, 300, 400, 4000));
504
+
505
+ const std::string black = manager.viewForTag(10)->describeTree();
506
+ EXPECT(black.find("scrollbar-v=") != std::string::npos);
507
+ // A default thumb prints no colour, like every other field at its default.
508
+ EXPECT(black.find("scrollbar-colour") == std::string::npos);
509
+
510
+ ShadowViewMutationList mutations;
511
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
512
+ makeScrollView(10, 0, 0, 400, 300, 400, 4000),
513
+ makeScrollView(10,
514
+ 0,
515
+ 0,
516
+ 400,
517
+ 300,
518
+ 400,
519
+ 4000,
520
+ true,
521
+ Point{0, 0},
522
+ false,
523
+ facebook::react::ScrollViewIndicatorStyle::White),
524
+ kSurfaceId));
525
+ apply(manager, std::move(mutations));
526
+
527
+ const std::string white = manager.viewForTag(10)->describeTree();
528
+ EXPECT(white.find("scrollbar-colour=(1,1,1,0.35)") != std::string::npos);
529
+
530
+ manager.destroySurfaceRoot(kSurfaceId);
531
+ }
532
+
533
+ // --- maintainVisibleContentPosition ------------------------------------------
534
+ //
535
+ // The prop a chat list is written with: messages arrive above what the person
536
+ // is reading, and the content under their eye must not jump by the height of
537
+ // whatever was inserted.
538
+ //
539
+ // Driven through real transactions, which is the only way to drive it: the two
540
+ // halves run before and after the mutations, so a test that called them itself
541
+ // would not be testing that anything calls them. The arithmetic is
542
+ // core/tests/test_scroll_visible_position.cpp's.
543
+ namespace {
544
+
545
+ facebook::react::ScrollViewMaintainVisibleContentPosition maintainFrom(int minIndex) {
546
+ facebook::react::ScrollViewMaintainVisibleContentPosition maintain;
547
+ maintain.minIndexForVisible = minIndex;
548
+ return maintain;
549
+ }
550
+
551
+ ShadowView listScroller(
552
+ std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition> maintain) {
553
+ return makeScrollView(10,
554
+ 0,
555
+ 0,
556
+ 400,
557
+ 300,
558
+ 400,
559
+ 1000,
560
+ true,
561
+ Point{0, 0},
562
+ false,
563
+ facebook::react::ScrollViewIndicatorStyle::Default,
564
+ maintain);
565
+ }
566
+
567
+ // A ScrollView with a content view and two hundred-point rows under it, which
568
+ // is the smallest tree the prop can be asked about.
569
+ void mountList(Win32MountingManager &manager, const ShadowView &scroller) {
570
+ ShadowViewMutationList mutations;
571
+ mutations.push_back(ShadowViewMutation::CreateMutation(scroller));
572
+ mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, scroller, 0));
573
+ const ShadowView content = makeView(11, 0, 0, 400, 1000);
574
+ mutations.push_back(ShadowViewMutation::CreateMutation(content));
575
+ mutations.push_back(ShadowViewMutation::InsertMutation(10, content, 0));
576
+ const ShadowView first = makeView(12, 0, 0, 400, 100);
577
+ const ShadowView second = makeView(13, 0, 100, 400, 100);
578
+ mutations.push_back(ShadowViewMutation::CreateMutation(first));
579
+ mutations.push_back(ShadowViewMutation::InsertMutation(11, first, 0));
580
+ mutations.push_back(ShadowViewMutation::CreateMutation(second));
581
+ mutations.push_back(ShadowViewMutation::InsertMutation(11, second, 1));
582
+ apply(manager, std::move(mutations));
583
+ }
584
+
585
+ // The first row grows by a hundred points, which pushes the second one down by
586
+ // the same -- the shape an insertion above the visible content has.
587
+ void growTheFirstRow(Win32MountingManager &manager) {
588
+ ShadowViewMutationList mutations;
589
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
590
+ makeView(12, 0, 0, 400, 100), makeView(12, 0, 0, 400, 200), kSurfaceId));
591
+ mutations.push_back(ShadowViewMutation::UpdateMutation(
592
+ makeView(13, 0, 100, 400, 100), makeView(13, 0, 200, 400, 100), kSurfaceId));
593
+ apply(manager, std::move(mutations));
594
+ }
595
+
596
+ } // namespace
597
+
598
+ TEST(win32_maintain_visible_content_position_holds_the_content_still) {
599
+ Win32MountingManager manager;
600
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
601
+ root->setFrame(0, 0, 400, 300);
602
+ mountList(manager, listScroller(maintainFrom(0)));
603
+
604
+ // Scrolled to 150: the first row is entirely above the viewport and the
605
+ // second is the first with anything on screen, so the second is held still.
606
+ manager.scrollAt(root, 200, 150, 0, 150);
607
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 150.0, 0.01);
608
+
609
+ growTheFirstRow(manager);
610
+
611
+ // The row moved down by a hundred, so the offset did: what was under the eye
612
+ // still is.
613
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 250.0, 0.01);
614
+
615
+ manager.destroySurfaceRoot(kSurfaceId);
616
+ }
617
+
618
+ TEST(win32_a_list_that_did_not_ask_lets_the_content_jump) {
619
+ // The control, and the behaviour of every list that never set the prop.
620
+ Win32MountingManager manager;
621
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
622
+ root->setFrame(0, 0, 400, 300);
623
+ mountList(manager, listScroller(std::nullopt));
624
+
625
+ manager.scrollAt(root, 200, 150, 0, 150);
626
+ growTheFirstRow(manager);
627
+
628
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 150.0, 0.01);
629
+
630
+ manager.destroySurfaceRoot(kSurfaceId);
631
+ }
632
+
633
+ TEST(win32_min_index_for_visible_skips_a_header) {
634
+ // At the top of the list the first row is the visible one, and an index of 1
635
+ // takes it out of the running -- so the second row is watched, and that one
636
+ // moves. A list that watched the header would adjust by nothing.
637
+ Win32MountingManager manager;
638
+ RnWin32View *root = manager.createSurfaceRoot(kSurfaceId);
639
+ root->setFrame(0, 0, 400, 300);
640
+ mountList(manager, listScroller(maintainFrom(1)));
641
+
642
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 0.0, 0.01);
643
+ growTheFirstRow(manager);
644
+ EXPECT_NEAR(manager.viewForTag(10)->scrollY(), 100.0, 0.01);
645
+
646
+ manager.destroySurfaceRoot(kSurfaceId);
647
+ }