Skip to content

Commit

Permalink
feat: render children (#56)
Browse files Browse the repository at this point in the history
* feat: render children in template[data-hono-template]

* refactor: exclude "children" in order to reduce serialized data size

* feat: support async attribute on Script component

* feat: re-create children from rendered template element

* test: add tests for Server and Client Composition Patterns

* test: update data for integration test

* refactor: make createChildren async

* refactor: `yarn format:fix`

* refactor: import buildCreateChildrenFn dynamically only when needed

* chore: `rm bun.lockb && bun install`
  • Loading branch information
usualoma authored Feb 16, 2024
1 parent 61edd87 commit 63ee1bf
Show file tree
Hide file tree
Showing 19 changed files with 1,087 additions and 899 deletions.
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

0 comments on commit 63ee1bf

Please sign in to comment.