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
40 changes: 40 additions & 0 deletions packages/preact/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@scrolloop/preact",
"version": "0.1.0",
"description": "Preact adapter for @scrolloop/core",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"sideEffects": [
"**/*.css"
],
"files": [
"dist",
"src"
Comment on lines +20 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Including the src directory in the files array increases the size of the published package. For a library, it's common practice to only include the compiled dist folder. Consider removing src unless there's a specific reason to publish the source files.

    "dist"

],
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"peerDependencies": {
"preact": ">=10.0.0"
},
"dependencies": {
"@scrolloop/core": "workspace:*",
"@scrolloop/shared": "workspace:*"
},
"devDependencies": {
"preact": "^10.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
"license": "MIT"
}
105 changes: 105 additions & 0 deletions packages/preact/src/components/InfiniteList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useCallback, useMemo } from "preact/hooks";
import type { CSSProperties } from "preact";
import type { InfiniteListProps } from "../types";
import { VirtualList } from "./VirtualList";
import { useInfinitePages } from "../hooks/useInfinitePages";
import "./infiniteList.css";

export function InfiniteList<T>({
fetchPage,
renderItem,
itemSize,
pageSize = 20,
initialPage = 0,
height = 400,
overscan: userOverscan,
class: className,
style,
renderLoading,
renderError,
renderEmpty,
onPageLoad,
onError,
}: InfiniteListProps<T>) {
const overscan = useMemo(
() => userOverscan ?? Math.max(20, pageSize * 2),
[userOverscan, pageSize]
);

const { pages, total, loadingPages, hasMore, error, loadPage, retry } =
useInfinitePages({ fetchPage, pageSize, initialPage, onPageLoad, onError });

useEffect(() => {
if (total === 0 && !error) {
const needed = Math.ceil(height / itemSize) + overscan * 2;
const pagesToLoad = Math.ceil(needed / pageSize);
for (let p = 0; p < pagesToLoad; p++) loadPage(p);
}
}, [total, error, height, itemSize, pageSize, overscan, loadPage]);

const handleRangeChange = useCallback(
(range: { startIndex: number; endIndex: number }) => {
const ps = (range.startIndex / pageSize) | 0;
const pe = ((range.endIndex / pageSize) | 0) + 1;
for (let p = ps; p <= pe; p++) loadPage(p);
},
[pageSize, loadPage]
);

const virtualRenderItem = useCallback(
(index: number, itemStyle: CSSProperties) =>
renderItem(
pages.get(Math.floor(index / pageSize))?.[index % pageSize],
index,
itemStyle
),
[pages, pageSize, renderItem]
);

if (error && total === 0) {
if (renderError)
return <div style={{ height }}>{renderError(error, retry)}</div>;
return (
<div class="scrolloop-state-container" style={{ height }}>
<div class="scrolloop-error-content">
<p class="scrolloop-error-message">Error.</p>
<p class="scrolloop-error-detail">{error.message}</p>
<button class="scrolloop-retry-button" onClick={retry}>
Retry
</button>
</div>
</div>
);
}

if (total === 0 && loadingPages.size > 0) {
if (renderLoading) return <div style={{ height }}>{renderLoading()}</div>;
return (
<div class="scrolloop-state-container" style={{ height }}>
<p class="scrolloop-state-text">Loading...</p>
</div>
);
}

if (total === 0 && !hasMore) {
if (renderEmpty) return <div style={{ height }}>{renderEmpty()}</div>;
return (
<div class="scrolloop-state-container" style={{ height }}>
<p class="scrolloop-state-text">No data.</p>
</div>
);
}

return (
<VirtualList
count={total}
itemSize={itemSize}
height={height}
overscan={overscan}
class={className}
style={style}
onRangeChange={handleRangeChange}
renderItem={virtualRenderItem}
/>
);
}
108 changes: 108 additions & 0 deletions packages/preact/src/components/VirtualList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
useRef,
useState,
useEffect,
useCallback,
useMemo,
} from "preact/hooks";
import type { CSSProperties } from "preact";
import { calculateVirtualRange } from "@scrolloop/core";
import type { VirtualListProps } from "../types";

export function VirtualList({
count,
itemSize,
renderItem,
height = 400,
overscan = 4,
class: className,
style,
onRangeChange,
}: VirtualListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollTopRef = useRef(0);
const prevScrollTopRef = useRef(0);
const onRangeChangeRef = useRef(onRangeChange);
const [, forceUpdate] = useState(0);
const prevRangeRef = useRef({ start: -1, end: -1 });

useEffect(() => {
onRangeChangeRef.current = onRangeChange;
}, [onRangeChange]);

const totalHeight = count * itemSize;

const { renderStart, renderEnd } = calculateVirtualRange(
scrollTopRef.current,
height,
itemSize,
count,
overscan,
prevScrollTopRef.current
);

useEffect(() => {
const cb = onRangeChangeRef.current;
if (
cb &&
(prevRangeRef.current.start !== renderStart ||
prevRangeRef.current.end !== renderEnd)
) {
prevRangeRef.current = { start: renderStart, end: renderEnd };
cb({ startIndex: renderStart, endIndex: renderEnd });
}
}, [renderStart, renderEnd]);

const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
prevScrollTopRef.current = scrollTopRef.current;
scrollTopRef.current = el.scrollTop;
forceUpdate((n) => n + 1);
}, []);

useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);

const items = useMemo(() => {
const result = [];
for (let i = renderStart; i <= renderEnd; i++) {
const itemStyle: CSSProperties = {
position: "absolute",
top: i * itemSize,
left: 0,
right: 0,
height: itemSize,
};
result.push(
<div key={i} role="listitem">
{renderItem(i, itemStyle)}
</div>
);
}
return result;
}, [renderStart, renderEnd, itemSize, renderItem]);

const containerStyle: CSSProperties = {
overflow: "auto",
height,
...style,
};
Comment on lines +90 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The containerStyle object is recreated on every render. To improve performance, you can memoize it using the useMemo hook since its dependencies (height and style) are props.

Suggested change
const containerStyle: CSSProperties = {
overflow: "auto",
height,
...style,
};
const containerStyle = useMemo<CSSProperties>(() => ({
overflow: "auto",
height,
...style,
}), [height, style]);


return (
<div
ref={containerRef}
role="list"
class={className}
style={containerStyle}
>
<div style={{ position: "relative", height: totalHeight, width: "100%" }}>
{items}
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions packages/preact/src/components/infiniteList.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.scrolloop-state-container {
display: flex;
align-items: center;
justify-content: center;
}

.scrolloop-error-content {
text-align: center;
}

.scrolloop-error-message {
margin: 0 0 4px;
}

.scrolloop-error-detail {
margin: 0 0 8px;
color: #666;
font-size: 0.9em;
}

.scrolloop-retry-button {
padding: 4px 12px;
cursor: pointer;
}

.scrolloop-state-text {
margin: 0;
}
46 changes: 46 additions & 0 deletions packages/preact/src/hooks/useInfinitePages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState, useCallback, useEffect } from "preact/hooks";
import { InfiniteSource } from "@scrolloop/shared";
import type {
InfiniteSourceState,
InfiniteSourceOptions,
} from "@scrolloop/shared";

export function useInfinitePages<T>(options: InfiniteSourceOptions<T>) {
const { fetchPage, pageSize, initialPage, onPageLoad, onError } = options;

const [manager] = useState<InfiniteSource<T>>(
() =>
new InfiniteSource({
fetchPage,
pageSize,
initialPage,
onPageLoad,
onError,
})
);

const [state, setState] = useState<InfiniteSourceState<T>>(() =>
manager.getState()
);

useEffect(() => {
const unsubscribe = manager.subscribe(setState);
return () => {
unsubscribe();
manager.destroy();
};
}, [manager]);

useEffect(() => {
manager.updateCallbacks({ fetchPage, onPageLoad, onError });
}, [manager, fetchPage, onPageLoad, onError]);

const loadPage = useCallback(
(page: number) => manager.loadPage(page),
[manager]
);
const retry = useCallback(() => manager.retry(), [manager]);
const reset = useCallback(() => manager.reset(), [manager]);

return { ...state, loadPage, retry, reset };
}
4 changes: 4 additions & 0 deletions packages/preact/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { VirtualList } from "./components/VirtualList";
export { InfiniteList } from "./components/InfiniteList";
export { useInfinitePages } from "./hooks/useInfinitePages";
export type { VirtualListProps, InfiniteListProps } from "./types";
36 changes: 36 additions & 0 deletions packages/preact/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CSSProperties, VNode } from "preact";
import type { PageResponse, Range } from "@scrolloop/shared";

export type { PageResponse, Range };

export interface VirtualListProps {
count: number;
itemSize: number;
renderItem: (index: number, style: CSSProperties) => VNode | null;
height?: number;
overscan?: number;
class?: string;
style?: CSSProperties;
onRangeChange?: (range: Range) => void;
}

export interface InfiniteListProps<T> {
fetchPage: (page: number, size: number) => Promise<PageResponse<T>>;
renderItem: (
item: T | undefined,
index: number,
style: CSSProperties
) => VNode | null;
itemSize: number;
pageSize?: number;
initialPage?: number;
height?: number;
overscan?: number;
class?: string;
style?: CSSProperties;
renderLoading?: () => VNode | null;
renderError?: (error: Error, retry: () => void) => VNode | null;
renderEmpty?: () => VNode | null;
onPageLoad?: (page: number, items: T[]) => void;
onError?: (error: Error) => void;
}
11 changes: 11 additions & 0 deletions packages/preact/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"jsxImportSource": "preact"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tsup.config.ts"],
"exclude": ["node_modules", "dist"]
}
Loading
Loading