/* Copyright 2026 Marimo. All rights reserved. */ import { useIntersectionObserver } from "@uidotdev/usehooks"; import { Loader2Icon } from "lucide-react"; import type { JSX } from "react"; import { z } from "zod"; import { useAsyncData } from "@/hooks/useAsyncData"; import { createPlugin } from "@/plugins/core/builder"; import { rpc } from "@/plugins/core/rpc"; import { renderHTML } from "../core/RenderHTML"; import { ErrorBanner } from "../impl/common/error-banner"; interface Data { showLoadingIndicator: boolean; } // oxlint-disable-next-line typescript/consistent-type-definitions type PluginFunctions = { load: (req: {}) => Promise<{ html: string; }>; }; // Whether it has been loaded type S = boolean; export const LazyPlugin = createPlugin("marimo-lazy") .withData( z.object({ showLoadingIndicator: z.boolean().default(false), }), ) .withFunctions({ load: rpc.input(z.object({})).output( z.object({ html: z.string(), }), ), }) .renderer((props) => ( )); interface Props extends PluginFunctions, Data { value: boolean; setValue: (value: S) => void; } const LazyComponent = ({ load, showLoadingIndicator, value, setValue, }: Props): JSX.Element => { const [ref, entry] = useIntersectionObserver({ threshold: 0, root: null, rootMargin: "0px", }); if (entry?.isIntersecting && !value) { setValue(true); } // For each re-render, we have to make a waterfall request // We could improve performance if the BE was able to know if the same // mo.lazy has already been loaded, and when re-rendering it would // include the 'lazy' content by default (unlazily) const { data, error, isPending } = useAsyncData( (ctx) => { if (!value) { ctx.previous(); return Promise.resolve(undefined); } return load({}); }, [value], ); if (error) { return ; } return (
{isPending && showLoadingIndicator ? ( ) : ( data && renderHTML({ html: data.html }) )}
); };