From fd6c048cbfe79dfae95b6c0e9d4e136d2e53e535 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 21 Apr 2023 17:01:55 +0300 Subject: [PATCH] feat: support state save/load Addresses a flicker with alignToBottom and React 18. Fixes #883 --- e2e/initial-topmost-item.test.ts | 2 +- examples/chat.tsx | 296 +++++++++++--------- examples/iti-multiple.tsx | 38 +-- examples/state.tsx | 35 +++ package.json | 3 +- pnpm-lock.yaml | 7 + src/AATree.ts | 2 +- src/Virtuoso.tsx | 4 + src/alignToBottomSystem.ts | 1 + src/component-interfaces/Virtuoso.ts | 14 + src/gridSystem.ts | 2 +- src/hooks/__mocks__/useChangedChildSizes.ts | 2 +- src/hooks/useChangedChildSizes.ts | 3 +- src/initialTopMostItemIndexSystem.ts | 22 +- src/interfaces.ts | 13 + src/listStateSystem.ts | 2 +- src/listSystem.ts | 16 +- src/sizeSystem.ts | 17 +- src/stateLoadSystem.ts | 35 +++ src/urx/utils.ts | 4 + test/listSystem.test.ts | 21 +- test/sizeSystem.test.ts | 18 +- 22 files changed, 373 insertions(+), 184 deletions(-) create mode 100644 examples/state.tsx create mode 100644 src/stateLoadSystem.ts diff --git a/e2e/initial-topmost-item.test.ts b/e2e/initial-topmost-item.test.ts index 48b00c2dd..33ff1a2ca 100644 --- a/e2e/initial-topmost-item.test.ts +++ b/e2e/initial-topmost-item.test.ts @@ -38,7 +38,7 @@ test.describe('jagged list with initial topmost item', () => { test('sticks the item to the bottom', async ({ page }) => { await page.click('#initial-end-80') - await page.waitForTimeout(100) + await page.waitForTimeout(200) const scrollTop = await page.evaluate(() => { const listContainer = document.querySelector('[data-test-id=virtuoso-scroller]') as HTMLElement diff --git a/examples/chat.tsx b/examples/chat.tsx index 3d8dacd1a..9a666328d 100644 --- a/examples/chat.tsx +++ b/examples/chat.tsx @@ -1,158 +1,194 @@ -import { useRef, useState } from 'react' -import * as React from 'react' -import styled from '@emotion/styled' -import { Virtuoso } from '../src/' +import React from 'react' +import { StateSnapshot, Virtuoso, VirtuosoHandle } from '../src/' import { faker } from '@faker-js/faker' +import { produce } from 'immer' -interface BubbleProps { - text: string - fromUser?: boolean - className?: string -} - -const BubbleWrap = styled.div<{ fromUser?: boolean }>` - display: flex; - justify-content: ${({ fromUser }) => fromUser && 'flex-end'}; - width: 100%; - padding: 12px 0; -` - -const Content = styled.div<{ fromUser?: boolean }>` - background: ${({ fromUser }) => (fromUser ? 'orange' : 'red')}; - color: white; - width: 60%; - padding: 12px; - border-radius: 4px; - word-break: break-word; -` +const OWN_USER_ID = '1' -function Bubble({ text, fromUser, className }: BubbleProps) { - return ( - - {text} - - ) +interface Message { + id: string + message: string } -interface ChatListProps { - messages: { id: string; message: string }[] - userId: string - onSend: (message: string) => void - onReceive: () => void - height?: number - placeholder?: string +function generateMessages(length: number): Message[] { + return Array.from({ length }, (_) => ({ + id: faker.datatype.number({ min: 1, max: 2 }).toString(), + message: faker.lorem.sentences(), + })) } -const Root = styled.div<{ fromUser?: boolean }>` - padding: 12px 24px; -` +const initialChannelData = Array.from({ length: 3 }, (_, index) => { + return { + id: index, + name: `Channel ${index}`, + messages: generateMessages(130), + } +}) -const TextWrapper = styled.div` - display: flex; - justify-content: space-between; - width: 100%; - height: 100%; - margin-top: 12px; -` +initialChannelData.push({ + id: 3, + name: 'Channel 3', + messages: generateMessages(1), +}) -function ChatList({ userId, messages = [], onSend, onReceive, placeholder }: ChatListProps) { - const [newMessage, setNewMessage] = useState('') - const ref = useRef(null) - const isMyOwnMessage = useRef(false) - const onSendMessage = () => { - isMyOwnMessage.current = true - onSend(newMessage) - setNewMessage('') - } +export function Example() { + const [channels, setChannels] = React.useState(initialChannelData) + const [currentChannelId, setCurrentChannelId] = React.useState(null) + const channel = channels.find((x) => x.id === currentChannelId) + const virtuosoRef = React.useRef(null) + const channelStateCache = React.useRef(new Map()) + const [newMessage, setNewMessage] = React.useState('') + const [isOwnMessage, setIsOwnMessage] = React.useState(false) - const onReceiveMessage = () => { - isMyOwnMessage.current = false - onReceive() - } + const addMessage = React.useCallback( + (message: Message) => { + setChannels((channels) => { + return produce(channels, (draft) => { + const channel = draft.find((x) => x.id === currentChannelId) + channel?.messages.push(message) + }) + }) + }, + [currentChannelId, channels] + ) + + const selectChannel = React.useCallback( + (id: number) => { + if (currentChannelId !== null) { + virtuosoRef.current?.getState((snapshot) => { + channelStateCache.current.set(currentChannelId, snapshot) + }) + } + setCurrentChannelId(id) + }, + [currentChannelId] + ) - const row = React.useMemo( - () => - (i: number, { message, id }: { message: string; id: string }) => { - const fromUser = id === userId - return - }, - [userId] + const followOutput = React.useCallback( + (isAtBottom: boolean) => { + if (isOwnMessage) { + // if the user has scrolled away and sends a message, bring him to the bottom instantly + return isAtBottom ? 'smooth' : 'auto' + } else { + // a message from another user has been received - don't pull to bottom unless already there + return isAtBottom ? 'smooth' : false + } + }, + [isOwnMessage] ) + const channelState = channelStateCache.current.get(currentChannelId) + return ( - - { - if (isMyOwnMessage.current) { - // if the user has scrolled away and sends a message, bring him to the bottom instantly - return isAtBottom ? 'smooth' : 'auto' - } else { - // a message from another user has been received - don't pull to bottom unless already there - return isAtBottom ? 'smooth' : false - } +
+
+
    + {channels.map((x) => ( +
  • + +
  • + ))} +
+
+
- -
{ - e.preventDefault() - onSendMessage() - }} - > - setNewMessage((e.target as HTMLInputElement).value)} - placeholder={placeholder} - /> - | - -
-
- + > + {channel ? ( + <> +

{channel.name}

+ + + +
+
{ + e.preventDefault() + setIsOwnMessage(true) + addMessage({ id: OWN_USER_ID, message: newMessage }) + }} + > + setNewMessage((e.target as HTMLInputElement).value)} + placeholder="Say hi!" + /> + + +
+
+ + ) : ( + 'Select a channel..' + )} +
+
) } -const data = Array.from({ length: 130 }, (_) => ({ - id: faker.datatype.number({ min: 1, max: 2 }).toString(), - message: faker.lorem.sentences(), -})) - -export function Example() { - const [messages, setMessages] = React.useState(data) - const userId = '1' +function virtosoItemContent(_: number, { id, message }: Message, { ownUserId }: { ownUserId: string }) { + const fromUser = id === ownUserId return (
- setMessages((x) => [...x, { id: userId, message }])} - onReceive={() => { - setMessages((x) => [...x, { id: '2', message: faker.lorem.sentences() }]) +
+ > + {message} +
) } diff --git a/examples/iti-multiple.tsx b/examples/iti-multiple.tsx index 2fc7249e2..c8b1dc34f 100644 --- a/examples/iti-multiple.tsx +++ b/examples/iti-multiple.tsx @@ -1,16 +1,16 @@ import * as React from 'react' -import { Virtuoso } from '../src' +import { Virtuoso } from '../src' const itemContent = (index: number) =>
Item {index}
export function App() { const data = Array(50) - .fill(undefined) - .map((_, i) => i); - + .fill(undefined) + .map((_, i) => i) + return (
-
+
{Array(20) .fill(undefined) .map((_, i) => ( @@ -19,31 +19,33 @@ export function App() { style={{ flex: 1, height: 400, - border: "2px black solid", + border: '2px black solid', }} data={data} - itemContent={(_, i) => ( -
- {i} -
- )} + itemContent={(_, i) =>
{i}
} //initialScrollTop={200} initialTopMostItemIndex={5} /> ))}
- ); + ) } -export function Example() { +export function Example() { return ( <> -
- {Array.from({length: 30}).map((_, key) => { - return +
+ {Array.from({ length: 30 }).map((_, key) => { + return ( + + ) })}
diff --git a/examples/state.tsx b/examples/state.tsx new file mode 100644 index 000000000..9e7ead2f1 --- /dev/null +++ b/examples/state.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { Virtuoso, VirtuosoHandle } from '../src' +import { StateSnapshot } from '../src/stateLoadSystem' + +export function Example() { + const ref = React.useRef(null) + const state = React.useRef(undefined) + const [key, setKey] = React.useState(0) + + console.log('Rendering with key', key) + return ( +
+ + + `item-${key.toString()}`} + totalCount={100} + itemContent={(index) =>
Item {index}
} + style={{ height: 300 }} + /> +
+ ) +} diff --git a/package.json b/package.json index 9ac23392b..b6184c187 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,10 @@ "@emotion/core": "^10.1.0", "@emotion/styled": "^10.0.27", "@faker-js/faker": "^7.6.0", - "@testing-library/react": "^13.4.0", "@ladle/react": "^2.4.5", "@microsoft/api-extractor": "^7.33.7", "@playwright/test": "^1.29.1", + "@testing-library/react": "^13.4.0", "@types/jsdom": "^16.2.3", "@types/lodash": "^4.14.165", "@types/react": "^18.0.5", @@ -72,6 +72,7 @@ "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^6.0.0", + "immer": "^10.0.1", "jsdom": "^20.0.3", "lodash": "^4.17.21", "playwright": "^1.29.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f3ce1ed0..6fca2bd70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ devDependencies: husky: specifier: ^6.0.0 version: 6.0.0 + immer: + specifier: ^10.0.1 + version: 10.0.1 jsdom: specifier: ^20.0.3 version: 20.0.3 @@ -5016,6 +5019,10 @@ packages: engines: {node: '>= 4'} dev: true + /immer@10.0.1: + resolution: {integrity: sha512-zg++jJLsKKTwXGeSYIw0HgChSYQGtu0UDTnbKx5aGLYgte4CwTmH9eJDYyQ6FheyUtBe+lQW9FrGxya1G+Dtmg==} + dev: true + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} diff --git a/src/AATree.ts b/src/AATree.ts index 55e2436b2..db8cfa43d 100644 --- a/src/AATree.ts +++ b/src/AATree.ts @@ -4,7 +4,7 @@ interface NilNode { const NIL_NODE: NilNode = { lvl: 0 } -interface NodeData { +export interface NodeData { k: number v: T } diff --git a/src/Virtuoso.tsx b/src/Virtuoso.tsx index ef72e4ade..3f41c9b83 100644 --- a/src/Virtuoso.tsx +++ b/src/Virtuoso.tsx @@ -112,6 +112,7 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa const isSeeking = useEmitterValue('isSeeking') const hasGroups = useEmitterValue('groupIndices').length > 0 const paddingTopAddition = useEmitterValue('paddingTopAddition') + const scrolledToInitialItem = useEmitterValue('scrolledToInitialItem') const containerStyle: React.CSSProperties = showTopList ? {} @@ -120,6 +121,7 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa paddingTop: listState.offsetTop + paddingTopAddition, paddingBottom: listState.offsetBottom, marginTop: deviation, + ...(scrolledToInitialItem ? {} : { visibility: 'hidden' }), } if (!showTopList && listState.totalCount === 0 && EmptyPlaceholder) { @@ -396,6 +398,7 @@ export const { { required: {}, optional: { + restoreStateFrom: 'restoreStateFrom', context: 'context', followOutput: 'followOutput', itemContent: 'itemContent', @@ -431,6 +434,7 @@ export const { scrollTo: 'scrollTo', scrollBy: 'scrollBy', autoscrollToBottom: 'autoscrollToBottom', + getState: 'getState', }, events: { isScrolling: 'isScrolling', diff --git a/src/alignToBottomSystem.ts b/src/alignToBottomSystem.ts index b2b1eb0a6..74b8b4706 100644 --- a/src/alignToBottomSystem.ts +++ b/src/alignToBottomSystem.ts @@ -13,6 +13,7 @@ export const alignToBottomSystem = u.system( u.map(([, viewportHeight, totalListHeight]) => { return Math.max(0, viewportHeight - totalListHeight) }), + u.throttleTime(0), u.distinctUntilChanged() ), 0 diff --git a/src/component-interfaces/Virtuoso.ts b/src/component-interfaces/Virtuoso.ts index cfa14d18e..4f3b252c2 100644 --- a/src/component-interfaces/Virtuoso.ts +++ b/src/component-interfaces/Virtuoso.ts @@ -14,6 +14,8 @@ import type { ScrollIntoViewLocation, ScrollSeekConfiguration, SizeFunction, + StateSnapshot, + StateCallback, } from '../interfaces' import { LogLevel } from '../loggerSystem' @@ -243,6 +245,13 @@ export interface VirtuosoProps extends ListRootProps { * Ensure that you have "all levels" enabled in the browser console too see the messages. */ logLevel?: LogLevel + + /** + * pass a state obtained from the getState() method to restore the list state - this includes the previously measured item sizes and the scroll location. + * Notice that you should still pass the same data and totalCount properties as before, so that the list can match the data with the stored measurements. + * This is useful when you want to keep the list state when the component is unmounted and remounted, for example when navigating to a different page. + */ + restoreStateFrom?: StateSnapshot } export interface GroupedVirtuosoProps extends Omit, 'totalCount' | 'itemContent'> { @@ -299,6 +308,11 @@ export interface VirtuosoHandle { * Use this with combination with follow output if you have images loading in the list. Listen to the image loading and call the method. */ autoscrollToBottom(): void + + /** + * Obtains the internal size state of the component, so that it can be restored later. This does not include the data items. + */ + getState(stateCb: StateCallback): void } export interface GroupedVirtuosoHandle { diff --git a/src/gridSystem.ts b/src/gridSystem.ts index 2184b49c7..60f1d4fe2 100644 --- a/src/gridSystem.ts +++ b/src/gridSystem.ts @@ -161,7 +161,7 @@ export const gridSystem = /*#__PURE__*/ u.system( u.connect( u.pipe( data, - u.filter((data) => data !== undefined), + u.filter(u.isDefined), u.map((data) => data!.length) ), totalCount diff --git a/src/hooks/__mocks__/useChangedChildSizes.ts b/src/hooks/__mocks__/useChangedChildSizes.ts index 00deaadd9..177233356 100644 --- a/src/hooks/__mocks__/useChangedChildSizes.ts +++ b/src/hooks/__mocks__/useChangedChildSizes.ts @@ -1,4 +1,4 @@ -import { SizeRange } from '../../sizeSystem' +import { SizeRange } from '../../' type CallbackRefParam = HTMLElement | null diff --git a/src/hooks/useChangedChildSizes.ts b/src/hooks/useChangedChildSizes.ts index a89de52a7..98405338a 100644 --- a/src/hooks/useChangedChildSizes.ts +++ b/src/hooks/useChangedChildSizes.ts @@ -1,8 +1,7 @@ import React from 'react' import { Log, LogLevel } from '../loggerSystem' -import { SizeRange } from '../sizeSystem' import { useSizeWithElRef } from './useSize' -import { SizeFunction, ScrollContainerState } from '../interfaces' +import { SizeRange, SizeFunction, ScrollContainerState } from '../interfaces' export default function useChangedListContentsSizes( callback: (ranges: SizeRange[]) => void, itemSize: SizeFunction, diff --git a/src/initialTopMostItemIndexSystem.ts b/src/initialTopMostItemIndexSystem.ts index ad4c1f659..06fd5f69c 100644 --- a/src/initialTopMostItemIndexSystem.ts +++ b/src/initialTopMostItemIndexSystem.ts @@ -12,10 +12,19 @@ export function getInitialTopMostItemIndexNumber(location: number | FlatIndexLoc return index } +function skipFrames(frameCount: number, callback: () => void) { + if (frameCount == 0) { + callback() + } else { + requestAnimationFrame(() => skipFrames(frameCount - 1, callback)) + } +} + export const initialTopMostItemIndexSystem = u.system( ([{ sizes, listRefresh, defaultItemSize }, { scrollTop }, { scrollToIndex }, { didMount }]) => { const scrolledToInitialItem = u.statefulStream(true) const initialTopMostItemIndex = u.statefulStream(0) + const scrollScheduled = u.statefulStream(false) u.connect( u.pipe( @@ -31,17 +40,16 @@ export const initialTopMostItemIndexSystem = u.system( u.subscribe( u.pipe( u.combineLatest(listRefresh, didMount), - u.withLatestFrom(scrolledToInitialItem, sizes, defaultItemSize), - u.filter(([[, didMount], scrolledToInitialItem, { sizeTree }, defaultItemSize]) => { - return didMount && (!empty(sizeTree) || defaultItemSize !== undefined) && !scrolledToInitialItem + u.withLatestFrom(scrolledToInitialItem, sizes, defaultItemSize, scrollScheduled), + u.filter(([[, didMount], scrolledToInitialItem, { sizeTree }, defaultItemSize, scrollScheduled]) => { + return didMount && (!empty(sizeTree) || u.isDefined(defaultItemSize)) && !scrolledToInitialItem && !scrollScheduled }), u.withLatestFrom(initialTopMostItemIndex) ), ([, initialTopMostItemIndex]) => { - setTimeout(() => { - u.handleNext(scrollTop, () => { - u.publish(scrolledToInitialItem, true) - }) + u.publish(scrollScheduled, true) + skipFrames(2, () => { + u.handleNext(scrollTop, () => u.publish(scrolledToInitialItem, true)) u.publish(scrollToIndex, initialTopMostItemIndex) }) } diff --git a/src/interfaces.ts b/src/interfaces.ts index fb93d2b13..58c6555bf 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -398,3 +398,16 @@ export interface ScrollContainerState { /** Calculates the height of `el`, which will be the `Item` element in the DOM. */ export type SizeFunction = (el: HTMLElement, field: 'offsetHeight' | 'offsetWidth') => number + +export interface SizeRange { + startIndex: number + endIndex: number + size: number +} + +export interface StateSnapshot { + ranges: SizeRange[] + scrollTop: number +} + +export type StateCallback = (state: StateSnapshot) => void diff --git a/src/listStateSystem.ts b/src/listStateSystem.ts index 258e92176..f4791912e 100644 --- a/src/listStateSystem.ts +++ b/src/listStateSystem.ts @@ -287,7 +287,7 @@ export const listStateSystem = u.system( u.connect( u.pipe( data, - u.filter((data) => data !== undefined), + u.filter(u.isDefined), u.map((data) => data?.length) ), totalCount diff --git a/src/listSystem.ts b/src/listSystem.ts index 32b730807..c9e545966 100644 --- a/src/listSystem.ts +++ b/src/listSystem.ts @@ -18,9 +18,8 @@ import { alignToBottomSystem } from './alignToBottomSystem' import { windowScrollerSystem } from './windowScrollerSystem' import { loggerSystem } from './loggerSystem' import { scrollIntoViewSystem } from './scrollIntoViewSystem' +import { stateLoadSystem } from './stateLoadSystem' -// workaround the growing list of systems below -// fix this with 4.1 recursive conditional types const featureGroup1System = u.system( ([ sizeRange, @@ -32,6 +31,7 @@ const featureGroup1System = u.system( alignToBottom, windowScroller, scrollIntoView, + logger, ]) => { return { ...sizeRange, @@ -43,6 +43,7 @@ const featureGroup1System = u.system( ...alignToBottom, ...windowScroller, ...scrollIntoView, + ...logger, } }, u.tup( @@ -54,7 +55,8 @@ const featureGroup1System = u.system( initialScrollTopSystem, alignToBottomSystem, windowScrollerSystem, - scrollIntoViewSystem + scrollIntoViewSystem, + loggerSystem ) ) @@ -76,6 +78,7 @@ export const listSystem = u.system( }, { initialTopMostItemIndex, scrolledToInitialItem }, domIO, + stateLoad, followOutput, { listState, topItemsIndexes, ...flags }, { scrollToIndex }, @@ -83,7 +86,6 @@ export const listSystem = u.system( { topItemCount }, { groupCounts }, featureGroup1, - log, ]) => { u.connect(flags.rangeChanged, featureGroup1.scrollSeekRangeChanged) u.connect( @@ -123,21 +125,21 @@ export const listSystem = u.system( // the bag of IO from featureGroup1System ...featureGroup1, ...domIO, - ...log, sizes, + ...stateLoad, } }, u.tup( sizeSystem, initialTopMostItemIndexSystem, domIOSystem, + stateLoadSystem, followOutputSystem, listStateSystem, scrollToIndexSystem, upwardScrollFixSystem, topItemCountSystem, groupedListSystem, - featureGroup1System, - loggerSystem + featureGroup1System ) ) diff --git a/src/sizeSystem.ts b/src/sizeSystem.ts index 5b88a1521..9bc4a4c6d 100644 --- a/src/sizeSystem.ts +++ b/src/sizeSystem.ts @@ -4,13 +4,7 @@ import * as arrayBinarySearch from './utils/binaryArraySearch' import { correctItemSize } from './utils/correctItemSize' import { Log, loggerSystem, LogLevel } from './loggerSystem' import { recalcSystem } from './recalcSystem' -import { SizeFunction } from './interfaces' - -export interface SizeRange { - startIndex: number - endIndex: number - size: number -} +import { SizeFunction, SizeRange } from './interfaces' export type Data = readonly unknown[] | undefined @@ -279,6 +273,15 @@ export function hasGroups(sizes: SizeState) { return !empty(sizes.groupOffsetTree) } +export function sizeTreeToRanges(sizeTree: AANode): SizeRange[] { + return walk(sizeTree).map(({ k: startIndex, v: size }, index, sizeArray) => { + const nextSize = sizeArray[index + 1] + const endIndex = nextSize ? nextSize.k - 1 : Infinity + + return { startIndex, endIndex, size } + }) +} + type OptionalNumber = number | undefined const SIZE_MAP = { diff --git a/src/stateLoadSystem.ts b/src/stateLoadSystem.ts new file mode 100644 index 000000000..7b956f233 --- /dev/null +++ b/src/stateLoadSystem.ts @@ -0,0 +1,35 @@ +import { domIOSystem } from './domIOSystem' +import { initialTopMostItemIndexSystem } from './initialTopMostItemIndexSystem' +import { StateSnapshot, StateCallback } from './interfaces' +import { sizeSystem, sizeTreeToRanges } from './sizeSystem' +import * as u from './urx' + +export const stateLoadSystem = u.system(([{ sizes, sizeRanges }, { scrollTop }, { initialTopMostItemIndex }]) => { + const getState = u.stream() + const restoreStateFrom = u.statefulStream(undefined) + + u.subscribe(u.pipe(getState, u.withLatestFrom(sizes, scrollTop)), ([callback, sizes, scrollTop]) => { + const ranges = sizeTreeToRanges(sizes.sizeTree) + callback({ ranges, scrollTop }) + }) + + u.connect(u.pipe(restoreStateFrom, u.filter(u.isDefined), u.map(locationFromSnapshot)), initialTopMostItemIndex) + + u.connect( + u.pipe( + restoreStateFrom, + u.filter(u.isDefined), + u.map((snapshot) => snapshot!.ranges) + ), + sizeRanges + ) + + return { + getState, + restoreStateFrom, + } +}, u.tup(sizeSystem, domIOSystem, initialTopMostItemIndexSystem)) + +function locationFromSnapshot(snapshot: StateSnapshot | undefined) { + return { offset: snapshot!.scrollTop, index: 0, align: 'start' } +} diff --git a/src/urx/utils.ts b/src/urx/utils.ts index 84ee1085a..11586bf65 100644 --- a/src/urx/utils.ts +++ b/src/urx/utils.ts @@ -89,5 +89,9 @@ export function joinProc(...procs: Proc[]) { } } +export function isDefined(arg: T): boolean { + return arg !== undefined +} + // eslint-disable-next-line @typescript-eslint/no-empty-function export function noop() {} diff --git a/test/listSystem.test.ts b/test/listSystem.test.ts index f3c6d1ce4..6d92b4530 100644 --- a/test/listSystem.test.ts +++ b/test/listSystem.test.ts @@ -119,7 +119,7 @@ describe('list engine', () => { publish(scrollTop, INITIAL_INDEX * SIZE) expect(getValue(listState).items).toHaveLength(7) resolve(true) - }) + }, 100) }) }) @@ -155,7 +155,7 @@ describe('list engine', () => { publish(scrollTop, INITIAL_INDEX * SIZE) expect(getValue(listState).items).toHaveLength(7) resolve(true) - }) + }, 100) }) }) }) @@ -316,7 +316,7 @@ describe('list engine', () => { expect(scrollBySub).toHaveBeenCalledWith({ behavior: 'auto', top: 40 }) resolve(true) }, 1000) - }) + }, 100) }) }) }) @@ -529,9 +529,18 @@ describe('list engine', () => { const sub = vi.fn() subscribe(paddingTopAddition, sub) publish(propsReady, true) - expect(sub).toHaveBeenCalledWith(1200 - 5 * 30) - publish(viewportHeight, 1100) - expect(sub).toHaveBeenCalledWith(1100 - 5 * 30) + + // throttling is necessary due to react 18 + return new Promise((resolve) => { + setTimeout(() => { + expect(sub).toHaveBeenCalledWith(1200 - 5 * 30) + publish(viewportHeight, 1100) + setTimeout(() => { + expect(sub).toHaveBeenCalledWith(1100 - 5 * 30) + resolve(void 0) + }) + }) + }) }) }) diff --git a/test/sizeSystem.test.ts b/test/sizeSystem.test.ts index 8483a3f6d..c36d75433 100644 --- a/test/sizeSystem.test.ts +++ b/test/sizeSystem.test.ts @@ -1,6 +1,6 @@ import { getValue, init, publish, subscribe } from '../src/urx' import { AANode, ranges, walk } from '../src/AATree' -import { initialSizeState, offsetOf, rangesWithinOffsets, sizeStateReducer, sizeSystem } from '../src/sizeSystem' +import { initialSizeState, offsetOf, rangesWithinOffsets, sizeStateReducer, sizeSystem, sizeTreeToRanges } from '../src/sizeSystem' import { describe, it, expect, vi } from 'vitest' function toKV(tree: AANode) { @@ -587,6 +587,22 @@ describe('ranges within offsets', () => { }) }) +describe('state save', () => { + it('serializes the size tree to ranges', () => { + let state = initialSizeState() + expect(sizeTreeToRanges(state.sizeTree)).toEqual([]) + state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 1 }], [], mockLogger, 0]) + expect(sizeTreeToRanges(state.sizeTree)).toEqual([{ startIndex: 0, endIndex: Infinity, size: 1 }]) + + state = sizeStateReducer(state, [[{ startIndex: 3, endIndex: 5, size: 2 }], [], mockLogger, 0]) + expect(sizeTreeToRanges(state.sizeTree)).toEqual([ + { startIndex: 0, endIndex: 2, size: 1 }, + { startIndex: 3, endIndex: 5, size: 2 }, + { startIndex: 6, endIndex: Infinity, size: 1 }, + ]) + }) +}) + /* describe.only('benchmarks', () => { const COUNT = 20000