Skip to content

Commit

Permalink
feat: Add Fragment Portal page with dynamic rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
Sheraff committed Jun 2, 2024
1 parent 774f084 commit 22525f0
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 1 deletion.
90 changes: 90 additions & 0 deletions src/pages/fragment-portal/FragmentPortal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>
}) {
const div = useRef<HTMLElement>()

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 <Element ref={div} />
}

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,
)
}
100 changes: 100 additions & 0 deletions src/pages/fragment-portal/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.main}>
<Link href="/">back</Link>
<h1>{meta.title}</h1>
<Slots fragment={fragment} />
<LiveComponents fragment={fragment} />
</div>
)
}

function LiveComponents({ fragment }: { fragment: FragmentPortal }) {
const [, rerender] = useState({})
const i = useRef(0).current++
return (
<Portal fragment={fragment}>
<button onClick={() => rerender({})}>rerender: {i}</button>
<Video />
<Timer />
</Portal>
)
}


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 (
<>
<button onClick={onClick}>
move somewhere else in DOM
</button>
<ul className={styles.grid}>
{/* make many receptacles */}
{[0, 1].map((i) => (
[0, 1, 2].map((j) => (
<li key={i + '-' + j}>
<Receptacle
fragment={(side[0] === i && side[1] === j) ? fragment : undefined}
props={{ label: `${j + 1}x${i + 1}` }}
/>
</li>
))
))}
</ul>
</>
)
}

function Video({ label }: { label?: string }) {
const i = useRef(0).current++
return (
<>
<video width="320" height="240" controls loop muted autoPlay playsInline>
<source
src="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
type="video/mp4"
/>
</video>
<div>
renders: {i}, parent: {label}
</div>
</>
)
}

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 (
<p>
{time}s since mounted, renders: {i}, parent: {label}
</p>
)
}
25 changes: 25 additions & 0 deletions src/pages/fragment-portal/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 7 additions & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Routes, Route>

0 comments on commit 22525f0

Please sign in to comment.