@pyreon/vite-plugin 0.14.0 → 0.16.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/src/index.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
- import { join as pathJoin } from 'node:path'
36
+ import { dirname, join as pathJoin } from 'node:path'
37
37
  import { generateContext, transformJSX } from '@pyreon/compiler'
38
38
  import type { Plugin, ViteDevServer } from 'vite'
39
39
 
@@ -41,6 +41,12 @@ import type { Plugin, ViteDevServer } from 'vite'
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
81
 
77
- // ── Compat JSX import sources ─────────────────────────────────────────────────
78
-
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 ─────────────────────────────────────────────────────────
@@ -112,6 +136,73 @@ const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
112
136
  },
113
137
  }
114
138
 
139
+ /**
140
+ * Detect whether a file id resolves to a `@pyreon/*` framework-package source
141
+ * (i.e. a published Pyreon package whose .tsx is being pulled in via the
142
+ * `bun` condition workspace-link, NOT user code, NOT an example app).
143
+ *
144
+ * Why this exists: in compat mode, OXC's per-project `importSource` is set
145
+ * to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
146
+ * to the compat package. That's correct for user code (the whole point of
147
+ * compat mode) but WRONG for framework-internal sources like
148
+ * `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
149
+ * The fix skips the redirect when the importer is a `@pyreon/*` framework
150
+ * file. Result: published-package consumers (where `@pyreon/zero` resolves
151
+ * to its pre-built `lib/`) and workspace-dev consumers (where it resolves
152
+ * to source) both get correct JSX runtime resolution.
153
+ *
154
+ * Detection heuristic: walk to nearest `package.json`, require BOTH:
155
+ * 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
156
+ * 2. file path contains `/packages/` AND NOT `/examples/`
157
+ *
158
+ * Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
159
+ * apps under `examples/`. Without it, user code in those apps would skip the
160
+ * compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
161
+ * directly — breaking the React/Vue/Solid/Preact compat layer's contract.
162
+ *
163
+ * Result cached per directory. The `/packages/` + `/examples/` check is a
164
+ * structural property of the monorepo (workspace layout), not the package
165
+ * name — so it's robust against renames.
166
+ */
167
+ function isPyreonWorkspaceFile(id: string, cache: Map<string, boolean>): boolean {
168
+ // Strip query strings (e.g. `?vue&type=script`) to get the bare path.
169
+ const queryIdx = id.indexOf('?')
170
+ const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx)
171
+ if (!filePath || filePath[0] === '\0') return false
172
+
173
+ // Path-based filter first (cheap): file must live under `<root>/packages/`
174
+ // and not under `<root>/examples/`. This excludes example apps even when
175
+ // they have `@pyreon/example-*` names.
176
+ if (!filePath.includes('/packages/') || filePath.includes('/examples/')) {
177
+ return false
178
+ }
179
+
180
+ let dir = dirname(filePath)
181
+ // Walk up at most ~12 levels — enough for any realistic monorepo depth.
182
+ for (let i = 0; i < 12; i++) {
183
+ const cached = cache.get(dir)
184
+ if (cached !== undefined) return cached
185
+
186
+ const pkgPath = pathJoin(dir, 'package.json')
187
+ if (existsSync(pkgPath)) {
188
+ let isPyreon = false
189
+ try {
190
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string }
191
+ isPyreon = typeof pkg.name === 'string' && pkg.name.startsWith('@pyreon/')
192
+ } catch {
193
+ // Malformed package.json — treat as not-pyreon.
194
+ }
195
+ cache.set(dir, isPyreon)
196
+ return isPyreon
197
+ }
198
+
199
+ const parent = dirname(dir)
200
+ if (parent === dir) break // reached filesystem root
201
+ dir = parent
202
+ }
203
+ return false
204
+ }
205
+
115
206
  /**
116
207
  * Return the Pyreon compat target for an import specifier, or undefined if
117
208
  * the import should not be redirected.
@@ -131,9 +222,44 @@ function getCompatTarget(compat: CompatFramework | undefined, id: string): strin
131
222
  return undefined
132
223
  }
133
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
+
134
256
  export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
135
257
  const ssrConfig = options?.ssr
136
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
137
263
  let isBuild = false
138
264
  let projectRoot = ''
139
265
 
@@ -144,6 +270,16 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
144
270
  const signalExportRegistry = new Map<string, Set<string>>()
145
271
  // Cache resolved import specifiers to avoid redundant resolution calls
146
272
  const resolveCache = new Map<string, string | null>()
273
+ // Cache `isPyreonWorkspaceFile` lookups by directory — package.json reads
274
+ // happen at most once per containing directory across the build.
275
+ const pyreonWorkspaceDirCache = new Map<string, boolean>()
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[]>()
147
283
 
148
284
  return {
149
285
  name: 'pyreon',
@@ -156,9 +292,31 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
156
292
 
157
293
  // Tell Vite's dep scanner not to pre-bundle the aliased framework imports —
158
294
  // they resolve to workspace packages via our resolveId hook, not node_modules.
159
- const optimizeDepsExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
160
-
161
- const jsxSource = compat ? COMPAT_JSX_SOURCE[compat] : '@pyreon/core'
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
+ )
312
+
313
+ // Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
314
+ // we redirect `@pyreon/core/jsx-runtime` imports to the compat package
315
+ // VIA `resolveId` — but ONLY for user code, never for `@pyreon/*`
316
+ // workspace-package files (zero, router, runtime-dom, etc.). Setting
317
+ // OXC's importSource directly to the compat package would force the
318
+ // compat runtime on framework internals too, which they cannot handle.
319
+ const jsxSource = '@pyreon/core'
162
320
 
163
321
  return {
164
322
  // Use "bun" condition for workspace resolution — source .ts/.tsx files
@@ -195,11 +353,37 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
195
353
  // ordering problem where component.tsx is transformed before
196
354
  // store.ts — without pre-scanning, the registry would be empty.
197
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
+ }
198
365
  },
199
366
 
200
367
  // ── Virtual module + compat alias resolution ─────────────────────────────
201
368
  async resolveId(id, importer) {
202
369
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
370
+ if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
371
+
372
+ // `@pyreon/core/jsx-runtime` resolves to the compat package only for
373
+ // user code — never for `@pyreon/*` framework files (zero, router,
374
+ // runtime-dom, etc.). Without this importer guard, every JSX file in
375
+ // the build (including framework internals resolved via the `bun`
376
+ // workspace condition) would get redirected to a compat runtime that
377
+ // doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
378
+ if (
379
+ compat &&
380
+ (id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
381
+ importer &&
382
+ isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
383
+ ) {
384
+ return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
385
+ }
386
+
203
387
  const target = getCompatTarget(compat, id)
204
388
  if (!target) return
205
389
 
@@ -213,6 +397,9 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
213
397
  if (id === HMR_RUNTIME_ID) {
214
398
  return HMR_RUNTIME_SOURCE
215
399
  }
400
+ if (id === ISLANDS_REGISTRY_ID) {
401
+ return renderIslandsRegistry(islandRegistry, islandsEnabled)
402
+ }
216
403
  },
217
404
 
218
405
  async transform(code, id, transformOptions) {
@@ -236,6 +423,12 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
236
423
  // files created/modified after buildStart (dev mode HMR).
237
424
  scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
238
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
+
239
432
  // ── Resolve imported signals from the registry ─────────────────────
240
433
  // Check each import in this file: if the imported module has signal
241
434
  // exports in the registry, pass them as knownSignals to the compiler.
@@ -361,8 +554,19 @@ function generateProjectContext(root: string): void {
361
554
  * The arguments are extracted via balanced-paren matching in `injectHmr`.
362
555
  * A brace-depth check filters out matches inside functions/blocks — only
363
556
  * module-scope (depth 0) signals are rewritten for HMR state preservation.
557
+ *
558
+ * The optional `<...>` group accepts a TypeScript type parameter so that
559
+ * `signal<T>(initial)` declarations are also rewritten — without it, any
560
+ * generic-typed module-scope signal silently skipped HMR preservation.
561
+ *
562
+ * The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
563
+ * (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
564
+ * Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
565
+ * not-rewritten — tracked as a follow-up if real consumers need it,
566
+ * but unlikely at module scope where generics are usually shallow.
364
567
  */
365
- const SIGNAL_PREFIX_RE = /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal\(/gm
568
+ const SIGNAL_PREFIX_RE =
569
+ /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
366
570
 
367
571
  /**
368
572
  * Detect whether the module exports any component-like functions
@@ -591,6 +795,189 @@ function normalizeModuleId(id: string): string {
591
795
  return queryIndex >= 0 ? id.slice(0, queryIndex) : id
592
796
  }
593
797
 
798
+ // ─── Island declaration scanner ────────────────────────────────────────────
799
+
800
+ /**
801
+ * One island() call site discovered in source.
802
+ *
803
+ * `loaderAbsPath` is the dynamic-import target resolved relative to the
804
+ * source file where the call was written. Vite's resolver finds the actual
805
+ * file (.tsx / .jsx / .ts / .js extension auto-added) when the registry
806
+ * module emits `() => import('<loaderAbsPath>')`.
807
+ */
808
+ interface IslandDecl {
809
+ name: string
810
+ hydrate: string
811
+ loaderAbsPath: string
812
+ }
813
+
814
+ /**
815
+ * Pre-scan all source files in the project for `island()` declarations.
816
+ *
817
+ * Called from `buildStart` (when `islands: true`) so the registry is fully
818
+ * populated before any transforms run. Mirrors `prescanSignalExports` shape;
819
+ * the per-file regex pattern matches:
820
+ *
821
+ * island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
822
+ *
823
+ * Edge cases the regex deliberately doesn't cover (user falls back to manual
824
+ * `hydrateIslands({ ... })`):
825
+ * - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
826
+ * - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
827
+ * - Options come from a spread: `island(loader, { ...opts })`
828
+ */
829
+ async function prescanIslandDeclarations(
830
+ root: string,
831
+ registry: Map<string, IslandDecl[]>,
832
+ ): Promise<void> {
833
+ const files: string[] = []
834
+
835
+ function walk(dir: string) {
836
+ try {
837
+ for (const entry of readdirSync(dir)) {
838
+ if (
839
+ entry.startsWith('.') ||
840
+ entry === 'node_modules' ||
841
+ entry === 'dist' ||
842
+ entry === 'lib' ||
843
+ entry === 'build'
844
+ )
845
+ continue
846
+ const full = pathJoin(dir, entry)
847
+ try {
848
+ const stat = statSync(full)
849
+ if (stat.isDirectory()) walk(full)
850
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
851
+ } catch {
852
+ /* permission error, etc. */
853
+ }
854
+ }
855
+ } catch {
856
+ /* dir doesn't exist */
857
+ }
858
+ }
859
+
860
+ walk(root)
861
+
862
+ for (const file of files) {
863
+ try {
864
+ const code = readFileSync(file, 'utf-8')
865
+ scanIslandDeclarations(code, file, registry)
866
+ } catch {
867
+ /* read error */
868
+ }
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Scan a single source file for `island()` declarations and record them.
874
+ *
875
+ * The regex captures:
876
+ * - Group 1: dynamic-import path (`./components/Counter`)
877
+ * - Group 2: options block contents
878
+ *
879
+ * Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
880
+ * options block. Single-line and multi-line forms both work.
881
+ *
882
+ * Resolves the loader path relative to the file where the call lives so
883
+ * the emitted virtual-module registry gets an absolute path Vite's resolver
884
+ * can find.
885
+ */
886
+ function scanIslandDeclarations(
887
+ code: string,
888
+ filePath: string,
889
+ registry: Map<string, IslandDecl[]>,
890
+ ): void {
891
+ // `[\s\S]` lets the options block span multiple lines. The lazy `?` after
892
+ // the options block prevents over-matching when several `island()` calls
893
+ // appear in the same file.
894
+ const ISLAND_CALL_RE =
895
+ /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g
896
+ const decls: IslandDecl[] = []
897
+ let match: RegExpExecArray | null
898
+ while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
899
+ const importPath = match[1]!
900
+ const optsBlock = match[2]!
901
+ const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
902
+ if (!nameMatch) continue // can't auto-register without a name
903
+ const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
904
+ const hydrate = hydrateMatch ? hydrateMatch[1]! : 'load'
905
+ const loaderAbsPath = importPath.startsWith('.')
906
+ ? resolveRelative(filePath, importPath)
907
+ : importPath
908
+ decls.push({ name: nameMatch[1]!, hydrate, loaderAbsPath })
909
+ }
910
+ if (decls.length > 0) {
911
+ registry.set(normalizeModuleId(filePath), decls)
912
+ } else {
913
+ // Clean up if file no longer declares islands (e.g. after edit)
914
+ registry.delete(normalizeModuleId(filePath))
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Resolve a dynamic-import specifier to an absolute path, mirroring how Node
920
+ * / Vite resolve `import('./X')` from the source file's directory.
921
+ */
922
+ function resolveRelative(fromFile: string, relPath: string): string {
923
+ return pathJoin(dirname(fromFile), relPath)
924
+ }
925
+
926
+ /**
927
+ * Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
928
+ *
929
+ * export const __pyreonIslandRegistry = {
930
+ * Counter: () => import('/abs/path/to/components/Counter'),
931
+ * IdleClock: () => import('/abs/path/to/components/IdleClock'),
932
+ * // never-strategy islands deliberately omitted
933
+ * }
934
+ *
935
+ * `hydrate: 'never'` islands are skipped — registering a loader for them
936
+ * would defeat the strategy by pulling the component module into the
937
+ * client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
938
+ * at runtime regardless; emitting here would still create the dynamic-
939
+ * import chunk.
940
+ *
941
+ * Duplicate `name` across declarations: the LAST one wins. Documented as
942
+ * an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
943
+ */
944
+ function renderIslandsRegistry(
945
+ registry: Map<string, IslandDecl[]>,
946
+ enabled: boolean,
947
+ ): string {
948
+ if (!enabled) {
949
+ return [
950
+ `// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
951
+ `// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
952
+ `// or use manual hydrateIslands({ ... }) instead.`,
953
+ `export const __pyreonIslandRegistry = {};`,
954
+ `export const __pyreonIslandsEnabled = false;`,
955
+ ].join('\n')
956
+ }
957
+ const entries: string[] = []
958
+ const seen = new Set<string>()
959
+ // Deterministic order: sort by name for stable output / predictable HMR.
960
+ const all = Array.from(registry.values()).flat()
961
+ all.sort((a, b) => a.name.localeCompare(b.name))
962
+ for (const { name, hydrate, loaderAbsPath } of all) {
963
+ if (hydrate === 'never') continue
964
+ if (seen.has(name)) continue
965
+ seen.add(name)
966
+ // JSON.stringify gives proper escaping for both name (object key) and path.
967
+ entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`)
968
+ }
969
+ return [
970
+ `// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
971
+ `// Sourced from island() declarations in your project. Never-strategy`,
972
+ `// islands are intentionally omitted — registering a loader for them`,
973
+ `// would defeat the zero-JS contract.`,
974
+ `export const __pyreonIslandRegistry = {`,
975
+ ...entries,
976
+ `};`,
977
+ `export const __pyreonIslandsEnabled = true;`,
978
+ ].join('\n')
979
+ }
980
+
594
981
  /**
595
982
  * Pre-scan all source files in the project for signal exports.
596
983
  *
@@ -5,6 +5,7 @@
5
5
  * resolveId hook + the JSX-runtime aliasing branch.
6
6
  */
7
7
 
8
+ import { resolve } from 'node:path'
8
9
  import { describe, expect, it } from 'vitest'
9
10
  import pyreonPlugin, { type PyreonPluginOptions } from '../index'
10
11
 
@@ -36,6 +37,7 @@ async function callResolveId(
36
37
  plugin: ReturnType<typeof pyreonPlugin>,
37
38
  id: string,
38
39
  resolveMap: Record<string, string> = {},
40
+ importer?: string,
39
41
  ): Promise<string | undefined> {
40
42
  const hook = plugin.resolveId as ResolveIdHook
41
43
  return hook.call(
@@ -46,6 +48,7 @@ async function callResolveId(
46
48
  },
47
49
  },
48
50
  id,
51
+ importer,
49
52
  )
50
53
  }
51
54
 
@@ -159,6 +162,85 @@ describe('compat-mode resolveId — solid', () => {
159
162
  })
160
163
  })
161
164
 
165
+ describe('compat-mode resolveId — framework-importer carve-out', () => {
166
+ // Regression: in compat mode, `@pyreon/core/jsx-runtime` must NOT be
167
+ // redirected to the compat package when the importer is itself a
168
+ // `@pyreon/*` workspace-package source file (zero, router, runtime-dom,
169
+ // etc.). Pre-fix, OXC's project-wide importSource was set to the compat
170
+ // package, so framework-internal JSX got rewritten to import a runtime
171
+ // shape it doesn't speak. The fix sets OXC to `@pyreon/core` always and
172
+ // redirects in `resolveId` only for non-framework importers. Caught by
173
+ // `cpa-smoke-app-*-compat` cells in `scripts/scaffold-smoke.ts`.
174
+ // Bisect-verified: dropping the `isPyreonWorkspaceFile(importer)` guard
175
+ // makes these tests fail with the redirected jsx-runtime path.
176
+
177
+ const repoRoot = resolve(import.meta.dirname, '../../../../..')
178
+ const frameworkImporter = `${repoRoot}/packages/zero/zero/src/link.tsx`
179
+ const userImporter = `${repoRoot}/examples/some-user-app/src/foo.tsx`
180
+ // The 4 existing compat-layer example apps under `examples/` have
181
+ // package.json names like `@pyreon/example-react-compat`. The carve-out
182
+ // helper must NOT treat their source files as framework files — doing so
183
+ // skips the JSX-runtime redirect and breaks the compat layer end-to-end.
184
+ // Bisect-verified: when the helper checked `name.startsWith('@pyreon/')`
185
+ // alone (without the `/examples/` exclusion), all 4 compat-layer e2e
186
+ // suites failed in CI with `section.demo` never rendering.
187
+ const exampleAppImporter = `${repoRoot}/examples/react-compat/src/Foo.tsx`
188
+
189
+ it('does NOT redirect @pyreon/core/jsx-runtime when imported FROM @pyreon/zero workspace source (react)', async () => {
190
+ const plugin = bootstrap({ compat: 'react' })
191
+ const resolved = await callResolveId(
192
+ plugin,
193
+ '@pyreon/core/jsx-runtime',
194
+ { '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
195
+ frameworkImporter,
196
+ )
197
+ expect(resolved).toBeUndefined() // pass through to Vite's resolver
198
+ })
199
+
200
+ it('does NOT redirect @pyreon/core/jsx-dev-runtime when imported FROM framework source (preact)', async () => {
201
+ const plugin = bootstrap({ compat: 'preact' })
202
+ const resolved = await callResolveId(
203
+ plugin,
204
+ '@pyreon/core/jsx-dev-runtime',
205
+ { '@pyreon/preact-compat/jsx-runtime': '/abs/preact-compat/jsx-runtime.ts' },
206
+ frameworkImporter,
207
+ )
208
+ expect(resolved).toBeUndefined()
209
+ })
210
+
211
+ it('STILL redirects @pyreon/core/jsx-runtime when imported FROM user code (react)', async () => {
212
+ const plugin = bootstrap({ compat: 'react' })
213
+ const resolved = await callResolveId(
214
+ plugin,
215
+ '@pyreon/core/jsx-runtime',
216
+ { '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
217
+ userImporter,
218
+ )
219
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
220
+ })
221
+
222
+ it('STILL redirects @pyreon/core/jsx-runtime when no importer (entry point)', async () => {
223
+ const plugin = bootstrap({ compat: 'react' })
224
+ const resolved = await callResolveId(
225
+ plugin,
226
+ '@pyreon/core/jsx-runtime',
227
+ { '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
228
+ )
229
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
230
+ })
231
+
232
+ it('STILL redirects @pyreon/core/jsx-runtime when imported FROM an example app under examples/ (e.g. @pyreon/example-react-compat)', async () => {
233
+ const plugin = bootstrap({ compat: 'react' })
234
+ const resolved = await callResolveId(
235
+ plugin,
236
+ '@pyreon/core/jsx-runtime',
237
+ { '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts' },
238
+ exampleAppImporter,
239
+ )
240
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
241
+ })
242
+ })
243
+
162
244
  describe('compat-mode resolveId — no compat', () => {
163
245
  it('returns undefined for any framework alias when compat is unset', async () => {
164
246
  const plugin = bootstrap()