Skip to content
Merged
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
permissions:
contents: read
pull-requests: write
issues: write

concurrency:
group: ci-${{ github.ref }}
Expand Down
13 changes: 7 additions & 6 deletions docs/.vitepress/theme/Hero.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { withBase } from "vitepress";
import SlotMachine from "./SlotMachine.vue";

const millionsCount = ref(0);
Expand Down Expand Up @@ -80,11 +81,8 @@ onUnmounted(() => {
</h1>

<p class="hero-tagline animate-on-scroll fade-up delay-100">
<SlotMachine
:value="millionsCount"
class-name="counter-wrapper"
/>+ of items.
<SlotMachine :value="kbCount" class-name="counter-wrapper" />KB
<SlotMachine :value="millionsCount" class-name="counter-wrapper" />+ of
items. <SlotMachine :value="kbCount" class-name="counter-wrapper" />KB
of code.
<span class="highlight">Zero lag.</span>
</p>
Expand All @@ -95,7 +93,10 @@ onUnmounted(() => {
</p>

<div class="hero-actions animate-on-scroll fade-up delay-300">
<a href="/guide/quick-start" class="btn primary glow-on-hover">
<a
:href="withBase('/guide/introduction')"
class="btn primary glow-on-hover"
>
<span>Get Started</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "git",
"url": "git+https://github.com/976520/scrolloop.git"
},
"description": "Just a virtual scrolling library for React",
"description": "The modern scrolling component for React and React Native",
"type": "module",
"workspaces": [
"packages/*"
Expand Down
27 changes: 13 additions & 14 deletions packages/core/src/strategies/layout/FixedLayoutStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
import type { Range } from '../../types';
import type { LayoutStrategy } from './LayoutStrategy';
import { clamp } from '../../utils/clamp';
import type { Range } from "../../types";
import type { LayoutStrategy } from "./LayoutStrategy";
import { clamp } from "../../utils/clamp";

export class FixedLayoutStrategy implements LayoutStrategy {
constructor(private itemSize: number) {}
readonly #itemSize: number;

constructor(itemSize: number) {
this.#itemSize = itemSize;
}
Comment on lines +6 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using readonly with a private field (#itemSize) is a great improvement for ensuring immutability and true privacy. This is a good modern practice.


getItemOffset(index: number): number {
return index * this.itemSize;
return index * this.#itemSize;
}

getItemSize(_index: number): number {
return this.itemSize;
return this.#itemSize;
}

getTotalSize(count: number): number {
return count * this.itemSize;
return count * this.#itemSize;
}

getVisibleRange(
scrollOffset: number,
viewportSize: number,
count: number
): Range {
const startIndex = clamp(
0,
Math.floor(scrollOffset / this.itemSize),
count - 1
);
const startIndex = clamp(0, (scrollOffset / this.#itemSize) | 0, count - 1);

const visibleCount = Math.ceil(viewportSize / this.itemSize);
const visibleCount = Math.ceil(viewportSize / this.#itemSize);
const endIndex = Math.min(count - 1, startIndex + visibleCount);

return { startIndex, endIndex };
}
}

30 changes: 15 additions & 15 deletions packages/core/src/strategies/scroll/VirtualScrollSource.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
import type { ScrollSource } from "./ScrollSource";

export class VirtualScrollSource implements ScrollSource {
private scrollOffset = 0;
private viewportSize = 0;
private listeners = new Set<(offset: number) => void>();
#scrollOffset = 0;
#viewportSize = 0;
#listeners = new Set<(offset: number) => void>();

getScrollOffset(): number {
return this.scrollOffset;
return this.#scrollOffset;
}

getViewportSize(): number {
return this.viewportSize;
return this.#viewportSize;
}

setScrollOffset(offset: number): void {
if (this.scrollOffset !== offset) {
this.scrollOffset = offset;
this.notifyListeners();
if (this.#scrollOffset !== offset) {
this.#scrollOffset = offset;
this.#notifyListeners();
}
}

setViewportSize(size: number): void {
if (this.viewportSize !== size) {
this.viewportSize = size;
this.notifyListeners();
if (this.#viewportSize !== size) {
this.#viewportSize = size;
this.#notifyListeners();
}
}

subscribe(callback: (offset: number) => void): () => void {
this.listeners.add(callback);
this.#listeners.add(callback);
return () => {
this.listeners.delete(callback);
this.#listeners.delete(callback);
};
}

private notifyListeners(): void {
this.listeners.forEach((listener) => listener(this.scrollOffset));
#notifyListeners(): void {
this.#listeners.forEach((listener) => listener(this.#scrollOffset));
}
}
4 changes: 2 additions & 2 deletions packages/core/src/utils/calculateVirtualRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function calculateVirtualRange(
overscan: number,
prevScrollOffset?: number
): VirtualRange {
const startIndex = Math.max(0, Math.floor(scrollOffset / itemSize));
const startIndex = Math.max(0, (scrollOffset / itemSize) | 0);
const endIndex = Math.min(
totalCount - 1,
startIndex + Math.ceil(viewportSize / itemSize)
Expand All @@ -22,7 +22,7 @@ export function calculateVirtualRange(
const overscanStart = isScrollingUp ? overscan * 1.5 : overscan;
const overscanEnd = isScrollingDown ? overscan * 1.5 : overscan;

const renderStart = Math.max(0, Math.floor(startIndex - overscanStart));
const renderStart = Math.max(0, (startIndex - overscanStart) | 0);
const renderEnd = Math.min(totalCount - 1, Math.ceil(endIndex + overscanEnd));

return {
Expand Down
134 changes: 80 additions & 54 deletions packages/core/src/virtualizer/Virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,108 +2,135 @@ import type {
VirtualItem,
VirtualizerState,
VirtualizerOptions,
} from '../types';
import type { LayoutStrategy } from '../strategies/layout/LayoutStrategy';
import type { ScrollSource } from '../strategies/scroll/ScrollSource';
import type { Plugin } from '../plugins/Plugin';
} from "../types";
import type { LayoutStrategy } from "../strategies/layout/LayoutStrategy";
import type { ScrollSource } from "../strategies/scroll/ScrollSource";
import type { Plugin } from "../plugins/Plugin";

export class Virtualizer {
private count: number;
private overscan: number;
private plugins: Plugin[];
private onChange?: (state: VirtualizerState) => void;
#count: number;
readonly #overscan: number;
#plugins: Plugin[];
readonly #onChange?: (state: VirtualizerState) => void;

private layoutStrategy: LayoutStrategy;
private scrollSource: ScrollSource;
readonly #layoutStrategy: LayoutStrategy;
readonly #scrollSource: ScrollSource;

private state: VirtualizerState;
private unsubscribe?: () => void;
#state: VirtualizerState;
readonly #unsubscribe?: () => void;

#prevRenderRange?: { startIndex: number; endIndex: number };
#prevVirtualItems?: VirtualItem[];

constructor(
layoutStrategy: LayoutStrategy,
scrollSource: ScrollSource,
options: VirtualizerOptions
) {
this.layoutStrategy = layoutStrategy;
this.scrollSource = scrollSource;
this.count = options.count;
this.overscan = options.overscan ?? 4;
this.onChange = options.onChange;
this.plugins = [];
this.#layoutStrategy = layoutStrategy;
this.#scrollSource = scrollSource;
this.#count = options.count;
this.#overscan = options.overscan ?? 4;
this.#onChange = options.onChange;
this.#plugins = [];

this.state = this.calculateState();
this.#state = this.#calculateState();

this.plugins.forEach((plugin) => plugin.onInit?.());
this.#plugins.forEach((plugin) => plugin.onInit?.());

this.unsubscribe = this.scrollSource.subscribe(() => {
this.#unsubscribe = this.#scrollSource.subscribe(() => {
this.update();
});
}

addPlugin(plugin: Plugin): void {
this.plugins.push(plugin);
this.#plugins.push(plugin);
plugin.onInit?.();
this.#prevRenderRange = undefined;
this.#prevVirtualItems = undefined;
}

getState(): VirtualizerState {
return this.state;
return this.#state;
}

setCount(count: number): void {
if (this.count !== count) {
this.count = count;
if (this.#count !== count) {
this.#count = count;
this.#prevRenderRange = undefined;
this.#prevVirtualItems = undefined;
this.update();
}
}

update(): void {
const newState = this.calculateState();
const newState = this.#calculateState();

let finalState = newState;
for (const plugin of this.plugins) {
for (const plugin of this.#plugins) {
const result = plugin.beforeStateChange?.(finalState);
if (result) finalState = result;
}

this.state = finalState;
this.#state = finalState;

this.plugins.forEach((plugin) => plugin.afterStateChange?.(this.state));
this.#plugins.forEach((plugin) => plugin.afterStateChange?.(this.#state));

this.onChange?.(this.state);
this.#onChange?.(this.#state);
}

private calculateState(): VirtualizerState {
const scrollOffset = this.scrollSource.getScrollOffset();
const viewportSize = this.scrollSource.getViewportSize();
const totalSize = this.layoutStrategy.getTotalSize(this.count);
#calculateState(): VirtualizerState {
const scrollOffset = this.#scrollSource.getScrollOffset();
const viewportSize = this.#scrollSource.getViewportSize();
const totalSize = this.#layoutStrategy.getTotalSize(this.#count);

let visibleRange = this.layoutStrategy.getVisibleRange(
let visibleRange = this.#layoutStrategy.getVisibleRange(
scrollOffset,
viewportSize,
this.count
this.#count
);

let renderRange = {
startIndex: Math.max(0, visibleRange.startIndex - this.overscan),
endIndex: Math.min(this.count - 1, visibleRange.endIndex + this.overscan),
startIndex: (visibleRange.startIndex - this.#overscan) | 0,
endIndex: (visibleRange.endIndex + this.#overscan) | 0,
};

for (const plugin of this.plugins) {

if (renderRange.startIndex < 0) renderRange.startIndex = 0;
if (renderRange.endIndex > this.#count - 1)
renderRange.endIndex = this.#count - 1;
Comment on lines 93 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The previous implementation for clamping the renderRange using Math.max(0, ...) and Math.min(this.count - 1, ...) was more declarative and easier to read than the new approach of flooring with | 0 and then using separate if statements for clamping. While the new code may be a micro-optimization, it comes at the cost of readability. For clarity and maintainability, I'd recommend reverting to the Math.max/Math.min approach, which is less error-prone and more expressive of the intent.

Suggested change
let renderRange = {
startIndex: Math.max(0, visibleRange.startIndex - this.overscan),
endIndex: Math.min(this.count - 1, visibleRange.endIndex + this.overscan),
startIndex: (visibleRange.startIndex - this.#overscan) | 0,
endIndex: (visibleRange.endIndex + this.#overscan) | 0,
};
for (const plugin of this.plugins) {
if (renderRange.startIndex < 0) renderRange.startIndex = 0;
if (renderRange.endIndex > this.#count - 1)
renderRange.endIndex = this.#count - 1;
let renderRange = {
startIndex: Math.max(0, visibleRange.startIndex - this.#overscan),
endIndex: Math.min(this.#count - 1, visibleRange.endIndex + this.#overscan),
};


for (const plugin of this.#plugins) {
if (plugin.onRangeCalculated) {
renderRange = plugin.onRangeCalculated(visibleRange, this.count);
renderRange = plugin.onRangeCalculated(visibleRange, this.#count);
}
}

const virtualItems: VirtualItem[] = [];
for (let i = renderRange.startIndex; i <= renderRange.endIndex; i++) {
const start = this.layoutStrategy.getItemOffset(i);
const size = this.layoutStrategy.getItemSize(i);
virtualItems.push({
index: i,
start,
size,
end: start + size,
});
let virtualItems: VirtualItem[];
if (
this.#prevRenderRange &&
this.#prevVirtualItems &&
this.#prevRenderRange.startIndex === renderRange.startIndex &&
this.#prevRenderRange.endIndex === renderRange.endIndex
) {
virtualItems = this.#prevVirtualItems;
} else {
virtualItems = [];
const startIdx = renderRange.startIndex;
const endIdx = renderRange.endIndex;

for (let i = startIdx; i <= endIdx; i++) {
const start = this.#layoutStrategy.getItemOffset(i);
const size = this.#layoutStrategy.getItemSize(i);
virtualItems.push({
index: i,
start,
size,
end: start + size,
});
}

this.#prevRenderRange = renderRange;
this.#prevVirtualItems = virtualItems;
}
Comment on lines +108 to 134
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Caching the virtualItems array and only re-calculating it when the renderRange changes is an excellent performance optimization. This will prevent a lot of unnecessary computation and object creation during scrolling. Great addition!


return {
Expand All @@ -117,8 +144,7 @@ export class Virtualizer {
}

destroy(): void {
this.unsubscribe?.();
this.plugins.forEach((plugin) => plugin.onDestroy?.());
this.#unsubscribe?.();
this.#plugins.forEach((plugin) => plugin.onDestroy?.());
}
}

9 changes: 7 additions & 2 deletions packages/core/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ export default defineConfig({
minify: "terser",
terserOptions: {
compress: {
passes: 2,
passes: 3,
drop_console: true,
drop_debugger: true,
pure_funcs: ["console.log", "console.debug"],
unsafe: false,
unsafe_arrows: false,
unsafe_methods: true,
booleans_as_integers: false,
ecma: 2020,
},
mangle: {
safari10: false,
Expand All @@ -22,7 +27,7 @@ export default defineConfig({
comments: false,
},
},
target: "es2020",
target: "es2022",
outExtension({ format }) {
return {
js: format === "esm" ? ".mjs" : ".cjs",
Expand Down
Loading