/* Copyright 2026 Marimo. All rights reserved. */ import React, { type JSX, type PropsWithChildren, useEffect, useMemo, useState, } from "react"; import useEvent from "react-use-event-hook"; import { z } from "zod"; import { TinyRouter } from "@/utils/routes"; import type { IStatelessPlugin, IStatelessPluginProps, } from "../stateless-plugin"; interface Data { /** * Route paths to render. */ routes: string[]; } export class RoutesPlugin implements IStatelessPlugin { tagName = "marimo-routes"; validator = z.object({ routes: z.array(z.string()), }); render(props: IStatelessPluginProps): JSX.Element { return {props.children}; } } const RoutesComponent = ({ routes, children, }: PropsWithChildren): JSX.Element => { const childCount = React.Children.count(children); if (childCount !== routes.length) { throw new Error( `Expected ${routes.length} children, but got ${childCount}`, ); } const router = useMemo(() => new TinyRouter(routes), [routes]); const [matched, setMatched] = useState(() => { const match = router.match(window.location); return match ? match[1] : null; }); const handleFindMatch = useEvent((location: Location) => { const match = router.match(location); setMatched(match ? match[1] : null); }); useEffect(() => { // Listen for route changes const listener = (e: PopStateEvent | HashChangeEvent) => { handleFindMatch(window.location); }; window.addEventListener("hashchange", listener); window.addEventListener("popstate", listener); return () => { window.removeEventListener("hashchange", listener); window.removeEventListener("popstate", listener); }; }, [handleFindMatch]); if (!matched) { // oxlint-disable-next-line react/jsx-no-useless-fragment return <>; } const matchedIndex = routes.indexOf(matched); const child = React.Children.toArray(children)[matchedIndex]; return <>{child}; };