From 6513064bd5349bc8249b09183611930ab69ff828 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 10 May 2024 18:32:17 +0900 Subject: [PATCH 1/6] feat: enable placing islands anywhere --- mocks/app/components/$counter.tsx | 19 ++++++++++ mocks/app/routes/interaction/anywhere.tsx | 9 +++++ src/vite/inject-importing-islands.ts | 19 +++++++--- src/vite/island-components.test.ts | 39 +-------------------- src/vite/island-components.ts | 40 +++++---------------- src/vite/utils/path.test.ts | 42 +++++++++++++++++++++++ src/vite/utils/path.ts | 27 +++++++++++++++ test-integration/apps.test.ts | 20 ++++++++++- test-integration/vitest.config.ts | 8 +++-- 9 files changed, 146 insertions(+), 77 deletions(-) create mode 100644 mocks/app/components/$counter.tsx create mode 100644 mocks/app/routes/interaction/anywhere.tsx create mode 100644 src/vite/utils/path.test.ts create mode 100644 src/vite/utils/path.ts diff --git a/mocks/app/components/$counter.tsx b/mocks/app/components/$counter.tsx new file mode 100644 index 0000000..22165a7 --- /dev/null +++ b/mocks/app/components/$counter.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from 'hono/jsx' +import { useState } from 'hono/jsx' + +export default function Counter({ + children, + initial = 0, +}: PropsWithChildren<{ + initial?: number +}>) { + const [count, setCount] = useState(initial) + const increment = () => setCount(count + 1) + return ( +
+

Count: {count}

+ + {children} +
+ ) +} diff --git a/mocks/app/routes/interaction/anywhere.tsx b/mocks/app/routes/interaction/anywhere.tsx new file mode 100644 index 0000000..63b21b1 --- /dev/null +++ b/mocks/app/routes/interaction/anywhere.tsx @@ -0,0 +1,9 @@ +import Counter from '../../components/$counter' + +export default function Interaction() { + return ( + <> + + + ) +} diff --git a/src/vite/inject-importing-islands.ts b/src/vite/inject-importing-islands.ts index e42fa50..aae4038 100644 --- a/src/vite/inject-importing-islands.ts +++ b/src/vite/inject-importing-islands.ts @@ -6,14 +6,20 @@ import { parse } from '@babel/parser' import precinct from 'precinct' import { normalizePath, type Plugin } from 'vite' import { IMPORTING_ISLANDS_ID } from '../constants.js' +import { matchIslandComponentId } from './utils/path.js' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const generate = (_generate.default as typeof _generate) ?? _generate -export async function injectImportingIslands(): Promise { - const isIslandRegex = new RegExp(/(\/islands\/|\_[a-zA-Z0-9[-]+\.island\.[tj]sx$)/) - const fileRegex = new RegExp(/(routes|_renderer|_error|_404)\/.*\.[tj]sx$/) +type InjectImportingIslandsOptions = { + appDir?: string +} + +export async function injectImportingIslands( + options?: InjectImportingIslandsOptions +): Promise { + let appPath = '' const cache: Record = {} const walkDependencyTree: ( @@ -47,14 +53,17 @@ export async function injectImportingIslands(): Promise { return { name: 'inject-importing-islands', + configResolved: async (config) => { + appPath = path.join(config.root, options?.appDir ?? '/app') + }, async transform(sourceCode, id) { - if (!fileRegex.test(id)) { + if (!path.resolve(id).startsWith(appPath)) { return } const hasIslandsImport = (await walkDependencyTree(id)) .flat() - .some((x) => isIslandRegex.test(normalizePath(x))) + .some((x) => matchIslandComponentId(normalizePath(x))) if (!hasIslandsImport) { return diff --git a/src/vite/island-components.test.ts b/src/vite/island-components.test.ts index 25fdfef..fa147e5 100644 --- a/src/vite/island-components.test.ts +++ b/src/vite/island-components.test.ts @@ -1,44 +1,7 @@ import fs from 'fs' import os from 'os' import path from 'path' -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() - }) - }) - }) -}) +import { transformJsxTags, islandComponents } from './island-components.js' describe('transformJsxTags', () => { it('Should add component-wrapper and component-name attribute', () => { diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts index 1882536..63116c4 100644 --- a/src/vite/island-components.ts +++ b/src/vite/island-components.ts @@ -35,34 +35,7 @@ import { import { parse as parseJsonc } from 'jsonc-parser' // eslint-disable-next-line node/no-extraneous-import import type { Plugin } from 'vite' - -/** - * Check if the name is a valid component name - * - * @param name - The name to check - * @returns true if the name is a valid component name - * @example - * isComponentName('Badge') // true - * isComponentName('BadgeComponent') // true - * isComponentName('badge') // false - * isComponentName('MIN') // false - * isComponentName('Badge_Component') // false - */ -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$)/ - ) -} +import { matchIslandComponentId, isComponentName } from './utils/path.js' function addSSRCheck(funcName: string, componentName: string, componentExport?: string) { const isSSR = memberExpression( @@ -228,16 +201,19 @@ export const transformJsxTags = (contents: string, componentName: string) => { type IsIsland = (id: string) => boolean export type IslandComponentsOptions = { isIsland?: IsIsland + appDir?: string reactApiImportSource?: string } export function islandComponents(options?: IslandComponentsOptions): Plugin { let root = '' let reactApiImportSource = options?.reactApiImportSource + let appPath = '' return { name: 'transform-island-components', configResolved: async (config) => { root = config.root + appPath = path.join(root, options?.appDir ?? '/app') if (!reactApiImportSource) { const tsConfigPath = path.resolve(process.cwd(), 'tsconfig.json') @@ -268,14 +244,16 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin { } const defaultIsIsland: IsIsland = (id) => { - const islandDirectoryPath = path.join(root, 'app') - return path.resolve(id).startsWith(islandDirectoryPath) + return path.resolve(id).startsWith(appPath) } + const matchIslandPath = options?.isIsland ?? defaultIsIsland if (!matchIslandPath(id)) { return } - const match = matchIslandComponentId(id) + + const pathFromAppPath = id.replace(appPath, '') + const match = matchIslandComponentId(pathFromAppPath) if (match) { const componentName = match[0] const contents = await fs.readFile(id, 'utf-8') diff --git a/src/vite/utils/path.test.ts b/src/vite/utils/path.test.ts new file mode 100644 index 0000000..543cf55 --- /dev/null +++ b/src/vite/utils/path.test.ts @@ -0,0 +1,42 @@ +import { matchIslandComponentId } from './path' + +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', + '/$counter.tsx', + '/directory/$counter.tsx', + '/_counter.island.tsx', + '/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() + }) + }) + }) +}) diff --git a/src/vite/utils/path.ts b/src/vite/utils/path.ts new file mode 100644 index 0000000..5957f90 --- /dev/null +++ b/src/vite/utils/path.ts @@ -0,0 +1,27 @@ +/** + * Check if the name is a valid component name + * + * @param name - The name to check + * @returns true if the name is a valid component name + * @example + * isComponentName('Badge') // true + * isComponentName('BadgeComponent') // true + * isComponentName('badge') // false + * isComponentName('MIN') // false + * isComponentName('Badge_Component') // false + */ +export 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$|.*\/(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\$[a-zA-Z0-9-]+\.tsx$)/ + ) +} diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts index 5f59b79..b37cd39 100644 --- a/test-integration/apps.test.ts +++ b/test-integration/apps.test.ts @@ -78,6 +78,16 @@ describe('Basic', () => { method: 'GET', handler: expect.any(Function), }, + { + path: '/interaction/anywhere', + method: 'GET', + handler: expect.any(Function), + }, + { + path: '/interaction/anywhere', + method: 'GET', + handler: expect.any(Function), + }, { path: '/interaction/children', method: 'GET', @@ -309,6 +319,14 @@ describe('With preserved', () => { ) }) + it('Should return 200 response - /interaction/anywhere', async () => { + const res = await app.request('/interaction/anywhere') + expect(res.status).toBe(200) + expect(await res.text()).toBe( + '

Count: 5

' + ) + }) + it('Should return 200 response - /interaction/nested', async () => { const res = await app.request('/interaction/nested') expect(res.status).toBe(200) @@ -343,7 +361,7 @@ describe('With preserved', () => { expect(res.status).toBe(200) // hono/jsx escape a single quote to ' expect(await res.text()).toBe( - '

UnderScoreCount: 5

DollarCount: 5

' + '

UnderScoreCount: 5

DollarCount: 5

' ) }) diff --git a/test-integration/vitest.config.ts b/test-integration/vitest.config.ts index e4473a5..c0eb211 100644 --- a/test-integration/vitest.config.ts +++ b/test-integration/vitest.config.ts @@ -5,6 +5,7 @@ import { injectImportingIslands } from '../src/vite/inject-importing-islands' import { islandComponents } from '../src/vite/island-components' const root = './mocks' +const appDir = '/mocks' export default defineConfig({ resolve: { @@ -18,12 +19,15 @@ 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[^\\\\/]*[\\\\/]routes[\\\\/].*\\$.+\.tsx?$` + `${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].*\\$.+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]components[\\\\/].*\\$.+\.tsx?$` ) return regexp.test(path.resolve(id)) }, + appDir, + }), + injectImportingIslands({ + appDir, }), - injectImportingIslands(), mdx({ jsxImportSource: 'hono/jsx', }), From 46b6f996be7122e56334783e941a11917eb8dc19 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 10 May 2024 19:57:10 +0900 Subject: [PATCH 2/6] add e2e test --- mocks/vite.config.ts | 2 +- src/client/client.ts | 5 +++-- test-e2e/e2e.test.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mocks/vite.config.ts b/mocks/vite.config.ts index 9ee0164..cb0e9b9 100644 --- a/mocks/vite.config.ts +++ b/mocks/vite.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[^\\\\/]*[\\\\/]components[\\\\/].*\\$.+\.tsx?$` ) return regexp.test(path.resolve(id)) }, diff --git a/src/client/client.ts b/src/client/client.ts index a48f9d1..21f98b3 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -34,8 +34,9 @@ export type ClientOptions = { export const createClient = async (options?: ClientOptions) => { const FILES = options?.ISLAND_FILES ?? { - ...import.meta.glob('/app/islands/**/[a-zA-Z0-9[-]+.(tsx|ts)'), - ...import.meta.glob('/app/routes/**/_[a-zA-Z0-9[-]+.island.(tsx|ts)'), + ...import.meta.glob('/app/islands/**/[a-zA-Z0-9-]+.(tsx|ts)'), + ...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.(tsx|ts)'), + ...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.(tsx|ts)'), } const root = options?.island_root ?? '/app' diff --git a/test-e2e/e2e.test.ts b/test-e2e/e2e.test.ts index a348b67..83eaf6f 100644 --- a/test-e2e/e2e.test.ts +++ b/test-e2e/e2e.test.ts @@ -37,6 +37,17 @@ test('test counter - island in the same directory', async ({ page }) => { await page.getByText('Count: 6').click() }) +test('test counter - island in anywhere', async ({ page }) => { + await page.goto('/interaction/anywhere') + await page.waitForSelector('body[data-client-loaded]') + + await page.getByText('Count: 5').click() + await page.getByRole('button', { name: 'Increment' }).click({ + clickCount: 1, + }) + await page.getByText('Count: 6').click() +}) + test('children - sync', async ({ page }) => { await page.goto('/interaction/children') await page.waitForSelector('body[data-client-loaded]') From 0cbcaf276a8fd239f02c05c5484dffb74f39893e Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 10 May 2024 20:09:48 +0900 Subject: [PATCH 3/6] fixed path resolving --- src/vite/island-components.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts index 63116c4..8f37f2c 100644 --- a/src/vite/island-components.ts +++ b/src/vite/island-components.ts @@ -252,7 +252,7 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin { return } - const pathFromAppPath = id.replace(appPath, '') + const pathFromAppPath = '/' + path.relative(appPath, id).replace(/\\/g, '/') const match = matchIslandComponentId(pathFromAppPath) if (match) { const componentName = match[0] From ce477629e55aa30ca8d7d1687b6db052a4938e4d Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 11 May 2024 09:57:25 +0900 Subject: [PATCH 4/6] improve resolving paths --- mocks/vite.config.ts | 11 --------- src/client/client.ts | 9 +++---- src/vite/inject-importing-islands.ts | 35 ++++++++++++++++++++++------ src/vite/island-components.ts | 21 ++++++----------- src/vite/utils/path.ts | 7 +++--- test-e2e/e2e.test.ts | 4 ++-- test-integration/apps.test.ts | 24 +++++++++---------- test-integration/vitest.config.ts | 12 +++------- 8 files changed, 61 insertions(+), 62 deletions(-) diff --git a/mocks/vite.config.ts b/mocks/vite.config.ts index cb0e9b9..db932c3 100644 --- a/mocks/vite.config.ts +++ b/mocks/vite.config.ts @@ -3,8 +3,6 @@ import mdx from '@mdx-js/rollup' import { defineConfig } from 'vite' import honox from '../src/vite' -const root = './' - export default defineConfig({ resolve: { alias: { @@ -14,15 +12,6 @@ export default defineConfig({ plugins: [ honox({ entry: './app/server.ts', - islandComponents: { - isIsland: (id) => { - const resolvedPath = path.resolve(root).replace(/\\/g, '\\\\') - const regexp = new RegExp( - `${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]components[\\\\/].*\\$.+\.tsx?$` - ) - return regexp.test(path.resolve(id)) - }, - }, }), mdx({ jsxImportSource: 'hono/jsx', diff --git a/src/client/client.ts b/src/client/client.ts index 21f98b3..50cee3f 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -29,6 +29,9 @@ export type ClientOptions = { */ triggerHydration?: TriggerHydration ISLAND_FILES?: Record Promise> + /** + * @deprecated + */ island_root?: string } @@ -39,11 +42,9 @@ export const createClient = async (options?: ClientOptions) => { ...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.(tsx|ts)'), } - const root = options?.island_root ?? '/app' - const hydrateComponent: HydrateComponent = async (document) => { const filePromises = Object.keys(FILES).map(async (filePath) => { - const componentName = filePath.replace(root, '') + const componentName = filePath const elements = document.querySelectorAll( `[${COMPONENT_NAME}="${componentName}"]:not([data-hono-hydrated])` ) @@ -74,7 +75,7 @@ export const createClient = async (options?: ClientOptions) => { const { buildCreateChildrenFn } = await import('./runtime') createChildren = buildCreateChildrenFn( createElement as CreateElement, - async (name: string) => (await (FILES[`${root}${name}`] as FileCallback)()).default + async (name: string) => (await (FILES[`${name}`] as FileCallback)()).default ) } props[propKey] = await createChildren( diff --git a/src/vite/inject-importing-islands.ts b/src/vite/inject-importing-islands.ts index aae4038..d9f2754 100644 --- a/src/vite/inject-importing-islands.ts +++ b/src/vite/inject-importing-islands.ts @@ -14,20 +14,30 @@ const generate = (_generate.default as typeof _generate) ?? _generate type InjectImportingIslandsOptions = { appDir?: string + islandDir?: string +} + +type ResolvedId = { + id: string } export async function injectImportingIslands( options?: InjectImportingIslandsOptions ): Promise { let appPath = '' + const islandDir = options?.islandDir ?? '/app/islands' + let root = '' const cache: Record = {} const walkDependencyTree: ( baseFile: string, - dependencyFile?: string - ) => Promise = async (baseFile: string, dependencyFile?: string) => { + resolve: (path: string, importer?: string) => Promise, + dependencyFile?: ResolvedId | string + ) => Promise = async (baseFile: string, resolve, dependencyFile?) => { const depPath = dependencyFile - ? path.join(path.dirname(baseFile), dependencyFile) + '.tsx' //TODO: This only includes tsx files, how to also include JSX? + ? typeof dependencyFile === 'string' + ? path.join(path.dirname(baseFile), dependencyFile) + '.tsx' + : dependencyFile['id'] : baseFile const deps = [depPath] @@ -41,7 +51,10 @@ export async function injectImportingIslands( }) as string[] const childDeps = await Promise.all( - currentFileDeps.map(async (x) => await walkDependencyTree(depPath, x)) + currentFileDeps.map(async (file) => { + const resolvedId = await resolve(file, baseFile) + return await walkDependencyTree(depPath, resolve, resolvedId ?? file) + }) ) deps.push(...childDeps.flat()) return deps @@ -55,15 +68,23 @@ export async function injectImportingIslands( name: 'inject-importing-islands', configResolved: async (config) => { appPath = path.join(config.root, options?.appDir ?? '/app') + root = config.root }, async transform(sourceCode, id) { if (!path.resolve(id).startsWith(appPath)) { return } - const hasIslandsImport = (await walkDependencyTree(id)) - .flat() - .some((x) => matchIslandComponentId(normalizePath(x))) + const hasIslandsImport = ( + await Promise.all( + (await walkDependencyTree(id, async (id: string) => await this.resolve(id))) + .flat() + .map(async (x) => { + const rootPath = '/' + path.relative(root, normalizePath(x)).replace(/\\/g, '/') + return matchIslandComponentId(rootPath, islandDir) + }) + ) + ).some((matched) => matched) if (!hasIslandsImport) { return diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts index 8f37f2c..f28af88 100644 --- a/src/vite/island-components.ts +++ b/src/vite/island-components.ts @@ -200,20 +200,22 @@ export const transformJsxTags = (contents: string, componentName: string) => { type IsIsland = (id: string) => boolean export type IslandComponentsOptions = { + /** + * @deprecated + */ isIsland?: IsIsland - appDir?: string + islandDir?: string reactApiImportSource?: string } export function islandComponents(options?: IslandComponentsOptions): Plugin { let root = '' let reactApiImportSource = options?.reactApiImportSource - let appPath = '' + const islandDir = options?.islandDir ?? '/app/islands' return { name: 'transform-island-components', configResolved: async (config) => { root = config.root - appPath = path.join(root, options?.appDir ?? '/app') if (!reactApiImportSource) { const tsConfigPath = path.resolve(process.cwd(), 'tsconfig.json') @@ -243,17 +245,8 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin { } } - const defaultIsIsland: IsIsland = (id) => { - return path.resolve(id).startsWith(appPath) - } - - const matchIslandPath = options?.isIsland ?? defaultIsIsland - if (!matchIslandPath(id)) { - return - } - - const pathFromAppPath = '/' + path.relative(appPath, id).replace(/\\/g, '/') - const match = matchIslandComponentId(pathFromAppPath) + const rootPath = '/' + path.relative(root, id).replace(/\\/g, '/') + const match = matchIslandComponentId(rootPath, islandDir) if (match) { const componentName = match[0] const contents = await fs.readFile(id, 'utf-8') diff --git a/src/vite/utils/path.ts b/src/vite/utils/path.ts index 5957f90..63e66d9 100644 --- a/src/vite/utils/path.ts +++ b/src/vite/utils/path.ts @@ -20,8 +20,9 @@ export function isComponentName(name: string) { * @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$|.*\/(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\$[a-zA-Z0-9-]+\.tsx$)/ +export function matchIslandComponentId(id: string, islandDir: string = '/islands') { + const regExp = new RegExp( + `^${islandDir}\/.+?\.tsx$|.*\/(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\\\$[a-zA-Z0-9-]+\.tsx$)` ) + return id.match(regExp) } diff --git a/test-e2e/e2e.test.ts b/test-e2e/e2e.test.ts index 83eaf6f..aeca420 100644 --- a/test-e2e/e2e.test.ts +++ b/test-e2e/e2e.test.ts @@ -28,13 +28,13 @@ test('test counter - island in the same directory', async ({ page }) => { await page.getByRole('button', { name: 'UnderScore Increment' }).click({ clickCount: 1, }) - await page.getByText('Count: 6').click() + await page.getByText('UnderScoreCount: 6').click() await page.getByText('DollarCount: 5').click() await page.getByRole('button', { name: 'Dollar Increment' }).click({ clickCount: 1, }) - await page.getByText('Count: 6').click() + await page.getByText('DollarCount: 6').click() }) test('test counter - island in anywhere', async ({ page }) => { diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts index b37cd39..5ea3df3 100644 --- a/test-integration/apps.test.ts +++ b/test-integration/apps.test.ts @@ -315,7 +315,7 @@ describe('With preserved', () => { expect(res.status).toBe(200) // hono/jsx escape a single quote to ' expect(await res.text()).toBe( - '

Counter

Count: 5

Counter

Count: 10

Counter

Count: 15

Counter

Count: 30

Counter

Count: 20

Counter

Count: 30

Counter

Count: 25

' + '

Counter

Count: 5

Counter

Count: 10

Counter

Count: 15

Counter

Count: 30

Counter

Count: 20

Counter

Count: 30

Counter

Count: 25

' ) }) @@ -323,7 +323,7 @@ describe('With preserved', () => { const res = await app.request('/interaction/anywhere') expect(res.status).toBe(200) expect(await res.text()).toBe( - '

Count: 5

' + '

Count: 5

' ) }) @@ -341,7 +341,7 @@ describe('With preserved', () => {

Nested Island Test

- +

Counter

Count: 0

@@ -361,7 +361,7 @@ describe('With preserved', () => { expect(res.status).toBe(200) // hono/jsx escape a single quote to ' expect(await res.text()).toBe( - '

UnderScoreCount: 5

DollarCount: 5

' + '

UnderScoreCount: 5

DollarCount: 5

' ) }) @@ -529,7 +529,7 @@ describe('

Component

' + '

Component

' ) }) @@ -537,7 +537,7 @@ describe('

Component

' + '

Component

' ) }) }) @@ -557,7 +557,7 @@ describe('' + '

Component

' ) }) }) @@ -577,7 +577,7 @@ describe('' + '

Component

' ) }) }) @@ -601,7 +601,7 @@ describe(' Component with path aliases', () => { const res = await app.request('/has-islands') expect(res.status).toBe(200) expect(await res.text()).toBe( - '
Counter
' + '
Counter
' ) }) @@ -641,7 +641,7 @@ describe('Island Components with Preserved Files', () => { const res = await app.request('/foo') expect(res.status).toBe(404) expect(await res.text()).toBe( - 'Not Found

Count: 0

' + 'Not Found

Count: 0

' ) }) @@ -649,7 +649,7 @@ describe('Island Components with Preserved Files', () => { const res = await app.request('/throw_error') expect(res.status).toBe(500) expect(await res.text()).toBe( - 'Internal Server Error

Count: 0

' + 'Internal Server Error

Count: 0

' ) }) @@ -657,7 +657,7 @@ describe('Island Components with Preserved Files', () => { const res = await app.request('/nested/post') expect(res.status).toBe(200) expect(await res.text()).toBe( - '

Count: 0

Hello MDX

' + '

Count: 0

Hello MDX

' ) }) }) diff --git a/test-integration/vitest.config.ts b/test-integration/vitest.config.ts index c0eb211..1b66f38 100644 --- a/test-integration/vitest.config.ts +++ b/test-integration/vitest.config.ts @@ -4,8 +4,8 @@ import { defineConfig } from 'vitest/config' import { injectImportingIslands } from '../src/vite/inject-importing-islands' import { islandComponents } from '../src/vite/island-components' -const root = './mocks' const appDir = '/mocks' +const islandDir = '/mocks/[^/]+/islands' export default defineConfig({ resolve: { @@ -16,16 +16,10 @@ export default defineConfig({ }, plugins: [ islandComponents({ - isIsland: (id) => { - const resolvedPath = path.resolve(root).replace(/\\/g, '\\\\') - const regexp = new RegExp( - `${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].*\\$.+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]components[\\\\/].*\\$.+\.tsx?$` - ) - return regexp.test(path.resolve(id)) - }, - appDir, + islandDir, }), injectImportingIslands({ + islandDir, appDir, }), mdx({ From 26733f27e2c03c124a462b07dd088bbe064966e0 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 11 May 2024 10:04:24 +0900 Subject: [PATCH 5/6] add some tests --- src/vite/utils/path.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/vite/utils/path.test.ts b/src/vite/utils/path.test.ts index 543cf55..75240cd 100644 --- a/src/vite/utils/path.test.ts +++ b/src/vite/utils/path.test.ts @@ -30,6 +30,7 @@ describe('matchIslandComponentId', () => { '/routes/directory/foo$component.tsx', '/routes/directory/foo_component.island.tsx', '/routes/directory/component.island.tsx', + '/directory/islands/component.tsx', ] paths.forEach((path) => { @@ -39,4 +40,15 @@ describe('matchIslandComponentId', () => { }) }) }) + + describe('not match - with `islandDir`', () => { + const paths = ['/islands/component.tsx'] + + paths.forEach((path) => { + it(`Should not match ${path}`, () => { + const match = matchIslandComponentId(path, '/directory/islands') + expect(match).toBeNull() + }) + }) + }) }) From 7201b4aaa828f65264c47b2678011eabdffb4cff Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 11 May 2024 18:32:27 +0900 Subject: [PATCH 6/6] import only `.tsx` at the client --- src/client/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/client.ts b/src/client/client.ts index 50cee3f..b91f3e7 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -37,9 +37,9 @@ export type ClientOptions = { export const createClient = async (options?: ClientOptions) => { const FILES = options?.ISLAND_FILES ?? { - ...import.meta.glob('/app/islands/**/[a-zA-Z0-9-]+.(tsx|ts)'), - ...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.(tsx|ts)'), - ...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.(tsx|ts)'), + ...import.meta.glob('/app/islands/**/[a-zA-Z0-9-]+.tsx'), + ...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.tsx'), + ...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.tsx'), } const hydrateComponent: HydrateComponent = async (document) => {