Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: render children #56

Merged
merged 11 commits into from
Feb 16, 2024
Binary file modified bun.lockb
Binary file not shown.
23 changes: 21 additions & 2 deletions src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { render } from 'hono/jsx/dom'
import { jsx as jsxFn } from 'hono/jsx/dom/jsx-runtime'
import { COMPONENT_NAME, DATA_SERIALIZED_PROPS } from '../constants.js'
import type { CreateElement, Hydrate } from '../types.js'
import { COMPONENT_NAME, DATA_HONO_TEMPLATE, DATA_SERIALIZED_PROPS } from '../constants.js'
import type { CreateElement, CreateChildren, Hydrate } from '../types.js'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FileCallback = () => Promise<{ default: Promise<any> }>

export type ClientOptions = {
hydrate?: Hydrate
createElement?: CreateElement
/**
* Create "children" attribute of a component from a list of child nodes
*/
createChildren?: CreateChildren
ISLAND_FILES?: Record<string, () => Promise<unknown>>
island_root?: string
}
Expand All @@ -33,6 +37,21 @@ export const createClient = async (options?: ClientOptions) => {
const hydrate = options?.hydrate ?? render
const createElement = options?.createElement ?? jsxFn

const maybeTemplate = element.childNodes[element.childNodes.length - 1]
if (
maybeTemplate?.nodeName === 'TEMPLATE' &&
(maybeTemplate as HTMLElement)?.attributes.getNamedItem(DATA_HONO_TEMPLATE) !== null
) {
let createChildren = options?.createChildren
if (!createChildren) {
const { buildCreateChildrenFn } = await import('./runtime')
createChildren = buildCreateChildrenFn(createElement as CreateElement)
}
props.children = await createChildren(
(maybeTemplate as HTMLTemplateElement).content.childNodes
)
}

const newElem = await createElement(Component, props)
// @ts-expect-error default `render` cause a type error
await hydrate(newElem, element)
Expand Down
102 changes: 102 additions & 0 deletions src/client/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Suspense, use } from 'hono/jsx/dom'
import type { CreateElement, CreateChildren } from '../types.js'

export const buildCreateChildrenFn = (createElement: CreateElement): CreateChildren => {
const createChildren = async (childNodes: NodeListOf<ChildNode>): Promise<Node[]> => {
const children = []
for (let i = 0; i < childNodes.length; i++) {
const child = childNodes[i] as HTMLElement
if (child.nodeType === 8) {
// skip comments
continue
} else if (child.nodeType === 3) {
// text node
children.push(child.textContent)
} else if (child.nodeName === 'TEMPLATE' && child.id.match(/(?:H|E):\d+/)) {
const placeholderElement = document.createElement('hono-placeholder')
placeholderElement.style.display = 'none'

let resolve: (nodes: Node[]) => void
const promise = new Promise<Node[]>((r) => (resolve = r))

// Suspense: replace content by `replaceWith` when resolved
// ErrorBoundary: replace content by `replaceWith` when error
child.replaceWith = (node: DocumentFragment) => {
createChildren(node.childNodes).then(resolve)
placeholderElement.remove()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let fallback: any = []

// gather fallback content and find placeholder comment
for (
// equivalent to i++
placeholderElement.appendChild(child);
i < childNodes.length;
i++
) {
const child = childNodes[i]
if (child.nodeType === 8) {
// <!--/$--> or <!--E:1-->
placeholderElement.appendChild(child)
i--
break
} else if (child.nodeType === 3) {
fallback.push(child.textContent)
} else {
fallback.push(
await createElement(child.nodeName, {
children: await createChildren(child.childNodes),
})
)
}
}

// if already resolved or error, get content from added template element
const fallbackTemplates = document.querySelectorAll<HTMLTemplateElement>(
`[data-hono-target="${child.id}"]`
)
if (fallbackTemplates.length > 0) {
const fallbackTemplate = fallbackTemplates[fallbackTemplates.length - 1]
fallback = await createChildren(fallbackTemplate.content.childNodes)
}

// if no content available, wait for ErrorBoundary fallback content
if (fallback.length === 0 && child.id.startsWith('E:')) {
let resolve: (nodes: Node[]) => void
const promise = new Promise<Node[]>((r) => (resolve = r))
fallback = await createElement(Suspense, {
fallback: [],
children: [await createElement(() => use(promise), {})],
})
placeholderElement.insertBefore = ((node: DocumentFragment) => {
createChildren(node.childNodes).then(resolve)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
}

// wait for content to be resolved by placeholderElement
document.body.appendChild(placeholderElement)

// render fallback content
children.push(
await createElement(Suspense, {
fallback,
children: [await createElement(() => use(promise), {})],
})
)
} else {
children.push(
await createElement(child.nodeName, {
children: await createChildren(child.childNodes),
})
)
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return children as any
}

return createChildren
}
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const COMPONENT_NAME = 'component-name'
export const DATA_SERIALIZED_PROPS = 'data-serialized-props'
export const DATA_HONO_TEMPLATE = 'data-hono-template'
export const IMPORTING_ISLANDS_ID = '__importing_islands' as const
9 changes: 7 additions & 2 deletions src/server/components/script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HasIslands } from './has-islands.js'

type Options = {
src: string
async?: boolean
prod?: boolean
manifest?: Manifest
}
Expand All @@ -28,13 +29,17 @@ export const Script: FC<Options> = async (options) => {
if (scriptInManifest) {
return (
<HasIslands>
<script type='module' src={`/${scriptInManifest.file}`}></script>
<script
type='module'
async={!!options.async}
src={`/${scriptInManifest.file}`}
></script>
</HasIslands>
)
}
}
return <></>
} else {
return <script type='module' src={src}></script>
return <script type='module' async={!!options.async} src={src}></script>
}
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
/** JSX */
export type CreateElement = (type: any, props: any) => Node | Promise<Node>
export type Hydrate = (children: Node, parent: Element) => void | Promise<void>
export type CreateChildren = (childNodes: NodeListOf<ChildNode>) => Node[] | Promise<Node[]>
32 changes: 30 additions & 2 deletions src/vite/island-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,28 @@ import {
} from '@babel/types'
// eslint-disable-next-line node/no-extraneous-import
import type { Plugin } from 'vite'
import { COMPONENT_NAME, DATA_SERIALIZED_PROPS } from '../constants.js'
import { COMPONENT_NAME, DATA_HONO_TEMPLATE, DATA_SERIALIZED_PROPS } from '../constants.js'

function addSSRCheck(funcName: string, componentName: string, isAsync = false) {
const isSSR = memberExpression(
memberExpression(identifier('import'), identifier('meta')),
identifier('env.SSR')
)

const serializedProps = callExpression(identifier('JSON.stringify'), [identifier('props')])
// serialize props by excluding the children
const serializedProps = callExpression(identifier('JSON.stringify'), [
callExpression(memberExpression(identifier('Object'), identifier('fromEntries')), [
callExpression(
memberExpression(
callExpression(memberExpression(identifier('Object'), identifier('entries')), [
identifier('props'),
]),
identifier('filter')
),
[identifier('([key]) => key !== "children"')]
),
]),
])

const ssrElement = jsxElement(
jsxOpeningElement(
Expand All @@ -60,6 +73,21 @@ function addSSRCheck(funcName: string, componentName: string, isAsync = false) {
jsxClosingElement(jsxIdentifier(funcName)),
[]
),
jsxExpressionContainer(
conditionalExpression(
memberExpression(identifier('props'), identifier('children')),
jsxElement(
jsxOpeningElement(
jsxIdentifier('template'),
[jsxAttribute(jsxIdentifier(DATA_HONO_TEMPLATE), stringLiteral(''))],
false
),
jsxClosingElement(jsxIdentifier('template')),
[jsxExpressionContainer(memberExpression(identifier('props'), identifier('children')))]
),
identifier('null')
)
),
]
)

Expand Down
27 changes: 27 additions & 0 deletions test/hono-jsx/app-script/routes/_async_renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from '../../../../src/server'

export default jsxRenderer(
({ children }) => {
return (
<html>
<body>
{children}
<Script
async={true}
src='/app/client.ts'
prod={true}
manifest={{
'app/client.ts': {
file: 'static/client-abc.js',
},
}}
/>
</body>
</html>
)
},
{
docType: false,
}
)
4 changes: 4 additions & 0 deletions test/hono-jsx/app/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { createClient } from '../../../src/client'

createClient()

setTimeout(() => {
document.body.setAttribute('data-client-loaded', 'true')
})
13 changes: 11 additions & 2 deletions test/hono-jsx/app/islands/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import type { PropsWithChildren } from 'hono/jsx'
import { useState } from 'hono/jsx'

export default function Counter({ initial }: { initial: number }) {
export default function Counter({
children,
initial = 0,
id = '',
}: PropsWithChildren<{
initial?: number
id?: string
}>) {
const [count, setCount] = useState(initial)
const increment = () => setCount(count + 1)
return (
<div>
<div id={id}>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
{children}
</div>
)
}
31 changes: 18 additions & 13 deletions test/hono-jsx/app/routes/_renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { jsxRenderer } from 'hono/jsx-renderer'
import { HasIslands } from '../../../../src/server'

export default jsxRenderer(({ children, title }) => {
return (
<html>
<head>
<title>{title}</title>
<HasIslands>
<script type='module' src='/app/client.ts'></script>
</HasIslands>
</head>
<body>{children}</body>
</html>
)
})
export default jsxRenderer(
({ children, title }) => {
return (
<html>
<head>
<title>{title}</title>
</head>
<body>
{children}
<HasIslands>
<script type='module' async src='/app/client.ts'></script>
</HasIslands>
</body>
</html>
)
},
{ stream: true }
)
18 changes: 18 additions & 0 deletions test/hono-jsx/app/routes/interaction/children.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Counter from '../../islands/Counter'

const AsyncChild = async () => {
return <span>Async child</span>
}

export default function Interaction() {
return (
<>
<Counter id='sync'>
<span>Sync child</span>
</Counter>
<Counter id='async' initial={2}>
<AsyncChild />
</Counter>
</>
)
}
33 changes: 33 additions & 0 deletions test/hono-jsx/app/routes/interaction/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Suspense, ErrorBoundary } from 'hono/jsx'
import Counter from '../../islands/Counter'

const SuspenseChild = async () => {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 500))
return <span>Suspense child</span>
}

const SuspenseFailureChild = async () => {
throw new Error('Suspense failure')
return <span>Suspense child</span>
}

export default function Interaction() {
return (
<>
<Counter id='error-boundary-success' initial={2}>
<ErrorBoundary fallback={<span>Something went wrong</span>}>
<Suspense fallback={<span>Loading...</span>}>
<SuspenseChild />
</Suspense>
</ErrorBoundary>
</Counter>
<Counter id='error-boundary-failure' initial={4}>
<ErrorBoundary fallback={<span>Something went wrong</span>}>
<Suspense fallback={<span>Loading...</span>}>
<SuspenseFailureChild />
</Suspense>
</ErrorBoundary>
</Counter>
</>
)
}
Loading