Repository: honojs/honox
Pull request #171 (2024-05-08 13:54:59)

feat: `$` prefix named file as island component
With this PR, the component file whose name has `$` as a prefix, like `/app/routes/dir/$counter.tsx`, will be treated as an island component.

Before this PR, the filenames which are island components are below:

1. Under `/islands` - `/islands/conter.tsx`
2. An underscore and `.island.tsx` suffix - `/app/routes/dir/_counter.island.tsx`

From my experience using HonoX, it's sometimes easy to handle if the island component is in the same directory with the route file. So the `2` is better. But I'd make it a simpler and shorter name.

3. `$` prefix - `/app/routes/dir/$counter.tsx`

Why `$` - this is just my taste, but I think it's easy to understand.

---

I'll work on #159 after merging this PR.

yusukebe: Hey @usualoma !

What do you think about this? If it's okay, please review it!

Patch:
diff --git a/mocks/app/routes/directory/$counter.tsx b/mocks/app/routes/directory/$counter.tsx
new file mode 100644
index 0000000..0898773
--- /dev/null
+++ b/mocks/app/routes/directory/$counter.tsx
@@ -0,0 +1,21 @@
+import type { PropsWithChildren } from 'hono/jsx'
+import { useState } from 'hono/jsx'
+
+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 id={id}>
+      <p>DollarCount: {count}</p>
+      <button onClick={increment}>Dollar Increment</button>
+      {children}
+    </div>
+  )
+}
diff --git a/mocks/app/routes/directory/_Counter.island.tsx b/mocks/app/routes/directory/_Counter.island.tsx
index a716175..5fe2d06 100644
--- a/mocks/app/routes/directory/_Counter.island.tsx
+++ b/mocks/app/routes/directory/_Counter.island.tsx
@@ -13,8 +13,8 @@ export default function Counter({
   const increment = () => setCount(count + 1)
   return (
     <div id={id}>
-      <p>Count: {count}</p>
-      <button onClick={increment}>Increment</button>
+      <p>UnderScoreCount: {count}</p>
+      <button onClick={increment}>UnderScore Increment</button>
       {children}
     </div>
   )
diff --git a/mocks/app/routes/directory/index.tsx b/mocks/app/routes/directory/index.tsx
index f4d6f90..ca50897 100644
--- a/mocks/app/routes/directory/index.tsx
+++ b/mocks/app/routes/directory/index.tsx
@@ -1,5 +1,11 @@
-import Counter from './_Counter.island'
+import DollarCounter from './$counter'
+import UnderScoreCounter from './_Counter.island'
 
 export default function Interaction() {
-  return <Counter initial={5} />
+  return (
+    <>
+      <UnderScoreCounter id='under-score' initial={5} />
+      <DollarCounter id='dollar' initial={5} />
+    </>
+  )
 }
diff --git a/src/server/components/has-islands.tsx b/src/server/components/has-islands.tsx
index b100b3b..dbfb21a 100644
--- a/src/server/components/has-islands.tsx
+++ b/src/server/components/has-islands.tsx
@@ -1,8 +1,11 @@
-import type { FC } from 'hono/jsx'
-import { useRequestContext } from 'hono/jsx-renderer'
 import { IMPORTING_ISLANDS_ID } from '../../constants.js'
+import { contextStorage } from '../context-storage.js'
 
-export const HasIslands: FC = ({ children }) => {
-  const c = useRequestContext()
-  return <>{c.get(IMPORTING_ISLANDS_ID) ? children : <></>}</>
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const HasIslands = ({ children }: { children: any }): any => {
+  const c = contextStorage.getStore()
+  if (!c) {
+    throw new Error('No context found')
+  }
+  return <>{c.get(IMPORTING_ISLANDS_ID) && children}</>
 }
diff --git a/src/server/components/script.tsx b/src/server/components/script.tsx
index 3552d4f..062071c 100644
--- a/src/server/components/script.tsx
+++ b/src/server/components/script.tsx
@@ -1,4 +1,3 @@
-import type { FC } from 'hono/jsx'
 import type { Manifest } from 'vite'
 import { HasIslands } from './has-islands.js'
 
@@ -10,7 +9,8 @@ type Options = {
   nonce?: string
 }
 
-export const Script: FC<Options> = async (options) => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const Script = (options: Options): any => {
   const src = options.src
   if (options.prod ?? import.meta.env.PROD) {
     let manifest: Manifest | undefined = options.manifest
diff --git a/src/server/context-storage.ts b/src/server/context-storage.ts
new file mode 100644
index 0000000..4e958cf
--- /dev/null
+++ b/src/server/context-storage.ts
@@ -0,0 +1,3 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+import type { Context } from 'hono'
+export const contextStorage = new AsyncLocalStorage<Context>()
diff --git a/src/server/server.ts b/src/server/server.ts
index ec79a31..480ba57 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -10,6 +10,7 @@ import {
   listByDirectory,
   sortDirectoriesByDepth,
 } from '../utils/file.js'
+import { contextStorage } from './context-storage.js'
 
 const NOTFOUND_FILENAME = '_404.tsx'
 const ERROR_FILENAME = '_error.tsx'
@@ -62,6 +63,11 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
   const app = options.app ?? new Hono()
   const trailingSlash = options.trailingSlash ?? false
 
+  // Share context by AsyncLocalStorage
+  app.use(async function ShareContext(c, next) {
+    await contextStorage.run(c, () => next())
+  })
+
   if (options.init) {
     options.init(app)
   }
diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts
index c2aa466..1882536 100644
--- a/src/vite/island-components.ts
+++ b/src/vite/island-components.ts
@@ -52,6 +52,18 @@ function isComponentName(name: string) {
   return /^[A-Z][A-Z0-9]*[a-z][A-Za-z0-9]*$/.test(name)
 }
 
+/**
+ * Matches when id is the filename of Island component
+ *
+ * @param id - The id to match
+ * @returns The result object if id is matched or null
+ */
+export function matchIslandComponentId(id: string) {
+  return id.match(
+    /\/islands\/.+?\.tsx$|\/routes\/(?:.*\/)?(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\$[a-zA-Z0-9-]+\.tsx$)/
+  )
+}
+
 function addSSRCheck(funcName: string, componentName: string, componentExport?: string) {
   const isSSR = memberExpression(
     memberExpression(identifier('import'), identifier('meta')),
@@ -244,7 +256,7 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin {
     },
 
     async load(id) {
-      if (/\/honox\/.*?\/vite\/components\//.test(id)) {
+      if (/\/honox\/.*?\/(?:server|vite)\/components\//.test(id)) {
         if (!reactApiImportSource) {
           return
         }
@@ -263,7 +275,7 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin {
       if (!matchIslandPath(id)) {
         return
       }
-      const match = id.match(/(\/islands\/.+?\.tsx$)|(\/routes\/.*\_[a-zA-Z0-9[-]+\.island\.tsx$)/)
+      const match = matchIslandComponentId(id)
       if (match) {
         const componentName = match[0]
         const contents = await fs.readFile(id, 'utf-8')
diff --git a/tsup.config.ts b/tsup.config.ts
index 97066e1..ff35b96 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -2,6 +2,7 @@ import { glob } from 'glob'
 import { defineConfig } from 'tsup'
 
 const entryPoints = glob.sync('./src/**/*.+(ts|tsx|json)', {
+  posix: true,
   ignore: ['./src/**/*.test.+(ts|tsx)'],
 })
 


Test patch:
diff --git a/src/vite/island-components.test.ts b/src/vite/island-components.test.ts
index d9b8edc..25fdfef 100644
--- a/src/vite/island-components.test.ts
+++ b/src/vite/island-components.test.ts
@@ -1,5 +1,44 @@
+import fs from 'fs'
+import os from 'os'
 import path from 'path'
-import { transformJsxTags, islandComponents } from './island-components'
+import { matchIslandComponentId, transformJsxTags, islandComponents } from './island-components'
+
+describe('matchIslandComponentId', () => {
+  describe('match', () => {
+    const paths = [
+      '/islands/counter.tsx',
+      '/islands/directory/counter.tsx',
+      '/routes/$counter.tsx',
+      '/routes/directory/$counter.tsx',
+      '/routes/_counter.island.tsx',
+      '/routes/directory/_counter.island.tsx',
+    ]
+
+    paths.forEach((path) => {
+      it(`Should match ${path}`, () => {
+        const match = matchIslandComponentId(path)
+        expect(match).not.toBeNull()
+        expect(match![0]).toBe(path)
+      })
+    })
+  })
+
+  describe('not match', () => {
+    const paths = [
+      '/routes/directory/component.tsx',
+      '/routes/directory/foo$component.tsx',
+      '/routes/directory/foo_component.island.tsx',
+      '/routes/directory/component.island.tsx',
+    ]
+
+    paths.forEach((path) => {
+      it(`Should not match ${path}`, () => {
+        const match = matchIslandComponentId(path)
+        expect(match).toBeNull()
+      })
+    })
+  })
+})
 
 describe('transformJsxTags', () => {
   it('Should add component-wrapper and component-name attribute', () => {
@@ -218,30 +257,67 @@ export { utilityFn, WrappedExportViaVariable as default };`
 
 describe('options', () => {
   describe('reactApiImportSource', () => {
-    // get full path of honox-island.tsx
-    const component = path
-      .resolve(__dirname, '../vite/components/honox-island.tsx')
-      // replace backslashes for Windows
-      .replace(/\\/g, '/')
-
-    // prettier-ignore
-    it('use \'hono/jsx\' by default', async () => {
-      const plugin = islandComponents()
-      await (plugin.configResolved as Function)({ root: 'root' })
-      const res = await (plugin.load as Function)(component)
-      expect(res.code).toMatch(/'hono\/jsx'/)
-      expect(res.code).not.toMatch(/'react'/)
+    describe('vite/components', () => {
+      // get full path of honox-island.tsx
+      const component = path
+        .resolve(__dirname, '../vite/components/honox-island.tsx')
+        // replace backslashes for Windows
+        .replace(/\\/g, '/')
+
+      // prettier-ignore
+      it('use \'hono/jsx\' by default', async () => {
+        const plugin = islandComponents()
+        await (plugin.configResolved as Function)({ root: 'root' })
+        const res = await (plugin.load as Function)(component)
+        expect(res.code).toMatch(/'hono\/jsx'/)
+        expect(res.code).not.toMatch(/'react'/)
+      })
+
+      // prettier-ignore
+      it('enable to specify \'react\'', async () => {
+        const plugin = islandComponents({
+          reactApiImportSource: 'react',
+        })
+        await (plugin.configResolved as Function)({ root: 'root' })
+        const res = await (plugin.load as Function)(component)
+        expect(res.code).not.toMatch(/'hono\/jsx'/)
+        expect(res.code).toMatch(/'react'/)
+      })
     })
 
-    // prettier-ignore
-    it('enable to specify \'react\'', async () => {
-      const plugin = islandComponents({
-        reactApiImportSource: 'react',
+    describe('server/components', () => {
+      const tmpdir = os.tmpdir()
+
+      // has-islands.tsx under src/server/components does not contain 'hono/jsx'
+      // 'hono/jsx' is injected by `npm run build`
+      // so we need to create a file with 'hono/jsx' manually for testing
+      const component = path
+        .resolve(tmpdir, 'honox/dist/server/components/has-islands.js')
+        // replace backslashes for Windows
+        .replace(/\\/g, '/')
+      fs.mkdirSync(path.dirname(component), { recursive: true })
+      // prettier-ignore
+      fs.writeFileSync(component, 'import { jsx } from \'hono/jsx/jsx-runtime\'')
+
+      // prettier-ignore
+      it('use \'hono/jsx\' by default', async () => {
+        const plugin = islandComponents()
+        await (plugin.configResolved as Function)({ root: 'root' })
+        const res = await (plugin.load as Function)(component)
+        expect(res.code).toMatch(/'hono\/jsx\/jsx-runtime'/)
+        expect(res.code).not.toMatch(/'react\/jsx-runtime'/)
+      })
+
+      // prettier-ignore
+      it('enable to specify \'react\'', async () => {
+        const plugin = islandComponents({
+          reactApiImportSource: 'react',
+        })
+        await (plugin.configResolved as Function)({ root: 'root' })
+        const res = await (plugin.load as Function)(component)
+        expect(res.code).not.toMatch(/'hono\/jsx\/jsx-runtime'/)
+        expect(res.code).toMatch(/'react\/jsx-runtime'/)
       })
-      await (plugin.configResolved as Function)({ root: 'root' })
-      const res = await (plugin.load as Function)(component)
-      expect(res.code).not.toMatch(/'hono\/jsx'/)
-      expect(res.code).toMatch(/'react'/)
     })
   })
 })
diff --git a/test-e2e/e2e.test.ts b/test-e2e/e2e.test.ts
index 0e60111..a348b67 100644
--- a/test-e2e/e2e.test.ts
+++ b/test-e2e/e2e.test.ts
@@ -24,8 +24,14 @@ test('test counter - island in the same directory', async ({ page }) => {
   await page.goto('/directory')
   await page.waitForSelector('body[data-client-loaded]')
 
-  await page.getByText('Count: 5').click()
-  await page.getByRole('button', { name: 'Increment' }).click({
+  await page.getByText('UnderScoreCount: 5').click()
+  await page.getByRole('button', { name: 'UnderScore Increment' }).click({
+    clickCount: 1,
+  })
+  await page.getByText('Count: 6').click()
+
+  await page.getByText('DollarCount: 5').click()
+  await page.getByRole('button', { name: 'Dollar Increment' }).click({
     clickCount: 1,
   })
   await page.getByText('Count: 6').click()
diff --git a/test-integration/api.test.ts b/test-integration/api.test.ts
index 4b95232..1487762 100644
--- a/test-integration/api.test.ts
+++ b/test-integration/api.test.ts
@@ -17,6 +17,7 @@ describe('Basic', () => {
 
   it('Should have correct routes', () => {
     const routes = [
+      { path: '/*', method: 'ALL', handler: expect.anything() }, // ShareContext
       { path: '/*', method: 'ALL', handler: expect.anything() },
       {
         path: '/about/*',
diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts
index a47a023..5f59b79 100644
--- a/test-integration/apps.test.ts
+++ b/test-integration/apps.test.ts
@@ -18,6 +18,11 @@ describe('Basic', () => {
 
   it('Should have correct routes', () => {
     const routes = [
+      {
+        path: '/*',
+        method: 'ALL',
+        handler: expect.any(Function), // ShareContext
+      },
       {
         path: '/*',
         method: 'ALL',
@@ -338,7 +343,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="/routes/directory/_Counter.island.tsx" data-serialized-props="{&quot;initial&quot;:5}"><div id=""><p>Count: 5</p><button>Increment</button></div></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="/routes/directory/_Counter.island.tsx" data-serialized-props="{&quot;id&quot;:&quot;under-score&quot;,&quot;initial&quot;:5}"><div id="under-score"><p>UnderScoreCount: 5</p><button>UnderScore Increment</button></div></honox-island><honox-island component-name="/routes/directory/$counter.tsx" data-serialized-props="{&quot;id&quot;:&quot;dollar&quot;,&quot;initial&quot;:5}"><div id="dollar"><p>DollarCount: 5</p><button>Dollar Increment</button></div></honox-island><script type="module" async="" src="/app/client.ts"></script></body></html>'
     )
   })
 
diff --git a/test-integration/vitest.config.ts b/test-integration/vitest.config.ts
index 5f6068c..e4473a5 100644
--- a/test-integration/vitest.config.ts
+++ b/test-integration/vitest.config.ts
@@ -18,7 +18,7 @@ export default defineConfig({
       isIsland: (id) => {
         const resolvedPath = path.resolve(root).replace(/\\/g, '\\\\')
         const regexp = new RegExp(
-          `${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$`
+          `${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].*\\$.+\.tsx?$`
         )
         return regexp.test(path.resolve(id))
       },