scrollAnchor.test.ts2.9 KBView on GitHub
/**
 * Holding the reader's place when older messages are prepended.
 *
 * The scroll-load shipped without this and it is the defect that makes infinite scroll unusable:
 * older messages are PREPENDED, so the content above the viewport grows while `scrollTop` stays
 * put. The message being read slides down the page, and the reader is left sitting at the top
 * again — which re-triggers the load on the next scroll event, so it chains into itself.
 *
 * The effect in ChannelThreadView is a few lines of DOM arithmetic wrapped in React. Testing it
 * through the component would need a real layout engine (jsdom reports every height as 0), so the
 * arithmetic is extracted here and pinned directly — the same rule the effect applies.
 */

/** What the layout effect does, given the container's before/after heights. */
function anchor(input: {
  pinnedToBottom: boolean;
  scrollTop: number;
  prevScrollHeight: number;
  scrollHeight: number;
}): number {
  if (input.pinnedToBottom) return input.scrollHeight;
  if (input.prevScrollHeight && input.scrollHeight > input.prevScrollHeight) {
    return input.scrollTop + (input.scrollHeight - input.prevScrollHeight);
  }
  return input.scrollTop;
}

describe('scroll anchoring on prepend', () => {
  it('pushes the viewport down by exactly the height that was added above it', () => {
    // 50 older messages added 2000px above a reader sitting 100px from the top: without this they
    // would still be at 100px, now looking at completely different messages.
    expect(
      anchor({ pinnedToBottom: false, scrollTop: 100, prevScrollHeight: 5000, scrollHeight: 7000 }),
    ).toBe(2100);
  });

  it('leaves the reader at the top only when nothing was added', () => {
    // The end-of-history case: the load resolved with no new messages, so the position stands and
    // the "Beginning of #channel" marker is what changes.
    expect(
      anchor({ pinnedToBottom: false, scrollTop: 80, prevScrollHeight: 5000, scrollHeight: 5000 }),
    ).toBe(80);
  });

  it('stays pinned to the bottom for a new message or a send', () => {
    expect(
      anchor({ pinnedToBottom: true, scrollTop: 4000, prevScrollHeight: 5000, scrollHeight: 5200 }),
    ).toBe(5200);
  });

  it('does not move on the very first render, when there is no previous height', () => {
    // Opening a different row resets `prevScrollHeight` to 0; treating that as a delta would
    // scroll the new thread by the whole height of the one before it.
    expect(
      anchor({ pinnedToBottom: false, scrollTop: 0, prevScrollHeight: 0, scrollHeight: 7000 }),
    ).toBe(0);
  });

  it('ignores a SHRINKING container rather than scrolling backwards', () => {
    // Switching rows can render fewer messages before the new window lands; a negative delta
    // would yank the reader upward for no reason.
    expect(
      anchor({ pinnedToBottom: false, scrollTop: 900, prevScrollHeight: 7000, scrollHeight: 3000 }),
    ).toBe(900);
  });
});