diff --git a/packages/core/src/strategies/layout/FixedLayoutStrategy.ts b/packages/core/src/strategies/layout/FixedLayoutStrategy.ts index 2d4309b..46a0f66 100644 --- a/packages/core/src/strategies/layout/FixedLayoutStrategy.ts +++ b/packages/core/src/strategies/layout/FixedLayoutStrategy.ts @@ -1,20 +1,24 @@ -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; + } 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( @@ -22,16 +26,11 @@ export class FixedLayoutStrategy implements LayoutStrategy { 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 }; } } - diff --git a/packages/core/src/strategies/scroll/VirtualScrollSource.ts b/packages/core/src/strategies/scroll/VirtualScrollSource.ts index 08a9ac6..88a7add 100644 --- a/packages/core/src/strategies/scroll/VirtualScrollSource.ts +++ b/packages/core/src/strategies/scroll/VirtualScrollSource.ts @@ -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)); } } diff --git a/packages/core/src/utils/calculateVirtualRange.ts b/packages/core/src/utils/calculateVirtualRange.ts index 1537ef1..39b1479 100644 --- a/packages/core/src/utils/calculateVirtualRange.ts +++ b/packages/core/src/utils/calculateVirtualRange.ts @@ -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) @@ -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 { diff --git a/packages/core/src/virtualizer/Virtualizer.ts b/packages/core/src/virtualizer/Virtualizer.ts index d127bde..22ec056 100644 --- a/packages/core/src/virtualizer/Virtualizer.ts +++ b/packages/core/src/virtualizer/Virtualizer.ts @@ -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; + + 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; } return { @@ -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?.()); } } - diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 3a82893..af1b21d 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -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: true, + unsafe_arrows: true, + unsafe_methods: true, + booleans_as_integers: true, + ecma: 2020, }, mangle: { safari10: false, @@ -22,7 +27,7 @@ export default defineConfig({ comments: false, }, }, - target: "es2020", + target: "es2022", outExtension({ format }) { return { js: format === "esm" ? ".mjs" : ".cjs", diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index a6675ff..065836d 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -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: true, + unsafe_arrows: true, + unsafe_methods: true, + booleans_as_integers: true, + ecma: 2020, }, mangle: { safari10: false, @@ -22,9 +27,8 @@ export default defineConfig({ comments: false, }, }, - target: "es2024", - external: ["react", "react-native"], - noExternal: ["@scrolloop/core", "@scrolloop/shared"], + target: "es2020", + external: ["react", "react-native", "@scrolloop/core", "@scrolloop/shared"], outExtension({ format }) { return { js: format === "esm" ? ".mjs" : ".cjs", diff --git a/packages/react/src/components/InfiniteList.tsx b/packages/react/src/components/InfiniteList.tsx index 59a1852..c90d494 100644 --- a/packages/react/src/components/InfiniteList.tsx +++ b/packages/react/src/components/InfiniteList.tsx @@ -37,99 +37,63 @@ function InfiniteListInner(props: InfiniteListProps) { ); const containerRef = useRef(null); + const scrollTopRef = useRef(0); - const initialPagesRef = useRef>(new Map()); - const initialTotalRef = useRef(0); - const initialHasMoreRef = useRef(true); + const { allItems, pages, loadingPages, hasMore, error, loadPage, retry } = + useInfinitePages({ fetchPage, pageSize, initialPage, onPageLoad, onError }); - if (isServerSide && initialData && initialData.length > 0) { + const ssrData = useMemo(() => { + if (!isServerSide || !initialData?.length) return null; const initialPages = new Map(); const totalPages = Math.ceil(initialData.length / pageSize); - for (let i = 0; i < totalPages; i++) { - const start = i * pageSize; - const end = start + pageSize; - initialPages.set(i, initialData.slice(start, end)); + initialPages.set(i, initialData.slice(i * pageSize, (i + 1) * pageSize)); } - - initialPagesRef.current = initialPages; - initialTotalRef.current = initialTotal ?? initialData.length; - initialHasMoreRef.current = initialTotal - ? initialData.length < initialTotal - : true; - } - - const { allItems, pages, loadingPages, hasMore, error, loadPage, retry } = - useInfinitePages({ - fetchPage, - pageSize, - initialPage, - onPageLoad, - onError, - }); + const total = initialTotal ?? initialData.length; + return { + pages: initialPages, + total, + hasMore: initialTotal ? initialData.length < initialTotal : true, + }; + }, [isServerSide, initialData, initialTotal, pageSize]); const mergedPages = useMemo(() => { - if (isServerSide && initialPagesRef.current.size > 0) { + if (ssrData) { const merged = new Map(pages); - initialPagesRef.current.forEach((items, pageNum) => { - if (!merged.has(pageNum)) { - merged.set(pageNum, items); - } - }); + ssrData.pages.forEach((v, k) => !merged.has(k) && merged.set(k, v)); return merged; } return pages; - }, [pages, isServerSide]); + }, [pages, ssrData]); - const mergedTotal = useMemo(() => { - if (isServerSide && initialTotalRef.current > 0) { - return Math.max(initialTotalRef.current, allItems.length); - } - return allItems.length; - }, [isServerSide, allItems.length]); - - const mergedHasMore = useMemo(() => { - if (isServerSide && initialPagesRef.current.size > 0) { - return initialHasMoreRef.current || hasMore; - } - return hasMore; - }, [isServerSide, hasMore]); + const mergedTotal = ssrData + ? Math.max(ssrData.total, allItems.length) + : allItems.length; + const mergedHasMore = ssrData ? ssrData.hasMore || hasMore : hasMore; const mergedAllItems = useMemo(() => { - if (isServerSide && initialData && initialData.length > 0) { - const items: (T | undefined)[] = new Array(mergedTotal); - - initialData.forEach((item, index) => { - items[index] = item; - }); - - mergedPages.forEach((pageItems, pageNum) => { - const startIndex = pageNum * pageSize; - pageItems.forEach((item, i) => { - items[startIndex + i] = item; - }); + if (ssrData && initialData) { + const items = new Array(mergedTotal); + initialData.forEach((v, i) => (items[i] = v)); + mergedPages.forEach((v, k) => { + const start = k * pageSize; + v.forEach((it, i) => (items[start + i] = it)); }); - return items; } return allItems; - }, [isServerSide, initialData, mergedTotal, mergedPages, pageSize, allItems]); + }, [ssrData, initialData, mergedTotal, mergedPages, pageSize, allItems]); useEffect(() => { - if (!isServerSide && mergedPages.size === 0 && !error) { - const totalNeededItems = Math.ceil(height / itemSize) + overscan * 2; - for ( - let page = 0; - page < Math.ceil(totalNeededItems / pageSize) + prefetchThreshold; - page++ - ) - loadPage(page); + if (!isServerSide && !mergedPages.size && !error) { + const needed = Math.ceil(height / itemSize) + overscan * 2; + for (let p = 0; p < Math.ceil(needed / pageSize) + prefetchThreshold; p++) + loadPage(p); } }, [ isServerSide, mergedPages.size, loadPage, - initialPage, error, height, itemSize, @@ -138,23 +102,21 @@ function InfiniteListInner(props: InfiniteListProps) { overscan, ]); - const scrollTopRef = useRef(0); const visibleRange = useMemo(() => { - const scrollTop = scrollTopRef.current; + const st = scrollTopRef.current; const { renderStart, renderEnd } = calculateVirtualRange( - scrollTop, + st, height, itemSize, mergedAllItems.length, overscan, - scrollTop + st ); return { start: renderStart, end: renderEnd }; }, [height, itemSize, mergedAllItems.length, overscan]); - const shouldUseTransition = isServerSide; const { isVirtualized } = useTransition({ - enabled: shouldUseTransition, + enabled: isServerSide, containerRef, itemSize, totalItems: mergedAllItems.length, @@ -168,22 +130,16 @@ function InfiniteListInner(props: InfiniteListProps) { scrollTopRef.current = containerRef.current?.scrollTop ?? 0; return; } - - const prefetchStart = Math.max( + const ps = Math.max( 0, - Math.floor(range.startIndex / pageSize) - - Math.floor(range.endIndex / pageSize) + ((range.startIndex / pageSize) | 0) - ((range.endIndex / pageSize) | 0) ); - const prefetchEnd = - Math.floor(range.endIndex / pageSize) + + const pe = + ((range.endIndex / pageSize) | 0) + prefetchThreshold + Math.ceil(overscan / pageSize); - - findMissingPages(prefetchStart, prefetchEnd, mergedPages, loadingPages); - - for (let page = prefetchStart; page <= prefetchEnd; page++) { - loadPage(page); - } + findMissingPages(ps, pe, mergedPages, loadingPages); + for (let p = ps; p <= pe; p++) loadPage(p); }, [ isServerSide, @@ -199,96 +155,33 @@ function InfiniteListInner(props: InfiniteListProps) { useEffect(() => { if (!isServerSide || !containerRef.current) return; - - const container = containerRef.current; - const handleScroll = () => { - scrollTopRef.current = container.scrollTop; + const scroll = () => { + scrollTopRef.current = containerRef.current?.scrollTop ?? 0; }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleScroll); + containerRef.current.addEventListener("scroll", scroll, { passive: true }); + return () => containerRef.current?.removeEventListener("scroll", scroll); }, [isServerSide]); - const virtualListRenderItem = useCallback( - (index: number, itemStyle: CSSProperties) => { - const item = mergedAllItems[index]; - return renderItem(item, index, itemStyle); - }, - [mergedAllItems, renderItem] - ); - - const FullRenderItem = useCallback( - (item: T | undefined, index: number, style: CSSProperties) => { - return renderItem(item, index, style); - }, - [renderItem] - ); - - const errorContainerStyle = useMemo( - () => ({ - height, - display: "flex", - alignItems: "center", - justifyContent: "center", - }), - [height] - ); - - const errorContentStyle = useMemo( - () => ({ - textAlign: "center", - }), - [] - ); - - const errorMessageStyle = useMemo( - () => ({ - color: "#666", - fontSize: "0.9em", - }), - [] - ); - - const retryButtonStyle = useMemo( - () => ({ - marginTop: 8, - padding: "4px 12px", - cursor: "pointer", - }), - [] - ); - - const loadingContainerStyle = useMemo( - () => ({ - height, - display: "flex", - alignItems: "center", - justifyContent: "center", - }), - [height] - ); - - const emptyContainerStyle = useMemo( - () => ({ - height, - display: "flex", - alignItems: "center", - justifyContent: "center", - }), - [height] - ); - - const heightOnlyStyle = useMemo(() => ({ height }), [height]); + const commonContainerStyle: CSSProperties = { + height, + display: "flex", + alignItems: "center", + justifyContent: "center", + }; + const heightOnlyStyle: CSSProperties = { height }; - if (error && mergedAllItems.length === 0) { + if (error && !mergedAllItems.length) { if (renderError) return
{renderError(error, retry)}
; return ( -
-
+
+

Error.

-

{error.message}

-
@@ -296,37 +189,32 @@ function InfiniteListInner(props: InfiniteListProps) { ); } - if (mergedAllItems.length === 0 && loadingPages.size > 0) { - if (renderLoading) { - return
{renderLoading()}
; - } - return ( -
+ if (!mergedAllItems.length && loadingPages.size) { + return renderLoading ? ( +
{renderLoading()}
+ ) : ( +

Loading...

); } - if (mergedAllItems.length === 0 && !mergedHasMore) { - if (renderEmpty) { - return
{renderEmpty()}
; - } - return ( -
+ if (!mergedAllItems.length && !mergedHasMore) { + return renderEmpty ? ( +
{renderEmpty()}
+ ) : ( +

No data.

); } - const shouldRenderFullList = - isServerSideEnvironment() || (isServerSide && !isVirtualized); - - if (shouldRenderFullList) { + if (isServerSideEnvironment() || (isServerSide && !isVirtualized)) { return ( (props: InfiniteListProps) { className={className} style={style} onRangeChange={handleRangeChange} - renderItem={virtualListRenderItem} + renderItem={(index, itemStyle) => + renderItem(mergedAllItems[index], index, itemStyle) + } /> ); } diff --git a/packages/react/src/hooks/useTransition.ts b/packages/react/src/hooks/useTransition.ts index 6a4f334..010fed0 100644 --- a/packages/react/src/hooks/useTransition.ts +++ b/packages/react/src/hooks/useTransition.ts @@ -31,108 +31,78 @@ export function useTransition({ onTransitionError, }: useTransitionOptions) { const [state, setState] = useState({ type: "SSR_DOM" }); - const isHydratedRef = useRef(false); - const hasInteractedRef = useRef(false); - const pruneCancelRef = useRef<(() => void) | null>(null); - const transitionStrategy = { ...defaultTransitionStrategy, ...strategy }; + const isH = useRef(false); + const hasI = useRef(false); + const pC = useRef<(() => void) | null>(null); + const s = { ...defaultTransitionStrategy, ...strategy }; useEffect(() => { - if (!enabled || isHydratedRef.current) return; - - const checkHydration = () => { - if (containerRef.current && !isHydratedRef.current) { - isHydratedRef.current = true; + if (!enabled || isH.current) return; + const h = () => { + if (containerRef.current && !isH.current) { + isH.current = true; setState({ type: "HYDRATED" }); } }; - - checkHydration(); - - const timeoutId = setTimeout(checkHydration, 0); - - return () => clearTimeout(timeoutId); + h(); + const t = setTimeout(h, 0); + return () => clearTimeout(t); }, [enabled, containerRef]); useEffect(() => { if (!enabled || state.type !== "HYDRATED") return; + const c = containerRef.current; + if (!c) return; - const container = containerRef.current; - if (!container) return; - - const triggerTransition = () => { + const run = () => { try { onTransitionStart?.(); - - const snapshot = captureSnapshot(container, itemSize, totalItems); - - setState({ type: "SWITCHING", snapshot }); - - restoreSnapshot(container, snapshot); - - const pruneStrategy = transitionStrategy.pruneStrategy || "idle"; - const cancelPrune = - pruneStrategy === "chunk" + const sn = captureSnapshot(c, itemSize, totalItems); + setState({ type: "SWITCHING", snapshot: sn }); + restoreSnapshot(c, sn); + pC.current = + s.pruneStrategy === "chunk" ? pruneOffscreenDOMChunk( - container, + c, visibleRange, - transitionStrategy.chunkSize || 10, + s.chunkSize || 10, () => {} ) - : pruneOffscreenDOMIdle(container, visibleRange, () => {}); - - pruneCancelRef.current = cancelPrune; - + : pruneOffscreenDOMIdle(c, visibleRange, () => {}); setTimeout(() => { setState({ type: "VIRTUALIZED" }); onTransitionComplete?.(); }, 100); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - onTransitionError?.(err); - - if (transitionStrategy.transitionStrategy === "replace-offscreen") { + } catch (e) { + onTransitionError?.(e instanceof Error ? e : new Error(String(e))); + if (s.transitionStrategy === "replace-offscreen") setState({ type: "VIRTUALIZED" }); - } } }; - if (transitionStrategy.switchTrigger === "immediate") { - triggerTransition(); - } else if (transitionStrategy.switchTrigger === "first-interaction") { - const handleInteraction = () => { - if (!hasInteractedRef.current) { - hasInteractedRef.current = true; - triggerTransition(); - container.removeEventListener("click", handleInteraction); - container.removeEventListener("keydown", handleInteraction); - container.removeEventListener("scroll", handleInteraction); - } - }; - - container.addEventListener("click", handleInteraction, { once: true }); - container.addEventListener("keydown", handleInteraction, { once: true }); - container.addEventListener("scroll", handleInteraction, { once: true }); - - return () => { - container.removeEventListener("click", handleInteraction); - container.removeEventListener("keydown", handleInteraction); - container.removeEventListener("scroll", handleInteraction); - }; - } else if (transitionStrategy.switchTrigger === "idle") { - const handleIdle = () => { - if ("requestIdleCallback" in window) { - window.requestIdleCallback(() => { - triggerTransition(); - }); - } else { - setTimeout(triggerTransition, 1000); + if (s.switchTrigger === "immediate") run(); + else if (s.switchTrigger === "first-interaction") { + const i = () => { + if (!hasI.current) { + hasI.current = true; + run(); + ["click", "keydown", "scroll"].forEach((ev) => + c.removeEventListener(ev, i) + ); } }; - - handleIdle(); - return; + ["click", "keydown", "scroll"].forEach((ev) => + c.addEventListener(ev, i, { once: true }) + ); + return () => + ["click", "keydown", "scroll"].forEach((ev) => + c.removeEventListener(ev, i) + ); + } else if (s.switchTrigger === "idle") { + "requestIdleCallback" in window + ? window.requestIdleCallback(run) + : setTimeout(run, 1000); } - return undefined; }, [ enabled, @@ -141,26 +111,18 @@ export function useTransition({ itemSize, totalItems, visibleRange, - transitionStrategy, + s, onTransitionStart, onTransitionComplete, onTransitionError, ]); - useEffect(() => { - return () => { - if (pruneCancelRef.current) pruneCancelRef.current(); - }; - }, []); - - const isVirtualized = state.type === "VIRTUALIZED"; - const isSwitching = state.type === "SWITCHING"; - const snapshot = state.type === "SWITCHING" ? state.snapshot : null; + useEffect(() => () => pC.current?.(), []); return { state, - isVirtualized, - isSwitching, - snapshot, + isVirtualized: state.type === "VIRTUALIZED", + isSwitching: state.type === "SWITCHING", + snapshot: state.type === "SWITCHING" ? state.snapshot : null, }; } diff --git a/packages/react/src/utils/domPruner.ts b/packages/react/src/utils/domPruner.ts index 6b80205..4db7968 100644 --- a/packages/react/src/utils/domPruner.ts +++ b/packages/react/src/utils/domPruner.ts @@ -7,102 +7,65 @@ export const defaultTransitionStrategy: TransitionStrategy = { chunkSize: 10, }; +const RIC = (cb: () => void) => + typeof window !== "undefined" && "requestIdleCallback" in window + ? window.requestIdleCallback(cb) + : setTimeout(cb, 1); +const CIC = (id: any) => + typeof window !== "undefined" && "cancelIdleCallback" in window + ? window.cancelIdleCallback(id) + : clearTimeout(id); + export function pruneOffscreenDOMIdle( container: HTMLElement, - visibleRange: { start: number; end: number }, - onPrune: (index: number) => void -): () => void { - let cancelled = false; - let requestId: ReturnType | null = null; - - const pruneChunk = () => { + range: { start: number; end: number }, + onPrune: (idx: number) => void +) { + let id: any, + cancelled = false; + const prune = () => { if (cancelled) return; - - const items = container.querySelectorAll("[data-item-index]"); - let pruned = 0; - const maxPrunePerFrame = 5; - - for (const item of items) { - if (pruned >= maxPrunePerFrame) break; - - const index = parseInt(item.getAttribute("data-item-index") || "-1", 10); - if (index < 0) continue; - - if (index < visibleRange.start || index > visibleRange.end) { - onPrune(index); - pruned++; - } - } - - if (pruned > 0 && !cancelled) { - requestId = requestIdleCallback(pruneChunk); - } + let count = 0; + container.querySelectorAll("[data-item-index]").forEach((el) => { + if (count++ > 5) return; + const i = parseInt(el.getAttribute("data-item-index") || "-1", 10); + if (i >= 0 && (i < range.start || i > range.end)) onPrune(i); + }); + if (count > 0 && !cancelled) id = RIC(prune); }; - - requestId = requestIdleCallback(pruneChunk); - + id = RIC(prune); return () => { cancelled = true; - if (requestId !== null) { - cancelIdleCallback(requestId); - } + CIC(id); }; } export function pruneOffscreenDOMChunk( container: HTMLElement, - visibleRange: { start: number; end: number }, - chunkSize: number, - onPrune: (index: number) => void -): () => void { - let cancelled = false; - let timeoutId: number | null = null; - - const pruneChunk = () => { + range: { start: number; end: number }, + chunk: number, + onPrune: (idx: number) => void +) { + let id: any, + cancelled = false; + const prune = () => { if (cancelled) return; - - const items = Array.from(container.querySelectorAll("[data-item-index]")); - const offscreenItems = items.filter((item) => { - const index = parseInt(item.getAttribute("data-item-index") || "-1", 10); - return ( - index >= 0 && (index < visibleRange.start || index > visibleRange.end) - ); - }); - - const chunk = offscreenItems.slice(0, chunkSize); - chunk.forEach((item) => { - const index = parseInt(item.getAttribute("data-item-index") || "-1", 10); - if (index >= 0) { - onPrune(index); + const items = [...container.querySelectorAll("[data-item-index]")].filter( + (el) => { + const i = parseInt(el.getAttribute("data-item-index") || "-1", 10); + return i >= 0 && (i < range.start || i > range.end); } - }); - - if (offscreenItems.length > chunkSize && !cancelled) { - timeoutId = window.setTimeout(pruneChunk, 16) as unknown as number; - } + ); + items + .slice(0, chunk) + .forEach((el) => + onPrune(parseInt(el.getAttribute("data-item-index")!, 10)) + ); + if (items.length > chunk && !cancelled) id = setTimeout(prune, 16); }; - - timeoutId = window.setTimeout(pruneChunk, 16) as unknown as number; - + id = setTimeout(prune, 16); return () => { cancelled = true; - if (timeoutId !== null) { - window.clearTimeout(timeoutId as any); - } + clearTimeout(id); }; } - -function requestIdleCallback(callback: () => void): number { - if (typeof window !== "undefined" && "requestIdleCallback" in window) { - return (window as any).requestIdleCallback(callback) as number; - } - return setTimeout(callback, 1) as unknown as number; -} - -function cancelIdleCallback(id: number): void { - if (typeof window !== "undefined" && "cancelIdleCallback" in window) { - (window as any).cancelIdleCallback(id); - } else { - clearTimeout(id as any); - } -} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 769696f..78a86ef 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -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: true, + unsafe_arrows: true, + unsafe_methods: true, + booleans_as_integers: true, + ecma: 2020, }, mangle: { safari10: false, @@ -23,8 +28,7 @@ export default defineConfig({ }, }, target: "es2020", - external: ["react", "react-dom"], - noExternal: ["@scrolloop/core", "@scrolloop/shared"], + external: ["react", "react-dom", "@scrolloop/core", "@scrolloop/shared"], outExtension({ format }) { return { js: format === "esm" ? ".mjs" : ".cjs", diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index 6877331..f2fe949 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -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: true, + unsafe_arrows: true, + unsafe_methods: true, + booleans_as_integers: true, + ecma: 2020, }, mangle: { safari10: false, @@ -22,7 +27,7 @@ export default defineConfig({ comments: false, }, }, - target: "es2024", + target: "es2020", external: ["react"], outExtension({ format }) { return {