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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/wasm/rust/pkg
/wasm/rust/target
examples-dist/
.svelte-kit/

# Logs
logs
Expand Down
152 changes: 108 additions & 44 deletions bun.lock

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions core/buffers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Shared zero-allocation hot-path scratch space.
*
* Used by every framework adapter (React, Svelte, …) so the optimisation
* isn't duplicated. The pools are module-level singletons — adapters take
* turns through the `useBufferA` flag stored on each virtualizer instance.
*/

export const MAX_SAFE_SCROLL_HEIGHT = 15_000_000;
export const MAX_VISIBLE = 200;

export interface ScratchBuffer {
items: Int32Array;
offsets: Float64Array;
sizes: Float64Array;
}

export const bufferA: ScratchBuffer = {
items: new Int32Array(MAX_VISIBLE),
offsets: new Float64Array(MAX_VISIBLE),
sizes: new Float64Array(MAX_VISIBLE),
};

export const bufferB: ScratchBuffer = {
items: new Int32Array(MAX_VISIBLE),
offsets: new Float64Array(MAX_VISIBLE),
sizes: new Float64Array(MAX_VISIBLE),
};

export const cached = {
itemsA: [] as number[],
itemsB: [] as number[],
offsetsA: [] as number[],
offsetsB: [] as number[],
sizesA: [] as number[],
sizesB: [] as number[],
};

export interface VirtualRangeSnapshot {
startIndex: number;
endIndex: number;
items: readonly number[];
offsets: readonly number[];
sizes: readonly number[];
totalHeight: number;
paddingTop: number;
velocity: number;
}

export const EMPTY_RANGE: VirtualRangeSnapshot = Object.freeze({
startIndex: 0,
endIndex: 0,
items: Object.freeze([]) as readonly number[],
offsets: Object.freeze([]) as readonly number[],
sizes: Object.freeze([]) as readonly number[],
totalHeight: 0,
paddingTop: 0,
velocity: 0,
});
20 changes: 18 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./svelte": {
"types": "./dist/svelte/index.d.ts",
"svelte": "./dist/svelte/index.js",
"import": "./dist/svelte/index.js",
"default": "./dist/svelte/index.js"
},
"./wasm/*": "./dist/wasm/*",
"./package.json": "./package.json"
},
Expand Down Expand Up @@ -53,7 +59,8 @@
},
"scripts": {
"dev": "vite",
"build": "bun run build:wasm && bun run copy:wasm && tsc && mkdir -p dist/wasm && cp wasm/warper_wasm.js wasm/warper_wasm.d.ts wasm/warper_wasm_bg.wasm wasm/warper_wasm_bg.wasm.d.ts dist/wasm/",
"build": "bun run build:wasm && bun run copy:wasm && tsc && bun run build:svelte && mkdir -p dist/wasm && cp wasm/warper_wasm.js wasm/warper_wasm.d.ts wasm/warper_wasm_bg.wasm wasm/warper_wasm_bg.wasm.d.ts dist/wasm/",
"build:svelte": "svelte-package -i ./svelte -o ./dist/svelte --tsconfig ./tsconfig.svelte.json",
"build:wasm": "cd wasm/rust && wasm-pack build --target web --release && rm -f pkg/.gitignore",
"copy:wasm": "cp wasm/rust/pkg/warper_wasm.js wasm/rust/pkg/warper_wasm.d.ts wasm/rust/pkg/warper_wasm_bg.wasm wasm/rust/pkg/warper_wasm_bg.wasm.d.ts wasm/",
"build:examples": "mkdir -p examples-dist && cp examples/index.html examples-dist/ && bun vite build --config examples/list/vite.config.ts --outDir ../../examples-dist/list && bun vite build --config examples/grid/vite.config.ts --outDir ../../examples-dist/grid && bun vite build --config examples/one-million-rows/vite.config.ts --outDir ../../examples-dist/one-million-rows && bun vite build --config examples/chat/vite.config.ts --outDir ../../examples-dist/chat && bun vite build --config examples/tree/vite.config.ts --outDir ../../examples-dist/tree",
Expand All @@ -69,12 +76,21 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
"react-dom": "^18.0.0 || ^19.0.0",
"svelte": "^5.29.0"
},
"peerDependenciesMeta": {
"react": { "optional": true },
"react-dom": { "optional": true },
"svelte": { "optional": true }
},
"devDependencies": {
"@sveltejs/package": "^2.3.7",
"@types/react": "^18.2.66 || ^19.0.0",
"@types/react-dom": "^18.2.22 || ^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"svelte": "^5.29.0",
"svelte2tsx": "^0.7.0",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vite-plugin-top-level-await": "^1.5.0",
Expand Down
83 changes: 22 additions & 61 deletions react/hooks/useVirtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,28 @@

import { useRef, useEffect, useCallback, useState } from 'react';
import { VirtualizerOptions } from '../../types';
import {
initializeWasm,
import {
initializeWasm,
createVirtualizer,
createUniformVirtualizer,
QuantumVariable,
QuantumUniform,
} from '../../core/wasm';
import {
MAX_SAFE_SCROLL_HEIGHT,
MAX_VISIBLE,
bufferA,
bufferB,
cached,
EMPTY_RANGE,
type VirtualRangeSnapshot,
} from '../../core/buffers';

// ============================================================================
// Types
// ============================================================================

export interface VirtualRange {
startIndex: number;
endIndex: number;
items: readonly number[];
offsets: readonly number[];
sizes: readonly number[];
totalHeight: number;
paddingTop: number;
velocity: number;
}
export type VirtualRange = VirtualRangeSnapshot;

export interface UseVirtualizerResult<TElement extends HTMLElement> {
scrollElementRef: React.RefCallback<TElement>;
Expand All @@ -58,45 +58,6 @@ export interface UseVirtualizerResult<TElement extends HTMLElement> {
totalHeight: number;
}

// ============================================================================
// CONSTANTS
// ============================================================================

const MAX_SAFE_SCROLL_HEIGHT = 15_000_000;
const MAX_VISIBLE = 200;

// Double-buffered TypedArrays for zero-allocation hot path
// Using TypedArrays allows subarray() which creates views without copying
const bufferA = {
items: new Int32Array(MAX_VISIBLE),
offsets: new Float64Array(MAX_VISIBLE),
sizes: new Float64Array(MAX_VISIBLE),
};
const bufferB = {
items: new Int32Array(MAX_VISIBLE),
offsets: new Float64Array(MAX_VISIBLE),
sizes: new Float64Array(MAX_VISIBLE),
};

// Reusable array views to avoid allocation - will be populated from TypedArrays
let cachedItemsA: number[] = [];
let cachedItemsB: number[] = [];
let cachedOffsetsA: number[] = [];
let cachedOffsetsB: number[] = [];
let cachedSizesA: number[] = [];
let cachedSizesB: number[] = [];

const EMPTY_RANGE: VirtualRange = Object.freeze({
startIndex: 0,
endIndex: 0,
items: Object.freeze([]) as readonly number[],
offsets: Object.freeze([]) as readonly number[],
sizes: Object.freeze([]) as readonly number[],
totalHeight: 0,
paddingTop: 0,
velocity: 0,
});

// ============================================================================
// Main Hook
// ============================================================================
Expand Down Expand Up @@ -238,23 +199,23 @@ export function useVirtualizer<T, TElement extends HTMLElement = HTMLDivElement>

// Zero-allocation: reuse cached arrays and update their contents
// This avoids creating new arrays on every frame
let cachedItems = useA ? cachedItemsA : cachedItemsB;
let cachedOffsets = useA ? cachedOffsetsA : cachedOffsetsB;
let cachedSizes = useA ? cachedSizesA : cachedSizesB;
let cachedItems = useA ? cached.itemsA : cached.itemsB;
let cachedOffsets = useA ? cached.offsetsA : cached.offsetsB;
let cachedSizes = useA ? cached.sizesA : cached.sizesB;

// Resize cached arrays only if needed (rare)
if (cachedItems.length !== count) {
cachedItems = new Array(count);
cachedOffsets = new Array(count);
cachedSizes = new Array(count);
if (useA) {
cachedItemsA = cachedItems;
cachedOffsetsA = cachedOffsets;
cachedSizesA = cachedSizes;
cached.itemsA = cachedItems;
cached.offsetsA = cachedOffsets;
cached.sizesA = cachedSizes;
} else {
cachedItemsB = cachedItems;
cachedOffsetsB = cachedOffsets;
cachedSizesB = cachedSizes;
cached.itemsB = cachedItems;
cached.offsetsB = cachedOffsets;
cached.sizesB = cachedSizes;
}
}

Expand Down
119 changes: 119 additions & 0 deletions svelte/components/Warper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import type { VirtualizerOptions } from '../../types/core';
import { createVirtualizer } from '../hooks/createVirtualizer.svelte';

interface Props extends VirtualizerOptions<T> {
children: Snippet<[index: number]>;
loadingPlaceholder?: Snippet;
errorPlaceholder?: Snippet<[error: Error]>;
onRendered?: () => void;
class?: string;
style?: string;
}

let {
itemCount,
estimateSize,
overscan = 3,
height,
horizontal = false,
children,
loadingPlaceholder,
errorPlaceholder,
onRendered,
class: className = '',
style = '',
}: Props = $props();

const v = createVirtualizer<T>(() => ({
itemCount,
estimateSize,
overscan,
horizontal,
}));

// Exposed via `bind:this` so consumers can imperatively scroll.
export function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
v.scrollToOffset(offset, behavior);
}
export function scrollToIndex(index: number, behavior: ScrollBehavior = 'auto') {
v.scrollToIndex(index, behavior);
}

let firedRendered = false;
$effect(() => {
if (!v.isLoading && onRendered && !firedRendered) {
firedRendered = true;
onRendered();
}
});

const containerHeight = $derived(typeof height === 'number' ? `${height}px` : (height ?? '100%'));

const CONTAINER_STATIC =
'width:100%; overflow:auto; position:relative; overscroll-behavior:contain; contain:strict;';
const INNER_STATIC = 'width:100%; position:relative; pointer-events:none;';
const VIEWPORT_STATIC = 'position:absolute; top:0; left:0; width:100%; will-change:transform;';
const ROW_STATIC =
'position:absolute; top:0; left:0; width:100%; contain:layout style paint; will-change:transform;';
</script>

{#if v.error}
<div
{@attach v.scrollElement}
class={className}
style={CONTAINER_STATIC + style}
style:height={containerHeight}
>
{#if errorPlaceholder}
{@render errorPlaceholder(v.error)}
{:else}
<div style="padding:20px; color:#ef4444;">Error: {v.error.message}</div>
{/if}
</div>
{:else if v.isLoading}
<div
{@attach v.scrollElement}
class={className}
style={CONTAINER_STATIC + style}
style:height={containerHeight}
>
{#if loadingPlaceholder}
{@render loadingPlaceholder()}
{:else}
<div style="padding:20px; color:#888;">Loading…</div>
{/if}
</div>
{:else}
<div
{@attach v.scrollElement}
class={className}
data-warper-container
style={CONTAINER_STATIC + style}
style:height={containerHeight}
>
<div
data-warper-inner
style={INNER_STATIC}
style:height="{v.range.totalHeight || 1}px"
>
<div
data-warper-viewport
style={VIEWPORT_STATIC}
style:transform="translateY({v.range.paddingTop}px)"
>
{#each v.range.items as itemIndex, i (itemIndex)}
<div
data-index={itemIndex}
style={ROW_STATIC}
style:transform="translateY({v.range.offsets[i]}px)"
style:height="{v.range.sizes[i]}px"
>
{@render children(itemIndex)}
</div>
{/each}
</div>
</div>
</div>
{/if}
Loading