@pyreon/vite-plugin 0.15.0 → 0.18.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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"170e8aa8-1"}]}],"isRoot":true},"nodeParts":{"170e8aa8-1":{"renderedLength":22365,"gzipLength":7629,"brotliLength":0,"metaUid":"170e8aa8-0"}},"nodeMetas":{"170e8aa8-0":{"id":"/src/index.ts","moduleParts":{"index.js":"170e8aa8-1"},"imported":[{"uid":"170e8aa8-2"},{"uid":"170e8aa8-3"},{"uid":"170e8aa8-4"}],"importedBy":[],"isEntry":true},"170e8aa8-2":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"170e8aa8-0"}]},"170e8aa8-3":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"170e8aa8-0"}]},"170e8aa8-4":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"170e8aa8-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"9860678c-1"}]}],"isRoot":true},"nodeParts":{"9860678c-1":{"renderedLength":29090,"gzipLength":9553,"brotliLength":0,"metaUid":"9860678c-0"}},"nodeMetas":{"9860678c-0":{"id":"/src/index.ts","moduleParts":{"index.js":"9860678c-1"},"imported":[{"uid":"9860678c-2"},{"uid":"9860678c-3"},{"uid":"9860678c-4"}],"importedBy":[],"isEntry":true},"9860678c-2":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"9860678c-0"}]},"9860678c-3":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"9860678c-0"}]},"9860678c-4":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"9860678c-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { generateContext, transformJSX } from "@pyreon/compiler";
3
+ import { generateContext, transformDeferInline, transformJSX } from "@pyreon/compiler";
4
4
 
5
5
  //#region src/index.ts
6
6
  /**
@@ -38,6 +38,8 @@ import { generateContext, transformJSX } from "@pyreon/compiler";
38
38
  */
39
39
  const HMR_RUNTIME_ID = "\0pyreon/hmr-runtime";
40
40
  const HMR_RUNTIME_IMPORT = "virtual:pyreon/hmr-runtime";
41
+ const ISLANDS_REGISTRY_ID = "\0pyreon/islands-registry";
42
+ const ISLANDS_REGISTRY_IMPORT = "virtual:pyreon/islands-registry";
41
43
  const COMPAT_ALIASES = {
42
44
  react: {
43
45
  react: "@pyreon/react-compat",
@@ -132,23 +134,53 @@ function getCompatTarget(compat, id) {
132
134
  if (compat === "solid") return "@pyreon/solid-compat/jsx-runtime";
133
135
  }
134
136
  }
137
+ /**
138
+ * Scan the consumer's package.json for `@pyreon/*` deps. Result is the
139
+ * list of names to exclude from Vite's deps optimizer (avoids
140
+ * `.vite/deps/@pyreon_*.js: File does not exist` runtime errors caused
141
+ * by esbuild trying to pre-bundle TypeScript source files exposed via
142
+ * the `bun` resolve condition).
143
+ *
144
+ * Reads dependencies + devDependencies + peerDependencies. Best-effort:
145
+ * missing/malformed package.json returns an empty list so a typo in
146
+ * the consumer's manifest doesn't break the build.
147
+ */
148
+ function scanPyreonDeps(root) {
149
+ const pkgPath = join(root, "package.json");
150
+ if (!existsSync(pkgPath)) return [];
151
+ try {
152
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
153
+ const all = {
154
+ ...pkg.dependencies,
155
+ ...pkg.devDependencies,
156
+ ...pkg.peerDependencies
157
+ };
158
+ return Object.keys(all).filter((name) => name.startsWith("@pyreon/"));
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
135
163
  function pyreonPlugin(options) {
136
164
  const ssrConfig = options?.ssr;
137
165
  const compat = options?.compat;
166
+ const islandsEnabled = options?.islands !== false;
138
167
  let isBuild = false;
139
168
  let projectRoot = "";
140
169
  const signalExportRegistry = /* @__PURE__ */ new Map();
141
170
  const resolveCache = /* @__PURE__ */ new Map();
142
171
  const pyreonWorkspaceDirCache = /* @__PURE__ */ new Map();
172
+ const islandRegistry = /* @__PURE__ */ new Map();
143
173
  return {
144
174
  name: "pyreon",
145
175
  enforce: "pre",
146
176
  config(userConfig, env) {
147
177
  isBuild = env.command === "build";
148
178
  projectRoot = userConfig.root ?? process.cwd();
179
+ const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : [];
180
+ const pyreonExclude = scanPyreonDeps(projectRoot);
149
181
  return {
150
182
  resolve: { conditions: ["bun"] },
151
- optimizeDeps: { exclude: compat ? Object.keys(COMPAT_ALIASES[compat]) : [] },
183
+ optimizeDeps: { exclude: Array.from(new Set([...compatExclude, ...pyreonExclude])) },
152
184
  oxc: { jsx: {
153
185
  runtime: "automatic",
154
186
  importSource: "@pyreon/core"
@@ -161,9 +193,11 @@ function pyreonPlugin(options) {
161
193
  },
162
194
  async buildStart() {
163
195
  await prescanSignalExports(projectRoot, signalExportRegistry);
196
+ if (islandsEnabled) await prescanIslandDeclarations(projectRoot, islandRegistry);
164
197
  },
165
198
  async resolveId(id, importer) {
166
199
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID;
200
+ if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID;
167
201
  if (compat && (id === "@pyreon/core/jsx-runtime" || id === "@pyreon/core/jsx-dev-runtime") && importer && isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)) return;
168
202
  const target = getCompatTarget(compat, id);
169
203
  if (!target) return;
@@ -171,6 +205,7 @@ function pyreonPlugin(options) {
171
205
  },
172
206
  load(id) {
173
207
  if (id === HMR_RUNTIME_ID) return HMR_RUNTIME_SOURCE;
208
+ if (id === ISLANDS_REGISTRY_ID) return renderIslandsRegistry(islandRegistry, islandsEnabled);
174
209
  },
175
210
  async transform(code, id, transformOptions) {
176
211
  const ext = getExt(id);
@@ -186,8 +221,12 @@ function pyreonPlugin(options) {
186
221
  return;
187
222
  }
188
223
  scanSignalExports(code, normalizeModuleId(id), signalExportRegistry);
189
- const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache);
190
- const result = transformJSX(code, id, {
224
+ if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry);
225
+ const deferResult = transformDeferInline(code, id);
226
+ const sourceForJsx = deferResult.changed ? deferResult.code : code;
227
+ for (const w of deferResult.warnings) this.warn(`${w.message} (${id}:${w.line}:${w.column})`);
228
+ const knownSignals = await resolveImportedSignals(sourceForJsx, id, signalExportRegistry, this, resolveCache);
229
+ const result = transformJSX(sourceForJsx, id, {
191
230
  ssr: transformOptions?.ssr === true,
192
231
  knownSignals
193
232
  });
@@ -442,6 +481,129 @@ function normalizeModuleId(id) {
442
481
  return queryIndex >= 0 ? id.slice(0, queryIndex) : id;
443
482
  }
444
483
  /**
484
+ * Pre-scan all source files in the project for `island()` declarations.
485
+ *
486
+ * Called from `buildStart` (when `islands: true`) so the registry is fully
487
+ * populated before any transforms run. Mirrors `prescanSignalExports` shape;
488
+ * the per-file regex pattern matches:
489
+ *
490
+ * island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
491
+ *
492
+ * Edge cases the regex deliberately doesn't cover (user falls back to manual
493
+ * `hydrateIslands({ ... })`):
494
+ * - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
495
+ * - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
496
+ * - Options come from a spread: `island(loader, { ...opts })`
497
+ */
498
+ async function prescanIslandDeclarations(root, registry) {
499
+ const files = [];
500
+ function walk(dir) {
501
+ try {
502
+ for (const entry of readdirSync(dir)) {
503
+ if (entry.startsWith(".") || entry === "node_modules" || entry === "dist" || entry === "lib" || entry === "build") continue;
504
+ const full = join(dir, entry);
505
+ try {
506
+ if (statSync(full).isDirectory()) walk(full);
507
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full);
508
+ } catch {}
509
+ }
510
+ } catch {}
511
+ }
512
+ walk(root);
513
+ for (const file of files) try {
514
+ scanIslandDeclarations(readFileSync(file, "utf-8"), file, registry);
515
+ } catch {}
516
+ }
517
+ /**
518
+ * Scan a single source file for `island()` declarations and record them.
519
+ *
520
+ * The regex captures:
521
+ * - Group 1: dynamic-import path (`./components/Counter`)
522
+ * - Group 2: options block contents
523
+ *
524
+ * Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
525
+ * options block. Single-line and multi-line forms both work.
526
+ *
527
+ * Resolves the loader path relative to the file where the call lives so
528
+ * the emitted virtual-module registry gets an absolute path Vite's resolver
529
+ * can find.
530
+ */
531
+ function scanIslandDeclarations(code, filePath, registry) {
532
+ const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
533
+ const decls = [];
534
+ let match;
535
+ while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
536
+ const importPath = match[1];
537
+ const optsBlock = match[2];
538
+ const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock);
539
+ if (!nameMatch) continue;
540
+ const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock);
541
+ const hydrate = hydrateMatch ? hydrateMatch[1] : "load";
542
+ const loaderAbsPath = importPath.startsWith(".") ? resolveRelative(filePath, importPath) : importPath;
543
+ decls.push({
544
+ name: nameMatch[1],
545
+ hydrate,
546
+ loaderAbsPath
547
+ });
548
+ }
549
+ if (decls.length > 0) registry.set(normalizeModuleId(filePath), decls);
550
+ else registry.delete(normalizeModuleId(filePath));
551
+ }
552
+ /**
553
+ * Resolve a dynamic-import specifier to an absolute path, mirroring how Node
554
+ * / Vite resolve `import('./X')` from the source file's directory.
555
+ */
556
+ function resolveRelative(fromFile, relPath) {
557
+ return join(dirname(fromFile), relPath);
558
+ }
559
+ /**
560
+ * Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
561
+ *
562
+ * export const __pyreonIslandRegistry = {
563
+ * Counter: () => import('/abs/path/to/components/Counter'),
564
+ * IdleClock: () => import('/abs/path/to/components/IdleClock'),
565
+ * // never-strategy islands deliberately omitted
566
+ * }
567
+ *
568
+ * `hydrate: 'never'` islands are skipped — registering a loader for them
569
+ * would defeat the strategy by pulling the component module into the
570
+ * client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
571
+ * at runtime regardless; emitting here would still create the dynamic-
572
+ * import chunk.
573
+ *
574
+ * Duplicate `name` across declarations: the LAST one wins. Documented as
575
+ * an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
576
+ */
577
+ function renderIslandsRegistry(registry, enabled) {
578
+ if (!enabled) return [
579
+ `// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
580
+ `// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
581
+ `// or use manual hydrateIslands({ ... }) instead.`,
582
+ `export const __pyreonIslandRegistry = {};`,
583
+ `export const __pyreonIslandsEnabled = false;`
584
+ ].join("\n");
585
+ const entries = [];
586
+ const seen = /* @__PURE__ */ new Set();
587
+ const all = Array.from(registry.values()).flat();
588
+ all.sort((a, b) => a.name.localeCompare(b.name));
589
+ for (const { name, hydrate, loaderAbsPath } of all) {
590
+ if (hydrate === "never") continue;
591
+ if (seen.has(name)) continue;
592
+ seen.add(name);
593
+ entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`);
594
+ }
595
+ return [
596
+ `// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
597
+ `// Sourced from island() declarations in your project. Never-strategy`,
598
+ `// islands are intentionally omitted — registering a loader for them`,
599
+ `// would defeat the zero-JS contract.`,
600
+ `export const __pyreonIslandRegistry = {`,
601
+ ...entries,
602
+ `};`,
603
+ `export const __pyreonIslandsEnabled = true;`
604
+ ].join("\n");
605
+ }
606
+ /**
445
607
  * Pre-scan all source files in the project for signal exports.
446
608
  *
447
609
  * Called from `buildStart` so the registry is fully populated before any
@@ -29,6 +29,32 @@ interface PyreonPluginOptions {
29
29
  ssr?: {
30
30
  /** Server entry file path (e.g. "./src/entry-server.ts") */entry: string;
31
31
  };
32
+ /**
33
+ * Auto-discover `island()` declarations and expose them as
34
+ * `virtual:pyreon/islands-registry` for `hydrateIslandsAuto()` in
35
+ * `@pyreon/server/client`.
36
+ *
37
+ * Eliminates the manual sync between `island()` declarations and the
38
+ * client-side `hydrateIslands({ ... })` registry — typo / forgotten entry /
39
+ * registry drift is the #1 author foot-gun for islands.
40
+ *
41
+ * Defaults to `true`. The prescan is cheap (regex over the same files
42
+ * already walked by `prescanSignalExports`); set to `false` only if you
43
+ * have a reason not to support `hydrateIslandsAuto()`.
44
+ *
45
+ * `hydrate: 'never'` islands are deliberately OMITTED from the auto-
46
+ * registry — the whole point of the strategy is shipping zero client JS,
47
+ * so registering a loader (which would pull the component module into the
48
+ * client bundle graph) defeats it.
49
+ *
50
+ * @example
51
+ * pyreon({ islands: true })
52
+ *
53
+ * // src/entry-client.ts
54
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
55
+ * hydrateIslandsAuto()
56
+ */
57
+ islands?: boolean;
32
58
  }
33
59
  declare function pyreonPlugin(options?: PyreonPluginOptions): Plugin;
34
60
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/vite-plugin",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Vite plugin for Pyreon — .pyreon SFC support, HMR, compiler integration",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/vite-plugin#readme",
6
6
  "bugs": {
@@ -43,7 +43,7 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "@pyreon/compiler": "^0.15.0"
46
+ "@pyreon/compiler": "^0.18.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "vite": "^8.0.0"
package/src/index.ts CHANGED
@@ -34,13 +34,19 @@
34
34
 
35
35
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
36
  import { dirname, join as pathJoin } from 'node:path'
37
- import { generateContext, transformJSX } from '@pyreon/compiler'
37
+ import { generateContext, transformDeferInline, transformJSX } from '@pyreon/compiler'
38
38
  import type { Plugin, ViteDevServer } from 'vite'
39
39
 
40
40
  // Virtual module ID for the HMR runtime
41
41
  const HMR_RUNTIME_ID = '\0pyreon/hmr-runtime'
42
42
  const HMR_RUNTIME_IMPORT = 'virtual:pyreon/hmr-runtime'
43
43
 
44
+ // Virtual module ID for the auto-generated islands registry. See
45
+ // `prescanIslandDeclarations` + the `load` hook for emit shape. Consumed by
46
+ // `hydrateIslandsAuto()` in `@pyreon/server/client`.
47
+ const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
48
+ const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
49
+
44
50
  export type CompatFramework = 'react' | 'preact' | 'vue' | 'solid'
45
51
 
46
52
  export interface PyreonPluginOptions {
@@ -72,15 +78,33 @@ export interface PyreonPluginOptions {
72
78
  /** Server entry file path (e.g. "./src/entry-server.ts") */
73
79
  entry: string
74
80
  }
75
- }
76
-
77
- // ── Compat JSX import sources ─────────────────────────────────────────────────
78
81
 
79
- const COMPAT_JSX_SOURCE: Record<CompatFramework, string> = {
80
- react: '@pyreon/react-compat',
81
- preact: '@pyreon/preact-compat',
82
- vue: '@pyreon/vue-compat',
83
- solid: '@pyreon/solid-compat',
82
+ /**
83
+ * Auto-discover `island()` declarations and expose them as
84
+ * `virtual:pyreon/islands-registry` for `hydrateIslandsAuto()` in
85
+ * `@pyreon/server/client`.
86
+ *
87
+ * Eliminates the manual sync between `island()` declarations and the
88
+ * client-side `hydrateIslands({ ... })` registry — typo / forgotten entry /
89
+ * registry drift is the #1 author foot-gun for islands.
90
+ *
91
+ * Defaults to `true`. The prescan is cheap (regex over the same files
92
+ * already walked by `prescanSignalExports`); set to `false` only if you
93
+ * have a reason not to support `hydrateIslandsAuto()`.
94
+ *
95
+ * `hydrate: 'never'` islands are deliberately OMITTED from the auto-
96
+ * registry — the whole point of the strategy is shipping zero client JS,
97
+ * so registering a loader (which would pull the component module into the
98
+ * client bundle graph) defeats it.
99
+ *
100
+ * @example
101
+ * pyreon({ islands: true })
102
+ *
103
+ * // src/entry-client.ts
104
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
105
+ * hydrateIslandsAuto()
106
+ */
107
+ islands?: boolean
84
108
  }
85
109
 
86
110
  // ── Compat alias maps ─────────────────────────────────────────────────────────
@@ -198,9 +222,44 @@ function getCompatTarget(compat: CompatFramework | undefined, id: string): strin
198
222
  return undefined
199
223
  }
200
224
 
225
+ /**
226
+ * Scan the consumer's package.json for `@pyreon/*` deps. Result is the
227
+ * list of names to exclude from Vite's deps optimizer (avoids
228
+ * `.vite/deps/@pyreon_*.js: File does not exist` runtime errors caused
229
+ * by esbuild trying to pre-bundle TypeScript source files exposed via
230
+ * the `bun` resolve condition).
231
+ *
232
+ * Reads dependencies + devDependencies + peerDependencies. Best-effort:
233
+ * missing/malformed package.json returns an empty list so a typo in
234
+ * the consumer's manifest doesn't break the build.
235
+ */
236
+ function scanPyreonDeps(root: string): string[] {
237
+ const pkgPath = pathJoin(root, 'package.json')
238
+ if (!existsSync(pkgPath)) return []
239
+ try {
240
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
241
+ dependencies?: Record<string, string>
242
+ devDependencies?: Record<string, string>
243
+ peerDependencies?: Record<string, string>
244
+ }
245
+ const all = {
246
+ ...pkg.dependencies,
247
+ ...pkg.devDependencies,
248
+ ...pkg.peerDependencies,
249
+ }
250
+ return Object.keys(all).filter((name) => name.startsWith('@pyreon/'))
251
+ } catch {
252
+ return []
253
+ }
254
+ }
255
+
201
256
  export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
202
257
  const ssrConfig = options?.ssr
203
258
  const compat = options?.compat
259
+ // Default islands support to enabled — the prescan is cheap and the virtual
260
+ // module is harmless if the user has no `island()` calls. Opt out only if
261
+ // you have a specific reason.
262
+ const islandsEnabled = options?.islands !== false
204
263
  let isBuild = false
205
264
  let projectRoot = ''
206
265
 
@@ -215,6 +274,13 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
215
274
  // happen at most once per containing directory across the build.
216
275
  const pyreonWorkspaceDirCache = new Map<string, boolean>()
217
276
 
277
+ // ── Island declaration registry ─────────────────────────────────────────
278
+ // Tracks every `island(() => import('PATH'), { name: 'X', hydrate: 'Y' })`
279
+ // call across the source tree. Keyed by absolute source-file path of the
280
+ // declaration site so HMR can invalidate per-file. Each entry's loader path
281
+ // is resolved relative to the file where the call was written.
282
+ const islandRegistry = new Map<string, IslandDecl[]>()
283
+
218
284
  return {
219
285
  name: 'pyreon',
220
286
  enforce: 'pre',
@@ -226,7 +292,23 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
226
292
 
227
293
  // Tell Vite's dep scanner not to pre-bundle the aliased framework imports —
228
294
  // they resolve to workspace packages via our resolveId hook, not node_modules.
229
- const optimizeDepsExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
295
+ const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
296
+ // Auto-detect `@pyreon/*` deps in the consumer's package.json and add
297
+ // them to optimizeDeps.exclude. Vite's deps optimizer pre-bundles
298
+ // node_modules deps via esbuild, but the plugin's `bun` resolve
299
+ // condition redirects every `@pyreon/*` import to source `.ts(x)`
300
+ // files. Esbuild's pre-bundler can't process raw TS source from a
301
+ // published package and silently produces broken bundles in
302
+ // `.vite/deps/`, surfacing as `File does not exist at
303
+ // .../node_modules/.vite/deps/@pyreon_styler.js` errors at runtime.
304
+ // Excluding them sidesteps the optimizer entirely — they're resolved
305
+ // on demand via the plugin's resolveId hook + Vite's normal source
306
+ // pipeline. Workspace-linked apps in this monorepo aren't affected
307
+ // because Vite never tries to pre-bundle workspace deps.
308
+ const pyreonExclude = scanPyreonDeps(projectRoot)
309
+ const optimizeDepsExclude = Array.from(
310
+ new Set([...compatExclude, ...pyreonExclude]),
311
+ )
230
312
 
231
313
  // Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
232
314
  // we redirect `@pyreon/core/jsx-runtime` imports to the compat package
@@ -271,11 +353,21 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
271
353
  // ordering problem where component.tsx is transformed before
272
354
  // store.ts — without pre-scanning, the registry would be empty.
273
355
  await prescanSignalExports(projectRoot, signalExportRegistry)
356
+
357
+ // Mirror prescan for `island()` declarations. The result populates
358
+ // `virtual:pyreon/islands-registry`, consumed by `hydrateIslandsAuto()`
359
+ // in `@pyreon/server/client`. Eliminates the manual sync between
360
+ // `island()` source-of-truth and the client `hydrateIslands({ ... })`
361
+ // call — the #1 author foot-gun for islands.
362
+ if (islandsEnabled) {
363
+ await prescanIslandDeclarations(projectRoot, islandRegistry)
364
+ }
274
365
  },
275
366
 
276
367
  // ── Virtual module + compat alias resolution ─────────────────────────────
277
368
  async resolveId(id, importer) {
278
369
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
370
+ if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
279
371
 
280
372
  // `@pyreon/core/jsx-runtime` resolves to the compat package only for
281
373
  // user code — never for `@pyreon/*` framework files (zero, router,
@@ -305,6 +397,9 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
305
397
  if (id === HMR_RUNTIME_ID) {
306
398
  return HMR_RUNTIME_SOURCE
307
399
  }
400
+ if (id === ISLANDS_REGISTRY_ID) {
401
+ return renderIslandsRegistry(islandRegistry, islandsEnabled)
402
+ }
308
403
  },
309
404
 
310
405
  async transform(code, id, transformOptions) {
@@ -328,16 +423,37 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
328
423
  // files created/modified after buildStart (dev mode HMR).
329
424
  scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
330
425
 
426
+ // ── Same incremental update for island() declarations ──────────────
427
+ // HMR: when a user adds/renames/removes an island() call, the
428
+ // virtual:pyreon/islands-registry module needs to reflect it on the
429
+ // next dev-server module reload.
430
+ if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry)
431
+
432
+ // ── Inline-Defer pre-pass ──────────────────────────────────────────
433
+ // Rewrites `<Defer when={x}><Modal /></Defer>` into the explicit
434
+ // chunk-prop form so Rolldown emits a proper per-Defer chunk and
435
+ // the main bundle drops the static `import { Modal } from ...`
436
+ // when it's exclusively used inside this Defer's subtree. Runs
437
+ // BEFORE the JSX→runtime transform so the downstream pipeline
438
+ // sees an already-explicit `<Defer chunk={...}>` shape with no
439
+ // special-casing needed in `transformJSX`. See
440
+ // `@pyreon/compiler/defer-inline` for the rewrite contract.
441
+ const deferResult = transformDeferInline(code, id)
442
+ const sourceForJsx = deferResult.changed ? deferResult.code : code
443
+ for (const w of deferResult.warnings) {
444
+ this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
445
+ }
446
+
331
447
  // ── Resolve imported signals from the registry ─────────────────────
332
448
  // Check each import in this file: if the imported module has signal
333
449
  // exports in the registry, pass them as knownSignals to the compiler.
334
- const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache)
450
+ const knownSignals = await resolveImportedSignals(sourceForJsx, id, signalExportRegistry, this, resolveCache)
335
451
 
336
452
  // Vite passes `ssr: true` when transforming for the SSR module graph
337
453
  // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
338
454
  // `h()` calls in that mode so `runtime-server` can render to a string.
339
455
  const isSsr = transformOptions?.ssr === true
340
- const result = transformJSX(code, id, { ssr: isSsr, knownSignals })
456
+ const result = transformJSX(sourceForJsx, id, { ssr: isSsr, knownSignals })
341
457
  // Surface compiler warnings in the terminal
342
458
  for (const w of result.warnings) {
343
459
  this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
@@ -694,6 +810,189 @@ function normalizeModuleId(id: string): string {
694
810
  return queryIndex >= 0 ? id.slice(0, queryIndex) : id
695
811
  }
696
812
 
813
+ // ─── Island declaration scanner ────────────────────────────────────────────
814
+
815
+ /**
816
+ * One island() call site discovered in source.
817
+ *
818
+ * `loaderAbsPath` is the dynamic-import target resolved relative to the
819
+ * source file where the call was written. Vite's resolver finds the actual
820
+ * file (.tsx / .jsx / .ts / .js extension auto-added) when the registry
821
+ * module emits `() => import('<loaderAbsPath>')`.
822
+ */
823
+ interface IslandDecl {
824
+ name: string
825
+ hydrate: string
826
+ loaderAbsPath: string
827
+ }
828
+
829
+ /**
830
+ * Pre-scan all source files in the project for `island()` declarations.
831
+ *
832
+ * Called from `buildStart` (when `islands: true`) so the registry is fully
833
+ * populated before any transforms run. Mirrors `prescanSignalExports` shape;
834
+ * the per-file regex pattern matches:
835
+ *
836
+ * island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
837
+ *
838
+ * Edge cases the regex deliberately doesn't cover (user falls back to manual
839
+ * `hydrateIslands({ ... })`):
840
+ * - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
841
+ * - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
842
+ * - Options come from a spread: `island(loader, { ...opts })`
843
+ */
844
+ async function prescanIslandDeclarations(
845
+ root: string,
846
+ registry: Map<string, IslandDecl[]>,
847
+ ): Promise<void> {
848
+ const files: string[] = []
849
+
850
+ function walk(dir: string) {
851
+ try {
852
+ for (const entry of readdirSync(dir)) {
853
+ if (
854
+ entry.startsWith('.') ||
855
+ entry === 'node_modules' ||
856
+ entry === 'dist' ||
857
+ entry === 'lib' ||
858
+ entry === 'build'
859
+ )
860
+ continue
861
+ const full = pathJoin(dir, entry)
862
+ try {
863
+ const stat = statSync(full)
864
+ if (stat.isDirectory()) walk(full)
865
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
866
+ } catch {
867
+ /* permission error, etc. */
868
+ }
869
+ }
870
+ } catch {
871
+ /* dir doesn't exist */
872
+ }
873
+ }
874
+
875
+ walk(root)
876
+
877
+ for (const file of files) {
878
+ try {
879
+ const code = readFileSync(file, 'utf-8')
880
+ scanIslandDeclarations(code, file, registry)
881
+ } catch {
882
+ /* read error */
883
+ }
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Scan a single source file for `island()` declarations and record them.
889
+ *
890
+ * The regex captures:
891
+ * - Group 1: dynamic-import path (`./components/Counter`)
892
+ * - Group 2: options block contents
893
+ *
894
+ * Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
895
+ * options block. Single-line and multi-line forms both work.
896
+ *
897
+ * Resolves the loader path relative to the file where the call lives so
898
+ * the emitted virtual-module registry gets an absolute path Vite's resolver
899
+ * can find.
900
+ */
901
+ function scanIslandDeclarations(
902
+ code: string,
903
+ filePath: string,
904
+ registry: Map<string, IslandDecl[]>,
905
+ ): void {
906
+ // `[\s\S]` lets the options block span multiple lines. The lazy `?` after
907
+ // the options block prevents over-matching when several `island()` calls
908
+ // appear in the same file.
909
+ const ISLAND_CALL_RE =
910
+ /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g
911
+ const decls: IslandDecl[] = []
912
+ let match: RegExpExecArray | null
913
+ while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
914
+ const importPath = match[1]!
915
+ const optsBlock = match[2]!
916
+ const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
917
+ if (!nameMatch) continue // can't auto-register without a name
918
+ const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
919
+ const hydrate = hydrateMatch ? hydrateMatch[1]! : 'load'
920
+ const loaderAbsPath = importPath.startsWith('.')
921
+ ? resolveRelative(filePath, importPath)
922
+ : importPath
923
+ decls.push({ name: nameMatch[1]!, hydrate, loaderAbsPath })
924
+ }
925
+ if (decls.length > 0) {
926
+ registry.set(normalizeModuleId(filePath), decls)
927
+ } else {
928
+ // Clean up if file no longer declares islands (e.g. after edit)
929
+ registry.delete(normalizeModuleId(filePath))
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Resolve a dynamic-import specifier to an absolute path, mirroring how Node
935
+ * / Vite resolve `import('./X')` from the source file's directory.
936
+ */
937
+ function resolveRelative(fromFile: string, relPath: string): string {
938
+ return pathJoin(dirname(fromFile), relPath)
939
+ }
940
+
941
+ /**
942
+ * Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
943
+ *
944
+ * export const __pyreonIslandRegistry = {
945
+ * Counter: () => import('/abs/path/to/components/Counter'),
946
+ * IdleClock: () => import('/abs/path/to/components/IdleClock'),
947
+ * // never-strategy islands deliberately omitted
948
+ * }
949
+ *
950
+ * `hydrate: 'never'` islands are skipped — registering a loader for them
951
+ * would defeat the strategy by pulling the component module into the
952
+ * client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
953
+ * at runtime regardless; emitting here would still create the dynamic-
954
+ * import chunk.
955
+ *
956
+ * Duplicate `name` across declarations: the LAST one wins. Documented as
957
+ * an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
958
+ */
959
+ function renderIslandsRegistry(
960
+ registry: Map<string, IslandDecl[]>,
961
+ enabled: boolean,
962
+ ): string {
963
+ if (!enabled) {
964
+ return [
965
+ `// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
966
+ `// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
967
+ `// or use manual hydrateIslands({ ... }) instead.`,
968
+ `export const __pyreonIslandRegistry = {};`,
969
+ `export const __pyreonIslandsEnabled = false;`,
970
+ ].join('\n')
971
+ }
972
+ const entries: string[] = []
973
+ const seen = new Set<string>()
974
+ // Deterministic order: sort by name for stable output / predictable HMR.
975
+ const all = Array.from(registry.values()).flat()
976
+ all.sort((a, b) => a.name.localeCompare(b.name))
977
+ for (const { name, hydrate, loaderAbsPath } of all) {
978
+ if (hydrate === 'never') continue
979
+ if (seen.has(name)) continue
980
+ seen.add(name)
981
+ // JSON.stringify gives proper escaping for both name (object key) and path.
982
+ entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`)
983
+ }
984
+ return [
985
+ `// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
986
+ `// Sourced from island() declarations in your project. Never-strategy`,
987
+ `// islands are intentionally omitted — registering a loader for them`,
988
+ `// would defeat the zero-JS contract.`,
989
+ `export const __pyreonIslandRegistry = {`,
990
+ ...entries,
991
+ `};`,
992
+ `export const __pyreonIslandsEnabled = true;`,
993
+ ].join('\n')
994
+ }
995
+
697
996
  /**
698
997
  * Pre-scan all source files in the project for signal exports.
699
998
  *
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Auto-discovered island-registry tests for @pyreon/vite-plugin.
3
+ *
4
+ * Exercises the `pyreon({ islands: true })` path:
5
+ * 1. Materialize synthetic source files containing `island()` calls
6
+ * 2. Drive plugin.config() + plugin.buildStart() to populate the registry
7
+ * 3. Drive plugin.load('\0pyreon/islands-registry') + assert the emitted
8
+ * source contains the expected loader entries (and excludes
9
+ * hydrate: 'never' islands)
10
+ *
11
+ * Companion to `cross-module-signals.test.ts` — same harness shape.
12
+ */
13
+
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
18
+ import pyreonPlugin, { type PyreonPluginOptions } from '../index'
19
+
20
+ type ConfigHook = (
21
+ userConfig: Record<string, unknown>,
22
+ env: { command: string; isSsrBuild?: boolean },
23
+ ) => Record<string, unknown>
24
+
25
+ type BuildStartHook = (this: unknown) => Promise<void>
26
+ type LoadHook = (id: string) => string | undefined
27
+ type ResolveIdHook = (
28
+ this: unknown,
29
+ id: string,
30
+ importer?: string,
31
+ ) => Promise<string | null | undefined>
32
+
33
+ let root: string
34
+
35
+ beforeAll(() => {
36
+ root = mkdtempSync(join(tmpdir(), 'pyreon-islands-registry-'))
37
+ })
38
+ afterAll(() => {
39
+ rmSync(root, { recursive: true, force: true })
40
+ })
41
+ beforeEach(() => {
42
+ rmSync(root, { recursive: true, force: true })
43
+ mkdirSync(root, { recursive: true })
44
+ })
45
+
46
+ function writeFile(rel: string, contents: string): string {
47
+ const full = join(root, rel)
48
+ const dir = full.slice(0, full.lastIndexOf('/'))
49
+ mkdirSync(dir, { recursive: true })
50
+ writeFileSync(full, contents)
51
+ return full
52
+ }
53
+
54
+ function bootstrap(opts?: PyreonPluginOptions) {
55
+ const plugin = pyreonPlugin(opts)
56
+ ;(plugin.config as unknown as ConfigHook)({ root }, { command: 'build' })
57
+ return plugin
58
+ }
59
+
60
+ async function runBuildStart(plugin: ReturnType<typeof pyreonPlugin>) {
61
+ const buildStart = plugin.buildStart as BuildStartHook
62
+ await buildStart.call({})
63
+ }
64
+
65
+ function runLoad(plugin: ReturnType<typeof pyreonPlugin>, id: string): string {
66
+ const result = (plugin.load as LoadHook)(id)
67
+ if (typeof result !== 'string') {
68
+ throw new Error(`load('${id}') returned ${typeof result}, expected string`)
69
+ }
70
+ return result
71
+ }
72
+
73
+ async function runResolveId(
74
+ plugin: ReturnType<typeof pyreonPlugin>,
75
+ id: string,
76
+ ): Promise<string | null | undefined> {
77
+ return (plugin.resolveId as ResolveIdHook).call({}, id)
78
+ }
79
+
80
+ const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
81
+ const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
82
+
83
+ describe('vite-plugin — islands virtual module', () => {
84
+ it('resolveId redirects virtual:pyreon/islands-registry to the \\0-prefixed id', async () => {
85
+ const plugin = bootstrap()
86
+ expect(await runResolveId(plugin, ISLANDS_REGISTRY_IMPORT)).toBe(ISLANDS_REGISTRY_ID)
87
+ })
88
+
89
+ it('emits an empty registry when no island() calls exist', async () => {
90
+ writeFile('src/App.tsx', `export const App = () => null`)
91
+ const plugin = bootstrap()
92
+ await runBuildStart(plugin)
93
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
94
+ expect(source).toContain('__pyreonIslandsEnabled = true')
95
+ expect(source).toContain('__pyreonIslandRegistry = {')
96
+ // No entries beyond the opening and closing braces
97
+ expect(source).not.toMatch(/import\(.+\)/)
98
+ })
99
+
100
+ it('discovers `island(() => import("./X"), { name, hydrate: "load" })` calls', async () => {
101
+ writeFile(
102
+ 'src/islands.ts',
103
+ `import { island } from '@pyreon/server'
104
+ export const Counter = island(() => import('./components/Counter'), {
105
+ name: 'Counter',
106
+ hydrate: 'load',
107
+ })`,
108
+ )
109
+ const plugin = bootstrap()
110
+ await runBuildStart(plugin)
111
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
112
+ expect(source).toContain('"Counter":')
113
+ // Loader path was resolved relative to the file where the call lives
114
+ expect(source).toContain(`/components/Counter`)
115
+ })
116
+
117
+ it('omits hydrate: "never" islands from the registry', async () => {
118
+ writeFile(
119
+ 'src/islands.ts',
120
+ `import { island } from '@pyreon/server'
121
+ export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })
122
+ export const StaticBadge = island(() => import('./StaticBadge'), { name: 'StaticBadge', hydrate: 'never' })`,
123
+ )
124
+ const plugin = bootstrap()
125
+ await runBuildStart(plugin)
126
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
127
+ expect(source).toContain('"Counter":')
128
+ expect(source).not.toContain('"StaticBadge":')
129
+ expect(source).not.toContain('StaticBadge')
130
+ })
131
+
132
+ it('handles `media(...)` and `interaction` strategy strings without omitting them', async () => {
133
+ writeFile(
134
+ 'src/islands.ts',
135
+ `import { island } from '@pyreon/server'
136
+ export const Mobile = island(() => import('./Mobile'), { name: 'Mobile', hydrate: 'media((max-width: 768px))' })
137
+ export const Idle = island(() => import('./Idle'), { name: 'Idle', hydrate: 'idle' })
138
+ export const Visible = island(() => import('./Visible'), { name: 'Visible', hydrate: 'visible' })`,
139
+ )
140
+ const plugin = bootstrap()
141
+ await runBuildStart(plugin)
142
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
143
+ expect(source).toContain('"Mobile":')
144
+ expect(source).toContain('"Idle":')
145
+ expect(source).toContain('"Visible":')
146
+ })
147
+
148
+ it('discovers island() calls across multiple source files', async () => {
149
+ writeFile(
150
+ 'src/foo/A.ts',
151
+ `import { island } from '@pyreon/server'
152
+ export const A = island(() => import('./component'), { name: 'A', hydrate: 'load' })`,
153
+ )
154
+ writeFile(
155
+ 'src/bar/B.ts',
156
+ `import { island } from '@pyreon/server'
157
+ export const B = island(() => import('./component'), { name: 'B', hydrate: 'idle' })`,
158
+ )
159
+ const plugin = bootstrap()
160
+ await runBuildStart(plugin)
161
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
162
+ expect(source).toContain('"A":')
163
+ expect(source).toContain('"B":')
164
+ })
165
+
166
+ it('skips node_modules / dist / lib / build during the prescan walk', async () => {
167
+ writeFile(
168
+ 'node_modules/some-pkg/island.ts',
169
+ `island(() => import('./X'), { name: 'IgnoreMe', hydrate: 'load' })`,
170
+ )
171
+ writeFile(
172
+ 'dist/build-output.ts',
173
+ `island(() => import('./X'), { name: 'AlsoIgnoreMe', hydrate: 'load' })`,
174
+ )
175
+ writeFile(
176
+ 'src/Real.ts',
177
+ `import { island } from '@pyreon/server'
178
+ export const Real = island(() => import('./X'), { name: 'Real', hydrate: 'load' })`,
179
+ )
180
+ const plugin = bootstrap()
181
+ await runBuildStart(plugin)
182
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
183
+ expect(source).toContain('"Real":')
184
+ expect(source).not.toContain('"IgnoreMe":')
185
+ expect(source).not.toContain('"AlsoIgnoreMe":')
186
+ })
187
+
188
+ it('emits a stub registry when islands: false is set', async () => {
189
+ writeFile(
190
+ 'src/islands.ts',
191
+ `import { island } from '@pyreon/server'
192
+ export const Counter = island(() => import('./Counter'), { name: 'Counter', hydrate: 'load' })`,
193
+ )
194
+ const plugin = bootstrap({ islands: false })
195
+ await runBuildStart(plugin)
196
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
197
+ // Stub flips the enabled flag so hydrateIslandsAuto() throws at runtime
198
+ // with a clear message — better than a silent empty registry.
199
+ expect(source).toContain('__pyreonIslandsEnabled = false')
200
+ expect(source).not.toContain('"Counter":')
201
+ })
202
+
203
+ it('deduplicates duplicate names (last-wins order)', async () => {
204
+ writeFile(
205
+ 'src/a.ts',
206
+ `import { island } from '@pyreon/server'
207
+ export const A = island(() => import('./a-comp'), { name: 'Same', hydrate: 'load' })`,
208
+ )
209
+ writeFile(
210
+ 'src/b.ts',
211
+ `import { island } from '@pyreon/server'
212
+ export const B = island(() => import('./b-comp'), { name: 'Same', hydrate: 'load' })`,
213
+ )
214
+ const plugin = bootstrap()
215
+ await runBuildStart(plugin)
216
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
217
+ // Only one entry for "Same" emitted — registry can't have duplicate keys.
218
+ const matches = source.match(/"Same":/g) ?? []
219
+ expect(matches).toHaveLength(1)
220
+ })
221
+
222
+ it('skips island() calls without a name field (auto-registry has nothing to key on)', async () => {
223
+ writeFile(
224
+ 'src/islands.ts',
225
+ `import { island } from '@pyreon/server'
226
+ // Anomaly: island() without a name option. Auto-registry can't include this.
227
+ export const Bad = island(() => import('./X'), { hydrate: 'load' } as any)
228
+ export const Good = island(() => import('./Y'), { name: 'Good', hydrate: 'load' })`,
229
+ )
230
+ const plugin = bootstrap()
231
+ await runBuildStart(plugin)
232
+ const source = runLoad(plugin, ISLANDS_REGISTRY_ID)
233
+ expect(source).toContain('"Good":')
234
+ expect(source).not.toContain('"Bad":')
235
+ })
236
+ })
@@ -5,6 +5,8 @@
5
5
  * These test the plugin's transform logic directly (no Vite required).
6
6
  */
7
7
 
8
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { join as pathJoin } from 'node:path'
8
10
  import { describe, expect, it } from 'vitest'
9
11
 
10
12
  // ── Import internals ─────────────────────────────────────────────────────────
@@ -349,6 +351,85 @@ describe('plugin config', () => {
349
351
  expect(config.optimizeDeps.exclude).toContain('react-dom')
350
352
  })
351
353
 
354
+ // Regression: pre-fix, the plugin's `bun` resolve condition redirected
355
+ // every `@pyreon/*` import to source `.ts(x)` files. In a non-monorepo
356
+ // consumer app, Vite's deps optimizer (esbuild) tried to pre-bundle
357
+ // those packages from `node_modules` and silently produced broken
358
+ // bundles in `.vite/deps/`, surfacing as
359
+ // `File does not exist at .../node_modules/.vite/deps/@pyreon_styler.js`
360
+ // at runtime. Fix scans the consumer's package.json for `@pyreon/*`
361
+ // deps and adds them to optimizeDeps.exclude so the optimizer skips
362
+ // them (resolution then goes through the plugin's own resolveId hook
363
+ // and Vite's normal source pipeline).
364
+ it("auto-excludes consumer's @pyreon/* deps from optimizeDeps (Vite optimizer fix)", async () => {
365
+ // Build a fake consumer package.json with a few @pyreon/* deps.
366
+ const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-consumer')
367
+ rmSync(tmpRoot, { recursive: true, force: true })
368
+ mkdirSync(tmpRoot, { recursive: true })
369
+ writeFileSync(
370
+ pathJoin(tmpRoot, 'package.json'),
371
+ JSON.stringify({
372
+ name: 'fake-consumer',
373
+ dependencies: {
374
+ '@pyreon/core': '^0.15.0',
375
+ '@pyreon/styler': '^0.15.0',
376
+ '@pyreon/runtime-dom': '^0.15.0',
377
+ // Non-@pyreon dep MUST NOT leak into the exclude list.
378
+ react: '^19.0.0',
379
+ },
380
+ devDependencies: {
381
+ '@pyreon/vite-plugin': '^0.15.0',
382
+ },
383
+ }),
384
+ )
385
+
386
+ const plugin = pyreonPlugin()
387
+ const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
388
+ optimizeDeps: { exclude: string[] }
389
+ }
390
+ expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
391
+ expect(config.optimizeDeps.exclude).toContain('@pyreon/styler')
392
+ expect(config.optimizeDeps.exclude).toContain('@pyreon/runtime-dom')
393
+ expect(config.optimizeDeps.exclude).toContain('@pyreon/vite-plugin')
394
+ expect(config.optimizeDeps.exclude).not.toContain('react')
395
+
396
+ rmSync(tmpRoot, { recursive: true, force: true })
397
+ })
398
+
399
+ it("merges @pyreon/* deps with compat aliases without dup'ing", async () => {
400
+ const tmpRoot = pathJoin(import.meta.dirname, 'fixtures', 'pyreon-deps-compat')
401
+ rmSync(tmpRoot, { recursive: true, force: true })
402
+ mkdirSync(tmpRoot, { recursive: true })
403
+ writeFileSync(
404
+ pathJoin(tmpRoot, 'package.json'),
405
+ JSON.stringify({ dependencies: { '@pyreon/core': '^0.15.0' } }),
406
+ )
407
+
408
+ const plugin = pyreonPlugin({ compat: 'react' })
409
+ const config = getConfigHook(plugin)({ root: tmpRoot }, { command: 'serve' }) as {
410
+ optimizeDeps: { exclude: string[] }
411
+ }
412
+ // Compat list still present
413
+ expect(config.optimizeDeps.exclude).toContain('react')
414
+ // Pyreon list also present
415
+ expect(config.optimizeDeps.exclude).toContain('@pyreon/core')
416
+ // Deduplicated (Set)
417
+ const occurrences = config.optimizeDeps.exclude.filter((d) => d === 'react').length
418
+ expect(occurrences).toBe(1)
419
+
420
+ rmSync(tmpRoot, { recursive: true, force: true })
421
+ })
422
+
423
+ it('handles missing/malformed consumer package.json gracefully', async () => {
424
+ const plugin = pyreonPlugin()
425
+ // Point at a directory that doesn't exist — should not throw.
426
+ const config = getConfigHook(plugin)(
427
+ { root: '/nonexistent/path/that/does/not/exist' },
428
+ { command: 'serve' },
429
+ ) as { optimizeDeps: { exclude: string[] } }
430
+ expect(config.optimizeDeps.exclude).toEqual([])
431
+ })
432
+
352
433
  it('adds SSR build config when isSsrBuild', async () => {
353
434
  const plugin = pyreonPlugin({ ssr: { entry: './src/entry-server.ts' } })
354
435
  const config = getConfigHook(plugin)({}, { command: 'build', isSsrBuild: true }) as {