@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +61 -5
- package/lib/{rocketstyle-collapse-C4eMAnwR.js → rocketstyle-collapse-DGnwgDhC.js} +30 -4
- package/lib/types/index.d.ts +14 -1
- package/package.json +3 -4
- package/src/hmr-runtime.ts +0 -92
- package/src/index.ts +0 -2116
- package/src/rocketstyle-collapse.ts +0 -199
- package/src/tests/cache-eviction-on-delete.test.ts +0 -187
- package/src/tests/compat-resolve.test.ts +0 -260
- package/src/tests/cross-module-signals.test.ts +0 -425
- package/src/tests/dev-server.test.ts +0 -171
- package/src/tests/islands-registry.test.ts +0 -236
- package/src/tests/lpih-auto-bridge.test.ts +0 -408
- package/src/tests/lpih-injection.test.ts +0 -559
- package/src/tests/rocketstyle-collapse-dev.test.ts +0 -119
- package/src/tests/rocketstyle-collapse.test.ts +0 -352
- package/src/tests/ssr-no-external.test.ts +0 -82
- package/src/tests/vite-plugin.test.ts +0 -503
|
@@ -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
|
-
})
|