From 3e94548b9195f8ae60e662d49105525f51bc72ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:21:53 +0900 Subject: [PATCH 1/8] chore(preact): scaffold @scrolloop/preact package --- packages/preact/package.json | 38 ++++++++++++++++++++++++++++++++++ packages/preact/tsconfig.json | 11 ++++++++++ packages/preact/tsup.config.ts | 14 +++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 packages/preact/package.json create mode 100644 packages/preact/tsconfig.json create mode 100644 packages/preact/tsup.config.ts diff --git a/packages/preact/package.json b/packages/preact/package.json new file mode 100644 index 0000000..9127187 --- /dev/null +++ b/packages/preact/package.json @@ -0,0 +1,38 @@ +{ + "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": false, + "files": [ + "dist", + "src" + ], + "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" +} diff --git a/packages/preact/tsconfig.json b/packages/preact/tsconfig.json new file mode 100644 index 0000000..87c7c5e --- /dev/null +++ b/packages/preact/tsconfig.json @@ -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"] +} diff --git a/packages/preact/tsup.config.ts b/packages/preact/tsup.config.ts new file mode 100644 index 0000000..ff03e03 --- /dev/null +++ b/packages/preact/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + external: ["preact", "preact/hooks", "@scrolloop/core", "@scrolloop/shared"], + esbuildOptions(options) { + options.jsxImportSource = "preact"; + options.jsx = "automatic"; + }, +}); From 6218ff47d5d54409aba47d7c2a2918bb2033160b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:22:41 +0900 Subject: [PATCH 2/8] feat(preact): add VirtualList component --- .../preact/src/components/VirtualList.tsx | 108 ++++++++++++++++++ packages/preact/src/types.ts | 36 ++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/preact/src/components/VirtualList.tsx create mode 100644 packages/preact/src/types.ts diff --git a/packages/preact/src/components/VirtualList.tsx b/packages/preact/src/components/VirtualList.tsx new file mode 100644 index 0000000..fea0487 --- /dev/null +++ b/packages/preact/src/components/VirtualList.tsx @@ -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(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( +
+ {renderItem(i, itemStyle)} +
+ ); + } + return result; + }, [renderStart, renderEnd, itemSize, renderItem]); + + const containerStyle: CSSProperties = { + overflow: "auto", + height, + ...style, + }; + + return ( +
+
+ {items} +
+
+ ); +} diff --git a/packages/preact/src/types.ts b/packages/preact/src/types.ts new file mode 100644 index 0000000..0eaaf71 --- /dev/null +++ b/packages/preact/src/types.ts @@ -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 { + fetchPage: (page: number, size: number) => Promise>; + 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; +} From ede66b8fa3f61a8efcf34266e8f7865a20f89aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:22:47 +0900 Subject: [PATCH 3/8] feat(preact): add useInfinitePages hook --- packages/preact/src/hooks/useInfinitePages.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/preact/src/hooks/useInfinitePages.ts diff --git a/packages/preact/src/hooks/useInfinitePages.ts b/packages/preact/src/hooks/useInfinitePages.ts new file mode 100644 index 0000000..b476bbd --- /dev/null +++ b/packages/preact/src/hooks/useInfinitePages.ts @@ -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(options: InfiniteSourceOptions) { + const { fetchPage, pageSize, initialPage, onPageLoad, onError } = options; + + const [manager] = useState>( + () => + new InfiniteSource({ + fetchPage, + pageSize, + initialPage, + onPageLoad, + onError, + }) + ); + + const [state, setState] = useState>(() => + 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 }; +} From 3cae4e9a39dab7ca8caff116289e665f66f3a33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:22:51 +0900 Subject: [PATCH 4/8] feat(preact): add InfiniteList component --- .../preact/src/components/InfiniteList.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/preact/src/components/InfiniteList.tsx diff --git a/packages/preact/src/components/InfiniteList.tsx b/packages/preact/src/components/InfiniteList.tsx new file mode 100644 index 0000000..fcced6a --- /dev/null +++ b/packages/preact/src/components/InfiniteList.tsx @@ -0,0 +1,110 @@ +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"; + +export function InfiniteList({ + fetchPage, + renderItem, + itemSize, + pageSize = 20, + initialPage = 0, + height = 400, + overscan: userOverscan, + class: className, + style, + renderLoading, + renderError, + renderEmpty, + onPageLoad, + onError, +}: InfiniteListProps) { + const overscan = useMemo( + () => userOverscan ?? Math.max(20, pageSize * 2), + [userOverscan, pageSize] + ); + + const { allItems, loadingPages, hasMore, error, loadPage, retry } = + useInfinitePages({ fetchPage, pageSize, initialPage, onPageLoad, onError }); + + useEffect(() => { + if (!allItems.length && !error) { + const needed = Math.ceil(height / itemSize) + overscan * 2; + const pagesToLoad = Math.ceil(needed / pageSize); + for (let p = 0; p < pagesToLoad; p++) loadPage(p); + } + }, [allItems.length, 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(allItems[index], index, itemStyle), + [allItems, renderItem] + ); + + const centerStyle: CSSProperties = { + height, + display: "flex", + alignItems: "center", + justifyContent: "center", + }; + + if (error && !allItems.length) { + if (renderError) + return
{renderError(error, retry)}
; + return ( +
+
+

Error.

+

{error.message}

+ +
+
+ ); + } + + if (!allItems.length && loadingPages.size > 0) { + if (renderLoading) return
{renderLoading()}
; + return ( +
+

Loading...

+
+ ); + } + + if (!allItems.length && !hasMore) { + if (renderEmpty) return
{renderEmpty()}
; + return ( +
+

No data.

+
+ ); + } + + return ( + + ); +} From 0611fb0d637e573402fa85219f255542fffc10e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:22:55 +0900 Subject: [PATCH 5/8] feat(preact): export public API --- packages/preact/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/preact/src/index.ts diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts new file mode 100644 index 0000000..8c2ed36 --- /dev/null +++ b/packages/preact/src/index.ts @@ -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"; From e0369412071635c9c68dc4d4b6f2ebb5fa14934b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:24:36 +0900 Subject: [PATCH 6/8] chore: update lockfile for @scrolloop/preact --- pnpm-lock.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ad3f4..f799f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,25 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@24.10.0)(jsdom@24.1.3)(terser@5.44.0) + packages/preact: + dependencies: + '@scrolloop/core': + specifier: workspace:* + version: link:../core + '@scrolloop/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + preact: + specifier: ^10.0.0 + version: 10.28.0 + tsup: + specifier: ^8.0.0 + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/react: dependencies: '@scrolloop/core': From d0bdd3c7752d816ecb4f1cd8484978f28497b619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 30 Mar 2026 09:45:01 +0900 Subject: [PATCH 7/8] perf(preact): use total+pages instead of allItems in InfiniteList --- .../preact/src/components/InfiniteList.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/preact/src/components/InfiniteList.tsx b/packages/preact/src/components/InfiniteList.tsx index fcced6a..54606bd 100644 --- a/packages/preact/src/components/InfiniteList.tsx +++ b/packages/preact/src/components/InfiniteList.tsx @@ -25,16 +25,16 @@ export function InfiniteList({ [userOverscan, pageSize] ); - const { allItems, loadingPages, hasMore, error, loadPage, retry } = + const { pages, total, loadingPages, hasMore, error, loadPage, retry } = useInfinitePages({ fetchPage, pageSize, initialPage, onPageLoad, onError }); useEffect(() => { - if (!allItems.length && !error) { + 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); } - }, [allItems.length, error, height, itemSize, pageSize, overscan, loadPage]); + }, [total, error, height, itemSize, pageSize, overscan, loadPage]); const handleRangeChange = useCallback( (range: { startIndex: number; endIndex: number }) => { @@ -47,8 +47,12 @@ export function InfiniteList({ const virtualRenderItem = useCallback( (index: number, itemStyle: CSSProperties) => - renderItem(allItems[index], index, itemStyle), - [allItems, renderItem] + renderItem( + pages.get(Math.floor(index / pageSize))?.[index % pageSize], + index, + itemStyle + ), + [pages, pageSize, renderItem] ); const centerStyle: CSSProperties = { @@ -58,7 +62,7 @@ export function InfiniteList({ justifyContent: "center", }; - if (error && !allItems.length) { + if (error && total === 0) { if (renderError) return
{renderError(error, retry)}
; return ( @@ -77,7 +81,7 @@ export function InfiniteList({ ); } - if (!allItems.length && loadingPages.size > 0) { + if (total === 0 && loadingPages.size > 0) { if (renderLoading) return
{renderLoading()}
; return (
@@ -86,7 +90,7 @@ export function InfiniteList({ ); } - if (!allItems.length && !hasMore) { + if (total === 0 && !hasMore) { if (renderEmpty) return
{renderEmpty()}
; return (
@@ -97,7 +101,7 @@ export function InfiniteList({ return ( Date: Mon, 30 Mar 2026 09:49:16 +0900 Subject: [PATCH 8/8] style(preact): extract inline styles to CSS classes in InfiniteList --- packages/preact/package.json | 4 ++- .../preact/src/components/InfiniteList.tsx | 29 +++++++------------ .../preact/src/components/infiniteList.css | 28 ++++++++++++++++++ 3 files changed, 41 insertions(+), 20 deletions(-) create mode 100644 packages/preact/src/components/infiniteList.css diff --git a/packages/preact/package.json b/packages/preact/package.json index 9127187..4215aaf 100644 --- a/packages/preact/package.json +++ b/packages/preact/package.json @@ -13,7 +13,9 @@ "require": "./dist/index.cjs" } }, - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "src" diff --git a/packages/preact/src/components/InfiniteList.tsx b/packages/preact/src/components/InfiniteList.tsx index 54606bd..7aea297 100644 --- a/packages/preact/src/components/InfiniteList.tsx +++ b/packages/preact/src/components/InfiniteList.tsx @@ -3,6 +3,7 @@ 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({ fetchPage, @@ -55,25 +56,15 @@ export function InfiniteList({ [pages, pageSize, renderItem] ); - const centerStyle: CSSProperties = { - height, - display: "flex", - alignItems: "center", - justifyContent: "center", - }; - if (error && total === 0) { if (renderError) return
{renderError(error, retry)}
; return ( -
-
-

Error.

-

{error.message}

-
@@ -84,8 +75,8 @@ export function InfiniteList({ if (total === 0 && loadingPages.size > 0) { if (renderLoading) return
{renderLoading()}
; return ( -
-

Loading...

+
+

Loading...

); } @@ -93,8 +84,8 @@ export function InfiniteList({ if (total === 0 && !hasMore) { if (renderEmpty) return
{renderEmpty()}
; return ( -
-

No data.

+
+

No data.

); } diff --git a/packages/preact/src/components/infiniteList.css b/packages/preact/src/components/infiniteList.css new file mode 100644 index 0000000..b002d93 --- /dev/null +++ b/packages/preact/src/components/infiniteList.css @@ -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; +}