@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @pyreon/vite-plugin
2
2
 
3
- Vite plugin for the Pyreon framework. Applies the Pyreon JSX reactive transform to `.tsx`, `.jsx`, and `.pyreon` files, and optionally adds SSR dev middleware.
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 Start (SPA)
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
- ## SSR Mode
25
+ `tsconfig.json`:
24
26
 
25
- Pass an `ssr` option to enable SSR dev middleware. The plugin will load your server entry via Vite's `ssrLoadModule` and call its exported `handler` function for every non-asset GET request.
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 defineConfig({
43
+ export default {
33
44
  plugins: [pyreon({ ssr: { entry: './src/entry-server.ts' } })],
34
- })
45
+ }
35
46
  ```
36
47
 
37
- Your server entry must export a `handler` (or default export) with the signature `(req: Request) => Promise<Response>`:
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
- For production, build client and server bundles separately:
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
- ## API
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
- ### `pyreonPlugin(options?)`
139
+ ## Peer dependencies
62
140
 
63
- Default export. Returns a Vite `Plugin`.
141
+ - `vite >= 8.0.0`
64
142
 
65
- ### Options
143
+ ## Documentation
66
144
 
67
- | Option | Type | Description |
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
- ## What It Does
147
+ ## License
72
148
 
73
- - Configures `resolve.conditions: ["bun"]` so Vite resolves Pyreon workspace source files.
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":"f3fd0c0b-1"}]},{"name":"rocketstyle-collapse-C4eMAnwR.js","children":[{"name":"src/rocketstyle-collapse.ts","uid":"f3fd0c0b-3"}]}],"isRoot":true},"nodeParts":{"f3fd0c0b-1":{"renderedLength":33323,"gzipLength":10763,"brotliLength":0,"metaUid":"f3fd0c0b-0"},"f3fd0c0b-3":{"renderedLength":3424,"gzipLength":1530,"brotliLength":0,"metaUid":"f3fd0c0b-2"}},"nodeMetas":{"f3fd0c0b-0":{"id":"/src/index.ts","moduleParts":{"index.js":"f3fd0c0b-1"},"imported":[{"uid":"f3fd0c0b-4"},{"uid":"f3fd0c0b-5"},{"uid":"f3fd0c0b-6"},{"uid":"f3fd0c0b-2","dynamic":true}],"importedBy":[],"isEntry":true},"f3fd0c0b-2":{"id":"/src/rocketstyle-collapse.ts","moduleParts":{"rocketstyle-collapse-C4eMAnwR.js":"f3fd0c0b-3"},"imported":[{"uid":"f3fd0c0b-7","dynamic":true}],"importedBy":[{"uid":"f3fd0c0b-0"}]},"f3fd0c0b-4":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"f3fd0c0b-0"}]},"f3fd0c0b-5":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"f3fd0c0b-0"}]},"f3fd0c0b-6":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"f3fd0c0b-0"}]},"f3fd0c0b-7":{"id":"vite","moduleParts":{},"imported":[],"importedBy":[{"uid":"f3fd0c0b-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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*\{([\s\S]*?)\}\s*\)/g;
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*\{([^}]+)\}/g;
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(/\s+as\s+/);
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(/\s+as\s+/);
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.21.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.21.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*\{([\s\S]*?)\}\s*\)/g
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
- const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g
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(/\s+as\s+/)
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(/\s+as\s+/)
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
+ })