@hyperspan/plugin-preact 2.0.0-alpha.19 → 2.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/plugin-preact",
3
- "version": "2.0.0-alpha.19",
3
+ "version": "2.0.0-alpha.20",
4
4
  "description": "Hyperspan Plugin for Preact",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,7 +14,8 @@
14
14
  ".": {
15
15
  "types": "./src/index.ts",
16
16
  "default": "./src/index.ts"
17
- }
17
+ },
18
+ "./package.json": "./package.json"
18
19
  },
19
20
  "author": "Vance Lucas <vance@vancelucas.com>",
20
21
  "license": "BSD-3-Clause",
@@ -30,20 +31,21 @@
30
31
  "url": "git+https://github.com/vlucas/hyperspan.git"
31
32
  },
32
33
  "devDependencies": {
33
- "@hyperspan/vite-plugin": "^2.0.0-alpha.19",
34
+ "@hyperspan/vite-plugin": "^2.0.0-alpha.20",
35
+ "preact": "^10.28.3",
34
36
  "prettier": "^3.8.1",
35
37
  "typescript": "^5.9.3",
36
38
  "vite": "^8.2.1",
37
39
  "vitest": "^4.1.10"
38
40
  },
39
41
  "peerDependencies": {
40
- "@hyperspan/framework": "^2.0.0-alpha.19",
41
- "@hyperspan/vite-plugin": "^2.0.0-alpha.19",
42
+ "@hyperspan/framework": "^2.0.0-alpha.20",
43
+ "@hyperspan/vite-plugin": "^2.0.0-alpha.20",
44
+ "preact": "^10.28.3",
42
45
  "vite": "^8.0.0"
43
46
  },
44
47
  "dependencies": {
45
48
  "debug": "^4.4.3",
46
- "preact": "^10.28.3",
47
49
  "preact-render-to-string": "^6.6.5"
48
50
  }
49
51
  }
package/src/index.test.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { test, describe, expect } from 'vitest';
2
2
  import { h } from 'preact';
3
- import { buildIslandHtml, renderPreactSSR, renderPreactIsland } from './index';
3
+ import { useState } from 'preact/hooks';
4
+ import { buildIslandHtml } from '@hyperspan/vite-plugin/islands';
5
+ import { renderPreactSSR, renderPreactIsland } from './index';
4
6
 
5
7
  // ---------------------------------------------------------------------------
6
8
  // Simple Preact components defined inline — no .tsx compilation needed
@@ -91,6 +93,14 @@ describe('renderPreactSSR', () => {
91
93
  const output = renderPreactSSR(Hello, { name: '<script>alert(1)</script>' });
92
94
  expect(output).not.toContain('<script>alert');
93
95
  });
96
+
97
+ test('renders components that use hooks (same Preact as the renderer)', () => {
98
+ function Counter({ count: initial = 0 }: { count?: number }) {
99
+ const [count] = useState(initial);
100
+ return h('p', null, `Count: ${count}`);
101
+ }
102
+ expect(renderPreactSSR(Counter, { count: 3 })).toContain('Count: 3');
103
+ });
94
104
  });
95
105
 
96
106
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -2,43 +2,53 @@ import type { Plugin } from 'vite';
2
2
  import { resolve, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { assetHash } from '@hyperspan/framework/utils';
5
- import { registerImport, JS_ISLAND_PUBLIC_PATH } from '@hyperspan/framework/client/js';
6
5
  import { renderIsland } from '@hyperspan/framework';
7
6
  import type { Hyperspan as HS } from '@hyperspan/framework';
8
7
  import { html } from '@hyperspan/framework/html';
9
8
  import { h } from 'preact';
10
9
  import { render as preactRenderToString } from 'preact-render-to-string';
10
+
11
+ export { render as renderToString } from 'preact-render-to-string';
11
12
  import {
12
13
  registerIslandPlugin,
13
14
  isIslandModule,
14
15
  islandPluginResolveId,
15
16
  splitIslandId,
16
17
  registerClientChunkAliases,
18
+ registerIslandClientUrl,
19
+ registerRuntimeSpecifier,
20
+ isSsrTransform,
21
+ externalClientIslandRuntime,
22
+ isRuntimeSpecifier,
23
+ viteFsUrl,
24
+ buildIslandHtmlSource,
25
+ type IslandExportKind,
17
26
  } from '@hyperspan/vite-plugin/islands';
27
+ import {
28
+ preactSingletonAliases,
29
+ resolvePackageDir,
30
+ resolvePreactSpecifier,
31
+ } from './resolve-package';
18
32
  import debug from 'debug';
19
33
 
20
34
  const log = debug('hyperspan:plugin-preact');
21
35
 
22
- export type IslandExportKind = 'default' | 'named';
23
-
24
- export function buildIslandHtml(
25
- jsId: string,
26
- componentName: string,
27
- esmName: string,
28
- jsContent: string,
29
- ssrContent: string,
30
- options: { loading?: string; exportKind?: IslandExportKind } = {}
31
- ): string {
32
- const importStmt =
33
- options.exportKind === 'named'
34
- ? `import { ${componentName} } from "${esmName}";`
35
- : `import ${componentName} from "${esmName}";`;
36
- const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">${importStmt}${jsContent}</script>`;
37
- if (options.loading === 'lazy') {
38
- return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
39
- }
40
- return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
41
- }
36
+ const PREACT_ISLAND = {
37
+ framework: 'preact',
38
+ ext: '.tsx',
39
+ specifiers: [
40
+ 'preact',
41
+ 'preact/hooks',
42
+ 'preact/jsx-runtime',
43
+ 'preact/jsx-dev-runtime',
44
+ 'preact/compat',
45
+ 'react',
46
+ 'react-dom',
47
+ ] as const,
48
+ runtimePrefixes: ['preact', 'react', 'react-dom', 'preact-render-to-string'] as const,
49
+ } as const;
50
+
51
+ export type { IslandExportKind };
42
52
 
43
53
  export function renderPreactSSR(Component: unknown, props: Record<string, unknown> = {}): string {
44
54
  return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
@@ -135,40 +145,73 @@ function __hs_renderIsland_${componentName}(jsContent = '', ssrContent = '', opt
135
145
  `;
136
146
  }
137
147
 
138
- const PREACT_CLIENT_SPECIFIERS = [
139
- 'preact',
140
- 'preact/hooks',
141
- 'preact/jsx-runtime',
142
- 'preact/jsx-dev-runtime',
143
- 'preact/compat',
144
- 'react',
145
- 'react-dom',
146
- ] as const;
148
+ const PREACT_CLIENT_SPECIFIERS = PREACT_ISLAND.specifiers;
147
149
 
148
150
  /**
149
151
  * Vite plugin for Preact islands.
150
152
  */
151
153
  export function preactIslandPlugin(): Plugin {
154
+ const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
152
155
  const preactClientPath = resolve(dirname(fileURLToPath(import.meta.url)), './preact-client.ts');
156
+ let root = process.cwd();
157
+ const { framework, ext } = PREACT_ISLAND;
158
+ const resolveIslandId = islandPluginResolveId(framework, ext);
153
159
 
154
160
  return {
155
161
  name: 'hyperspan-preact',
156
162
  enforce: 'pre',
157
163
 
158
- config() {
164
+ config(config) {
165
+ const projectRoot = config.root ? String(config.root) : process.cwd();
166
+ const prerender = resolvePackageDir(pluginRoot, 'preact-render-to-string');
159
167
  return {
168
+ resolve: {
169
+ dedupe: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
170
+ },
171
+ ssr: {
172
+ noExternal: ['preact', 'preact/hooks', 'preact-render-to-string'],
173
+ resolve: {
174
+ alias: {
175
+ ...preactSingletonAliases(projectRoot, pluginRoot),
176
+ ...(prerender ? { 'preact-render-to-string': prerender } : {}),
177
+ },
178
+ },
179
+ },
160
180
  optimizeDeps: {
161
181
  include: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
162
182
  },
163
183
  };
164
184
  },
165
185
 
166
- resolveId: islandPluginResolveId('preact', '.tsx'),
186
+ configResolved(config) {
187
+ root = config.root;
188
+ },
189
+
190
+ async resolveId(id, importer, options) {
191
+ const island = await resolveIslandId.call(this, id, importer, options);
192
+ if (island) return island;
193
+ const ssr = Boolean(options?.ssr) || this.environment?.name === 'ssr';
194
+ if (!ssr) {
195
+ const isRuntime = (specifier: string) =>
196
+ isRuntimeSpecifier(specifier, PREACT_ISLAND.runtimePrefixes);
197
+ if (
198
+ (this.environment?.mode === 'dev' || this.meta.watchMode) &&
199
+ importer &&
200
+ isIslandModule(importer, framework, ext) &&
201
+ isRuntime(id)
202
+ ) {
203
+ return preactClientPath;
204
+ }
205
+ return externalClientIslandRuntime(id, importer, { ssr: false }, framework, ext, isRuntime);
206
+ }
207
+ return resolvePreactSpecifier(id, root, pluginRoot);
208
+ },
167
209
 
168
210
  configureServer() {
169
- const clientUrl = '/islands/preact-client.js';
211
+ const productionUrl = '/islands/preact-client.js';
212
+ const devUrl = viteFsUrl(preactClientPath);
170
213
  for (const spec of PREACT_CLIENT_SPECIFIERS) {
171
- registerImport(spec, clientUrl);
214
+ registerRuntimeSpecifier(spec, productionUrl, { devUrl });
172
215
  }
173
216
  },
174
217
 
@@ -181,21 +224,31 @@ export function preactIslandPlugin(): Plugin {
181
224
  );
182
225
  },
183
226
 
184
- async transform(code, id) {
185
- if (!isIslandModule(id, 'preact', '.tsx')) return;
227
+ async transform(code, id, options) {
228
+ if (!isIslandModule(id, framework, ext)) return;
186
229
 
187
230
  log('transform island', id);
188
231
  const cleanId = splitIslandId(id).path;
189
- const esmName = `island-${assetHash(cleanId)}`;
232
+ const esmName = registerIslandClientUrl(cleanId, framework, root);
190
233
  const exports = discoverPreactExports(code);
191
234
 
192
235
  if (exports.length === 0) {
193
236
  throw new Error(
194
- `No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: 'preact' }\`.`
237
+ `No component exports found in ${cleanId}. Export a default or named Preact component and import with \`with { island: '${framework}' }\`.`
195
238
  );
196
239
  }
197
240
 
198
- registerImport(esmName, `${JS_ISLAND_PUBLIC_PATH}/${esmName}.js`);
241
+ if (!isSsrTransform(this, options)) {
242
+ const esbuild = await import('esbuild');
243
+ const result = await esbuild.transform(code, {
244
+ loader: 'tsx',
245
+ jsx: 'transform',
246
+ jsxFactory: 'h',
247
+ jsxFragment: 'Fragment',
248
+ sourcefile: cleanId,
249
+ });
250
+ return { code: result.code, map: result.map || null };
251
+ }
199
252
 
200
253
  const attachments = exports
201
254
  .map((exp) => buildIslandAttachment(exp, cleanId, esmName))
@@ -204,7 +257,7 @@ export function preactIslandPlugin(): Plugin {
204
257
  const moduleCode = `// hyperspan:processed
205
258
  import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
206
259
  import { render as __hs_renderToString } from 'preact-render-to-string';
207
- import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
260
+ ${buildIslandHtmlSource}
208
261
 
209
262
  ${code}
210
263
  ${attachments}
@@ -232,7 +285,12 @@ ${attachments}
232
285
  * Hyperspan config plugin — register in hyperspan.config.ts plugins array.
233
286
  */
234
287
  export function preactPlugin(): HS.Plugin {
235
- registerIslandPlugin('preact', { vitePlugin: preactIslandPlugin });
288
+ registerIslandPlugin({
289
+ framework: PREACT_ISLAND.framework,
290
+ ext: PREACT_ISLAND.ext,
291
+ specifiers: PREACT_ISLAND.specifiers,
292
+ vitePlugin: preactIslandPlugin,
293
+ });
236
294
  return () => {
237
295
  log('preactPlugin loaded');
238
296
  };
@@ -1,4 +1,39 @@
1
- export * from 'preact/compat';
2
- export * from 'preact/hooks';
3
- export * from 'preact/jsx-runtime';
4
- export { h, render, hydrate } from 'preact';
1
+ // Named re-exports only — `export *` from compat + hooks + preact collides
2
+ // (h, useState, Fragment, …) and Vite's native ESM module then fails to load.
3
+ export {
4
+ h,
5
+ render,
6
+ hydrate,
7
+ Component,
8
+ Fragment,
9
+ createContext,
10
+ createRef,
11
+ toChildArray,
12
+ cloneElement,
13
+ createElement,
14
+ isValidElement,
15
+ } from 'preact';
16
+ export {
17
+ useState,
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useReducer,
21
+ useRef,
22
+ useMemo,
23
+ useCallback,
24
+ useContext,
25
+ useId,
26
+ useErrorBoundary,
27
+ useDebugValue,
28
+ useImperativeHandle,
29
+ } from 'preact/hooks';
30
+ export { jsx, jsxs } from 'preact/jsx-runtime';
31
+ export {
32
+ memo,
33
+ forwardRef,
34
+ Children,
35
+ createPortal,
36
+ lazy,
37
+ Suspense,
38
+ PureComponent,
39
+ } from 'preact/compat';
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ preactSingletonAliases,
7
+ resolvePackageDir,
8
+ resolvePackageFrom,
9
+ resolvePreactSpecifier,
10
+ } from './resolve-package';
11
+
12
+ function fakePackage(root: string, name: string, file = 'index.js') {
13
+ const dir = join(root, 'node_modules', ...name.split('/'));
14
+ mkdirSync(dir, { recursive: true });
15
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, main: file }));
16
+ writeFileSync(join(dir, file), 'export {}\n');
17
+ mkdirSync(join(dir, 'hooks'), { recursive: true });
18
+ writeFileSync(
19
+ join(dir, 'hooks', 'package.json'),
20
+ JSON.stringify({ name: `${name}/hooks`, main: 'index.js' })
21
+ );
22
+ writeFileSync(join(dir, 'hooks', 'index.js'), 'export {}\n');
23
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }));
24
+ return realpathSync(dir);
25
+ }
26
+
27
+ describe('resolvePackageFrom', () => {
28
+ test('resolves a package from the starting directory', () => {
29
+ const root = mkdtempSync(join(tmpdir(), 'hs-preact-resolve-'));
30
+ const dir = fakePackage(root, 'preact');
31
+ expect(resolvePackageFrom(root, 'preact')).toBe(join(dir, 'index.js'));
32
+ expect(resolvePackageDir(root, 'preact')).toBe(dir);
33
+ });
34
+
35
+ test('prefers the app copy when pinning preact', () => {
36
+ const app = mkdtempSync(join(tmpdir(), 'hs-preact-app-'));
37
+ const plugin = mkdtempSync(join(tmpdir(), 'hs-preact-plugin-'));
38
+ const appPreact = fakePackage(app, 'preact');
39
+ fakePackage(plugin, 'preact');
40
+
41
+ expect(preactSingletonAliases(app, plugin)).toEqual({ preact: appPreact });
42
+ expect(resolvePreactSpecifier('preact/hooks', app, plugin)).toContain(join(appPreact, 'hooks'));
43
+ });
44
+
45
+ test('falls back to the plugin copy when the app has no preact', () => {
46
+ const app = mkdtempSync(join(tmpdir(), 'hs-preact-empty-'));
47
+ const plugin = mkdtempSync(join(tmpdir(), 'hs-preact-plugin-'));
48
+ writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app' }));
49
+ const pluginPreact = fakePackage(plugin, 'preact');
50
+
51
+ expect(preactSingletonAliases(app, plugin)).toEqual({ preact: pluginPreact });
52
+ expect(resolvePreactSpecifier('preact', app, plugin)).toBe(join(pluginPreact, 'index.js'));
53
+ });
54
+
55
+ test('resolves preact-render-to-string from the plugin when the app lacks it', () => {
56
+ const app = mkdtempSync(join(tmpdir(), 'hs-prerender-app-'));
57
+ const plugin = mkdtempSync(join(tmpdir(), 'hs-prerender-plugin-'));
58
+ writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app' }));
59
+ const prerender = fakePackage(plugin, 'preact-render-to-string');
60
+ expect(resolvePreactSpecifier('preact-render-to-string', app, plugin)).toBe(
61
+ join(prerender, 'index.js')
62
+ );
63
+ });
64
+
65
+ test('ignores non-preact specifiers', () => {
66
+ expect(resolvePreactSpecifier('vue', '/', '/')).toBeUndefined();
67
+ });
68
+ });
@@ -0,0 +1,84 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join, sep } from 'node:path';
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ function realpath(path: string): string {
7
+ try {
8
+ return realpathSync(path);
9
+ } catch {
10
+ return path;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Resolve a specifier from a project's directory (its package.json / node_modules).
16
+ */
17
+ export function resolvePackageFrom(fromDir: string, specifier: string): string | undefined {
18
+ const parent = pathToFileURL(join(fromDir, 'package.json')).href;
19
+ try {
20
+ return realpath(fileURLToPath(import.meta.resolve(specifier, parent)));
21
+ } catch {
22
+ try {
23
+ return realpath(createRequire(join(fromDir, 'package.json')).resolve(specifier));
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Directory of an installed package, starting from `fromDir`.
32
+ */
33
+ export function resolvePackageDir(fromDir: string, name: string): string | undefined {
34
+ const file = resolvePackageFrom(fromDir, name);
35
+ if (!file) return undefined;
36
+
37
+ const needle = `${sep}node_modules${sep}${name.replaceAll('/', sep)}${sep}`;
38
+ const idx = file.lastIndexOf(needle);
39
+ if (idx !== -1) {
40
+ return realpath(file.slice(0, idx + needle.length - 1));
41
+ }
42
+
43
+ let dir = dirname(file);
44
+ while (true) {
45
+ const pkgFile = join(dir, 'package.json');
46
+ if (existsSync(pkgFile)) {
47
+ try {
48
+ const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { name?: string };
49
+ if (pkg.name === name) return dir;
50
+ } catch {
51
+ // keep walking
52
+ }
53
+ }
54
+ const parent = dirname(dir);
55
+ if (parent === dir) return undefined;
56
+ dir = parent;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Force every `preact` / `preact/*` import onto one physical install.
62
+ * Prefer the app's copy; fall back to the plugin's so file: / monorepo links
63
+ * do not load hooks from one Preact and render-to-string from another.
64
+ */
65
+ export function preactSingletonAliases(
66
+ projectRoot: string,
67
+ fallbackDir: string
68
+ ): Record<string, string> {
69
+ const dir = resolvePackageDir(projectRoot, 'preact') ?? resolvePackageDir(fallbackDir, 'preact');
70
+ if (!dir) return {};
71
+ return { preact: dir };
72
+ }
73
+
74
+ export function resolvePreactSpecifier(
75
+ specifier: string,
76
+ projectRoot: string,
77
+ fallbackDir: string
78
+ ): string | undefined {
79
+ if (specifier === 'preact-render-to-string') {
80
+ return resolvePackageFrom(fallbackDir, specifier) ?? resolvePackageFrom(projectRoot, specifier);
81
+ }
82
+ if (specifier !== 'preact' && !specifier.startsWith('preact/')) return undefined;
83
+ return resolvePackageFrom(projectRoot, specifier) ?? resolvePackageFrom(fallbackDir, specifier);
84
+ }