@pyreon/vite-plugin 0.24.5 → 0.25.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.
@@ -1,199 +0,0 @@
1
- /**
2
- * P0 — build-time rocketstyle-collapse resolver.
3
- *
4
- * For a collapsible call site (`<Button state="primary" size="md">Save</Button>`
5
- * — every dimension prop a string literal, children static text) this
6
- * resolves the FULL rocketstyle/styler pipeline ONCE by SSR-rendering the
7
- * REAL component, light AND dark, and returns: the resolved styler class
8
- * per mode, the styler rule text, and a class-stripped `_tpl` template.
9
- *
10
- * The render runs through a programmatic Vite SSR server bound to the
11
- * CONSUMER's own `vite.config` — so module resolution is identical to
12
- * the app's real build (workspace `bun` condition, app aliases,
13
- * app-local relative imports, whatever). Parity with the runtime-mounted
14
- * class is then guaranteed BY CONSTRUCTION: it is literally the same
15
- * `renderToString` + `@pyreon/styler` code path the client uses, and
16
- * styler's FNV-1a class hashing is identical in SSR and DOM (styler's
17
- * hydration contract). No reimplementation, no closure re-execution, no
18
- * drift (RFC decision 2).
19
- *
20
- * Every failure returns `null` (graceful bail → the call site keeps its
21
- * normal rocketstyle mount). Correct-but-slow is acceptable; wrong
22
- * output is not.
23
- */
24
- import type { InlineConfig, ViteDevServer } from 'vite'
25
-
26
- // Inline FNV-1a (same algorithm as @pyreon/styler/hash) — avoids pulling
27
- // the styler module graph into the vite-plugin's cheap entry path.
28
- function fnv1a(str: string): string {
29
- let h = 2166136261
30
- for (let i = 0; i < str.length; i++) {
31
- h ^= str.charCodeAt(i)
32
- h = Math.imul(h, 16777619)
33
- }
34
- return (h >>> 0).toString(36)
35
- }
36
-
37
- export interface CollapseImportSpec {
38
- /** Imported binding name, e.g. `PyreonUI` / `theme` / `useMode`. */
39
- name: string
40
- /** Module specifier, e.g. `@pyreon/ui-core` / `@pyreon/ui-theme`. */
41
- source: string
42
- }
43
-
44
- export interface CollapseConfig {
45
- /** Theme/mode provider component. Default: PyreonUI from @pyreon/ui-core. */
46
- provider: CollapseImportSpec
47
- /** Theme object. Default: theme from @pyreon/ui-theme. */
48
- theme: CollapseImportSpec
49
- /** Live mode accessor — emitted into the collapsed site for dual-emit. */
50
- mode: CollapseImportSpec
51
- }
52
-
53
- export const DEFAULT_COLLAPSE_CONFIG: CollapseConfig = {
54
- provider: { name: 'PyreonUI', source: '@pyreon/ui-core' },
55
- theme: { name: 'theme', source: '@pyreon/ui-theme' },
56
- mode: { name: 'useMode', source: '@pyreon/ui-core' },
57
- }
58
-
59
- export interface ResolveInput {
60
- /** The collapsible component's import. */
61
- component: CollapseImportSpec
62
- /** Literal dimension/HTML props, e.g. `{ state: 'primary', size: 'md' }`. */
63
- props: Record<string, string>
64
- /** Static text children (empty ⇒ no children). */
65
- childrenText: string
66
- config: CollapseConfig
67
- }
68
-
69
- export interface ResolvedCollapse {
70
- /** Element HTML with the root `class="..."` removed (the `_tpl` template). */
71
- templateHtml: string
72
- lightClass: string
73
- darkClass: string
74
- /** Pre-resolved styler rule text (full snapshot) for `injectRules`. */
75
- rules: string[]
76
- /** FNV over the rule set — `injectRules` idempotency + cross-site dedupe. */
77
- key: string
78
- }
79
-
80
- const FIRST_CLASS_RE = /^(\s*<[a-zA-Z][\w-]*)([^>]*?)\sclass="([^"]*)"([^>]*>)/
81
-
82
- /** Strip the FIRST element's `class="..."`, returning [stripped, class]. */
83
- export function stripRootClass(html: string): { stripped: string; cls: string } | null {
84
- const m = FIRST_CLASS_RE.exec(html)
85
- if (!m) return null
86
- const stripped = html.replace(FIRST_CLASS_RE, '$1$2$4')
87
- return { stripped, cls: m[3] ?? '' }
88
- }
89
-
90
- /**
91
- * Pure extraction half — given the two rendered HTML strings and the
92
- * styler rule snapshot, derive the ResolvedCollapse (or null on a shape
93
- * the slice doesn't collapse). Separated for direct unit-testing without
94
- * spinning Vite.
95
- */
96
- export function deriveCollapse(
97
- lightHtml: string,
98
- darkHtml: string,
99
- rules: string[],
100
- ): ResolvedCollapse | null {
101
- const light = stripRootClass(lightHtml)
102
- const dark = stripRootClass(darkHtml)
103
- if (!light || !dark || !light.cls || !dark.cls) return null
104
- // The structural template must be identical between modes (only the
105
- // class differs). Divergent markup ⇒ not a simple single-root
106
- // collapsible — bail.
107
- if (light.stripped !== dark.stripped) return null
108
- return {
109
- templateHtml: light.stripped.trim(),
110
- lightClass: light.cls,
111
- darkClass: dark.cls,
112
- rules,
113
- key: fnv1a(rules.join('\u0000')),
114
- }
115
- }
116
-
117
- export interface CollapseResolver {
118
- resolve(input: ResolveInput): Promise<ResolvedCollapse | null>
119
- dispose(): Promise<void>
120
- }
121
-
122
- /**
123
- * Create a resolver backed by ONE programmatic Vite SSR server bound to
124
- * `projectRoot`'s vite config. Reused across every call site in a build;
125
- * `dispose()` at buildEnd. Module loads are cached by Vite's own SSR
126
- * module graph (provider/theme/component import once).
127
- */
128
- export async function createCollapseResolver(projectRoot: string): Promise<CollapseResolver> {
129
- const { createServer } = (await import('vite')) as typeof import('vite')
130
- const inline: InlineConfig = {
131
- // No `configFile` override — Vite auto-loads the project's own
132
- // vite.config from `root`, so module resolution (workspace `bun`
133
- // condition, app aliases) matches the real build exactly.
134
- root: projectRoot,
135
- server: { middlewareMode: true },
136
- appType: 'custom',
137
- logLevel: 'silent',
138
- optimizeDeps: { noDiscovery: true, include: [] },
139
- }
140
- let server: ViteDevServer | null = await createServer(inline)
141
-
142
- // Resolved-bundle cache — identical input must hit the same result
143
- // without a second double-render (deterministic by construction).
144
- const cache = new Map<string, ResolvedCollapse | null>()
145
-
146
- async function load(spec: string): Promise<Record<string, unknown>> {
147
- return (await server!.ssrLoadModule(spec)) as Record<string, unknown>
148
- }
149
-
150
- return {
151
- async resolve(input) {
152
- const ck = JSON.stringify([
153
- input.component,
154
- input.props,
155
- input.childrenText,
156
- input.config.provider,
157
- input.config.theme,
158
- ])
159
- if (cache.has(ck)) return cache.get(ck) ?? null
160
- try {
161
- if (!server) return null
162
- const rs = await load('@pyreon/runtime-server')
163
- const core = await load('@pyreon/core')
164
- const styler = await load('@pyreon/styler')
165
- const prov = await load(input.config.provider.source)
166
- const thm = await load(input.config.theme.source)
167
- const comp = await load(input.component.source)
168
- const renderToString = rs.renderToString as (n: unknown) => Promise<string>
169
- const h = core.h as (t: unknown, p: unknown, ...c: unknown[]) => unknown
170
- const Provider = prov[input.config.provider.name]
171
- const themeVal = thm[input.config.theme.name]
172
- const Component = comp[input.component.name]
173
- const sheet = styler.sheet as { getStyleRules(): readonly string[] }
174
- if (typeof Component !== 'function' || Provider == null || themeVal == null) {
175
- cache.set(ck, null)
176
- return null
177
- }
178
- const childArgs = input.childrenText ? [input.childrenText] : []
179
- const node = (mode: string) =>
180
- h(Provider, { theme: themeVal, mode }, h(Component, input.props, ...childArgs))
181
- const lightHtml = await renderToString(node('light'))
182
- const darkHtml = await renderToString(node('dark'))
183
- const rules = sheet.getStyleRules().slice()
184
- const result = deriveCollapse(lightHtml, darkHtml, rules)
185
- cache.set(ck, result)
186
- return result
187
- } catch {
188
- cache.set(ck, null)
189
- return null
190
- }
191
- },
192
- async dispose() {
193
- const s = server
194
- server = null
195
- cache.clear()
196
- if (s) await s.close()
197
- },
198
- }
199
- }
@@ -1,187 +0,0 @@
1
- /**
2
- * REPRODUCTION + REGRESSION — `signalExportRegistry`, `resolveCache`,
3
- * and `islandRegistry` accumulated stale entries for the lifetime of
4
- * a `vite dev` session. Vite's `watchChange` hook fires on filesystem
5
- * `'create' | 'update' | 'delete'` events; pre-fix none of the four
6
- * per-instance caches subscribed, so deleting / renaming a source
7
- * file left orphaned entries forever.
8
- *
9
- * Bounded by total source-tree size in practice, but a real Class C
10
- * leak over hours of editing on a large project — every source file
11
- * the developer touches that later gets deleted leaves one entry per
12
- * cache stuck until process exit.
13
- */
14
- import { describe, expect, it } from 'vitest'
15
- import type { PyreonPluginOptions } from '../index'
16
- import pyreonPlugin from '../index'
17
-
18
- type ConfigHook = (
19
- userConfig: Record<string, unknown>,
20
- env: { command: string; isSsrBuild?: boolean },
21
- ) => Record<string, unknown>
22
-
23
- function createServePlugin(opts?: PyreonPluginOptions) {
24
- const plugin = pyreonPlugin(opts)
25
- ;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
26
- return plugin
27
- }
28
-
29
- interface PluginInternalShape {
30
- buildStart: () => Promise<void> | void
31
- transform: (
32
- this: {
33
- warn: (msg: string) => void
34
- resolve: (
35
- id: string,
36
- importer?: string,
37
- opts?: { skipSelf: boolean },
38
- ) => Promise<{ id: string } | null>
39
- },
40
- code: string,
41
- id: string,
42
- ) => Promise<{ code: string; map: null } | undefined>
43
- watchChange: (id: string, change: { event: 'create' | 'update' | 'delete' }) => void
44
- }
45
-
46
- interface PluginCaches {
47
- signalExportRegistry: Map<string, Set<string>>
48
- resolveCache: Map<string, string | null>
49
- pyreonWorkspaceDirCache: Map<string, boolean>
50
- islandRegistry: Map<string, unknown[]>
51
- }
52
-
53
- const CACHES_SYMBOL = Symbol.for('pyreon/vite-plugin:caches')
54
-
55
- function getCaches(plugin: ReturnType<typeof pyreonPlugin>): PluginCaches {
56
- const caches = (plugin as unknown as Record<symbol, PluginCaches | undefined>)[CACHES_SYMBOL]
57
- if (!caches) throw new Error('plugin should expose CACHES_SYMBOL')
58
- return caches
59
- }
60
-
61
- async function transform(
62
- plugin: ReturnType<typeof pyreonPlugin>,
63
- code: string,
64
- id: string,
65
- ): Promise<void> {
66
- const p = plugin as unknown as PluginInternalShape
67
- await p.transform.call(
68
- {
69
- warn: () => {},
70
- resolve: async (specifier: string, importer?: string) => {
71
- // Simulate resolution — bare relative imports map to virtual
72
- // paths so resolveCache gets real entries.
73
- if (specifier.startsWith('./') && importer) {
74
- return { id: `/test-project/${specifier.slice(2)}.ts` }
75
- }
76
- return null
77
- },
78
- },
79
- code,
80
- id,
81
- )
82
- }
83
-
84
- describe('@pyreon/vite-plugin — file-delete cache eviction (watchChange)', () => {
85
- it('REGRESSION: signalExportRegistry entry is evicted on file delete', async () => {
86
- const plugin = createServePlugin()
87
- const p = plugin as unknown as PluginInternalShape
88
- const caches = getCaches(plugin)
89
-
90
- // Transform a source file that exports a top-level signal. The
91
- // plugin's incremental scanner populates the registry.
92
- await transform(plugin, `export const count = signal(0)`, '/test-project/store.tsx')
93
- expect(caches.signalExportRegistry.has('/test-project/store.tsx')).toBe(true)
94
-
95
- // Fire the delete event. The critical assertion: the registry
96
- // entry is GONE post-delete. Pre-fix (no watchChange hook), the
97
- // entry would persist forever.
98
- p.watchChange('/test-project/store.tsx', { event: 'delete' })
99
- expect(caches.signalExportRegistry.has('/test-project/store.tsx')).toBe(false)
100
- })
101
-
102
- it('REGRESSION: resolveCache entries pointing at the deleted file are evicted', async () => {
103
- const plugin = createServePlugin()
104
- const p = plugin as unknown as PluginInternalShape
105
- const caches = getCaches(plugin)
106
-
107
- // Populate signalExportRegistry first.
108
- await transform(plugin, `export const a = signal(0)`, '/test-project/a.tsx')
109
- // Consumer file imports a.ts — populates resolveCache.
110
- await transform(
111
- plugin,
112
- `import { a } from './a'\nexport default () => a`,
113
- '/test-project/consumer.tsx',
114
- )
115
-
116
- const beforeSize = caches.resolveCache.size
117
- expect(beforeSize).toBeGreaterThan(0)
118
-
119
- // Delete `a.ts`. Both the importer-keyed entry AND any entry
120
- // whose VALUE is `/test-project/a.tsx` should evict.
121
- p.watchChange('/test-project/a.tsx', { event: 'delete' })
122
-
123
- // Critical: no entry in resolveCache references the deleted file.
124
- for (const [key, value] of caches.resolveCache) {
125
- expect(key.startsWith('/test-project/a.tsx::')).toBe(false)
126
- expect(value).not.toBe('/test-project/a.tsx')
127
- }
128
- })
129
-
130
- it('REGRESSION: islandRegistry entry is evicted on file delete', async () => {
131
- const plugin = createServePlugin({ islands: true })
132
- const p = plugin as unknown as PluginInternalShape
133
- const caches = getCaches(plugin)
134
-
135
- // Populate the island registry. Use a minimal island declaration.
136
- await transform(
137
- plugin,
138
- `import { island } from '@pyreon/server'\nexport const C = island(() => import('./c'), { name: 'C' })`,
139
- '/test-project/c-island.tsx',
140
- )
141
-
142
- // Either the absolute id or its normalized form may have landed
143
- // in the registry — assert at least one is there.
144
- const hasEntry
145
- = caches.islandRegistry.has('/test-project/c-island.tsx')
146
- || [...caches.islandRegistry.keys()].some((k) => k.includes('c-island'))
147
-
148
- if (hasEntry) {
149
- p.watchChange('/test-project/c-island.tsx', { event: 'delete' })
150
- // Post-delete the registry should NOT have the entry.
151
- expect(caches.islandRegistry.has('/test-project/c-island.tsx')).toBe(false)
152
- } else {
153
- // If the scanner didn't pick up the island (test fixture too
154
- // minimal), the watchChange call must still be a no-op without
155
- // throwing — verifies the defensive path.
156
- expect(() =>
157
- p.watchChange('/test-project/c-island.tsx', { event: 'delete' }),
158
- ).not.toThrow()
159
- }
160
- })
161
-
162
- it('REGRESSION: watchChange ignores create/update events (handled by transform)', async () => {
163
- const plugin = createServePlugin()
164
- const p = plugin as unknown as PluginInternalShape
165
- const caches = getCaches(plugin)
166
-
167
- // Populate then update — update should NOT evict.
168
- await transform(plugin, `export const v = signal(0)`, '/test-project/v.tsx')
169
- expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
170
- p.watchChange('/test-project/v.tsx', { event: 'create' })
171
- expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
172
- p.watchChange('/test-project/v.tsx', { event: 'update' })
173
- expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(true)
174
-
175
- // Only delete evicts.
176
- p.watchChange('/test-project/v.tsx', { event: 'delete' })
177
- expect(caches.signalExportRegistry.has('/test-project/v.tsx')).toBe(false)
178
- })
179
-
180
- it('REGRESSION: deleting an untracked file is a safe no-op', () => {
181
- const plugin = createServePlugin()
182
- const p = plugin as unknown as PluginInternalShape
183
- expect(() =>
184
- p.watchChange('/test-project/never-tracked.tsx', { event: 'delete' }),
185
- ).not.toThrow()
186
- })
187
- })
@@ -1,260 +0,0 @@
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
- })