|
8 | 8 | observeWindowOffset, |
9 | 9 | windowScroll, |
10 | 10 | } from '../src/index' |
| 11 | +import type { VirtualizerOptions } from '../src/index' |
11 | 12 |
|
12 | 13 | test('should export the Virtualizer class', () => { |
13 | 14 | expect(Virtualizer).toBeDefined() |
@@ -2537,20 +2538,18 @@ test.fails( |
2537 | 2538 | }, |
2538 | 2539 | ) |
2539 | 2540 |
|
2540 | | -test.fails( |
2541 | | - 'scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', |
2542 | | - () => { |
2543 | | - // Not iOS-specific. scrollOffset still holds the old position until the |
2544 | | - // scroll event, so row 38 (below the target) reads as above the viewport. |
2545 | | - const h = makeDeferralHarness({ ios: false, start: 2000 }) |
2546 | | - h.v.scrollToIndex(10, { align: 'start' }) |
2547 | | - expect(h.el.scrollTop).toBe(500) |
2548 | | - h.v.resizeItem(38, 100) |
2549 | | - h.scroll(true) |
2550 | | - h.frame() |
2551 | | - expect(h.el.scrollTop).toBe(500) |
2552 | | - }, |
2553 | | -) |
| 2541 | +test('scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', () => { |
| 2542 | + // Not iOS-specific. scrollOffset still holds the old position until the |
| 2543 | + // scroll event, so row 38 (below the target) reads as above the viewport; |
| 2544 | + // resizes before the read-back are left to reconcile. |
| 2545 | + const h = makeDeferralHarness({ ios: false, start: 2000 }) |
| 2546 | + h.v.scrollToIndex(10, { align: 'start' }) |
| 2547 | + expect(h.el.scrollTop).toBe(500) |
| 2548 | + h.v.resizeItem(38, 100) |
| 2549 | + h.scroll(true) |
| 2550 | + h.frame() |
| 2551 | + expect(h.el.scrollTop).toBe(500) |
| 2552 | +}) |
2554 | 2553 |
|
2555 | 2554 | test.fails( |
2556 | 2555 | 'scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves', |
@@ -5425,3 +5424,228 @@ test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036 |
5425 | 5424 | expect(virtualizer.getMeasurements()[5]!.size).toBe(140) |
5426 | 5425 | expect(notifies).toEqual([true]) |
5427 | 5426 | }) |
| 5427 | + |
| 5428 | +// 100 rows estimated at 50px in a 200px viewport. scrollToFn writes the |
| 5429 | +// element and clamps like a browser; scroll events are delivered by hand, so a |
| 5430 | +// test can resize between a programmatic write and its read-back. |
| 5431 | +function setupHandDrivenScroll( |
| 5432 | + extra: Partial<VirtualizerOptions<any, any>> = {}, |
| 5433 | +) { |
| 5434 | + const rafCallbacks: Array<FrameRequestCallback> = [] |
| 5435 | + const mockWindow = { |
| 5436 | + requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), |
| 5437 | + cancelAnimationFrame: () => {}, |
| 5438 | + } |
| 5439 | + const el = { |
| 5440 | + scrollTop: 0, |
| 5441 | + scrollLeft: 0, |
| 5442 | + scrollHeight: 5000, |
| 5443 | + clientHeight: 200, |
| 5444 | + offsetHeight: 200, |
| 5445 | + ownerDocument: { defaultView: mockWindow }, |
| 5446 | + } |
| 5447 | + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = |
| 5448 | + null |
| 5449 | + // Like iOS during a touch: drop programmatic writes on the floor. |
| 5450 | + const browser = { ignoreWrites: false } |
| 5451 | + const v = new Virtualizer({ |
| 5452 | + count: 100, |
| 5453 | + estimateSize: () => 50, |
| 5454 | + getScrollElement: () => el as any, |
| 5455 | + scrollToFn: (offset, { adjustments = 0 }) => { |
| 5456 | + if (browser.ignoreWrites) return |
| 5457 | + el.scrollTop = Math.max( |
| 5458 | + 0, |
| 5459 | + Math.min(offset + adjustments, el.scrollHeight - el.clientHeight), |
| 5460 | + ) |
| 5461 | + }, |
| 5462 | + observeElementRect: (_inst, cb) => { |
| 5463 | + cb({ width: 400, height: 200 }) |
| 5464 | + return () => {} |
| 5465 | + }, |
| 5466 | + observeElementOffset: (_inst, cb) => { |
| 5467 | + scrollCallback = cb |
| 5468 | + cb(0, false) |
| 5469 | + return () => {} |
| 5470 | + }, |
| 5471 | + ...extra, |
| 5472 | + }) |
| 5473 | + v._willUpdate() |
| 5474 | + v['getMeasurements']() |
| 5475 | + const scroll = (offset: number, isScrolling: boolean) => |
| 5476 | + scrollCallback!(offset, isScrolling) |
| 5477 | + const settle = () => { |
| 5478 | + for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { |
| 5479 | + rafCallbacks.splice(0).forEach((cb) => cb(0)) |
| 5480 | + scroll(el.scrollTop, true) |
| 5481 | + } |
| 5482 | + scroll(el.scrollTop, false) |
| 5483 | + } |
| 5484 | + // Runs frames without scroll events, as a browser does when a write leaves |
| 5485 | + // the element where it was. |
| 5486 | + const frames = (n = 10) => { |
| 5487 | + for (let i = 0; i < n && rafCallbacks.length > 0; i++) { |
| 5488 | + rafCallbacks.splice(0).forEach((cb) => cb(0)) |
| 5489 | + } |
| 5490 | + } |
| 5491 | + return { v, el, scroll, settle, frames, browser } |
| 5492 | +} |
| 5493 | + |
| 5494 | +test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { |
| 5495 | + // Scrolling up from 2000 to row 20 (1000), row 30 grows before the scroll |
| 5496 | + // event reports 1000. Compensating it against the stale 2000 used to write |
| 5497 | + // 2050, and reconcile (target unchanged) never rewrote it until the 5s |
| 5498 | + // safety valve. |
| 5499 | + const { v, el, scroll, settle } = setupHandDrivenScroll() |
| 5500 | + el.scrollTop = 2000 |
| 5501 | + scroll(2000, false) |
| 5502 | + |
| 5503 | + v.scrollToIndex(20, { align: 'start' }) |
| 5504 | + expect(el.scrollTop).toBe(1000) |
| 5505 | + v.resizeItem(30, 100) |
| 5506 | + expect(el.scrollTop).toBe(1000) |
| 5507 | + settle() |
| 5508 | + |
| 5509 | + expect(el.scrollTop).toBe(1000) |
| 5510 | + expect(v.scrollState).toBeNull() |
| 5511 | +}) |
| 5512 | + |
| 5513 | +test('a resize above the target before the read-back is picked up by reconcile', () => { |
| 5514 | + const { v, el, scroll, settle } = setupHandDrivenScroll() |
| 5515 | + el.scrollTop = 2000 |
| 5516 | + scroll(2000, false) |
| 5517 | + |
| 5518 | + v.scrollToIndex(20, { align: 'start' }) |
| 5519 | + v.resizeItem(10, 100) |
| 5520 | + // No compensation write before the read-back... |
| 5521 | + expect(el.scrollTop).toBe(1000) |
| 5522 | + settle() |
| 5523 | + // ...reconcile retargets to the row's new start. |
| 5524 | + expect(el.scrollTop).toBe(1050) |
| 5525 | + expect(v.scrollState).toBeNull() |
| 5526 | +}) |
| 5527 | + |
| 5528 | +test('rows at the old position measured during scrollToEnd do not push it off the end', () => { |
| 5529 | + // scrollToEnd writes 4800, but until the read-back the rendered rows are |
| 5530 | + // the ones at the top. They measure 20px smaller each; compensating them |
| 5531 | + // against the written offset would walk the view up from the end while |
| 5532 | + // the end target (the DOM max, sizer not re-rendered) stays put. |
| 5533 | + const { v, el, settle } = setupHandDrivenScroll() |
| 5534 | + |
| 5535 | + v.scrollToEnd() |
| 5536 | + expect(el.scrollTop).toBe(4800) |
| 5537 | + for (let i = 0; i < 5; i++) v.resizeItem(i, 30) |
| 5538 | + expect(el.scrollTop).toBe(4800) |
| 5539 | + settle() |
| 5540 | + |
| 5541 | + expect(el.scrollTop).toBe(4800) |
| 5542 | + expect(v.scrollState).toBeNull() |
| 5543 | +}) |
| 5544 | + |
| 5545 | +test('scrollBy right after a scrollToIndex builds on the written offset', () => { |
| 5546 | + const { v, el, settle } = setupHandDrivenScroll() |
| 5547 | + |
| 5548 | + v.scrollToIndex(20, { align: 'start' }) |
| 5549 | + v.scrollBy(100) |
| 5550 | + settle() |
| 5551 | + |
| 5552 | + expect(el.scrollTop).toBe(1100) |
| 5553 | +}) |
| 5554 | + |
| 5555 | +test('scrollBy after the user scrolled builds on the user position, not an old write', () => { |
| 5556 | + const { v, el, scroll, settle } = setupHandDrivenScroll() |
| 5557 | + |
| 5558 | + v.scrollToIndex(20, { align: 'start' }) |
| 5559 | + scroll(el.scrollTop, false) // read-back of 1000 |
| 5560 | + el.scrollTop = 1300 |
| 5561 | + scroll(1300, false) // the user moved on |
| 5562 | + v.scrollBy(100) |
| 5563 | + settle() |
| 5564 | + |
| 5565 | + expect(el.scrollTop).toBe(1400) |
| 5566 | +}) |
| 5567 | + |
| 5568 | +test('scrollBy during a smooth scroll builds on the current position', () => { |
| 5569 | + // The element is still travelling, so the destination isn't where it is. |
| 5570 | + const { v, el, settle } = setupHandDrivenScroll() |
| 5571 | + |
| 5572 | + v.scrollToIndex(20, { align: 'start', behavior: 'smooth' }) |
| 5573 | + v.scrollBy(100) |
| 5574 | + settle() |
| 5575 | + |
| 5576 | + expect(el.scrollTop).toBe(100) |
| 5577 | +}) |
| 5578 | + |
| 5579 | +test('an anchorTo end list scrolled up by scrollToIndex is not pinned back by a resize before the read-back', () => { |
| 5580 | + // At the end, scrollToIndex(20) writes 1000. The last row grows before the |
| 5581 | + // read-back; against the stale offset the list still looks pinned, and the |
| 5582 | + // end-anchoring compensation used to write 4850 over the index scroll. |
| 5583 | + const { v, el, scroll, settle } = setupHandDrivenScroll({ anchorTo: 'end' }) |
| 5584 | + el.scrollTop = 4800 |
| 5585 | + scroll(4800, false) |
| 5586 | + |
| 5587 | + v.scrollToIndex(20, { align: 'start' }) |
| 5588 | + v.resizeItem(99, 100) |
| 5589 | + settle() |
| 5590 | + |
| 5591 | + expect(el.scrollTop).toBe(1000) |
| 5592 | + expect(v.scrollState).toBeNull() |
| 5593 | +}) |
| 5594 | + |
| 5595 | +test('scrollBy past the end does not leave the next scrollBy building on an unreachable offset', () => { |
| 5596 | + // A write the browser clamps to where the element already is fires no |
| 5597 | + // scroll event, so nothing reads it back. |
| 5598 | + const { v, el, scroll, frames } = setupHandDrivenScroll() |
| 5599 | + el.scrollTop = 4800 |
| 5600 | + scroll(4800, false) |
| 5601 | + |
| 5602 | + v.scrollBy(100) |
| 5603 | + frames() |
| 5604 | + expect(el.scrollTop).toBe(4800) |
| 5605 | + v.scrollBy(-100) |
| 5606 | + |
| 5607 | + expect(el.scrollTop).toBe(4700) |
| 5608 | +}) |
| 5609 | + |
| 5610 | +test('scrollBy past the start does not leave the next scrollBy building on an unreachable offset', () => { |
| 5611 | + const { v, el, frames } = setupHandDrivenScroll() |
| 5612 | + |
| 5613 | + v.scrollBy(-100) |
| 5614 | + frames() |
| 5615 | + expect(el.scrollTop).toBe(0) |
| 5616 | + v.scrollBy(100) |
| 5617 | + |
| 5618 | + expect(el.scrollTop).toBe(100) |
| 5619 | +}) |
| 5620 | + |
| 5621 | +test('scrollBy after the safety valve dropped an ignored write builds on the real position', () => { |
| 5622 | + const { v, el, scroll, frames, browser } = setupHandDrivenScroll() |
| 5623 | + el.scrollTop = 2000 |
| 5624 | + scroll(2000, false) |
| 5625 | + let now = 0 |
| 5626 | + v['now'] = () => now |
| 5627 | + |
| 5628 | + browser.ignoreWrites = true |
| 5629 | + v.scrollToIndex(20, { align: 'start' }) |
| 5630 | + now = 6000 |
| 5631 | + frames() |
| 5632 | + expect(v.scrollState).toBeNull() |
| 5633 | + browser.ignoreWrites = false |
| 5634 | + v.scrollBy(100) |
| 5635 | + |
| 5636 | + expect(el.scrollTop).toBe(2100) |
| 5637 | +}) |
| 5638 | + |
| 5639 | +test('scrollBy after cancelScroll dropped an ignored write builds on the real position', () => { |
| 5640 | + const { v, el, scroll, browser } = setupHandDrivenScroll() |
| 5641 | + el.scrollTop = 2000 |
| 5642 | + scroll(2000, false) |
| 5643 | + |
| 5644 | + browser.ignoreWrites = true |
| 5645 | + v.scrollToIndex(20, { align: 'start' }) |
| 5646 | + v.cancelScroll() |
| 5647 | + browser.ignoreWrites = false |
| 5648 | + v.scrollBy(100) |
| 5649 | + |
| 5650 | + expect(el.scrollTop).toBe(2100) |
| 5651 | +}) |
0 commit comments