@pyreon/vite-plugin 0.21.0 → 0.23.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/README.md +97 -25
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +23 -4
- package/package.json +2 -2
- package/src/index.ts +86 -4
- package/src/tests/cache-eviction-on-delete.test.ts +187 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @pyreon/vite-plugin
|
|
2
2
|
|
|
3
|
-
Vite plugin for
|
|
3
|
+
Vite plugin for Pyreon — JSX transform, signal-preserving HMR, SSR middleware, islands auto-registry, compat aliasing.
|
|
4
|
+
|
|
5
|
+
`@pyreon/vite-plugin` is the single Vite integration Pyreon needs. It wires `@pyreon/compiler` into Vite's transform pipeline, sets `resolve.conditions: ["bun"]` so workspace source files resolve via the `bun` condition, configures the JSX runtime to `@pyreon/core`, and provides signal-preserving HMR (top-level `signal()` values survive hot reload). Optional features: SSR dev middleware (`ssr.entry`), an auto-discovered islands registry (`islands: true`, the `virtual:pyreon/islands-registry` module fed to `hydrateIslandsAuto()`), drop-in compat-mode aliasing (`compat: 'react' | 'preact' | 'vue' | 'solid' | 'svelte'`), and the opt-in compile-time rocketstyle wrapper collapse (`collapse: true`, build-only).
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,7 +10,7 @@ Vite plugin for the Pyreon framework. Applies the Pyreon JSX reactive transform
|
|
|
8
10
|
bun add -D @pyreon/vite-plugin
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Quick
|
|
13
|
+
## Quick start (SPA)
|
|
12
14
|
|
|
13
15
|
```ts
|
|
14
16
|
// vite.config.ts
|
|
@@ -20,21 +22,30 @@ export default defineConfig({
|
|
|
20
22
|
})
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
`tsconfig.json`:
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
```jsonc
|
|
28
|
+
{
|
|
29
|
+
"extends": "@pyreon/typescript/app",
|
|
30
|
+
"compilerOptions": {
|
|
31
|
+
"jsx": "react-jsx",
|
|
32
|
+
"jsxImportSource": "@pyreon/core"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## SSR dev mode
|
|
26
38
|
|
|
27
39
|
```ts
|
|
28
40
|
// vite.config.ts
|
|
29
41
|
import pyreon from '@pyreon/vite-plugin'
|
|
30
|
-
import { defineConfig } from 'vite'
|
|
31
42
|
|
|
32
|
-
export default
|
|
43
|
+
export default {
|
|
33
44
|
plugins: [pyreon({ ssr: { entry: './src/entry-server.ts' } })],
|
|
34
|
-
}
|
|
45
|
+
}
|
|
35
46
|
```
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
The entry must export a `handler` (or default export) of shape `(req: Request) => Promise<Response>`:
|
|
38
49
|
|
|
39
50
|
```tsx
|
|
40
51
|
// src/entry-server.ts
|
|
@@ -43,35 +54,96 @@ import App from './App'
|
|
|
43
54
|
|
|
44
55
|
export async function handler(req: Request): Promise<Response> {
|
|
45
56
|
const html = await renderToString(<App />)
|
|
46
|
-
return new Response(html, {
|
|
47
|
-
headers: { 'Content-Type': 'text/html' },
|
|
48
|
-
})
|
|
57
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html' } })
|
|
49
58
|
}
|
|
50
59
|
```
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
Production builds:
|
|
53
62
|
|
|
54
63
|
```bash
|
|
55
64
|
vite build # client bundle
|
|
56
65
|
vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
|
|
57
66
|
```
|
|
58
67
|
|
|
59
|
-
##
|
|
68
|
+
## Drop-in compat mode
|
|
69
|
+
|
|
70
|
+
Alias an existing framework's imports to Pyreon's compat layer — zero code changes:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
pyreon({ compat: 'react' }) // react + react-dom → @pyreon/react-compat
|
|
74
|
+
pyreon({ compat: 'preact' }) // preact + hooks + signals → @pyreon/preact-compat
|
|
75
|
+
pyreon({ compat: 'vue' }) // vue → @pyreon/vue-compat
|
|
76
|
+
pyreon({ compat: 'solid' }) // solid-js → @pyreon/solid-compat
|
|
77
|
+
pyreon({ compat: 'svelte' }) // svelte + svelte/store → @pyreon/svelte-compat
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Framework-internal `@pyreon/*` files are detected and skip the redirect so published `@pyreon/zero` etc. still load their real JSX runtime.
|
|
81
|
+
|
|
82
|
+
## Islands auto-registry
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
pyreon({ islands: true }) // default on
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Pre-scans `island(() => import('PATH'), { name, hydrate })` calls at `buildStart` and emits `virtual:pyreon/islands-registry`. Consume it in your client entry:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// src/entry-client.ts
|
|
92
|
+
import { hydrateIslandsAuto } from '@pyreon/server/client'
|
|
93
|
+
import islands from 'virtual:pyreon/islands-registry'
|
|
94
|
+
|
|
95
|
+
hydrateIslandsAuto(islands)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`hydrate: 'never'` islands are deliberately OMITTED from the registry — the strategy ships zero client JS, so registering a loader (which would pull the component into the client bundle graph) would defeat it. Manual `hydrateIslands({ … })` stays public for non-Vite consumers.
|
|
99
|
+
|
|
100
|
+
## Rocketstyle collapse (opt-in, build-only)
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
pyreon({ collapse: true })
|
|
104
|
+
// or with overrides
|
|
105
|
+
pyreon({ collapse: { sources: ['@pyreon/ui-components'], components: ['Button'] } })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A literal-prop rocketstyle call site (`<Button state="primary" size="medium">Save</Button>`) collapses from a 5-layer wrapper mount into one `_rsCollapse` cloneNode. The plugin SSR-resolves the real component twice (light + dark) and the compiler bakes the classes into a `_tpl` template. **Build-only** by design — dev keeps the normal mount so theme-source HMR edits stay reactive.
|
|
109
|
+
|
|
110
|
+
## Options
|
|
111
|
+
|
|
112
|
+
| Option | Type | Description |
|
|
113
|
+
| ------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
114
|
+
| `compat` | `'react' \| 'preact' \| 'vue' \| 'solid' \| 'svelte'` | Alias an existing framework's imports to the matching `@pyreon/*-compat` package. |
|
|
115
|
+
| `ssr.entry` | `string` | Server entry path. Enables SSR dev middleware. |
|
|
116
|
+
| `islands` | `boolean` | Auto-discover `island()` declarations into `virtual:pyreon/islands-registry`. Default `true`. |
|
|
117
|
+
| `collapse` | `boolean \| PyreonCollapseOptions` | Opt-in compile-time rocketstyle wrapper collapse. OFF by default. Build-only. |
|
|
118
|
+
|
|
119
|
+
`PyreonCollapseOptions`: `sources?: string[]` (default `['@pyreon/ui-components']`), `components?: string[]` (optional local-name allowlist), `provider?: { name, source }` (default `PyreonUI@@pyreon/ui-core`), `theme?: { name, source }` (default `theme@@pyreon/ui-theme`), `mode?: { name, source }` (default `useMode@@pyreon/ui-core`).
|
|
120
|
+
|
|
121
|
+
## What it does
|
|
122
|
+
|
|
123
|
+
- Wires `@pyreon/compiler`'s JSX reactive transform into `.tsx` / `.jsx` / `.pyreon` files (auto-call signals, hoist static subtrees, `_tpl` + `_bind`).
|
|
124
|
+
- Sets `resolve.conditions: ["bun"]` so Pyreon workspace source files resolve through the `bun` condition (no separate build step in dev).
|
|
125
|
+
- Configures the JSX runtime to `@pyreon/core` via `esbuild.jsx = 'automatic'` + `jsxImportSource`.
|
|
126
|
+
- In dev: auto-injects debug names into `signal()` calls so devtools show meaningful labels.
|
|
127
|
+
- In dev SSR: catch-all middleware loads the server entry via `ssrLoadModule` and renders every non-asset request.
|
|
128
|
+
- Provides signal-preserving HMR via `virtual:pyreon/hmr-runtime` — top-level signal values survive hot reload.
|
|
129
|
+
- Component-level fast-refresh: edits to a route component re-render in place via the router's `_hmrSwap` coordinator (registered on `globalThis.__pyreon_hmr_swap__`).
|
|
130
|
+
- Pre-scans signal exports across files so cross-module signal references auto-call correctly.
|
|
131
|
+
|
|
132
|
+
## Gotchas
|
|
133
|
+
|
|
134
|
+
- **Vite's config bundler hardcodes `conditions: ["node"]`** — plugin source changes are INVISIBLE to a running dev server until `lib/` is rebuilt. After editing the plugin, `bun run --filter='@pyreon/vite-plugin' build` + restart Vite.
|
|
135
|
+
- **Compat-mode applies `jsxImportSource`** automatically for the user's code. Set `jsxImportSource: "@pyreon/<compat>-compat"` in your tsconfig for type resolution.
|
|
136
|
+
- **`collapse` is build-only by design.** Dev keeps the normal mount (HMR-reactive); the plugin emits `this.info('[Pyreon] collapse is build-only …')` once per dev process if `collapse: true` is set in `vite dev`.
|
|
137
|
+
- **HMR accept callback uses the fresh module Vite hands it** — NOT a re-run of the lazy import thunk (that would return the frozen `?t=` old module).
|
|
60
138
|
|
|
61
|
-
|
|
139
|
+
## Peer dependencies
|
|
62
140
|
|
|
63
|
-
|
|
141
|
+
- `vite >= 8.0.0`
|
|
64
142
|
|
|
65
|
-
|
|
143
|
+
## Documentation
|
|
66
144
|
|
|
67
|
-
|
|
68
|
-
| ----------- | -------- | --------------------------------------------------- |
|
|
69
|
-
| `ssr.entry` | `string` | Server entry file path. Enables SSR dev middleware. |
|
|
145
|
+
Full docs: [docs.pyreon.dev/docs/vite-plugin](https://docs.pyreon.dev/docs/vite-plugin) (or `docs/docs/vite-plugin.md` in this repo).
|
|
70
146
|
|
|
71
|
-
##
|
|
147
|
+
## License
|
|
72
148
|
|
|
73
|
-
|
|
74
|
-
- Sets `esbuild.jsx` to `automatic` with `@pyreon/core` as the JSX import source.
|
|
75
|
-
- Transforms `.tsx`, `.jsx`, and `.pyreon` files through `@pyreon/compiler` for reactive JSX optimizations.
|
|
76
|
-
- In dev mode, auto-injects debug names into `signal()` calls so devtools show meaningful labels instead of "anonymous".
|
|
77
|
-
- In SSR mode, adds a catch-all middleware that renders pages through your server entry with full HMR support.
|
|
149
|
+
MIT
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"120296e8-1"}]},{"name":"rocketstyle-collapse-C4eMAnwR.js","children":[{"name":"src/rocketstyle-collapse.ts","uid":"120296e8-3"}]}],"isRoot":true},"nodeParts":{"120296e8-1":{"renderedLength":34147,"gzipLength":11034,"brotliLength":0,"metaUid":"120296e8-0"},"120296e8-3":{"renderedLength":3424,"gzipLength":1530,"brotliLength":0,"metaUid":"120296e8-2"}},"nodeMetas":{"120296e8-0":{"id":"/src/index.ts","moduleParts":{"index.js":"120296e8-1"},"imported":[{"uid":"120296e8-4"},{"uid":"120296e8-5"},{"uid":"120296e8-6"},{"uid":"120296e8-2","dynamic":true}],"importedBy":[],"isEntry":true},"120296e8-2":{"id":"/src/rocketstyle-collapse.ts","moduleParts":{"rocketstyle-collapse-C4eMAnwR.js":"120296e8-3"},"imported":[{"uid":"120296e8-7","dynamic":true}],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-4":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-5":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-6":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-7":{"id":"vite","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -36,6 +36,8 @@ import { generateContext, scanCollapsibleSites, transformDeferInline, transformJ
|
|
|
36
36
|
* vite build # client bundle
|
|
37
37
|
* vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
|
|
38
38
|
*/
|
|
39
|
+
const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
40
|
+
const _countSink = globalThis;
|
|
39
41
|
let _createCollapseResolver = null;
|
|
40
42
|
async function loadCreateCollapseResolver() {
|
|
41
43
|
if (!_createCollapseResolver) _createCollapseResolver = (await import("./rocketstyle-collapse-C4eMAnwR.js")).createCollapseResolver;
|
|
@@ -245,6 +247,22 @@ function pyreonPlugin(options) {
|
|
|
245
247
|
await prescanSignalExports(projectRoot, signalExportRegistry);
|
|
246
248
|
if (islandsEnabled) await prescanIslandDeclarations(projectRoot, islandRegistry);
|
|
247
249
|
},
|
|
250
|
+
[Symbol.for("pyreon/vite-plugin:caches")]: {
|
|
251
|
+
signalExportRegistry,
|
|
252
|
+
resolveCache,
|
|
253
|
+
pyreonWorkspaceDirCache,
|
|
254
|
+
islandRegistry
|
|
255
|
+
},
|
|
256
|
+
watchChange(id, change) {
|
|
257
|
+
if (change.event !== "delete") return;
|
|
258
|
+
if (__DEV__) _countSink.__pyreon_count__?.("vite-plugin.watchChange.delete");
|
|
259
|
+
const normalized = normalizeModuleId(id);
|
|
260
|
+
signalExportRegistry.delete(normalized);
|
|
261
|
+
islandRegistry.delete(id);
|
|
262
|
+
if (normalized !== id) islandRegistry.delete(normalized);
|
|
263
|
+
const importerPrefix = `${normalized}::`;
|
|
264
|
+
for (const [key, value] of resolveCache) if (key.startsWith(importerPrefix) || value === normalized) resolveCache.delete(key);
|
|
265
|
+
},
|
|
248
266
|
async closeBundle() {
|
|
249
267
|
if (collapseResolver) {
|
|
250
268
|
await collapseResolver.dispose();
|
|
@@ -636,7 +654,7 @@ async function prescanIslandDeclarations(root, registry) {
|
|
|
636
654
|
* can find.
|
|
637
655
|
*/
|
|
638
656
|
function scanIslandDeclarations(code, filePath, registry) {
|
|
639
|
-
const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([
|
|
657
|
+
const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g;
|
|
640
658
|
const decls = [];
|
|
641
659
|
let match;
|
|
642
660
|
while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
|
|
@@ -750,6 +768,7 @@ async function prescanSignalExports(root, registry) {
|
|
|
750
768
|
*
|
|
751
769
|
* Uses simple regex — no AST parse needed.
|
|
752
770
|
*/
|
|
771
|
+
const AS_SPLIT_RE = /\s{1,10}as\s{1,10}/;
|
|
753
772
|
function scanSignalExports(code, moduleId, registry) {
|
|
754
773
|
const normalizedId = normalizeModuleId(moduleId);
|
|
755
774
|
let match;
|
|
@@ -760,13 +779,13 @@ function scanSignalExports(code, moduleId, registry) {
|
|
|
760
779
|
const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm;
|
|
761
780
|
while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) localSignals.add(match[1]);
|
|
762
781
|
if (localSignals.size > 0) {
|
|
763
|
-
const NAMED_EXPORT_RE = /export\s*\{([^}]
|
|
782
|
+
const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g;
|
|
764
783
|
while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
|
|
765
784
|
if (code.slice(match.index + match[0].length).trimStart().startsWith("from")) continue;
|
|
766
785
|
for (const spec of match[1].split(",")) {
|
|
767
786
|
const trimmed = spec.trim();
|
|
768
787
|
if (!trimmed) continue;
|
|
769
|
-
const parts = trimmed.split(
|
|
788
|
+
const parts = trimmed.split(AS_SPLIT_RE);
|
|
770
789
|
const localName = parts[0].trim();
|
|
771
790
|
const exportedName = (parts[1] ?? parts[0]).trim();
|
|
772
791
|
if (localSignals.has(localName)) signals.add(exportedName);
|
|
@@ -813,7 +832,7 @@ async function resolveImportedSignals(code, _moduleId, registry, pluginCtx, reso
|
|
|
813
832
|
for (const spec of specifiers.split(",")) {
|
|
814
833
|
const trimmed = spec.trim();
|
|
815
834
|
if (!trimmed) continue;
|
|
816
|
-
const parts = trimmed.split(
|
|
835
|
+
const parts = trimmed.split(AS_SPLIT_RE);
|
|
817
836
|
const importedName = parts[0].trim();
|
|
818
837
|
const localName = (parts[1] ?? parts[0]).trim();
|
|
819
838
|
if (exportedSignals.has(importedName)) knownSignals.push(localName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/vite-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Vite plugin for Pyreon — .pyreon SFC support, HMR, compiler integration",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/vite-plugin#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "bun run build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@pyreon/compiler": "^0.
|
|
46
|
+
"@pyreon/compiler": "^0.23.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"vite": "^8.0.0"
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
import type { CollapseResolver } from './rocketstyle-collapse'
|
|
45
45
|
import type { Plugin, ViteDevServer } from 'vite'
|
|
46
46
|
|
|
47
|
+
// Dev-mode counter sink — see packages/internals/perf-harness for contract.
|
|
48
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
49
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
50
|
+
|
|
47
51
|
// Lazy — the resolver module (and its `vite` SSR machinery) must NOT be
|
|
48
52
|
// on the static import path of this cheap entry. It loads ONLY when
|
|
49
53
|
// `pyreon({ collapse })` is enabled AND a collapsible site is scanned;
|
|
@@ -488,6 +492,76 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
488
492
|
}
|
|
489
493
|
},
|
|
490
494
|
|
|
495
|
+
// @internal — debug accessor for tests; returns live references to
|
|
496
|
+
// the per-instance caches so `cache-eviction-on-delete.test.ts` can
|
|
497
|
+
// assert on contents. Symbol.for-keyed so it's not part of the
|
|
498
|
+
// plugin's documented surface but stays stable across reloads.
|
|
499
|
+
[Symbol.for('pyreon/vite-plugin:caches')]: {
|
|
500
|
+
signalExportRegistry,
|
|
501
|
+
resolveCache,
|
|
502
|
+
pyreonWorkspaceDirCache,
|
|
503
|
+
islandRegistry,
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
// ── Cache invalidation on file delete (long-running `vite dev`) ─────
|
|
507
|
+
// Vite's `watchChange` hook fires on filesystem events for files in
|
|
508
|
+
// the watched module graph. Without this, the four per-instance
|
|
509
|
+
// caches (`signalExportRegistry`, `resolveCache`, `islandRegistry`,
|
|
510
|
+
// `pyreonWorkspaceDirCache`) accumulated stale entries for the
|
|
511
|
+
// entire lifetime of the dev server — a long `vite dev` session
|
|
512
|
+
// that edited / renamed / deleted source files would grow each
|
|
513
|
+
// cache by one entry per dead file. Bounded by total source tree
|
|
514
|
+
// size in practice, but a real leak over hours of editing.
|
|
515
|
+
//
|
|
516
|
+
// `'create' | 'update'` events are handled implicitly by the
|
|
517
|
+
// existing transform-time `scanSignalExports` /
|
|
518
|
+
// `scanIslandDeclarations` calls — they re-populate the registry
|
|
519
|
+
// every time a file's `transform` hook fires, overwriting any
|
|
520
|
+
// stale entry. So watchChange only needs to handle `'delete'`.
|
|
521
|
+
watchChange(id: string, change: { event: 'create' | 'update' | 'delete' }) {
|
|
522
|
+
if (change.event !== 'delete') return
|
|
523
|
+
|
|
524
|
+
// Leak-class C diagnostic — emit per handled delete event. Bounded
|
|
525
|
+
// by file-deletion count in a dev session; should grow strictly
|
|
526
|
+
// monotonically with developer edit activity. Zero in a session
|
|
527
|
+
// with known deletes = the watchChange hook regressed (and the
|
|
528
|
+
// 4 per-instance caches will leak again).
|
|
529
|
+
if (__DEV__) _countSink.__pyreon_count__?.('vite-plugin.watchChange.delete')
|
|
530
|
+
|
|
531
|
+
const normalized = normalizeModuleId(id)
|
|
532
|
+
|
|
533
|
+
// 1) signalExportRegistry — keyed by normalized module id.
|
|
534
|
+
signalExportRegistry.delete(normalized)
|
|
535
|
+
|
|
536
|
+
// 2) islandRegistry — keyed by absolute source path of the
|
|
537
|
+
// declaration site (the original `id`, not normalized).
|
|
538
|
+
islandRegistry.delete(id)
|
|
539
|
+
// Also try the normalized form just in case the registry was
|
|
540
|
+
// populated with a slightly different shape.
|
|
541
|
+
if (normalized !== id) islandRegistry.delete(normalized)
|
|
542
|
+
|
|
543
|
+
// 3) resolveCache — keyed by `${importer}::${source}` where
|
|
544
|
+
// `importer` is normalized AND values can be the deleted
|
|
545
|
+
// file's resolved path. Sweep both directions:
|
|
546
|
+
// a) entries WHERE the deleted file is the importer (this
|
|
547
|
+
// file's resolved imports are no longer relevant).
|
|
548
|
+
// b) entries WHERE the deleted file is the resolved value
|
|
549
|
+
// (other files importing the deleted file need to
|
|
550
|
+
// re-resolve so they see `null` next time).
|
|
551
|
+
const importerPrefix = `${normalized}::`
|
|
552
|
+
for (const [key, value] of resolveCache) {
|
|
553
|
+
if (key.startsWith(importerPrefix) || value === normalized) {
|
|
554
|
+
resolveCache.delete(key)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 4) pyreonWorkspaceDirCache — keyed by DIRECTORY, not file. A
|
|
559
|
+
// single file deletion doesn't invalidate the directory's
|
|
560
|
+
// workspace status (other files may still live there), so
|
|
561
|
+
// this cache stays. Bounded by source-tree directory count
|
|
562
|
+
// in any case (small + finite).
|
|
563
|
+
},
|
|
564
|
+
|
|
491
565
|
// Tear down the one programmatic Vite SSR server the collapse
|
|
492
566
|
// resolver holds (created lazily on first client-graph transform).
|
|
493
567
|
async closeBundle() {
|
|
@@ -1157,8 +1231,11 @@ function scanIslandDeclarations(
|
|
|
1157
1231
|
// `[\s\S]` lets the options block span multiple lines. The lazy `?` after
|
|
1158
1232
|
// the options block prevents over-matching when several `island()` calls
|
|
1159
1233
|
// appear in the same file.
|
|
1234
|
+
// `[^}]{0,500}` instead of `[\s\S]*?` — real island() option blocks
|
|
1235
|
+
// are tiny (`{ name: 'X', hydrate: 'load' }`); excluding `}` from
|
|
1236
|
+
// the inner class also tightens the match against the outer `\}`.
|
|
1160
1237
|
const ISLAND_CALL_RE =
|
|
1161
|
-
/island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([
|
|
1238
|
+
/island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g
|
|
1162
1239
|
const decls: IslandDecl[] = []
|
|
1163
1240
|
let match: RegExpExecArray | null
|
|
1164
1241
|
while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
|
|
@@ -1298,6 +1375,10 @@ async function prescanSignalExports(root: string, registry: Map<string, Set<stri
|
|
|
1298
1375
|
*
|
|
1299
1376
|
* Uses simple regex — no AST parse needed.
|
|
1300
1377
|
*/
|
|
1378
|
+
// Bounded `\s{1,10}` instead of unbounded `\s+` to remove worst-case
|
|
1379
|
+
// backtracking; real import specifiers have 1-2 spaces around `as`.
|
|
1380
|
+
const AS_SPLIT_RE = /\s{1,10}as\s{1,10}/
|
|
1381
|
+
|
|
1301
1382
|
function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
|
|
1302
1383
|
const normalizedId = normalizeModuleId(moduleId)
|
|
1303
1384
|
let match: RegExpExecArray | null
|
|
@@ -1319,7 +1400,8 @@ function scanSignalExports(code: string, moduleId: string, registry: Map<string,
|
|
|
1319
1400
|
|
|
1320
1401
|
// Then check named exports: export { x, y as z }
|
|
1321
1402
|
if (localSignals.size > 0) {
|
|
1322
|
-
|
|
1403
|
+
// Bounded `[^}]{1,500}` — real export blocks fit easily.
|
|
1404
|
+
const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g
|
|
1323
1405
|
while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
|
|
1324
1406
|
// Skip re-exports (export { x } from '...')
|
|
1325
1407
|
const afterBrace = code.slice(match.index + match[0].length).trimStart()
|
|
@@ -1328,7 +1410,7 @@ function scanSignalExports(code: string, moduleId: string, registry: Map<string,
|
|
|
1328
1410
|
for (const spec of match[1]!.split(',')) {
|
|
1329
1411
|
const trimmed = spec.trim()
|
|
1330
1412
|
if (!trimmed) continue
|
|
1331
|
-
const parts = trimmed.split(
|
|
1413
|
+
const parts = trimmed.split(AS_SPLIT_RE)
|
|
1332
1414
|
const localName = parts[0]!.trim()
|
|
1333
1415
|
const exportedName = (parts[1] ?? parts[0])!.trim()
|
|
1334
1416
|
if (localSignals.has(localName)) {
|
|
@@ -1404,7 +1486,7 @@ async function resolveImportedSignals(
|
|
|
1404
1486
|
const trimmed = spec.trim()
|
|
1405
1487
|
if (!trimmed) continue
|
|
1406
1488
|
|
|
1407
|
-
const parts = trimmed.split(
|
|
1489
|
+
const parts = trimmed.split(AS_SPLIT_RE)
|
|
1408
1490
|
const importedName = parts[0]!.trim()
|
|
1409
1491
|
const localName = (parts[1] ?? parts[0])!.trim()
|
|
1410
1492
|
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
})
|