Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/lit-controller-set-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@tanstack/lit-virtual': minor
---

Add `setOptions` to `VirtualizerController` and `WindowVirtualizerController` so virtualizer options can be updated after construction.

Previously the controller only accepted options once, in its constructor, so options derived from reactive Lit properties (`count`, `estimateSize`, `horizontal`, …) could never be re-applied. `getVirtualizer().setOptions(...)` is not a substitute: it replaces options wholesale and drops the controller's `onChange` wrapper, which is what asks the host to re-render.

`setOptions` takes a partial set of options, merges them over the options the controller already holds (skipping `undefined`, so an omitted value does not wipe a stored one), re-applies the host `requestUpdate` wrapper around `onChange`, and flushes the change:

```ts
willUpdate(changed: PropertyValues<this>) {
if (changed.has('count')) {
this.virtualizerController.setOptions({ count: this.count })
}
}
```
25 changes: 25 additions & 0 deletions docs/framework/lit/lit-virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ private windowVirtualizerController = new WindowVirtualizerController<TItemEleme
```

This class stands of window-based `Virtualizer` instance configured to work with an HTML element as the scrollElement.

## Updating options

The controller reads options once, in its constructor. When a reactive property changes, pass the new values to `setOptions` from `willUpdate` (before render). `VirtualizerController` and `WindowVirtualizerController` both implement it.

`setOptions` merges that partial over the options the controller already holds, including its defaults, and keeps the `onChange` wrapper that re-renders the host. Keys set to `undefined` are ignored, so a partial update does not erase an option from an earlier call.

```ts
@property({ type: Number })
count = 1000

private virtualizerController = new VirtualizerController(this, {
getScrollElement: () => this.scrollElementRef.value,
count: this.count,
estimateSize: () => 35,
})

protected willUpdate(changed: PropertyValues<this>) {
if (changed.has('count')) {
this.virtualizerController.setOptions({ count: this.count })
}
}
```

Call `setOptions` on the controller. `getVirtualizer().setOptions(...)` replaces options wholesale, drops the host `onChange` wrapper, and leaves the controller's stored options stale.
12 changes: 12 additions & 0 deletions packages/lit-virtual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ class MyVirtualElement extends LitElement {

Note that a [Ref](https://lit.dev/docs/templates/directives/#ref) is attached to the scrolling container to allow the virtualizer to interact with it.

Options passed to the constructor are captured once. When a reactive property changes, merge the new values in `willUpdate` with `setOptions`. The same method exists on `WindowVirtualizerController`.

```ts
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has('count')) {
this.virtualizerController.setOptions({ count: this.count })
}
}
```

`setOptions` merges a partial update over the controller's current options and keeps the `onChange` wrapper that re-renders the host. `undefined` values are ignored. Use the controller method rather than `getVirtualizer().setOptions(...)`, which replaces options wholesale and drops that wrapper.

## `WindowVirtualizerController`

You can also create a virtualizer controller that attaches to the Window:
Expand Down
86 changes: 77 additions & 9 deletions packages/lit-virtual/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,38 +20,106 @@ class VirtualizerControllerBase<

private readonly virtualizer: Virtualizer<TScrollElement, TItemElement>

private options: VirtualizerOptions<TScrollElement, TItemElement>

private cleanup: () => void = () => {}

/**
* True between `hostUpdate` and `hostUpdated`. `willUpdate` runs in that
* window, before render, so applying options there is already part of the
* current update.
*/
private hostIsUpdating = false

constructor(
host: ReactiveControllerHost,
options: VirtualizerOptions<TScrollElement, TItemElement>,
) {
const resolvedOptions: VirtualizerOptions<TScrollElement, TItemElement> = {
...options,
onChange: (instance, sync) => {
this.host.updateComplete.then(() => this.host.requestUpdate())
options.onChange?.(instance, sync)
},
}
this.virtualizer = new Virtualizer(resolvedOptions)
;(this.host = host).addController(this)
this.host = host
this.options = { ...options }
this.virtualizer = new Virtualizer(this.resolveOptions(this.options))
this.host.addController(this)
}

public getVirtualizer() {
return this.virtualizer
}

/**
* Merge a partial options update into the options this controller already
* holds, push them to the virtualizer, and re-render the host when this is
* called outside an update.
*
* Keys set to `undefined` are ignored, matching core `setOptions`, so a
* partial update cannot wipe an option stored earlier. Defaults supplied by
* `VirtualizerController` / `WindowVirtualizerController` stay in place
* unless a later call replaces them.
*/
public setOptions(
options: Partial<VirtualizerOptions<TScrollElement, TItemElement>>,
) {
if (!this.applyOptions(options)) return

this.virtualizer.setOptions(this.resolveOptions(this.options))
this.virtualizer._willUpdate()

if (!this.hostIsUpdating) {
this.host.requestUpdate()
}
}

hostUpdate() {
this.hostIsUpdating = true
}

hostConnected() {
this.cleanup = this.virtualizer._didMount()
}

hostUpdated() {
this.hostIsUpdating = false
this.virtualizer._willUpdate()
}

hostDisconnected() {
this.cleanup()
}

private applyOptions(
partial: Partial<VirtualizerOptions<TScrollElement, TItemElement>>,
): boolean {
const next: VirtualizerOptions<TScrollElement, TItemElement> = {
...this.options,
}
let changed = false

for (const key in partial) {
if (!Object.prototype.hasOwnProperty.call(partial, key)) continue

const value = partial[key as keyof typeof partial]
if (value === undefined) continue

if (!Object.is(next[key as keyof typeof next], value)) {
changed = true
Object.assign(next, { [key]: value })
}
}

if (changed) this.options = next
return changed
}

private resolveOptions(
options: VirtualizerOptions<TScrollElement, TItemElement>,
): VirtualizerOptions<TScrollElement, TItemElement> {
return {
...options,
onChange: (instance, sync) => {
this.host.updateComplete.then(() => this.host.requestUpdate())
options.onChange?.(instance, sync)
},
}
}
}

export class VirtualizerController<
Expand Down
201 changes: 201 additions & 0 deletions packages/lit-virtual/tests/set-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { expect, test } from 'vitest'
import { elementUpdated, fixture, html } from '@open-wc/testing'
import { LitElement, type PropertyValues } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import type { ReactiveControllerHost } from 'lit'
import {
VirtualizerController,
WindowVirtualizerController,
observeWindowRect,
windowScroll,
} from '../src'

function createHost() {
let requestUpdateCount = 0

const host: ReactiveControllerHost = {
addController() {},
removeController() {},
requestUpdate() {
requestUpdateCount++
},
get updateComplete() {
return Promise.resolve(true)
},
}

return {
host,
get requestUpdateCount() {
return requestUpdateCount
},
}
}

test('constructor options reach the virtualizer', () => {
const { host } = createHost()
const estimateSize = () => 50
const controller = new VirtualizerController(host, {
count: 10,
estimateSize,
getScrollElement: () => null,
})

const virtualizer = controller.getVirtualizer()
expect(virtualizer.options.count).toBe(10)
expect(virtualizer.options.estimateSize).toBe(estimateSize)
expect(virtualizer.getTotalSize()).toBe(500)
})

test('setOptions merges partial updates and ignores undefined', () => {
const { host } = createHost()
const estimateSize = () => 50
const controller = new VirtualizerController(host, {
count: 10,
estimateSize,
getScrollElement: () => null,
})
const virtualizer = controller.getVirtualizer()

controller.setOptions({ count: 20, estimateSize: undefined })

expect(virtualizer.options.count).toBe(20)
expect(virtualizer.options.estimateSize).toBe(estimateSize)
expect(virtualizer.getTotalSize()).toBe(1000)

const nextEstimate = () => 25
controller.setOptions({ estimateSize: nextEstimate, paddingEnd: 12 })
expect(virtualizer.options.count).toBe(20)
expect(virtualizer.options.estimateSize).toBe(nextEstimate)
expect(virtualizer.options.paddingEnd).toBe(12)
expect(virtualizer.getTotalSize()).toBe(1012)
})

test('setOptions keeps the user onChange and the host update wrapper', async () => {
const harness = createHost()
const calls: Array<boolean> = []
const controller = new VirtualizerController(harness.host, {
count: 4,
estimateSize: () => 25,
getScrollElement: () => null,
onChange: (_instance, sync) => {
calls.push(sync)
},
})

controller.setOptions({ count: 6 })
controller
.getVirtualizer()
.options.onChange?.(controller.getVirtualizer(), true)

expect(calls).toEqual([true])
expect(controller.getVirtualizer().options.count).toBe(6)
expect(controller.getVirtualizer().getTotalSize()).toBe(150)

await Promise.resolve()
expect(harness.requestUpdateCount).toBeGreaterThan(0)
})

test('setOptions requests a host update outside an update, and not again for the same options', async () => {
const harness = createHost()
const controller = new VirtualizerController(harness.host, {
count: 10,
estimateSize: () => 50,
getScrollElement: () => null,
})

controller.setOptions({ count: 12 })
await Promise.resolve()
const afterChange = harness.requestUpdateCount
expect(afterChange).toBeGreaterThan(0)

controller.setOptions({ count: 12 })
await Promise.resolve()
expect(harness.requestUpdateCount).toBe(afterChange)
})

test('setOptions during a host update does not schedule another update', () => {
const harness = createHost()
const controller = new VirtualizerController(harness.host, {
count: 10,
estimateSize: () => 50,
getScrollElement: () => null,
})

controller.hostUpdate()
const before = harness.requestUpdateCount
controller.setOptions({ count: 15 })
controller.setOptions({
getScrollElement: () => null,
})
expect(controller.getVirtualizer().options.count).toBe(15)
expect(controller.getVirtualizer().getTotalSize()).toBe(750)
expect(harness.requestUpdateCount).toBe(before)
controller.hostUpdated()
})

test('WindowVirtualizerController.setOptions keeps the window defaults', () => {
const { host } = createHost()
const controller = new WindowVirtualizerController(host, {
count: 2,
estimateSize: () => 10,
})
controller.hostConnected()

controller.setOptions({ count: 4 })

const options = controller.getVirtualizer().options
expect(options.count).toBe(4)
expect(controller.getVirtualizer().getTotalSize()).toBe(40)
expect(options.scrollToFn).toBe(windowScroll)
expect(options.observeElementRect).toBe(observeWindowRect)
expect(options.getScrollElement()).toBe(window)

controller.hostDisconnected()
})

@customElement('test-list-set-options')
class ListSetOptions extends LitElement {
@property({ type: Number })
count = 10

private virtualizerController: VirtualizerController<HTMLDivElement, Element>

constructor() {
super()
this.virtualizerController = new VirtualizerController(this, {
getScrollElement: () => null,
count: this.count,
estimateSize: () => 50,
})
}

protected willUpdate(changed: PropertyValues<this>) {
if (changed.has('count')) {
this.virtualizerController.setOptions({ count: this.count })
}
}

render() {
const virtualizer = this.virtualizerController.getVirtualizer()
return html`<div data-testid="total">${virtualizer.getTotalSize()}</div>`
}
}

test('a reactive count property updates the rendered total size', async () => {
const el = await fixture(
html`<test-list-set-options></test-list-set-options>`,
)
await elementUpdated(el)

// Raising count does not paint a higher index unless the viewport can
// hold it. The rendered total size is what changes: 10 * 50, then 20 * 50.
const total = () =>
el.shadowRoot!.querySelector('[data-testid="total"]')?.textContent

expect(total()).toBe('500')
;(el as ListSetOptions).count = 20
await elementUpdated(el)

expect(total()).toBe('1000')
})
Loading