From f2040a8561d139a89513f7bb41d60e6f9bcdd547 Mon Sep 17 00:00:00 2001 From: Denis Hilt Date: Wed, 21 Oct 2026 22:19:01 +0200 Subject: [PATCH 1/5] Keep insert virtualization flag internal --- src/interfaces/adapter.ts | 1 - src/processes/adapter/insert.ts | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/interfaces/adapter.ts b/src/interfaces/adapter.ts index fee6441f..6eb7661b 100644 --- a/src/interfaces/adapter.ts +++ b/src/interfaces/adapter.ts @@ -110,7 +110,6 @@ export interface AdapterInsertOptions { beforeIndex?: number; afterIndex?: number; decrease?: boolean; - virtualize?: boolean; } export interface AdapterReplaceOptions { diff --git a/src/processes/adapter/insert.ts b/src/processes/adapter/insert.ts index e7c54320..783c9eb1 100644 --- a/src/processes/adapter/insert.ts +++ b/src/processes/adapter/insert.ts @@ -5,6 +5,10 @@ import { Item } from '../../classes/item'; import { Direction } from '../../inputs/index'; import { AdapterInsertOptions, AdapterUpdateOptions } from '../../interfaces/index'; +interface InsertParams extends AdapterInsertOptions { + virtualize?: boolean; +} + export default class Insert extends BaseAdapterProcessFactory(AdapterProcess.insert) { static run(scroller: Scroller, options: AdapterInsertOptions): void { const { params } = Insert.parseInput(scroller, options); @@ -19,7 +23,7 @@ export default class Insert extends BaseAdapterProcessFactory(AdapterProcess.ins }); } - static doInsert(scroller: Scroller, params: AdapterInsertOptions): boolean { + static doInsert(scroller: Scroller, params: InsertParams): boolean { if (!Insert.insertEmpty(scroller, params)) { if (!Insert.insertInBuffer(scroller, params)) { if (!Insert.insertVirtually(scroller, params)) { @@ -52,7 +56,7 @@ export default class Insert extends BaseAdapterProcessFactory(AdapterProcess.ins return true; } - static insertInBuffer(scroller: Scroller, params: AdapterInsertOptions): boolean { + static insertInBuffer(scroller: Scroller, params: InsertParams): boolean { const { before, after, beforeIndex, afterIndex, items, decrease } = params; const indexToInsert = scroller.buffer.getIndexToInsert( before || after, From ad3f517203dc1561b7b30a3c714f49258508b771 Mon Sep 17 00:00:00 2001 From: Denis Hilt Date: Wed, 21 Oct 2026 22:19:36 +0200 Subject: [PATCH 2/5] Correct viewport factory return type --- docs/configuration.md | 4 ++-- src/classes/settings.ts | 2 +- src/interfaces/settings.ts | 6 +++--- tests/unit/settings.spec.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fd026c94..43b8d73f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,11 +58,11 @@ The viewport is the scrollable area whose position and size the scroller tracks; | Setting | Type | Default | Effect | | --- | --- | --- | --- | | `windowViewport` | `boolean` | `false` | Use the browser window for scrolling and viewport dimensions. | -| `viewportElement` | `HTMLElement`, synchronous function returning one, or `null` | `null` | Use an explicit viewport instead of the content element's parent. Experimental. | +| `viewportElement` | `HTMLElement`, `null`, or a synchronous function returning either | `null` | Use an explicit viewport instead of the content element's parent. Experimental. | | `horizontal` | `boolean` | `false` | Scroll and measure along the horizontal axis; the DOM/CSS layout must also be horizontal. | | `alignShortContentToEnd` | `boolean` | `false` | Align content shorter than the viewport to the bottom/right. | -`windowViewport` takes precedence over `viewportElement`. A viewport factory is evaluated when the scroller is created; it must return an element synchronously. An invalid result falls back to the parent viewport. +`windowViewport` takes precedence over `viewportElement`. A viewport factory is evaluated when the scroller is created; it must return an element or `null` synchronously. A `null` or invalid result falls back to the parent viewport. With `alignShortContentToEnd: true`, content shorter than the viewport is aligned to the bottom in vertical mode or to the right in horizontal mode; `false` aligns it to the top or left. For content that fills or exceeds the viewport, scrolling and the initial position selected by `startIndex` are unchanged. The setting neither reverses item order nor automatically keeps the viewport at the end when items are added. diff --git a/src/classes/settings.ts b/src/classes/settings.ts index bb3bf826..d7f77dfa 100644 --- a/src/classes/settings.ts +++ b/src/classes/settings.ts @@ -26,7 +26,7 @@ export class Settings implements ISettings, IDevSettings { infinite: boolean; horizontal: boolean; windowViewport: boolean; - viewportElement: HTMLElement | (() => void) | null; + viewportElement: HTMLElement | (() => HTMLElement | null) | null; alignShortContentToEnd: boolean; // align content shorter than the viewport to the bottom/right onBeforeClip: ItemsProcessor | null; // if set, it will be run before clipping items from Buffer after they are hidden sizeStrategy: SizeStrategy; // "average" | "frequent", determines behavior of unknown items diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index 51d2ef19..4870f1ba 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -65,11 +65,11 @@ export interface Settings { windowViewport?: boolean; /** - * Explicit viewport element, or a synchronous factory returning one; windowViewport takes precedence. - * An invalid factory result falls back to the content element's parent. + * Explicit viewport element, or a synchronous factory returning an element or null; windowViewport takes precedence. + * A null or invalid factory result falls back to the content element's parent. * Experimental. Default: null (the content element's parent). */ - viewportElement?: HTMLElement | (() => void) | null; + viewportElement?: HTMLElement | (() => HTMLElement | null) | null; /** * Aligns content shorter than the viewport to the bottom vertically or to the right horizontally. diff --git a/tests/unit/settings.spec.ts b/tests/unit/settings.spec.ts index b61ba03c..6e5cfb69 100644 --- a/tests/unit/settings.spec.ts +++ b/tests/unit/settings.spec.ts @@ -210,7 +210,7 @@ describe('Settings', () => { expect(calls).toBe(1); }); - it('falls back to null for invalid or asynchronous factory results', () => { + it('falls back to null when a factory does not return an element', () => { [ undefined, null, From 2ef3e2ad7f2e499e820ac672bb72c7b144246ca6 Mon Sep 17 00:00:00 2001 From: Denis Hilt Date: Wed, 21 Oct 2026 22:45:53 +0200 Subject: [PATCH 3/5] Preserve datasource types in adapter.reset() --- src/classes/adapter.ts | 2 +- src/interfaces/adapter.ts | 2 +- tests/e2e/scaffolding/data.ts | 4 +- .../specs/adapter.reset-persistence.spec.ts | 12 ++-- tests/e2e/specs/adapter.reset.spec.ts | 11 ++-- tests/unit/adapter-types.spec.ts | 60 +++++++++++++++++++ 6 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 tests/unit/adapter-types.spec.ts diff --git a/src/classes/adapter.ts b/src/classes/adapter.ts index 54befe8b..ff709ff5 100644 --- a/src/classes/adapter.ts +++ b/src/classes/adapter.ts @@ -442,7 +442,7 @@ export class Adapter implements IAdapter { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - reset(options?: IDatasourceOptional): any { + reset(options?: IDatasourceOptional): any { this.reloadCounter++; this.logger.logAdapterMethod('reset', options, ` of ${this.reloadId}`); this.workflow.call({ diff --git a/src/interfaces/adapter.ts b/src/interfaces/adapter.ts index 6eb7661b..08f99aea 100644 --- a/src/interfaces/adapter.ts +++ b/src/interfaces/adapter.ts @@ -163,7 +163,7 @@ export interface IAdapter { readonly eof$: Reactive; readonly paused: boolean; readonly paused$: Reactive; - reset(datasource?: IDatasourceOptional): MethodResult; + reset(datasource?: IDatasourceOptional): MethodResult; reload(reloadIndex?: number | string): MethodResult; append(options: AdapterAppendOptions): MethodResult; append(items: Data | Data[], eof?: boolean): MethodResult; // old signature diff --git a/tests/e2e/scaffolding/data.ts b/tests/e2e/scaffolding/data.ts index 4dc7ee6a..0e842fd8 100644 --- a/tests/e2e/scaffolding/data.ts +++ b/tests/e2e/scaffolding/data.ts @@ -11,10 +11,10 @@ export const makeItems = (firstId: number, count: number): TestItem[] => export const starGet = (( index: number, count: number, - success: (items: unknown[]) => void + success: (items: TestItem[]) => void ): void => { success(makeItems(index, count).map(item => ({ ...item, text: `${item.text} *` }))); -}) as DatasourceGet; +}) as DatasourceGet; const minSize = 1; const maxSize = 100; diff --git a/tests/e2e/specs/adapter.reset-persistence.spec.ts b/tests/e2e/specs/adapter.reset-persistence.spec.ts index 5b4f12c6..3802a71b 100644 --- a/tests/e2e/specs/adapter.reset-persistence.spec.ts +++ b/tests/e2e/specs/adapter.reset-persistence.spec.ts @@ -6,14 +6,14 @@ import { makeTest, Misc, starGet, - DatasourceGet, IDatasourceOptional, - TestConfig + TestConfig, + TestItem } from '../scaffolding'; interface ResetVariant { isNew: boolean; - options: IDatasourceOptional; + options: IDatasourceOptional; } type PermanentProp = Adapter.id | Adapter.version; @@ -63,10 +63,8 @@ const reset = (misc: Misc, variant: ResetVariant) => { return misc.adapter.reset(variant.options); } - const datasource = new Datasource({ - get: - variant.options.get ?? - (misc.datasource.get as unknown as DatasourceGet), + const datasource = new Datasource({ + get: variant.options.get ?? misc.datasource.get, settings: variant.options.settings, devSettings: variant.options.devSettings }); diff --git a/tests/e2e/specs/adapter.reset.spec.ts b/tests/e2e/specs/adapter.reset.spec.ts index 0e166dec..c68ce375 100644 --- a/tests/e2e/specs/adapter.reset.spec.ts +++ b/tests/e2e/specs/adapter.reset.spec.ts @@ -6,7 +6,8 @@ import { Routines, starGet, IDatasourceOptional, - TestConfig + TestConfig, + TestItem } from '../scaffolding'; interface ResetSnapshot { @@ -20,7 +21,7 @@ interface ResetSnapshot { interface ResetScenario { config: TestConfig; - options?: IDatasourceOptional; + options?: IDatasourceOptional; scrolls?: number; direction?: Direction; interruption?: boolean; @@ -62,7 +63,7 @@ const settingsScenarios: ResetScenario[] = [baseConfigs[0], baseConfigs[2]].map( settings: { ...config.datasourceSettings, startIndex: 999 - } as IDatasourceOptional['settings'] + } as IDatasourceOptional['settings'] } }) ); @@ -172,7 +173,7 @@ const expectReset = ( misc.expect.uniqueItems(); }; -const reset = (misc: Misc, options?: IDatasourceOptional) => +const reset = (misc: Misc, options?: IDatasourceOptional) => options ? misc.adapter.reset(options) : misc.adapter.reset(); const expectScrollListeners = ( @@ -358,7 +359,7 @@ describe('Adapter Reset Spec', () => { const before = takeSnapshot(misc); const result = await misc.adapter.reset( - options as IDatasourceOptional + options as IDatasourceOptional ); expect(result.success).toBe(false); diff --git a/tests/unit/adapter-types.spec.ts b/tests/unit/adapter-types.spec.ts new file mode 100644 index 00000000..037ebc09 --- /dev/null +++ b/tests/unit/adapter-types.spec.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import ts from 'typescript'; + +describe('Adapter Types Spec', () => + ['IAdapter', 'Adapter'].forEach(type => + it(`should preserve datasource types on ${type}.reset`, () => { + const fileName = path.resolve(__dirname, 'adapter-types.ts').replace(/\\/g, '/'); + const source = ` + import type { IAdapter } from '../../src'; + import type { Adapter } from '../../src/classes/adapter'; + + declare const adapter: ${type}<{ id: number }>; + + adapter.reset(); + adapter.reset({ devSettings: { debug: true } }); + adapter.reset({ + get: (_index, _count, success) => success([{ id: 1 }]), + settings: { + onBeforeClip: items => items[0].data.id.toFixed() + } + }); + adapter.reset({ + settings: { + onBeforeClip: items => items[0].data.id.toFixed() + } + }); + + const get = ( + index: number, + count: number, + success: (rows: string[]) => void + ) => success(['wrong']); + + // @ts-expect-error reset must preserve the Adapter's data type. + adapter.reset({ get }); + `; + const options: ts.CompilerOptions = { + strict: true, + strictPropertyInitialization: false, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + types: [] + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => + name === fileName + ? ts.createSourceFile(name, source, languageVersion, true) + : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + const program = ts.createProgram([fileName], options, host); + + expect( + ts.getPreEmitDiagnostics(program).map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ) + ).toEqual([]); + }) + )); From 5e47dbbacf39bce4f0a3e88a96e65ee64281e9b0 Mon Sep 17 00:00:00 2001 From: Denis Hilt Date: Wed, 21 Oct 2026 22:57:23 +0200 Subject: [PATCH 4/5] Correct Observable datasource typing and remove compiler-based tests --- src/interfaces/datasource.ts | 2 +- tests/unit/adapter-types.spec.ts | 60 -------------------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 tests/unit/adapter-types.spec.ts diff --git a/src/interfaces/datasource.ts b/src/interfaces/datasource.ts index 1e86b8be..e7650612 100644 --- a/src/interfaces/datasource.ts +++ b/src/interfaces/datasource.ts @@ -14,7 +14,7 @@ type DSGetCallback = ( success: SuccessFn, fail?: ErrorFn ) => void; -type DSGetObservable = (index: number, count: number) => ObservableLike; +type DSGetObservable = (index: number, count: number) => ObservableLike; type DSGetPromise = (index: number, count: number) => PromiseLike; export type DatasourceGet = DSGetCallback | DSGetObservable | DSGetPromise; diff --git a/tests/unit/adapter-types.spec.ts b/tests/unit/adapter-types.spec.ts deleted file mode 100644 index 037ebc09..00000000 --- a/tests/unit/adapter-types.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import path from 'node:path'; -import ts from 'typescript'; - -describe('Adapter Types Spec', () => - ['IAdapter', 'Adapter'].forEach(type => - it(`should preserve datasource types on ${type}.reset`, () => { - const fileName = path.resolve(__dirname, 'adapter-types.ts').replace(/\\/g, '/'); - const source = ` - import type { IAdapter } from '../../src'; - import type { Adapter } from '../../src/classes/adapter'; - - declare const adapter: ${type}<{ id: number }>; - - adapter.reset(); - adapter.reset({ devSettings: { debug: true } }); - adapter.reset({ - get: (_index, _count, success) => success([{ id: 1 }]), - settings: { - onBeforeClip: items => items[0].data.id.toFixed() - } - }); - adapter.reset({ - settings: { - onBeforeClip: items => items[0].data.id.toFixed() - } - }); - - const get = ( - index: number, - count: number, - success: (rows: string[]) => void - ) => success(['wrong']); - - // @ts-expect-error reset must preserve the Adapter's data type. - adapter.reset({ get }); - `; - const options: ts.CompilerOptions = { - strict: true, - strictPropertyInitialization: false, - noEmit: true, - skipLibCheck: true, - target: ts.ScriptTarget.ES2017, - module: ts.ModuleKind.CommonJS, - types: [] - }; - const host = ts.createCompilerHost(options); - const getSourceFile = host.getSourceFile.bind(host); - host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => - name === fileName - ? ts.createSourceFile(name, source, languageVersion, true) - : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); - const program = ts.createProgram([fileName], options, host); - - expect( - ts.getPreEmitDiagnostics(program).map(diagnostic => - ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') - ) - ).toEqual([]); - }) - )); From 35a408fbae5ee5167295f21a6e9bd822c5aceef3 Mon Sep 17 00:00:00 2001 From: Denis Hilt Date: Wed, 21 Oct 2026 23:02:21 +0200 Subject: [PATCH 5/5] Reflect Item.element lifecycle in types --- src/classes/item.ts | 6 +++--- src/classes/viewport.ts | 2 +- src/interfaces/item.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/classes/item.ts b/src/classes/item.ts index 54f9ad9d..216ff119 100644 --- a/src/classes/item.ts +++ b/src/classes/item.ts @@ -36,8 +36,8 @@ export class Item implements _Item { this.container.data = value; } - get element(): HTMLElement { - return this.container.element as HTMLElement; + get element(): HTMLElement | undefined { + return this.container.element; } set element(value: HTMLElement) { this.container.element = value; @@ -68,7 +68,7 @@ export class Item implements _Item { } makeVisible(): void { - this.routines.makeElementVisible(this.element); + this.routines.makeElementVisible(this.element as HTMLElement); this.invisible = false; } diff --git a/src/classes/viewport.ts b/src/classes/viewport.ts index f2733d06..26622579 100644 --- a/src/classes/viewport.ts +++ b/src/classes/viewport.ts @@ -96,7 +96,7 @@ export class Viewport { bwd ? i <= items.length - 1 : i >= 0; i += bwd ? 1 : -1 ) { - const itemEdge = this.routines.getEdge(items[i].element, opposite); + const itemEdge = this.routines.getEdge(items[i].element as HTMLElement, opposite); diff = itemEdge - viewportEdge; if ((bwd && diff > 0) || (!bwd && diff < 0)) { item = items[i]; diff --git a/src/interfaces/item.ts b/src/interfaces/item.ts index dd1c4aee..bde21096 100644 --- a/src/interfaces/item.ts +++ b/src/interfaces/item.ts @@ -3,7 +3,7 @@ import { ItemAdapter } from './adapter'; export interface Item { uid: number; $index: number; - element: HTMLElement; + element?: HTMLElement; data: Data; invisible: boolean; get: () => ItemAdapter;