Skip to content

Commit

Permalink
feat(): support logging diagnostics
Browse files Browse the repository at this point in the history
Fix #439
  • Loading branch information
petyosi committed Aug 30, 2021
1 parent 54de57b commit d126cec
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 21 deletions.
8 changes: 8 additions & 0 deletions site/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ slug: /troubleshooting
React Virtuoso tries to hide as much complexity as possible, while maintaining sensible behavior with any kind of configuration.
The magic has certain limits though, so please check this section if something does not work as you expect.

## List is jumping around or misbehaving

The list relies on measuring the item sizes and dynamically updating its position based on the received data. This is more of an art than science in some use cases, especially when it comes to reverse scrolling.
Certain content factors like dynamic content (images, iframes, etc) can cause trouble.

To get a better sense if this is your case, you can enable debug logging either by setting the `logLevel` property to `LogLevel.DEBUG` or by setting a `globalThis.VIRTUOSO_LOG_LEVEL` to `LogLevel.DEBUG`. Import `LogLevel` from the `react-virtuoso` package.
Afterwards, set the logging level in your browser to `"all levels"` and observe the messages for unexpected item sizes being reported outside of the normal render cycle.

## List does not scroll to the bottom / items jump around

This is the most common setup error. It happens because the DOM elements inside the items (or the items themselves) have margins.
Expand Down
4 changes: 3 additions & 1 deletion src/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,9 @@ export const Items = React.memo(function VirtuosoItems({ showTopList = false }:
const groupContent = useEmitterValue('groupContent')
const trackItemSizes = useEmitterValue('trackItemSizes')
const itemSize = useEmitterValue('itemSize')
const log = useEmitterValue('log')

const ref = useChangedChildSizes(sizeRanges, itemSize, trackItemSizes)
const ref = useChangedChildSizes(sizeRanges, itemSize, trackItemSizes, log)
const EmptyPlaceholder = useEmitterValue('EmptyPlaceholder')
const ScrollSeekPlaceholder = useEmitterValue('ScrollSeekPlaceholder') || DefaultScrollSeekPlaceholder
const ListComponent = useEmitterValue('ListComponent')!
Expand Down Expand Up @@ -428,6 +429,7 @@ export const { Component: List, usePublisher, useEmitterValue, useEmitter } = sy
alignToBottom: 'alignToBottom',
useWindowScroll: 'useWindowScroll',
scrollerRef: 'scrollerRef',
logLevel: 'logLevel',

// deprecated
item: 'item',
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/useChangedChildSizes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Log, LogLevel } from '../loggerSystem'
import { SizeFunction, SizeRange } from '../sizeSystem'
import useSize from './useSize'

export default function useChangedChildSizes(callback: (ranges: SizeRange[]) => void, itemSize: SizeFunction, enabled: boolean) {
export default function useChangedChildSizes(callback: (ranges: SizeRange[]) => void, itemSize: SizeFunction, enabled: boolean, log: Log) {
return useSize((el: HTMLElement) => {
const ranges = getChangedChildSizes(el.children, itemSize, 'offsetHeight')
const ranges = getChangedChildSizes(el.children, itemSize, 'offsetHeight', log)
if (ranges !== null) {
callback(ranges)
}
}, enabled)
}

function getChangedChildSizes(children: HTMLCollection, itemSize: SizeFunction, field: 'offsetHeight' | 'offsetWidth') {
function getChangedChildSizes(children: HTMLCollection, itemSize: SizeFunction, field: 'offsetHeight' | 'offsetWidth', log: Log) {
const length = children.length

if (length === 0) {
Expand All @@ -31,7 +32,7 @@ function getChangedChildSizes(children: HTMLCollection, itemSize: SizeFunction,
const size = itemSize(child, field)

if (size === 0) {
throw new Error('Zero-sized element, this should not happen')
log('Zero-sized element, this should not happen', { child }, LogLevel.ERROR)
}

if (size === knownSize) {
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components'
export * from './interfaces'
export { LogLevel } from './loggerSystem'
6 changes: 5 additions & 1 deletion src/listSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { upwardScrollFixSystem } from './upwardScrollFixSystem'
import { initialScrollTopSystem } from './initialScrollTopSystem'
import { alignToBottomSystem } from './alignToBottomSystem'
import { windowScrollerSystem } from './windowScrollerSystem'
import { loggerSystem } from './loggerSystem'

// workaround the growing list of systems below
// fix this with 4.1 recursive conditional types
Expand Down Expand Up @@ -56,6 +57,7 @@ export const listSystem = u.system(
{ topItemCount },
{ groupCounts },
featureGroup1,
log,
]) => {
u.connect(flags.rangeChanged, featureGroup1.scrollSeekRangeChanged)
u.connect(u.pipe(featureGroup1.windowViewportRect, u.map(u.prop('visibleHeight'))), domIO.viewportHeight)
Expand Down Expand Up @@ -87,6 +89,7 @@ export const listSystem = u.system(
// the bag of IO from featureGroup1System
...featureGroup1,
...domIO,
...log,
}
},
u.tup(
Expand All @@ -99,6 +102,7 @@ export const listSystem = u.system(
upwardScrollFixSystem,
topItemCountSystem,
groupedListSystem,
featureGroup1System
featureGroup1System,
loggerSystem
)
)
48 changes: 48 additions & 0 deletions src/loggerSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as u from '@virtuoso.dev/urx'

// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace globalThis {
let VIRTUOSO_LOG_LEVEL: LogLevel | undefined
}

export enum LogLevel {
DEBUG,
INFO,
WARN,
ERROR,
}
export interface LogMessage {
level: LogLevel
message: any
label: string
}

export type Log = (label: string, message: any, level?: LogLevel) => void

const CONSOLE_METHOD_MAP = {
[LogLevel.DEBUG]: 'debug',
[LogLevel.INFO]: 'log',
[LogLevel.WARN]: 'warn',
[LogLevel.ERROR]: 'error',
}

export const loggerSystem = u.system(
() => {
const logLevel = u.statefulStream<LogLevel>(LogLevel.ERROR)
const log = u.statefulStream<Log>((label: string, message: any, level: LogLevel = LogLevel.INFO) => {
const currentLevel = globalThis['VIRTUOSO_LOG_LEVEL'] ?? u.getValue(logLevel)
if (level >= currentLevel) {
// @ts-expect-error we can call console the way we want
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
console[CONSOLE_METHOD_MAP[level]]('%creact-virutoso: %c%s %o', 'color: #0253b3; font-weight: bold', 'color: black', label, message)
}
})

return {
log,
logLevel,
}
},
[],
{ singleton: true }
)
20 changes: 16 additions & 4 deletions src/sizeSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as u from '@virtuoso.dev/urx'
import { arrayToRanges, AANode, empty, findMaxKeyValue, insert, newTree, Range, rangesWithin, remove, walk } from './AATree'
import * as arrayBinarySearch from './utils/binaryArraySearch'
import { correctItemSize } from './utils/correctItemSize'
import { loggerSystem, Log, LogLevel } from './loggerSystem'

export interface SizeRange {
startIndex: number
Expand Down Expand Up @@ -130,7 +131,8 @@ export function rangesWithinOffsets(
return arrayToRanges(arrayBinarySearch.findRange(tree, startOffset, endOffset, offsetComparator), offsetPointParser)
}

export function sizeStateReducer(state: SizeState, [ranges, groupIndices]: [SizeRange[], number[]]) {
export function sizeStateReducer(state: SizeState, [ranges, groupIndices, log]: [SizeRange[], number[], Log]) {
log('received item sizes', ranges, LogLevel.DEBUG)
const sizeTree = state.sizeTree
let offsetTree = state.offsetTree
let newSizeTree: AANode<number> = sizeTree
Expand Down Expand Up @@ -238,7 +240,7 @@ const SIZE_MAP = {
export type SizeFunction = (el: HTMLElement, field: 'offsetHeight' | 'offsetWidth') => number

export const sizeSystem = u.system(
() => {
([{ log }]) => {
const sizeRanges = u.stream<SizeRange[]>()
const totalCount = u.stream<number>()
const unshiftWith = u.stream<number>()
Expand All @@ -251,7 +253,7 @@ export const sizeSystem = u.system(
const initial = initialSizeState()

const sizes = u.statefulStreamFromEmitter(
u.pipe(sizeRanges, u.withLatestFrom(groupIndices), u.scan(sizeStateReducer, initial), u.distinctUntilChanged()),
u.pipe(sizeRanges, u.withLatestFrom(groupIndices, log), u.scan(sizeStateReducer, initial), u.distinctUntilChanged()),
initial
)

Expand Down Expand Up @@ -348,6 +350,16 @@ export const sizeSystem = u.system(
unshiftWith
)

u.subscribe(u.pipe(firstItemIndex, u.withLatestFrom(log)), ([index, log]) => {
if (index < 0) {
log(
"`firstItemIndex` prop should not be set to less than zero. If you don't know the total count, just use a very high value",
{ firstItemIndex },
LogLevel.ERROR
)
}
})

// Capture the current list top item before the sizes get refreshed
const beforeUnshiftWith = u.streamFromEmitter(unshiftWith)

Expand Down Expand Up @@ -398,6 +410,6 @@ export const sizeSystem = u.system(
itemSize,
}
},
[],
u.tup(loggerSystem),
{ singleton: true }
)
36 changes: 25 additions & 11 deletions test/sizeSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ function toKV<T>(tree: AANode<T>) {
return walk(tree).map((node) => [node.k, node.v] as [number, T])
}

const mockLogger = function () {
void 0
}

describe('size state reducer', () => {
describe('insert', () => {
it('sets the initial insert as a baseline', () => {
const state = initialSizeState()
const { sizeTree, offsetTree } = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 1 }], []])
const { sizeTree, offsetTree } = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 1 }], [], mockLogger])
expect(toKV(sizeTree)).toEqual([[0, 1]])
expect(offsetTree).toEqual([{ offset: 0, index: 0, size: 1 }])
})
Expand All @@ -24,6 +28,7 @@ describe('size state reducer', () => {
{ startIndex: 9, endIndex: 10, size: 2 },
],
[],
mockLogger,
])
expect(toKV(sizeTree)).toEqual([
[0, 1],
Expand All @@ -50,6 +55,7 @@ describe('size state reducer', () => {
{ startIndex: 3, endIndex: 8, size: 1 },
],
[],
mockLogger,
])

expect(toKV(sizeTree)).toEqual([[0, 1]])
Expand All @@ -64,6 +70,7 @@ describe('size state reducer', () => {
{ startIndex: 0, endIndex: 0, size: 2 },
],
[],
mockLogger,
])

expect(toKV(sizeTree)).toEqual([
Expand All @@ -86,9 +93,10 @@ describe('size state reducer', () => {
{ startIndex: 2, endIndex: 4, size: 2 },
],
[],
mockLogger,
])

state = sizeStateReducer(state, [[{ startIndex: 5, endIndex: 9, size: 2 }], []])
state = sizeStateReducer(state, [[{ startIndex: 5, endIndex: 9, size: 2 }], [], mockLogger])

const { sizeTree, offsetTree } = state

Expand All @@ -115,6 +123,7 @@ describe('size state reducer', () => {
{ startIndex: 2, endIndex: 4, size: 2 },
],
[],
mockLogger,
])

expect(toKV(sizeTree)).toEqual([
Expand All @@ -141,6 +150,7 @@ describe('size state reducer', () => {
{ startIndex: 7, endIndex: 11, size: 3 },
],
[],
mockLogger,
])

expect(toKV(sizeTree)).toEqual([
Expand Down Expand Up @@ -169,6 +179,7 @@ describe('size state reducer', () => {
{ startIndex: 3, endIndex: 12, size: 1 },
],
[],
mockLogger,
])

expect(toKV(sizeTree)).toEqual([[0, 1]])
Expand All @@ -178,19 +189,19 @@ describe('size state reducer', () => {
it('handles subsequent insertions correctly (bug)', () => {
const state = initialSizeState()

let nextState = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 158 }], []])
let nextState = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 158 }], [], mockLogger])

expect(ranges(nextState.sizeTree)).toEqual([{ start: 0, end: Infinity, value: 158 }])

nextState = sizeStateReducer(nextState, [[{ startIndex: 1, endIndex: 1, size: 206 }], []])
nextState = sizeStateReducer(nextState, [[{ startIndex: 1, endIndex: 1, size: 206 }], [], mockLogger])

expect(ranges(nextState.sizeTree)).toEqual([
{ start: 0, end: 0, value: 158 },
{ start: 1, end: 1, value: 206 },
{ start: 2, end: Infinity, value: 158 },
])

nextState = sizeStateReducer(nextState, [[{ startIndex: 3, endIndex: 3, size: 182 }], []])
nextState = sizeStateReducer(nextState, [[{ startIndex: 3, endIndex: 3, size: 182 }], [], mockLogger])

expect(ranges(nextState.sizeTree)).toEqual([
{ start: 0, end: 0, value: 158 },
Expand All @@ -209,6 +220,7 @@ describe('size state reducer', () => {
},
],
[],
mockLogger,
])

expect(ranges(nextState.sizeTree)).toEqual([
Expand All @@ -235,6 +247,7 @@ describe('size state reducer', () => {
{ startIndex: 6, endIndex: 6, size: 230 },
],
[],
mockLogger,
])

expect(ranges(sizeTree)).toEqual([
Expand All @@ -251,16 +264,16 @@ describe('size state reducer', () => {
it('finds the offset of a given index (simple tree)', () => {
let state = initialSizeState()

state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 30 }], []])
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 30 }], [], mockLogger])

expect(offsetOf(10, state.offsetTree)).toBe(300)
})

it('finds the offset of a given index (complex tree)', () => {
let state = initialSizeState()

state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 30 }], []])
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 4, size: 20 }], []])
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 0, size: 30 }], [], mockLogger])
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 4, size: 20 }], [], mockLogger])

expect(offsetOf(10, state.offsetTree)).toBe(250)
})
Expand All @@ -269,7 +282,7 @@ describe('size state reducer', () => {
let state = initialSizeState()

for (let index = 0; index < 5; index++) {
state = sizeStateReducer(state, [[{ startIndex: index, endIndex: index, size: index % 2 ? 50 : 30 }], []])
state = sizeStateReducer(state, [[{ startIndex: index, endIndex: index, size: index % 2 ? 50 : 30 }], [], mockLogger])
}

const { sizeTree, offsetTree } = state
Expand All @@ -282,7 +295,7 @@ describe('size state reducer', () => {
let state = initialSizeState()

for (let index = 4; index >= 0; index--) {
state = sizeStateReducer(state, [[{ startIndex: index, endIndex: index, size: index % 2 ? 50 : 30 }], []])
state = sizeStateReducer(state, [[{ startIndex: index, endIndex: index, size: index % 2 ? 50 : 30 }], [], mockLogger])
}

const { offsetTree, sizeTree } = state
Expand All @@ -294,7 +307,7 @@ describe('size state reducer', () => {
describe('group indices', () => {
it('merges groups and items if a single size is reported', () => {
let state = initialSizeState()
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 1, size: 30 }], [0, 6, 11]])
state = sizeStateReducer(state, [[{ startIndex: 0, endIndex: 1, size: 30 }], [0, 6, 11], mockLogger])
expect(toKV(state.sizeTree)).toEqual([[0, 30]])

expect(state.offsetTree).toEqual([{ index: 0, size: 30, offset: 0 }])
Expand All @@ -308,6 +321,7 @@ describe('size state reducer', () => {
{ startIndex: 1, endIndex: 1, size: 20 },
],
[0, 6, 11],
mockLogger,
])
expect(toKV(state.sizeTree)).toEqual([
[0, 30],
Expand Down

0 comments on commit d126cec

Please sign in to comment.