@hyperspan/plugin-preact 2.0.0-alpha.4 → 2.0.0

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.4",
3
+ "version": "2.0.0",
4
4
  "description": "Hyperspan Plugin for Preact",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -8,13 +8,14 @@
8
8
  "public": true,
9
9
  "publishConfig": {
10
10
  "access": "public",
11
- "tag": "alpha"
11
+ "tag": "latest"
12
12
  },
13
13
  "exports": {
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,21 +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.4",
34
+ "@hyperspan/vite-plugin": "^2.0.0",
35
+ "preact": "^10.28.3",
34
36
  "prettier": "^3.8.1",
35
37
  "typescript": "^5.9.3",
36
- "vite": "^6.3.5",
37
- "vitest": "^3.2.4"
38
+ "vite": "^8.2.1",
39
+ "vitest": "^4.1.10"
38
40
  },
39
41
  "peerDependencies": {
40
- "@hyperspan/html": "^2.0.0-alpha.4",
41
- "@hyperspan/framework": "^2.0.0-alpha.4",
42
- "@hyperspan/vite-plugin": "^2.0.0-alpha.4",
43
- "vite": "^6.0.0 || ^7.0.0"
42
+ "@hyperspan/framework": "^2.0.0",
43
+ "@hyperspan/vite-plugin": "^2.0.0",
44
+ "preact": "^10.28.3",
45
+ "vite": "^8.0.0"
44
46
  },
45
47
  "dependencies": {
46
48
  "debug": "^4.4.3",
47
- "preact": "^10.28.3",
48
49
  "preact-render-to-string": "^6.6.5"
49
50
  }
50
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,42 +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
- import { html } from '@hyperspan/html';
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,
17
+ registerClientChunkAliases,
18
+ registerIslandClientUrl,
19
+ registerRuntimeSpecifier,
20
+ isSsrTransform,
21
+ externalClientIslandRuntime,
22
+ isRuntimeSpecifier,
23
+ viteFsUrl,
24
+ buildIslandHtmlSource,
25
+ type IslandExportKind,
16
26
  } from '@hyperspan/vite-plugin/islands';
27
+ import {
28
+ preactSingletonAliases,
29
+ resolvePackageDir,
30
+ resolvePreactSpecifier,
31
+ } from './resolve-package';
17
32
  import debug from 'debug';
18
33
 
19
34
  const log = debug('hyperspan:plugin-preact');
20
35
 
21
- export type IslandExportKind = 'default' | 'named';
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;
22
50
 
23
- export function buildIslandHtml(
24
- jsId: string,
25
- componentName: string,
26
- esmName: string,
27
- jsContent: string,
28
- ssrContent: string,
29
- options: { loading?: string; exportKind?: IslandExportKind } = {}
30
- ): string {
31
- const importStmt =
32
- options.exportKind === 'named'
33
- ? `import { ${componentName} } from "${esmName}";`
34
- : `import ${componentName} from "${esmName}";`;
35
- const scriptTag = `<script type="module" id="${jsId}_script" data-source-id="${jsId}">${importStmt}${jsContent}</script>`;
36
- if (options.loading === 'lazy') {
37
- return `<div id="${jsId}">${ssrContent}</div><div data-loading="lazy" style="height:1px;width:1px;overflow:hidden;"><template>\n${scriptTag}</template></div>`;
38
- }
39
- return `<div id="${jsId}">${ssrContent}</div>\n${scriptTag}`;
40
- }
51
+ export type { IslandExportKind };
41
52
 
42
53
  export function renderPreactSSR(Component: unknown, props: Record<string, unknown> = {}): string {
43
54
  return preactRenderToString(h(Component as Parameters<typeof h>[0], props));
@@ -134,52 +145,110 @@ function __hs_renderIsland_${componentName}(jsContent = '', ssrContent = '', opt
134
145
  `;
135
146
  }
136
147
 
148
+ const PREACT_CLIENT_SPECIFIERS = PREACT_ISLAND.specifiers;
149
+
137
150
  /**
138
151
  * Vite plugin for Preact islands.
139
152
  */
140
153
  export function preactIslandPlugin(): Plugin {
154
+ const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
141
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);
142
159
 
143
160
  return {
144
161
  name: 'hyperspan-preact',
145
162
  enforce: 'pre',
146
163
 
147
- config() {
164
+ config(config) {
165
+ const projectRoot = config.root ? String(config.root) : process.cwd();
166
+ const prerender = resolvePackageDir(pluginRoot, 'preact-render-to-string');
148
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
+ },
149
180
  optimizeDeps: {
150
181
  include: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
151
182
  },
152
183
  };
153
184
  },
154
185
 
155
- 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
+ },
156
209
 
157
210
  configureServer() {
158
- const clientUrl = '/islands/preact-client.js';
159
- registerImport('preact', clientUrl);
160
- registerImport('preact/hooks', clientUrl);
161
- registerImport('preact/jsx-runtime', clientUrl);
162
- registerImport('preact/jsx-dev-runtime', clientUrl);
163
- registerImport('preact/compat', clientUrl);
164
- registerImport('react', clientUrl);
165
- registerImport('react-dom', clientUrl);
211
+ const productionUrl = '/islands/preact-client.js';
212
+ const devUrl = viteFsUrl(preactClientPath);
213
+ for (const spec of PREACT_CLIENT_SPECIFIERS) {
214
+ registerRuntimeSpecifier(spec, productionUrl, { devUrl });
215
+ }
166
216
  },
167
217
 
168
- async transform(code, id) {
169
- if (!isIslandModule(id, 'preact', '.tsx')) return;
218
+ generateBundle(_options, bundle) {
219
+ registerClientChunkAliases(
220
+ bundle,
221
+ (fileName) =>
222
+ fileName.includes('islands/preact-client') || fileName.endsWith('preact-client.js'),
223
+ PREACT_CLIENT_SPECIFIERS
224
+ );
225
+ },
226
+
227
+ async transform(code, id, options) {
228
+ if (!isIslandModule(id, framework, ext)) return;
170
229
 
171
230
  log('transform island', id);
172
231
  const cleanId = splitIslandId(id).path;
173
- const esmName = `island-${assetHash(cleanId)}`;
232
+ const esmName = registerIslandClientUrl(cleanId, framework, root);
174
233
  const exports = discoverPreactExports(code);
175
234
 
176
235
  if (exports.length === 0) {
177
236
  throw new Error(
178
- `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}' }\`.`
179
238
  );
180
239
  }
181
240
 
182
- 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
+ }
183
252
 
184
253
  const attachments = exports
185
254
  .map((exp) => buildIslandAttachment(exp, cleanId, esmName))
@@ -188,7 +257,7 @@ export function preactIslandPlugin(): Plugin {
188
257
  const moduleCode = `// hyperspan:processed
189
258
  import { h as __hs_h, render as __hs_render, hydrate as __hs_hydrate } from 'preact';
190
259
  import { render as __hs_renderToString } from 'preact-render-to-string';
191
- import { buildIslandHtml as __hs_buildIslandHtml } from '@hyperspan/plugin-preact';
260
+ ${buildIslandHtmlSource}
192
261
 
193
262
  ${code}
194
263
  ${attachments}
@@ -216,7 +285,12 @@ ${attachments}
216
285
  * Hyperspan config plugin — register in hyperspan.config.ts plugins array.
217
286
  */
218
287
  export function preactPlugin(): HS.Plugin {
219
- 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
+ });
220
294
  return () => {
221
295
  log('preactPlugin loaded');
222
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
+ }