| 1 | // @effect-diagnostics nodeBuiltinImport:off - reads the installed native JS bundle for synchronous VM tests. |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeVM from "node:vm"; |
| 4 | import { 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. |
| 8 | function 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 | |
| 81 | describe.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 | ); |