From 22525f08c1a68bc0c13a44274d1dd9debe41c7e9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 2 Jun 2024 16:54:35 +0200 Subject: [PATCH] feat: Add Fragment Portal page with dynamic rendering --- src/pages/fragment-portal/FragmentPortal.tsx | 90 +++++++++++++++++ src/pages/fragment-portal/index.tsx | 100 +++++++++++++++++++ src/pages/fragment-portal/styles.module.css | 25 +++++ src/router.ts | 8 +- 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/pages/fragment-portal/FragmentPortal.tsx create mode 100644 src/pages/fragment-portal/index.tsx create mode 100644 src/pages/fragment-portal/styles.module.css diff --git a/src/pages/fragment-portal/FragmentPortal.tsx b/src/pages/fragment-portal/FragmentPortal.tsx new file mode 100644 index 0000000..dda5010 --- /dev/null +++ b/src/pages/fragment-portal/FragmentPortal.tsx @@ -0,0 +1,90 @@ +import { + Children, + cloneElement, + isValidElement, + useEffect, + useLayoutEffect, + useRef, + useState, + type ElementType, + type ReactNode, +} from 'react' +import { createPortal } from 'react-dom' + +const fragments = document.createDocumentFragment() +const getProps = Symbol() +const setProps = Symbol() +const currentProps = Symbol() + +export type FragmentPortal = HTMLElement & { + [getProps]?: () => any + [setProps]?: (props: any) => void + [currentProps]?: any + +} + +export function useFragment(type = 'div') { + const [fragment] = useState(() => { + const element = document.createElement(type) + fragments.appendChild(element) + return element as FragmentPortal + }) + + useEffect( + () => () => { + fragment.parentElement?.removeChild(fragment) + }, + [fragment], + ) + + return fragment +} + +export function Receptacle({ fragment, props, type = 'div', slot }: { + fragment?: FragmentPortal + props: any + type?: ElementType + slot?: React.MutableRefObject +}) { + const div = useRef() + + useLayoutEffect(() => { + if (!fragment) return + const receptacle = slot ? slot.current : div.current + if (!receptacle) return + receptacle.appendChild(fragment) + return () => { + fragments.appendChild(fragment) + } + }, [slot, fragment]) + + useLayoutEffect(() => { + if (!fragment || fragment[currentProps]() === props) return + fragment[setProps]?.(props) + }, [fragment, props]) + + if (!fragment) return null + + fragment[getProps] = () => props + + if (slot) return null + + const Element = type + return +} + +export function Portal({ children, fragment }: { + children: ReactNode + fragment: FragmentPortal +}) { + const [extraProps, setExtraProps] = useState(fragment[getProps]?.() || {}) + fragment[setProps] = setExtraProps + fragment[currentProps] = () => extraProps + return createPortal( + Children.map(children, (child) => { + if (!isValidElement(child)) return child + return cloneElement(child, extraProps) + }), + fragment, + ) +} \ No newline at end of file diff --git a/src/pages/fragment-portal/index.tsx b/src/pages/fragment-portal/index.tsx new file mode 100644 index 0000000..30b9d3b --- /dev/null +++ b/src/pages/fragment-portal/index.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from "react" +import { useFragment, Receptacle, Portal, type FragmentPortal } from './FragmentPortal' +import styles from './styles.module.css' +import { Link } from "~/Navigation" + +export const meta = { + title: 'Fragment Portal' +} + +export default function () { + const fragment = useFragment('div') + + return ( +
+ back +

{meta.title}

+ + +
+ ) +} + +function LiveComponents({ fragment }: { fragment: FragmentPortal }) { + const [, rerender] = useState({}) + const i = useRef(0).current++ + return ( + + + + ) +} + + +function Slots({ fragment }: { fragment: FragmentPortal }) { + const [side, setSide] = useState([0, 0]) + const onClick = () => { + // pick a random receptacle to display the `fragment` in + setSide(([_i, _j]) => { + let i, j + do { + i = Math.floor(Math.random() * 2) + j = Math.floor(Math.random() * 3) + } while (i === _i && j === _j) + return [i, j] + }) + } + return ( + <> + +
    + {/* make many receptacles */} + {[0, 1].map((i) => ( + [0, 1, 2].map((j) => ( +
  • + +
  • + )) + ))} +
+ + ) +} + +function Video({ label }: { label?: string }) { + const i = useRef(0).current++ + return ( + <> + +
+ renders: {i}, parent: {label} +
+ + ) +} + +function Timer({ label }: { label?: string }) { + const [time, setTime] = useState(0) + useEffect(() => { + const id = setInterval(() => setTime((t) => t + 1), 1000) + return () => clearInterval(id) + }, []) + const i = useRef(0).current++ + return ( +

+ {time}s since mounted, renders: {i}, parent: {label} +

+ ) +} \ No newline at end of file diff --git a/src/pages/fragment-portal/styles.module.css b/src/pages/fragment-portal/styles.module.css new file mode 100644 index 0000000..ef66f7b --- /dev/null +++ b/src/pages/fragment-portal/styles.module.css @@ -0,0 +1,25 @@ +.main { + font-family: sans-serif; + + ul { + list-style: none; + padding: 0; + } + + video { + display: block; + margin: auto; + } +} + +.grid { + text-align: center; + + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: 1fr; + + >* { + border: 1px solid red; + } +} \ No newline at end of file diff --git a/src/router.ts b/src/router.ts index 88a2444..2badf5a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,7 +2,7 @@ /* eslint-disable */ import { lazy } from "react" -export type Routes = "quad-tree" | "pong-pang" | "paint-worklet" | "lightning" +export type Routes = "quad-tree" | "pong-pang" | "paint-worklet" | "lightning" | "fragment-portal" export type RouteMeta = { title: string @@ -37,5 +37,11 @@ export const ROUTES = { meta: { title: 'Lightning' }, + }, + "fragment-portal": { + Component: lazy(() => import("./pages/fragment-portal/index.tsx")), + meta: { + title: 'Fragment Portal' + }, } } as const satisfies Record