@pyreon/vite-plugin 0.13.1 → 0.15.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
@@ -32,8 +32,8 @@
32
32
  * vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
33
33
  */
34
34
 
35
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
36
- import { join as pathJoin } from 'node:path'
35
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
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
 
@@ -112,6 +112,73 @@ const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
112
112
  },
113
113
  }
114
114
 
115
+ /**
116
+ * Detect whether a file id resolves to a `@pyreon/*` framework-package source
117
+ * (i.e. a published Pyreon package whose .tsx is being pulled in via the
118
+ * `bun` condition workspace-link, NOT user code, NOT an example app).
119
+ *
120
+ * Why this exists: in compat mode, OXC's per-project `importSource` is set
121
+ * to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
122
+ * to the compat package. That's correct for user code (the whole point of
123
+ * compat mode) but WRONG for framework-internal sources like
124
+ * `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
125
+ * The fix skips the redirect when the importer is a `@pyreon/*` framework
126
+ * file. Result: published-package consumers (where `@pyreon/zero` resolves
127
+ * to its pre-built `lib/`) and workspace-dev consumers (where it resolves
128
+ * to source) both get correct JSX runtime resolution.
129
+ *
130
+ * Detection heuristic: walk to nearest `package.json`, require BOTH:
131
+ * 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
132
+ * 2. file path contains `/packages/` AND NOT `/examples/`
133
+ *
134
+ * Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
135
+ * apps under `examples/`. Without it, user code in those apps would skip the
136
+ * compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
137
+ * directly — breaking the React/Vue/Solid/Preact compat layer's contract.
138
+ *
139
+ * Result cached per directory. The `/packages/` + `/examples/` check is a
140
+ * structural property of the monorepo (workspace layout), not the package
141
+ * name — so it's robust against renames.
142
+ */
143
+ function isPyreonWorkspaceFile(id: string, cache: Map<string, boolean>): boolean {
144
+ // Strip query strings (e.g. `?vue&type=script`) to get the bare path.
145
+ const queryIdx = id.indexOf('?')
146
+ const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx)
147
+ if (!filePath || filePath[0] === '\0') return false
148
+
149
+ // Path-based filter first (cheap): file must live under `<root>/packages/`
150
+ // and not under `<root>/examples/`. This excludes example apps even when
151
+ // they have `@pyreon/example-*` names.
152
+ if (!filePath.includes('/packages/') || filePath.includes('/examples/')) {
153
+ return false
154
+ }
155
+
156
+ let dir = dirname(filePath)
157
+ // Walk up at most ~12 levels — enough for any realistic monorepo depth.
158
+ for (let i = 0; i < 12; i++) {
159
+ const cached = cache.get(dir)
160
+ if (cached !== undefined) return cached
161
+
162
+ const pkgPath = pathJoin(dir, 'package.json')
163
+ if (existsSync(pkgPath)) {
164
+ let isPyreon = false
165
+ try {
166
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string }
167
+ isPyreon = typeof pkg.name === 'string' && pkg.name.startsWith('@pyreon/')
168
+ } catch {
169
+ // Malformed package.json — treat as not-pyreon.
170
+ }
171
+ cache.set(dir, isPyreon)
172
+ return isPyreon
173
+ }
174
+
175
+ const parent = dirname(dir)
176
+ if (parent === dir) break // reached filesystem root
177
+ dir = parent
178
+ }
179
+ return false
180
+ }
181
+
115
182
  /**
116
183
  * Return the Pyreon compat target for an import specifier, or undefined if
117
184
  * the import should not be redirected.
@@ -137,6 +204,17 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
137
204
  let isBuild = false
138
205
  let projectRoot = ''
139
206
 
207
+ // ── Cross-module signal export registry ─────────────────────────────────
208
+ // Tracks which modules export signal() declarations so imported signals
209
+ // can be auto-called in JSX across file boundaries.
210
+ // Key: normalized module ID, Value: set of exported signal names
211
+ const signalExportRegistry = new Map<string, Set<string>>()
212
+ // Cache resolved import specifiers to avoid redundant resolution calls
213
+ const resolveCache = new Map<string, string | null>()
214
+ // Cache `isPyreonWorkspaceFile` lookups by directory — package.json reads
215
+ // happen at most once per containing directory across the build.
216
+ const pyreonWorkspaceDirCache = new Map<string, boolean>()
217
+
140
218
  return {
141
219
  name: 'pyreon',
142
220
  enforce: 'pre',
@@ -150,7 +228,13 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
150
228
  // they resolve to workspace packages via our resolveId hook, not node_modules.
151
229
  const optimizeDepsExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
152
230
 
153
- const jsxSource = compat ? COMPAT_JSX_SOURCE[compat] : '@pyreon/core'
231
+ // Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
232
+ // we redirect `@pyreon/core/jsx-runtime` imports to the compat package
233
+ // VIA `resolveId` — but ONLY for user code, never for `@pyreon/*`
234
+ // workspace-package files (zero, router, runtime-dom, etc.). Setting
235
+ // OXC's importSource directly to the compat package would force the
236
+ // compat runtime on framework internals too, which they cannot handle.
237
+ const jsxSource = '@pyreon/core'
154
238
 
155
239
  return {
156
240
  // Use "bun" condition for workspace resolution — source .ts/.tsx files
@@ -180,9 +264,34 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
180
264
  }
181
265
  },
182
266
 
267
+ // ── Pre-scan all source files for signal exports ──────────────────────
268
+ async buildStart() {
269
+ // Pre-scan all source files for signal exports so the registry
270
+ // is complete before any transforms run. This solves the build
271
+ // ordering problem where component.tsx is transformed before
272
+ // store.ts — without pre-scanning, the registry would be empty.
273
+ await prescanSignalExports(projectRoot, signalExportRegistry)
274
+ },
275
+
183
276
  // ── Virtual module + compat alias resolution ─────────────────────────────
184
277
  async resolveId(id, importer) {
185
278
  if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
279
+
280
+ // `@pyreon/core/jsx-runtime` resolves to the compat package only for
281
+ // user code — never for `@pyreon/*` framework files (zero, router,
282
+ // runtime-dom, etc.). Without this importer guard, every JSX file in
283
+ // the build (including framework internals resolved via the `bun`
284
+ // workspace condition) would get redirected to a compat runtime that
285
+ // doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
286
+ if (
287
+ compat &&
288
+ (id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
289
+ importer &&
290
+ isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
291
+ ) {
292
+ return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
293
+ }
294
+
186
295
  const target = getCompatTarget(compat, id)
187
296
  if (!target) return
188
297
 
@@ -198,7 +307,7 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
198
307
  }
199
308
  },
200
309
 
201
- transform(code, id, transformOptions) {
310
+ async transform(code, id, transformOptions) {
202
311
  const ext = getExt(id)
203
312
  if (ext !== '.tsx' && ext !== '.jsx' && ext !== '.pyreon') return
204
313
 
@@ -213,11 +322,22 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
213
322
  return
214
323
  }
215
324
 
325
+ // ── Scan for exported signal declarations (populate registry) ──────
326
+ // This runs on every .tsx/.jsx file so the registry is built
327
+ // incrementally. buildStart pre-scans all files, but this handles
328
+ // files created/modified after buildStart (dev mode HMR).
329
+ scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
330
+
331
+ // ── Resolve imported signals from the registry ─────────────────────
332
+ // Check each import in this file: if the imported module has signal
333
+ // exports in the registry, pass them as knownSignals to the compiler.
334
+ const knownSignals = await resolveImportedSignals(code, id, signalExportRegistry, this, resolveCache)
335
+
216
336
  // Vite passes `ssr: true` when transforming for the SSR module graph
217
337
  // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
218
338
  // `h()` calls in that mode so `runtime-server` can render to a string.
219
339
  const isSsr = transformOptions?.ssr === true
220
- const result = transformJSX(code, id, { ssr: isSsr })
340
+ const result = transformJSX(code, id, { ssr: isSsr, knownSignals })
221
341
  // Surface compiler warnings in the terminal
222
342
  for (const w of result.warnings) {
223
343
  this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
@@ -333,8 +453,19 @@ function generateProjectContext(root: string): void {
333
453
  * The arguments are extracted via balanced-paren matching in `injectHmr`.
334
454
  * A brace-depth check filters out matches inside functions/blocks — only
335
455
  * module-scope (depth 0) signals are rewritten for HMR state preservation.
456
+ *
457
+ * The optional `<...>` group accepts a TypeScript type parameter so that
458
+ * `signal<T>(initial)` declarations are also rewritten — without it, any
459
+ * generic-typed module-scope signal silently skipped HMR preservation.
460
+ *
461
+ * The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
462
+ * (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
463
+ * Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
464
+ * not-rewritten — tracked as a follow-up if real consumers need it,
465
+ * but unlikely at module scope where generics are usually shallow.
336
466
  */
337
- const SIGNAL_PREFIX_RE = /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal\(/gm
467
+ const SIGNAL_PREFIX_RE =
468
+ /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
338
469
 
339
470
  /**
340
471
  * Detect whether the module exports any component-like functions
@@ -552,6 +683,211 @@ function isAssetRequest(url: string): boolean {
552
683
  // Inlined here so it's available without a filesystem read. This is the
553
684
  // compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
554
685
 
686
+ // ─── Cross-module signal auto-call helpers ──────────────────────────────────
687
+
688
+ /**
689
+ * Normalize a Vite module ID by stripping query strings (?v=..., ?t=...)
690
+ * and resolving to an absolute path for consistent registry lookups.
691
+ */
692
+ function normalizeModuleId(id: string): string {
693
+ const queryIndex = id.indexOf('?')
694
+ return queryIndex >= 0 ? id.slice(0, queryIndex) : id
695
+ }
696
+
697
+ /**
698
+ * Pre-scan all source files in the project for signal exports.
699
+ *
700
+ * Called from `buildStart` so the registry is fully populated before any
701
+ * transforms run. This solves the build ordering problem where component.tsx
702
+ * is transformed before store.ts — without pre-scanning, the registry would
703
+ * be empty and imported signals would not be auto-called.
704
+ */
705
+ async function prescanSignalExports(root: string, registry: Map<string, Set<string>>): Promise<void> {
706
+ const files: string[] = []
707
+
708
+ function walk(dir: string) {
709
+ try {
710
+ for (const entry of readdirSync(dir)) {
711
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'lib' || entry === 'build') continue
712
+ const full = pathJoin(dir, entry)
713
+ try {
714
+ const stat = statSync(full)
715
+ if (stat.isDirectory()) walk(full)
716
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
717
+ } catch {
718
+ /* permission error, etc. */
719
+ }
720
+ }
721
+ } catch {
722
+ /* dir doesn't exist */
723
+ }
724
+ }
725
+
726
+ walk(root)
727
+
728
+ for (const file of files) {
729
+ try {
730
+ const code = readFileSync(file, 'utf-8')
731
+ scanSignalExports(code, file, registry)
732
+ } catch {
733
+ /* read error */
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Scan a module's source for exported signal declarations and register them.
740
+ *
741
+ * Detects patterns:
742
+ * 1. `export const x = signal(...)` or `export const x = computed(...)` — inline export
743
+ * 2. `const x = signal(...); export { x }` — separate declaration + named export
744
+ * 3. `export default signal(...)` — default export (tracked as 'default')
745
+ *
746
+ * Re-exports (`export { x } from './signals'`) are NOT detected — the source
747
+ * module must be scanned directly. This is a known limitation.
748
+ *
749
+ * Uses simple regex — no AST parse needed.
750
+ */
751
+ function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
752
+ const normalizedId = normalizeModuleId(moduleId)
753
+ let match: RegExpExecArray | null
754
+ const signals = new Set<string>()
755
+
756
+ // Pattern 1: export const x = signal(...) or export const x = computed(...)
757
+ const EXPORT_CONST_RE = /export\s+const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/g
758
+ while ((match = EXPORT_CONST_RE.exec(code)) !== null) {
759
+ signals.add(match[1]!)
760
+ }
761
+
762
+ // Pattern 2: const x = signal(...) followed by export { x }
763
+ // First, find all local `const x = signal(` or `const x = computed(` declarations
764
+ const localSignals = new Set<string>()
765
+ const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm
766
+ while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) {
767
+ localSignals.add(match[1]!)
768
+ }
769
+
770
+ // Then check named exports: export { x, y as z }
771
+ if (localSignals.size > 0) {
772
+ const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g
773
+ while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
774
+ // Skip re-exports (export { x } from '...')
775
+ const afterBrace = code.slice(match.index + match[0].length).trimStart()
776
+ if (afterBrace.startsWith('from')) continue
777
+
778
+ for (const spec of match[1]!.split(',')) {
779
+ const trimmed = spec.trim()
780
+ if (!trimmed) continue
781
+ const parts = trimmed.split(/\s+as\s+/)
782
+ const localName = parts[0]!.trim()
783
+ const exportedName = (parts[1] ?? parts[0])!.trim()
784
+ if (localSignals.has(localName)) {
785
+ signals.add(exportedName)
786
+ }
787
+ }
788
+ }
789
+ }
790
+
791
+ // Pattern 3: export default signal(...) or export default computed(...) — tracked as 'default'
792
+ if (/export\s+default\s+(?:signal|computed)\s*[<(]/.test(code)) {
793
+ signals.add('default')
794
+ }
795
+
796
+ if (signals.size > 0) {
797
+ registry.set(normalizedId, signals)
798
+ } else {
799
+ // Clean up if module no longer exports signals (e.g. after edit)
800
+ registry.delete(normalizedId)
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Resolve imported signal names from the signal export registry.
806
+ *
807
+ * For each import in the source, resolves the module and checks if it has
808
+ * signal exports in the registry. Returns the local names of imported signals.
809
+ *
810
+ * Handles named imports (`import { x } from ...`) and default imports
811
+ * (`import x from ...` — matched against 'default' in the registry).
812
+ */
813
+ async function resolveImportedSignals(
814
+ code: string,
815
+ _moduleId: string,
816
+ registry: Map<string, Set<string>>,
817
+ pluginCtx: { resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
818
+ resolveCache: Map<string, string | null>,
819
+ ): Promise<string[]> {
820
+ if (registry.size === 0) return []
821
+
822
+ const knownSignals: string[] = []
823
+ let match: RegExpExecArray | null
824
+
825
+ /** Resolve a source specifier to a normalized module ID, using the cache. */
826
+ async function resolveSource(source: string): Promise<string | null> {
827
+ const cacheKey = `${_moduleId}::${source}`
828
+ if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null
829
+ let resolvedId: string | null = null
830
+ try {
831
+ const resolved = await pluginCtx.resolve(source, _moduleId, { skipSelf: true })
832
+ resolvedId = resolved?.id ? normalizeModuleId(resolved.id) : null
833
+ } catch {
834
+ /* resolve error */
835
+ }
836
+ resolveCache.set(cacheKey, resolvedId)
837
+ return resolvedId
838
+ }
839
+
840
+ // Named imports: import { name1, name2 as alias } from 'source'
841
+ // Excludes `import type { ... }` — type-only imports have no runtime value
842
+ const IMPORT_RE = /import\s+(?!type\s)\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
843
+ while ((match = IMPORT_RE.exec(code)) !== null) {
844
+ const specifiers = match[1]!
845
+ const source = match[2]!
846
+
847
+ const resolvedId = await resolveSource(source)
848
+ if (!resolvedId) continue
849
+ const exportedSignals = registry.get(resolvedId)
850
+ if (!exportedSignals) continue
851
+
852
+ // Parse import specifiers: "count, theme as t, other"
853
+ for (const spec of specifiers.split(',')) {
854
+ const trimmed = spec.trim()
855
+ if (!trimmed) continue
856
+
857
+ const parts = trimmed.split(/\s+as\s+/)
858
+ const importedName = parts[0]!.trim()
859
+ const localName = (parts[1] ?? parts[0])!.trim()
860
+
861
+ if (exportedSignals.has(importedName)) {
862
+ knownSignals.push(localName)
863
+ }
864
+ }
865
+ }
866
+
867
+ // Default imports: import count from './store'
868
+ // Excludes: `import { ... }`, `import type X`, `import * as X`
869
+ const DEFAULT_IMPORT_RE = /import\s+(?!type\s)(\w+)\s+from\s*['"]([^'"]+)['"]/g
870
+ while ((match = DEFAULT_IMPORT_RE.exec(code)) !== null) {
871
+ // Skip if this is actually a `import type X from` pattern
872
+ const fullMatch = match[0]
873
+ if (/import\s+type\s+/.test(fullMatch)) continue
874
+
875
+ const localName = match[1]!
876
+ const source = match[2]!
877
+
878
+ const resolvedId = await resolveSource(source)
879
+ if (!resolvedId) continue
880
+ const exportedSignals = registry.get(resolvedId)
881
+ if (!exportedSignals) continue
882
+
883
+ if (exportedSignals.has('default')) {
884
+ knownSignals.push(localName)
885
+ }
886
+ }
887
+
888
+ return knownSignals
889
+ }
890
+
555
891
  const HMR_RUNTIME_SOURCE = `
556
892
  const REGISTRY_KEY = "__pyreon_hmr_registry__";
557
893
 
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Compat-mode `resolveId` and `getCompatTarget` coverage for
3
+ * @pyreon/vite-plugin (PR #323). The existing test file covers
4
+ * compat-mode `transform` short-circuiting; this covers the
5
+ * resolveId hook + the JSX-runtime aliasing branch.
6
+ */
7
+
8
+ import { resolve } from 'node:path'
9
+ import { describe, expect, it } from 'vitest'
10
+ import pyreonPlugin, { type PyreonPluginOptions } from '../index'
11
+
12
+ type ConfigHook = (
13
+ userConfig: Record<string, unknown>,
14
+ env: { command: string; isSsrBuild?: boolean },
15
+ ) => Record<string, unknown>
16
+
17
+ type ResolveIdCtx = {
18
+ resolve: (
19
+ id: string,
20
+ importer?: string,
21
+ options?: { skipSelf: boolean },
22
+ ) => Promise<{ id: string } | null>
23
+ }
24
+ type ResolveIdHook = (
25
+ this: ResolveIdCtx,
26
+ id: string,
27
+ importer?: string,
28
+ ) => Promise<string | undefined>
29
+
30
+ function bootstrap(opts?: PyreonPluginOptions) {
31
+ const plugin = pyreonPlugin(opts)
32
+ ;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
33
+ return plugin
34
+ }
35
+
36
+ async function callResolveId(
37
+ plugin: ReturnType<typeof pyreonPlugin>,
38
+ id: string,
39
+ resolveMap: Record<string, string> = {},
40
+ importer?: string,
41
+ ): Promise<string | undefined> {
42
+ const hook = plugin.resolveId as ResolveIdHook
43
+ return hook.call(
44
+ {
45
+ resolve: async (specifier: string) => {
46
+ const resolved = resolveMap[specifier]
47
+ return resolved ? { id: resolved } : null
48
+ },
49
+ },
50
+ id,
51
+ importer,
52
+ )
53
+ }
54
+
55
+ describe('compat-mode resolveId — react', () => {
56
+ it('redirects "react" → @pyreon/react-compat', async () => {
57
+ const plugin = bootstrap({ compat: 'react' })
58
+ const resolved = await callResolveId(plugin, 'react', {
59
+ '@pyreon/react-compat': '/abs/react-compat/index.ts',
60
+ })
61
+ expect(resolved).toBe('/abs/react-compat/index.ts')
62
+ })
63
+
64
+ it('redirects "react/jsx-runtime" → @pyreon/react-compat/jsx-runtime', async () => {
65
+ const plugin = bootstrap({ compat: 'react' })
66
+ const resolved = await callResolveId(plugin, 'react/jsx-runtime', {
67
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
68
+ })
69
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
70
+ })
71
+
72
+ it('redirects @pyreon/core/jsx-runtime → @pyreon/react-compat/jsx-runtime in react compat', async () => {
73
+ const plugin = bootstrap({ compat: 'react' })
74
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
75
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
76
+ })
77
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
78
+ })
79
+
80
+ it('redirects @pyreon/core/jsx-dev-runtime in react compat', async () => {
81
+ const plugin = bootstrap({ compat: 'react' })
82
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-dev-runtime', {
83
+ '@pyreon/react-compat/jsx-runtime': '/abs/react-compat/jsx-runtime.ts',
84
+ })
85
+ expect(resolved).toBe('/abs/react-compat/jsx-runtime.ts')
86
+ })
87
+
88
+ it('returns undefined for non-aliased imports', async () => {
89
+ const plugin = bootstrap({ compat: 'react' })
90
+ const resolved = await callResolveId(plugin, 'lodash', {})
91
+ expect(resolved).toBeUndefined()
92
+ })
93
+ })
94
+
95
+ describe('compat-mode resolveId — preact', () => {
96
+ it('redirects "preact" → @pyreon/preact-compat', async () => {
97
+ const plugin = bootstrap({ compat: 'preact' })
98
+ const resolved = await callResolveId(plugin, 'preact', {
99
+ '@pyreon/preact-compat': '/abs/preact-compat/index.ts',
100
+ })
101
+ expect(resolved).toBe('/abs/preact-compat/index.ts')
102
+ })
103
+
104
+ it('redirects "preact/hooks" → @pyreon/preact-compat/hooks', async () => {
105
+ const plugin = bootstrap({ compat: 'preact' })
106
+ const resolved = await callResolveId(plugin, 'preact/hooks', {
107
+ '@pyreon/preact-compat/hooks': '/abs/preact-compat/hooks.ts',
108
+ })
109
+ expect(resolved).toBe('/abs/preact-compat/hooks.ts')
110
+ })
111
+
112
+ it('redirects @pyreon/core/jsx-runtime in preact compat', async () => {
113
+ const plugin = bootstrap({ compat: 'preact' })
114
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
115
+ '@pyreon/preact-compat/jsx-runtime': '/abs/preact-compat/jsx-runtime.ts',
116
+ })
117
+ expect(resolved).toBe('/abs/preact-compat/jsx-runtime.ts')
118
+ })
119
+
120
+ it('redirects @preact/signals → @pyreon/preact-compat/signals', async () => {
121
+ const plugin = bootstrap({ compat: 'preact' })
122
+ const resolved = await callResolveId(plugin, '@preact/signals', {
123
+ '@pyreon/preact-compat/signals': '/abs/preact-compat/signals.ts',
124
+ })
125
+ expect(resolved).toBe('/abs/preact-compat/signals.ts')
126
+ })
127
+ })
128
+
129
+ describe('compat-mode resolveId — vue', () => {
130
+ it('redirects "vue" → @pyreon/vue-compat', async () => {
131
+ const plugin = bootstrap({ compat: 'vue' })
132
+ const resolved = await callResolveId(plugin, 'vue', {
133
+ '@pyreon/vue-compat': '/abs/vue-compat/index.ts',
134
+ })
135
+ expect(resolved).toBe('/abs/vue-compat/index.ts')
136
+ })
137
+
138
+ it('redirects @pyreon/core/jsx-runtime in vue compat', async () => {
139
+ const plugin = bootstrap({ compat: 'vue' })
140
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
141
+ '@pyreon/vue-compat/jsx-runtime': '/abs/vue-compat/jsx-runtime.ts',
142
+ })
143
+ expect(resolved).toBe('/abs/vue-compat/jsx-runtime.ts')
144
+ })
145
+ })
146
+
147
+ describe('compat-mode resolveId — solid', () => {
148
+ it('redirects "solid-js" → @pyreon/solid-compat', async () => {
149
+ const plugin = bootstrap({ compat: 'solid' })
150
+ const resolved = await callResolveId(plugin, 'solid-js', {
151
+ '@pyreon/solid-compat': '/abs/solid-compat/index.ts',
152
+ })
153
+ expect(resolved).toBe('/abs/solid-compat/index.ts')
154
+ })
155
+
156
+ it('redirects @pyreon/core/jsx-runtime in solid compat', async () => {
157
+ const plugin = bootstrap({ compat: 'solid' })
158
+ const resolved = await callResolveId(plugin, '@pyreon/core/jsx-runtime', {
159
+ '@pyreon/solid-compat/jsx-runtime': '/abs/solid-compat/jsx-runtime.ts',
160
+ })
161
+ expect(resolved).toBe('/abs/solid-compat/jsx-runtime.ts')
162
+ })
163
+ })
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
+
244
+ describe('compat-mode resolveId — no compat', () => {
245
+ it('returns undefined for any framework alias when compat is unset', async () => {
246
+ const plugin = bootstrap()
247
+ expect(await callResolveId(plugin, 'react', {})).toBeUndefined()
248
+ expect(await callResolveId(plugin, 'vue', {})).toBeUndefined()
249
+ expect(await callResolveId(plugin, 'preact', {})).toBeUndefined()
250
+ expect(await callResolveId(plugin, 'solid-js', {})).toBeUndefined()
251
+ })
252
+
253
+ it('still resolves the HMR runtime virtual id (independent of compat)', async () => {
254
+ const plugin = bootstrap()
255
+ const resolved = await callResolveId(plugin, 'virtual:pyreon/hmr-runtime', {})
256
+ // Internal ID — has the leading '\0' marker convention or similar
257
+ expect(resolved).toBeDefined()
258
+ expect(typeof resolved).toBe('string')
259
+ })
260
+ })