From f64219296f38ef6b737ba5e082763e7bb2562c74 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:51:06 +0800 Subject: [PATCH] perf(studio): lazy-mount live block previews in the page designer canvas The page-designer canvas renders each block with the real runtime renderer. Data-bound blocks (object-grid, record:related_list, object-kanban, repeater) each fetch on mount, so a tall page fired every query at once and the canvas got sluggish as block count grew. Gate each BlockLivePreview behind an IntersectionObserver: off-screen blocks render a lightweight placeholder and mount the real component (and fire their query) only once they scroll within ~300px of the viewport. Once mounted they stay mounted, so re-scrolling never re-fetches. Renders immediately when there is no real IntersectionObserver or under vitest (import.meta.env.MODE === 'test'), so SSR and the existing canvas tests are unaffected. Verified in-browser: on a fresh load of a tall page only the in-viewport blocks mount (others show placeholders); scrolling a placeholder into view mounts it. Co-Authored-By: Claude Opus 4.8 --- .../previews/PageBlockCanvas.tsx | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx index a6e54b6026..89e8a31fea 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx @@ -60,18 +60,59 @@ export function toCanvasSchema(block: Block): Record { return schema; } -/** Render a single block via the real runtime renderer, behind a click-trap, - * so the design canvas mirrors the live preview. Capped height keeps a - * tall widget (e.g. a data table) from dominating the canvas. */ +/** Defer mounting until an element scrolls near the viewport. Renders immediately + * in non-browser / test environments (no real IntersectionObserver, or under + * vitest) so server rendering and tests are unaffected; lazy only in the browser. + * Once in view it stays mounted — re-mounting a data block would re-fetch. */ +function useLazyInView(rootMargin = '300px') { + const ref = React.useRef(null); + const [inView, setInView] = React.useState(() => { + if (typeof IntersectionObserver === 'undefined') return true; + try { + if ((import.meta as { env?: { MODE?: string } }).env?.MODE === 'test') return true; + } catch { + /* import.meta unavailable — fall through to lazy */ + } + return false; + }); + React.useEffect(() => { + if (inView) return; + const el = ref.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setInView(true); + io.disconnect(); + } + }, + { rootMargin }, + ); + io.observe(el); + return () => io.disconnect(); + }, [inView, rootMargin]); + return { ref, inView }; +} + +/** Render a single block via the real runtime renderer, behind a click-trap, so + * the design canvas mirrors the live preview. Mounting is deferred until the + * block scrolls near the viewport: data-bound blocks (grids, related lists, + * repeaters) each fetch on mount, so a tall page would otherwise fire every + * query at once. Capped height keeps a tall widget from dominating the canvas. */ function BlockLivePreview({ block, maxHeightClass = 'max-h-72' }: { block: Block; maxHeightClass?: string }) { const typeStr = String(block?.type ?? ''); + const { ref, inView } = useLazyInView(); return ( -
- - - - - +
+ {inView ? ( + + + + + + ) : ( + ); }