Repository: honojs/honox
Pull request #162 (2024-04-29 21:24:20)

feat: serialize element in props. (not only children)
In #56 we can now pass child components to children.

The documentation for next.js stated the following.

> You're not limited to the children prop. you can use any prop to pass JSX.

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns

Honox also allows components to be passed for any key in this PR.

usualoma: @yusukebe Would you please review?
yusukebe: @usualoma 

Thanks! Merging now.

Patch:
diff --git a/mocks/app/islands/Counter.tsx b/mocks/app/islands/Counter.tsx
index e2c3739..3c0ca61 100644
--- a/mocks/app/islands/Counter.tsx
+++ b/mocks/app/islands/Counter.tsx
@@ -1,4 +1,4 @@
-import type { PropsWithChildren } from 'hono/jsx'
+import type { PropsWithChildren, Child } from 'hono/jsx'
 import { useState } from 'hono/jsx'
 import Badge from './Badge'
 
@@ -6,9 +6,11 @@ export default function Counter({
   children,
   initial = 0,
   id = '',
+  slot,
 }: PropsWithChildren<{
   initial?: number
   id?: string
+  slot?: Child
 }>) {
   const [count, setCount] = useState(initial)
   const increment = () => setCount(count + 1)
@@ -18,6 +20,7 @@ export default function Counter({
       <p>Count: {count}</p>
       <button onClick={increment}>Increment</button>
       {children}
+      {slot && <div>{slot}</div>}
     </div>
   )
 }
diff --git a/mocks/app/routes/interaction/index.tsx b/mocks/app/routes/interaction/index.tsx
index 4cc6d4b..624c96f 100644
--- a/mocks/app/routes/interaction/index.tsx
+++ b/mocks/app/routes/interaction/index.tsx
@@ -7,6 +7,9 @@ export default function Interaction() {
       <Counter initial={10}>
         <Counter initial={15} />
       </Counter>
+      <Counter initial={20} slot={<Counter id='slot' initial={25} />}>
+        <Counter initial={30} />
+      </Counter>
     </>
   )
 }
diff --git a/src/client/client.ts b/src/client/client.ts
index 5c9fd49..bfca9b0 100644
--- a/src/client/client.ts
+++ b/src/client/client.ts
@@ -55,11 +55,13 @@ 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 maybeTemplate = element.childNodes[element.childNodes.length - 1]
+          while (maybeTemplate?.nodeName === 'TEMPLATE') {
+            const propKey = (maybeTemplate as HTMLElement).getAttribute(DATA_HONO_TEMPLATE)
+            if (propKey == null) {
+              break
+            }
+
             let createChildren = options?.createChildren
             if (!createChildren) {
               const { buildCreateChildrenFn } = await import('./runtime')
@@ -68,9 +70,11 @@ export const createClient = async (options?: ClientOptions) => {
                 async (name: string) => (await (FILES[`${root}${name}`] as FileCallback)()).default
               )
             }
-            props.children = await createChildren(
+            props[propKey] = await createChildren(
               (maybeTemplate as HTMLTemplateElement).content.childNodes
             )
+
+            maybeTemplate = maybeTemplate.previousSibling as ChildNode
           }
 
           const newElem = await createElement(Component, props)
diff --git a/src/vite/components/honox-island.tsx b/src/vite/components/honox-island.tsx
index ace617a..122522b 100644
--- a/src/vite/components/honox-island.tsx
+++ b/src/vite/components/honox-island.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext } from 'hono/jsx'
+import { createContext, useContext, isValidElement } from 'hono/jsx'
 import { COMPONENT_NAME, DATA_SERIALIZED_PROPS, DATA_HONO_TEMPLATE } from '../../constants'
 
 const inIsland = Symbol()
@@ -8,6 +8,11 @@ const IslandContext = createContext({
   [inChildren]: false,
 })
 
+const isElementPropValue = (value: unknown): boolean =>
+  Array.isArray(value)
+    ? value.some(isElementPropValue)
+    : typeof value === 'object' && isValidElement(value)
+
 export const HonoXIsland = ({
   componentName,
   Component,
@@ -18,23 +23,35 @@ export const HonoXIsland = ({
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   props: any
 }) => {
-  const { children, ...rest } = props
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const elementProps: Record<string, any> = {}
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const restProps: Record<string, any> = {}
+  for (const key in props) {
+    const value = props[key]
+    if (isElementPropValue(value)) {
+      elementProps[key] = value
+    } else {
+      restProps[key] = value
+    }
+  }
+
   const islandState = useContext(IslandContext)
   return islandState[inChildren] || !islandState[inIsland] ? (
     // top-level or slot content
     <honox-island
-      {...{ [COMPONENT_NAME]: componentName, [DATA_SERIALIZED_PROPS]: JSON.stringify(rest) }}
+      {...{ [COMPONENT_NAME]: componentName, [DATA_SERIALIZED_PROPS]: JSON.stringify(restProps) }}
     >
       <IslandContext.Provider value={{ ...islandState, [inIsland]: true }}>
         <Component {...props} />
       </IslandContext.Provider>
-      {children && (
-        <template {...{ [DATA_HONO_TEMPLATE]: '' }}>
+      {Object.entries(elementProps).map(([key, children]) => (
+        <template {...{ [DATA_HONO_TEMPLATE]: key }}>
           <IslandContext.Provider value={{ ...islandState, [inChildren]: true }}>
             {children}
           </IslandContext.Provider>
         </template>
-      )}
+      ))}
     </honox-island>
   ) : (
     // nested component


Test patch:
diff --git a/test-e2e/e2e.test.ts b/test-e2e/e2e.test.ts
index 2f22e29..b04f213 100644
--- a/test-e2e/e2e.test.ts
+++ b/test-e2e/e2e.test.ts
@@ -10,6 +10,16 @@ test('test counter', async ({ page }) => {
   await container.getByText('Count: 6').click()
 })
 
+test('test counter - child client component in props', async ({ page }) => {
+  await page.goto('/interaction')
+  await page.waitForSelector('body[data-client-loaded]')
+
+  const container = page.locator('id=slot')
+  await container.getByText('Count: 25').click()
+  await container.locator('button').click()
+  await container.getByText('Count: 26').click()
+})
+
 test('test counter - island in the same directory', async ({ page }) => {
   await page.goto('/directory')
   await page.waitForSelector('body[data-client-loaded]')
diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts
index 56c51ff..b2bd670 100644
--- a/test-integration/apps.test.ts
+++ b/test-integration/apps.test.ts
@@ -300,7 +300,7 @@ describe('With preserved', () => {
     expect(res.status).toBe(200)
     // hono/jsx escape a single quote to &#39;
     expect(await res.text()).toBe(
-      '<!DOCTYPE html><html><head><title></title></head><body><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:5,&quot;id&quot;:&quot;first&quot;}"><div id="first"><p>Counter</p><p>Count: 5</p><button>Increment</button></div></honox-island><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:10}"><div id=""><p>Counter</p><p>Count: 10</p><button>Increment</button><div id=""><p>Counter</p><p>Count: 15</p><button>Increment</button></div></div><template data-hono-template=""><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:15}"><div id=""><honox-island component-name="/islands/Badge.tsx" data-serialized-props="{&quot;name&quot;:&quot;Counter&quot;}"><p>Counter</p></honox-island><p>Count: 15</p><button>Increment</button></div></honox-island></template></honox-island><script type="module" async="" src="/app/client.ts"></script></body></html>'
+      '<!DOCTYPE html><html><head><title></title></head><body><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:5,&quot;id&quot;:&quot;first&quot;}"><div id="first"><p>Counter</p><p>Count: 5</p><button>Increment</button></div></honox-island><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:10}"><div id=""><p>Counter</p><p>Count: 10</p><button>Increment</button><div id=""><p>Counter</p><p>Count: 15</p><button>Increment</button></div></div><template data-hono-template="children"><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:15}"><div id=""><honox-island component-name="/islands/Badge.tsx" data-serialized-props="{&quot;name&quot;:&quot;Counter&quot;}"><p>Counter</p></honox-island><p>Count: 15</p><button>Increment</button></div></honox-island></template></honox-island><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:20}"><div id=""><p>Counter</p><p>Count: 20</p><button>Increment</button><div id=""><p>Counter</p><p>Count: 30</p><button>Increment</button></div><div><div id="slot"><p>Counter</p><p>Count: 25</p><button>Increment</button></div></div></div><template data-hono-template="slot"><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;id&quot;:&quot;slot&quot;,&quot;initial&quot;:25}"><div id="slot"><honox-island component-name="/islands/Badge.tsx" data-serialized-props="{&quot;name&quot;:&quot;Counter&quot;}"><p>Counter</p></honox-island><p>Count: 25</p><button>Increment</button></div></honox-island></template><template data-hono-template="children"><honox-island component-name="/islands/Counter.tsx" data-serialized-props="{&quot;initial&quot;:30}"><div id=""><honox-island component-name="/islands/Badge.tsx" data-serialized-props="{&quot;name&quot;:&quot;Counter&quot;}"><p>Counter</p></honox-island><p>Count: 30</p><button>Increment</button></div></honox-island></template></honox-island><script type="module" async="" src="/app/client.ts"></script></body></html>'
     )
   })