1// @effect-diagnostics nodeBuiltinImport:off - reads the installed native JS bundle for synchronous VM tests.
2import * as NodeFS from "node:fs";
3import * as NodeVM from "node:vm";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6// Exercise the shipped patch without loading React Native in the Node runner.
7// Native scroll delivery and animation frames advance independently here.
8function createList(bundle: string) {
9 const source = NodeFS.readFileSync(
10 new URL(`../apps/mobile/node_modules/@legendapp/list/${bundle}`, import.meta.url),
11 "utf8",
12 );
13 const renderState = source.slice(
14 source.indexOf("function setInitialRenderState("),
15 source.indexOf("// src/core/finishInitialScroll.ts"),
16 );
17 const watchdog = source.slice(
18 source.indexOf("var INSET_END_SETTLE_WATCHDOG_FRAMES"),
19 source.indexOf("function dispatchInitialScroll("),
20 );
21 const frames: Array<() => void> = [];
22 const values = new Map<string, unknown>();
23 const scrollTo = vi.fn();
24 const onLoad = vi.fn();
25 const state = {
26 props: { data: ["message"], onLoad, drawDistance: 500 },
27 initialScroll: { index: 0, viewPosition: 1 },
28 loadStartTime: 0,
29 didContainersLayout: true,
30 didFinishInitialScroll: true,
31 didLoad: false,
32 didUserDrag: false,
33 scrollLength: 800,
34 scroll: 400,
35 lastNativeScroll: 200 as number | undefined,
36 scrollingTo: undefined as { offset: number } | undefined,
37 maintainingScrollAtEnd: false,
38 refScroller: { current: { scrollTo } },
39 };
40 const ctx = { state };
41 let contentSize = 1200;
42 let inset = 100;
43 const api = NodeVM.runInNewContext(
44 `${renderState}\n${watchdog}\n({ start: startInsetEndSettleWatchdog, complete: setInitialRenderState })`,
45 {
46 requestAnimationFrame: (callback: () => void) => frames.push(callback),
47 getContentSize: () => contentSize,
48 getContentInsetStartAdjustment: () => inset,
49 peek$: (_ctx: unknown, key: string) => values.get(key),
50 set$: (_ctx: unknown, key: string, value: unknown) => values.set(key, value),
51 setAdaptiveRender: vi.fn(),
52 scheduleFullDrawDistancePrewarm: vi.fn(),
53 INITIAL_DRAW_DISTANCE: 250,
54 },
55 ) as {
56 start: (context: typeof ctx) => void;
57 complete: (context: typeof ctx, flags: Record<string, boolean>) => void;
58 };
59 return {
60 state,
61 scrollTo,
62 onLoad,
63 start: () => api.start(ctx),
64 complete: () => api.complete(ctx, {}),
65 ready: () => values.get("readyToRender") === true,
66 resize: (size: number) => {
67 contentSize = size;
68 },
69 setInset: (value: number) => {
70 inset = value;
71 },
72 advance(count: number) {
73 for (let index = 0; index < count; index++) {
74 const batch = frames.splice(0);
75 for (const frame of batch) frame();
76 }
77 },
78 };
79}
80
81describe.each(["react-native.js", "react-native.mjs"])(
82 "initial inset end reveal (%s)",
83 (bundle) => {
84 it("waits for the native offset instead of the optimistic scroll target", () => {
85 const list = createList(bundle);
86 list.start();
87 list.advance(8);
88 expect(list.ready()).toBe(false);
89 expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: 400 });
90 list.state.lastNativeScroll = 400;
91 list.advance(7);
92 expect(list.ready()).toBe(true);
93 expect(list.onLoad).toHaveBeenCalledTimes(1);
94 });
95
96 it("gates the seeded contentOffset completion path too", () => {
97 const list = createList(bundle);
98 list.complete();
99 expect(list.ready()).toBe(false);
100 list.state.lastNativeScroll = 400;
101 list.advance(7);
102 expect(list.ready()).toBe(true);
103 });
104
105 it("preserves an initial index before the last item", () => {
106 const list = createList(bundle);
107 list.state.props.data = ["requested message", "later message"];
108 list.state.scroll = 200;
109 list.complete();
110 list.advance(8);
111 expect(list.ready()).toBe(true);
112 expect(list.scrollTo).not.toHaveBeenCalled();
113 });
114
115 it("does not count an in-flight scroll as stability", () => {
116 const list = createList(bundle);
117 list.state.lastNativeScroll = 400;
118 list.state.scrollingTo = { offset: 400 };
119 list.start();
120 list.advance(8);
121 expect(list.ready()).toBe(false);
122 list.state.scrollingTo = undefined;
123 list.state.maintainingScrollAtEnd = true;
124 list.advance(8);
125 expect(list.ready()).toBe(false);
126 list.state.maintainingScrollAtEnd = false;
127 list.advance(7);
128 expect(list.ready()).toBe(true);
129 });
130
131 it("waits for a stable end target even when native scrolling follows each measurement", () => {
132 const list = createList(bundle);
133 list.start();
134 for (let index = 0; index < 10; index++) {
135 list.resize(1200 + index * 10);
136 list.state.lastNativeScroll = 400 + index * 10;
137 list.advance(1);
138 }
139 expect(list.ready()).toBe(false);
140 list.advance(7);
141 expect(list.ready()).toBe(true);
142 });
143
144 it("accounts for header insets when content is shorter than the full viewport", () => {
145 const list = createList(bundle);
146 list.resize(780);
147 list.state.scroll = -20;
148 list.state.lastNativeScroll = -100;
149 list.start();
150 list.advance(8);
151 expect(list.ready()).toBe(false);
152 expect(list.scrollTo).toHaveBeenCalledWith({ animated: false, x: 0, y: -20 });
153 list.state.lastNativeScroll = -20;
154 list.advance(7);
155 expect(list.ready()).toBe(true);
156 });
157
158 it("keeps the reveal bounded when native events never arrive", () => {
159 const list = createList(bundle);
160 list.state.lastNativeScroll = undefined;
161 list.start();
162 list.advance(8);
163 expect(list.ready()).toBe(false);
164 expect(list.scrollTo).not.toHaveBeenCalled();
165 list.advance(32);
166 expect(list.ready()).toBe(true);
167 });
168
169 it("releases control on drag and never starts another initial hold", () => {
170 const list = createList(bundle);
171 list.start();
172 list.state.didUserDrag = true;
173 list.advance(1);
174 expect(list.ready()).toBe(true);
175 expect(list.scrollTo).not.toHaveBeenCalled();
176 list.complete();
177 list.start();
178 list.advance(20);
179 expect(list.state.didUserDrag).toBe(true);
180 expect(list.scrollTo).not.toHaveBeenCalled();
181 expect(list.onLoad).toHaveBeenCalledTimes(1);
182 });
183
184 it("preserves a drag that started before initial layout completed", () => {
185 const list = createList(bundle);
186 list.state.didUserDrag = true;
187 list.complete();
188 list.advance(1);
189 expect(list.ready()).toBe(true);
190 expect(list.state.didUserDrag).toBe(true);
191 expect(list.scrollTo).not.toHaveBeenCalled();
192 });
193
194 it("reveals short content at UIKit's resting offset without awaiting a scroll event", () => {
195 const list = createList(bundle);
196 list.resize(700);
197 list.state.lastNativeScroll = undefined;
198 list.complete();
199 expect(list.ready()).toBe(true);
200 list.advance(10);
201 expect(list.scrollTo).not.toHaveBeenCalled();
202 });
203
204 it("does not gate empty lists or lists without an automatic header inset", () => {
205 const list = createList(bundle);
206 list.setInset(0);
207 list.complete();
208 expect(list.ready()).toBe(true);
209 const empty = createList(bundle);
210 empty.state.props.data = [];
211 empty.complete();
212 expect(empty.ready()).toBe(true);
213 });
214 },
215);