@pyreon/vite-plugin 0.24.5 → 0.24.6

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,352 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { dirname, join, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { afterAll, beforeAll, describe, expect, it } from 'vitest'
5
- import pyreon from '../index'
6
- import {
7
- type CollapseResolver,
8
- createCollapseResolver,
9
- DEFAULT_COLLAPSE_CONFIG,
10
- deriveCollapse,
11
- } from '../rocketstyle-collapse'
12
-
13
- // Layer 3: the build-time resolver SSR-renders the REAL
14
- // @pyreon/ui-components Button (light + dark) through a programmatic
15
- // Vite SSR bound to examples/ui-showcase's own config, and derives
16
- // resolved classes + styler rule text + a class-stripped template.
17
- // Byte-for-byte parity vs the actual runtime-mounted class is asserted
18
- // at the e2e layer (Phase 4); here we prove the resolver mechanism end
19
- // to end against the real component (no mocks).
20
-
21
- function repoRoot(): string {
22
- let dir = dirname(fileURLToPath(import.meta.url))
23
- for (let i = 0; i < 12; i++) {
24
- if (existsSync(join(dir, 'examples', 'ui-showcase', 'package.json'))) return dir
25
- const parent = resolve(dir, '..')
26
- if (parent === dir) break
27
- dir = parent
28
- }
29
- throw new Error('repo root with examples/ui-showcase not found')
30
- }
31
- const UI_SHOWCASE = join(repoRoot(), 'examples', 'ui-showcase')
32
-
33
- describe('deriveCollapse (pure extraction)', () => {
34
- it('strips the root class, keeps inner markup, derives both classes', () => {
35
- const light = '<button class="A B" data-x="1"><span class="inner">Save</span></button>'
36
- const dark = '<button class="C D" data-x="1"><span class="inner">Save</span></button>'
37
- const r = deriveCollapse(light, dark, ['.A{}', '.C{}'])
38
- expect(r).not.toBeNull()
39
- expect(r?.templateHtml).toBe(
40
- '<button data-x="1"><span class="inner">Save</span></button>',
41
- )
42
- expect(r?.lightClass).toBe('A B')
43
- expect(r?.darkClass).toBe('C D')
44
- expect(r?.key).toMatch(/^[0-9a-z]+$/)
45
- })
46
-
47
- it('bails (null) when light/dark markup diverges structurally', () => {
48
- expect(
49
- deriveCollapse('<button class="A">x</button>', '<button class="B"><i>x</i></button>', []),
50
- ).toBeNull()
51
- })
52
-
53
- it('bails (null) when the root has no class', () => {
54
- expect(deriveCollapse('<button>x</button>', '<button>x</button>', [])).toBeNull()
55
- })
56
-
57
- it('light===dark class is valid (mode-invariant component) — NOT a bail', () => {
58
- const r = deriveCollapse(
59
- '<button class="same">x</button>',
60
- '<button class="same">x</button>',
61
- ['.same{}'],
62
- )
63
- expect(r).not.toBeNull()
64
- expect(r?.lightClass).toBe('same')
65
- expect(r?.darkClass).toBe('same')
66
- })
67
- })
68
-
69
- describe('createCollapseResolver — real @pyreon/ui-components Button via Vite SSR', () => {
70
- let resolver: CollapseResolver
71
- beforeAll(async () => {
72
- resolver = await createCollapseResolver(UI_SHOWCASE)
73
- }, 60_000)
74
- afterAll(async () => {
75
- await resolver?.dispose()
76
- })
77
-
78
- it('resolves a single-root <button> template + non-empty classes + rules', async () => {
79
- const r = await resolver.resolve({
80
- component: { name: 'Button', source: '@pyreon/ui-components' },
81
- props: { state: 'primary', size: 'medium' },
82
- childrenText: 'Save',
83
- config: DEFAULT_COLLAPSE_CONFIG,
84
- })
85
- expect(r).not.toBeNull()
86
- if (!r) return
87
- expect(r.templateHtml.startsWith('<button')).toBe(true)
88
- expect(r.templateHtml).toContain('Save')
89
- // root class stripped (applied reactively); inner span class stays baked
90
- expect(/^<button[^>]*\sclass=/.test(r.templateHtml)).toBe(false)
91
- expect(r.lightClass.length).toBeGreaterThan(0)
92
- expect(r.darkClass.length).toBeGreaterThan(0)
93
- expect(r.rules.length).toBeGreaterThan(0)
94
- const joined = r.rules.join('')
95
- // The resolved root classes exist in the captured rule text.
96
- for (const cls of r.lightClass.split(/\s+/)) expect(joined).toContain(cls)
97
- for (const cls of r.darkClass.split(/\s+/)) expect(joined).toContain(cls)
98
- }, 60_000)
99
-
100
- it('is deterministic — same input ⇒ identical result (cached, no drift)', async () => {
101
- const input = {
102
- component: { name: 'Button', source: '@pyreon/ui-components' },
103
- props: { state: 'secondary', size: 'small' },
104
- childrenText: 'Go',
105
- config: DEFAULT_COLLAPSE_CONFIG,
106
- }
107
- const a = await resolver.resolve(input)
108
- const b = await resolver.resolve(input)
109
- expect(a).not.toBeNull()
110
- expect(a).toEqual(b)
111
- }, 60_000)
112
-
113
- it('returns null (graceful bail) for a non-existent component export', async () => {
114
- const r = await resolver.resolve({
115
- component: { name: 'NotARealExport', source: '@pyreon/ui-components' },
116
- props: { state: 'primary' },
117
- childrenText: 'x',
118
- config: DEFAULT_COLLAPSE_CONFIG,
119
- })
120
- expect(r).toBeNull()
121
- }, 60_000)
122
- })
123
-
124
- describe('end-to-end pipeline — real Button through resolver → scanner → compiler', () => {
125
- let resolver: CollapseResolver
126
- beforeAll(async () => {
127
- resolver = await createCollapseResolver(UI_SHOWCASE)
128
- }, 60_000)
129
- afterAll(async () => {
130
- await resolver?.dispose()
131
- })
132
-
133
- it('the emitted _rsCollapse embeds the REAL SSR-resolved class + template byte-for-byte', async () => {
134
- const { transformJSX, scanCollapsibleSites, rocketstyleCollapseKey } = await import(
135
- '@pyreon/compiler'
136
- )
137
- const src = `
138
- import { Button } from '@pyreon/ui-components'
139
- export const Save = () => <Button state="primary" size="medium">Save</Button>`
140
-
141
- // 1. plugin scan finds the site (same key the compiler will look up)
142
- const sites = scanCollapsibleSites(src, 'Save.tsx', new Set(['@pyreon/ui-components']))
143
- expect(sites).toHaveLength(1)
144
- const site = sites[0]!
145
- expect(site.key).toBe(
146
- rocketstyleCollapseKey(site.componentName, site.props, site.childrenText),
147
- )
148
-
149
- // 2. resolver SSR-renders the REAL component (light + dark)
150
- const resolved = await resolver.resolve({
151
- component: { name: site.importedName, source: site.source },
152
- props: site.props,
153
- childrenText: site.childrenText,
154
- config: DEFAULT_COLLAPSE_CONFIG,
155
- })
156
- expect(resolved).not.toBeNull()
157
- if (!resolved) return
158
-
159
- // 3. compiler emits the collapse from the resolver's real data
160
- const { code } = transformJSX(src, 'Save.tsx', {
161
- collapseRocketstyle: {
162
- candidates: new Set([site.componentName]),
163
- sites: new Map([
164
- [
165
- site.key,
166
- {
167
- templateHtml: resolved.templateHtml,
168
- lightClass: resolved.lightClass,
169
- darkClass: resolved.darkClass,
170
- rules: resolved.rules,
171
- ruleKey: resolved.key,
172
- },
173
- ],
174
- ]),
175
- mode: { name: 'useMode', source: '@pyreon/ui-core' },
176
- },
177
- })
178
-
179
- // The collapsed call carries the REAL resolved class strings + the
180
- // real class-stripped template — byte-for-byte, no reimplementation.
181
- expect(code).toContain(`${JSON.stringify(resolved.templateHtml)}`)
182
- expect(code).toContain(`${JSON.stringify(resolved.lightClass)}`)
183
- expect(code).toContain(`${JSON.stringify(resolved.darkClass)}`)
184
- expect(code).toContain('__rsCollapse(')
185
- expect(code).toContain('() => __pyrMode() === "dark"')
186
- // once-per-module idempotent rule injection with the resolver's rules
187
- expect(code).toContain('__rsSheet.injectRules(')
188
- expect(code).toContain(JSON.stringify(resolved.rules))
189
- // the 5-layer <Button> wrapper mount is gone from the client graph
190
- expect(code).not.toContain('<Button')
191
- }, 60_000)
192
- })
193
-
194
- // Layer 5: drive the collapse path through the REAL `pyreon()` plugin
195
- // hooks (config → transform → closeBundle), exercising the lazy resolver
196
- // init + the transform-hook collapse branch + dispose end-to-end. This is
197
- // the only test that runs `createCollapseResolver` *through the plugin*
198
- // rather than directly — closes the index.ts collapse-wiring coverage gap.
199
- describe('pyreon({ collapse }) plugin — transform hook drives the collapse', () => {
200
- type Ctx = {
201
- warn: (msg: string) => void
202
- info: (msg: string) => void
203
- resolve: (id: string, importer?: string, opts?: { skipSelf: boolean }) => Promise<null>
204
- }
205
- const ctx: Ctx = { warn: () => {}, info: () => {}, resolve: async () => null }
206
- const ID = join(UI_SHOWCASE, 'src', 'CollapseProbe.tsx')
207
- const SRC = `
208
- import { Button } from '@pyreon/ui-components'
209
- export const Save = () => <Button state="primary" size="medium">Save</Button>`
210
-
211
- type Plugin = ReturnType<typeof pyreon>
212
- function configure(plugin: Plugin): void {
213
- ;(plugin.config as unknown as (u: Record<string, unknown>, e: { command: string }) => void)(
214
- { root: UI_SHOWCASE },
215
- { command: 'build' },
216
- )
217
- }
218
- async function transform(
219
- plugin: Plugin,
220
- code: string,
221
- id: string,
222
- ssr: boolean,
223
- ): Promise<{ code: string } | undefined> {
224
- const hook = plugin.transform as (
225
- this: Ctx,
226
- c: string,
227
- i: string,
228
- o?: { ssr?: boolean },
229
- ) => Promise<{ code: string } | undefined>
230
- return hook.call(ctx, code, id, { ssr })
231
- }
232
-
233
- it('collapse:true → transform emits __rsCollapse for a literal-prop Button (client graph)', async () => {
234
- const plugin = pyreon({ collapse: true })
235
- configure(plugin)
236
- const out = await transform(plugin, SRC, ID, false)
237
- expect(out?.code).toContain('__rsCollapse(')
238
- expect(out?.code).toContain('__rsSheet.injectRules(')
239
- expect(out?.code).not.toContain('<Button')
240
- // SSR graph keeps the real mount (no collapse) — the !isSsr guard
241
- const ssrOut = await transform(plugin, SRC, ID, true)
242
- expect(ssrOut?.code).not.toContain('__rsCollapse(')
243
- // dispose the one programmatic Vite SSR server the resolver holds
244
- await (plugin.closeBundle as unknown as () => Promise<void>)()
245
- }, 90_000)
246
-
247
- it('collapse component filter excluding the component → no collapse (cheap, no resolver boot)', async () => {
248
- const plugin = pyreon({ collapse: { components: ['NotARealComponent'] } })
249
- configure(plugin)
250
- const out = await transform(plugin, SRC, ID, false)
251
- expect(out?.code).not.toContain('__rsCollapse(')
252
- // closeBundle is a no-op when the resolver was never created
253
- await (plugin.closeBundle as unknown as () => Promise<void>)()
254
- }, 30_000)
255
-
256
- it('collapse:true but no collapsible site in the file → resolver not booted', async () => {
257
- const plugin = pyreon({ collapse: true })
258
- configure(plugin)
259
- const plain = `export const X = () => <div class="x">hi</div>`
260
- const out = await transform(plugin, plain, ID, false)
261
- expect(out?.code).not.toContain('__rsCollapse(')
262
- await (plugin.closeBundle as unknown as () => Promise<void>)()
263
- }, 30_000)
264
- })
265
-
266
- // Layer 5: drive the WHOLE collapse path through the real `pyreon()`
267
- // plugin — `config` (projectRoot) → `transform` (scan → lazy
268
- // `ensureCollapseResolver` → real Vite-SSR resolve → thread
269
- // `collapseRocketstyle` into the compiler) → `closeBundle` (dispose the
270
- // one programmatic SSR server). This is the only test that exercises
271
- // the plugin's transform-hook collapse wiring + lifecycle end to end.
272
- describe('pyreon({ collapse }) — plugin transform/closeBundle wiring', () => {
273
- const COLLAPSIBLE = `
274
- import { Button } from '@pyreon/ui-components'
275
- export const Save = () => <Button state="primary" size="medium">Save</Button>`
276
- const PLAIN = `export const Hi = () => <div class="x">hi</div>`
277
- const ID = join(UI_SHOWCASE, 'src', 'Save.tsx')
278
-
279
- // Minimal Rollup/Vite transform-hook context. `resolve` returns null
280
- // (no cross-module signal resolution needed for these specs); `warn`
281
- // collects compiler/plugin warnings without failing the run.
282
- function ctx() {
283
- return { warn: () => {}, resolve: async () => null }
284
- }
285
- function callConfig(p: ReturnType<typeof pyreon>, root: string) {
286
- ;(p.config as unknown as (u: unknown, e: unknown) => unknown)(
287
- { root },
288
- { command: 'build' },
289
- )
290
- }
291
- function callTransform(
292
- p: ReturnType<typeof pyreon>,
293
- code: string,
294
- id: string,
295
- ssr: boolean,
296
- ) {
297
- return (
298
- p.transform as unknown as (
299
- this: unknown,
300
- c: string,
301
- i: string,
302
- o: { ssr: boolean },
303
- ) => Promise<{ code: string } | undefined>
304
- ).call(ctx(), code, id, { ssr })
305
- }
306
-
307
- it('collapse:true — transform emits __rsCollapse for a literal-prop site (client graph), bails on the SSR graph, and closeBundle disposes the resolver', async () => {
308
- const plugin = pyreon({ collapse: true })
309
- callConfig(plugin, UI_SHOWCASE)
310
-
311
- // CLIENT graph (ssr:false) → real resolver spins up, site collapses.
312
- const client = await callTransform(plugin, COLLAPSIBLE, ID, false)
313
- expect(client?.code).toContain('__rsCollapse(')
314
- expect(client?.code).toContain('__rsSheet.injectRules(')
315
- expect(client?.code).not.toContain('<Button')
316
-
317
- // SSR graph (ssr:true) → never collapse (renderToString needs the
318
- // real VNode tree; the resolver itself SSR-renders).
319
- const ssr = await callTransform(plugin, COLLAPSIBLE, ID, true)
320
- expect(ssr?.code ?? '').not.toContain('__rsCollapse(')
321
-
322
- // A file with no collapsible site → scan is empty, resolver path
323
- // is skipped entirely (no second SSR boot).
324
- const plain = await callTransform(plugin, PLAIN, ID, false)
325
- expect(plain?.code ?? '').not.toContain('__rsCollapse(')
326
-
327
- // Lifecycle: closeBundle tears down the one programmatic SSR server.
328
- await (plugin.closeBundle as unknown as () => Promise<void>).call({})
329
- // Idempotent — second close is a no-op (resolver already null).
330
- await (plugin.closeBundle as unknown as () => Promise<void>).call({})
331
- }, 90_000)
332
-
333
- it('collapse.components filter — a non-matching component name bails before the resolver ever boots', async () => {
334
- // No SSR server is created: the scanned site is filtered out by the
335
- // component allow-list before `ensureCollapseResolver()` is reached.
336
- const plugin = pyreon({ collapse: { components: ['NotARealComponent'] } })
337
- callConfig(plugin, UI_SHOWCASE)
338
- const out = await callTransform(plugin, COLLAPSIBLE, ID, false)
339
- expect(out?.code ?? '').not.toContain('__rsCollapse(')
340
- // closeBundle with no resolver created → the `if (collapseResolver)`
341
- // guard is false (cheap path, no dispose).
342
- await (plugin.closeBundle as unknown as () => Promise<void>).call({})
343
- }, 30_000)
344
-
345
- it('collapse off (default) — transform never touches the collapse path', async () => {
346
- const plugin = pyreon()
347
- callConfig(plugin, UI_SHOWCASE)
348
- const out = await callTransform(plugin, COLLAPSIBLE, ID, false)
349
- expect(out?.code ?? '').not.toContain('__rsCollapse(')
350
- await (plugin.closeBundle as unknown as () => Promise<void>).call({})
351
- }, 30_000)
352
- })
@@ -1,82 +0,0 @@
1
- /**
2
- * Regression lock: `@pyreon/vite-plugin` must set
3
- * `ssr.noExternal: [/@pyreon\//]` so every framework package goes through
4
- * Vite's transform pipeline. Without this, Vite externalizes some
5
- * `@pyreon/*` packages (loads via Node's `import()`) while transforming
6
- * others — producing TWO module instances of `@pyreon/core` (one at
7
- * `lib/index.js` via `import` condition, one at `src/index.ts` via the
8
- * `bun` condition). The two instances have SEPARATE `_current` lifecycle
9
- * state → `provide()` outside setup warning storm.
10
- *
11
- * Real-app symptom (bokisch.com dev-404 SSR, 0.24.4): 17 spurious
12
- * `[Pyreon] onUnmount() called outside component setup` warnings per
13
- * unmatched URL hit, even though every `provide()` IS structurally
14
- * inside a `runWithHooks` setup window.
15
- *
16
- * Reproduction trace (the differential):
17
- * at onUnmount (.../core/lib/index.js:68) ← LIB
18
- * at provide (.../core/lib/index.js:427) ← LIB
19
- * at HeadProvider (.../head/lib/provider.js:44) ← LIB
20
- * at runWithHooks (.../core/src/component.ts:34) ← SRC ❗
21
- * at renderComponent (.../runtime-server/lib/index.js:308)
22
- *
23
- * `head/lib` and `runtime-server/lib` resolve `@pyreon/core` via
24
- * different Vite paths — `head` lands at `lib/`, `runtime-server` lands
25
- * at `src/`. Two `_current` variables, two `setCurrentHooks` slots, the
26
- * one that gets set is NOT the one `provide()` reads from.
27
- *
28
- * Bisect-verified: removing `ssr.noExternal` from `vite-plugin`'s
29
- * `config()` return → bokisch reproduces 17 warnings on `/xyzzy-404`.
30
- * Restored → 1 (the residual is a separate `useWindowResize` bug class).
31
- */
32
- import pyreon from '@pyreon/vite-plugin'
33
- import { describe, expect, it } from 'vitest'
34
-
35
- describe('@pyreon/vite-plugin — ssr.noExternal regression lock', () => {
36
- it('config() return includes ssr.noExternal matching @pyreon/* via regex', () => {
37
- const plugin = pyreon()
38
- const cfg = (plugin as { config: (u: unknown, e: unknown) => unknown }).config(
39
- { root: process.cwd() },
40
- { command: 'serve' as const, mode: 'development', isPreview: false, isSsrBuild: false },
41
- ) as { ssr?: { noExternal?: unknown } }
42
-
43
- expect(cfg.ssr).toBeDefined()
44
- expect(cfg.ssr?.noExternal).toBeDefined()
45
- expect(Array.isArray(cfg.ssr?.noExternal)).toBe(true)
46
-
47
- const arr = cfg.ssr?.noExternal as readonly (string | RegExp)[]
48
- expect(arr.length).toBeGreaterThan(0)
49
-
50
- // The regex must match every @pyreon/* package name. Without this,
51
- // Vite externalizes some packages and the module-instance duplication
52
- // bug returns.
53
- const matches = (name: string) =>
54
- arr.some((entry) => (entry instanceof RegExp ? entry.test(name) : entry === name))
55
-
56
- expect(matches('@pyreon/core'), '@pyreon/core must be noExternal').toBe(true)
57
- expect(matches('@pyreon/runtime-server'), '@pyreon/runtime-server must be noExternal').toBe(true)
58
- expect(matches('@pyreon/router'), '@pyreon/router must be noExternal').toBe(true)
59
- expect(matches('@pyreon/head'), '@pyreon/head must be noExternal').toBe(true)
60
- expect(matches('@pyreon/ui-core'), '@pyreon/ui-core must be noExternal').toBe(true)
61
- expect(matches('@pyreon/styler'), '@pyreon/styler must be noExternal').toBe(true)
62
- expect(matches('@pyreon/elements'), '@pyreon/elements must be noExternal').toBe(true)
63
- expect(matches('@pyreon/rocketstyle'), '@pyreon/rocketstyle must be noExternal').toBe(true)
64
- // Hypothetical third-party @pyreon package — the regex should match too.
65
- expect(matches('@pyreon/anything-new'), 'regex must match future packages').toBe(true)
66
- })
67
-
68
- it('regex does NOT match non-@pyreon packages', () => {
69
- const plugin = pyreon()
70
- const cfg = (plugin as { config: (u: unknown, e: unknown) => unknown }).config(
71
- { root: process.cwd() },
72
- { command: 'serve' as const, mode: 'development', isPreview: false, isSsrBuild: false },
73
- ) as { ssr?: { noExternal?: readonly (string | RegExp)[] } }
74
- const arr = cfg.ssr?.noExternal ?? []
75
- const matches = (name: string) =>
76
- arr.some((entry) => (entry instanceof RegExp ? entry.test(name) : entry === name))
77
-
78
- expect(matches('react'), 'react must not be noExternal').toBe(false)
79
- expect(matches('vite'), 'vite must not be noExternal').toBe(false)
80
- expect(matches('pyreon'), 'unscoped pyreon must not be noExternal').toBe(false)
81
- })
82
- })