diff --git a/.changeset/lit-controller-set-options.md b/.changeset/lit-controller-set-options.md new file mode 100644 index 000000000..589c36069 --- /dev/null +++ b/.changeset/lit-controller-set-options.md @@ -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) { + if (changed.has('count')) { + this.virtualizerController.setOptions({ count: this.count }) + } +} +``` diff --git a/docs/framework/lit/lit-virtual.md b/docs/framework/lit/lit-virtual.md index 8700490a4..98df3c8c3 100644 --- a/docs/framework/lit/lit-virtual.md +++ b/docs/framework/lit/lit-virtual.md @@ -34,3 +34,28 @@ private windowVirtualizerController = new WindowVirtualizerController this.scrollElementRef.value, + count: this.count, + estimateSize: () => 35, +}) + +protected willUpdate(changed: PropertyValues) { + 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. diff --git a/packages/lit-virtual/README.md b/packages/lit-virtual/README.md index a16ac0e79..3f3ef0aef 100644 --- a/packages/lit-virtual/README.md +++ b/packages/lit-virtual/README.md @@ -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) { + 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: diff --git a/packages/lit-virtual/src/index.ts b/packages/lit-virtual/src/index.ts index 8cfa1d643..37804f623 100644 --- a/packages/lit-virtual/src/index.ts +++ b/packages/lit-virtual/src/index.ts @@ -20,38 +20,106 @@ class VirtualizerControllerBase< private readonly virtualizer: Virtualizer + private options: VirtualizerOptions + 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, ) { - const resolvedOptions: VirtualizerOptions = { - ...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>, + ) { + 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>, + ): boolean { + const next: VirtualizerOptions = { + ...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, + ): VirtualizerOptions { + return { + ...options, + onChange: (instance, sync) => { + this.host.updateComplete.then(() => this.host.requestUpdate()) + options.onChange?.(instance, sync) + }, + } + } } export class VirtualizerController< diff --git a/packages/lit-virtual/tests/set-options.test.ts b/packages/lit-virtual/tests/set-options.test.ts new file mode 100644 index 000000000..88015e7e2 --- /dev/null +++ b/packages/lit-virtual/tests/set-options.test.ts @@ -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 = [] + 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 + + constructor() { + super() + this.virtualizerController = new VirtualizerController(this, { + getScrollElement: () => null, + count: this.count, + estimateSize: () => 50, + }) + } + + protected willUpdate(changed: PropertyValues) { + if (changed.has('count')) { + this.virtualizerController.setOptions({ count: this.count }) + } + } + + render() { + const virtualizer = this.virtualizerController.getVirtualizer() + return html`
${virtualizer.getTotalSize()}
` + } +} + +test('a reactive count property updates the rendered total size', async () => { + const el = await fixture( + html``, + ) + 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') +})