@pyreon/vue-compat 0.2.0 → 0.3.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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Compat JSX runtime for Vue compatibility mode.
3
+ *
4
+ * When `jsxImportSource` is redirected to `@pyreon/vue-compat` (via the vite
5
+ * plugin's `compat: "vue"` option), OXC rewrites JSX to import from this file.
6
+ *
7
+ * For component VNodes, we wrap the component function so it returns a reactive
8
+ * accessor — enabling Vue-style re-renders on state change while Pyreon's
9
+ * existing renderer handles all DOM work.
10
+ *
11
+ * Key difference from react/preact compat: the component body runs inside
12
+ * `runUntracked` to prevent `.value` reads (which access underlying signals)
13
+ * from being tracked by the reactive accessor. Only the version signal
14
+ * triggers re-renders.
15
+ */
16
+
17
+ import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
18
+ import { Fragment, h, onUnmount } from "@pyreon/core"
19
+ import { runUntracked, signal } from "@pyreon/reactivity"
20
+
21
+ export { Fragment }
22
+
23
+ // ─── Render context (used by hooks) ──────────────────────────────────────────
24
+
25
+ export interface RenderContext {
26
+ hooks: unknown[]
27
+ scheduleRerender: () => void
28
+ /** Effect entries pending execution after render */
29
+ pendingEffects: EffectEntry[]
30
+ /** Layout effect entries pending execution after render */
31
+ pendingLayoutEffects: EffectEntry[]
32
+ /** Set to true when the component is unmounted */
33
+ unmounted: boolean
34
+ /** Callbacks to run on unmount (lifecycle + effect cleanups) */
35
+ unmountCallbacks: (() => void)[]
36
+ }
37
+
38
+ export interface EffectEntry {
39
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches Vue's effect signature
40
+ fn: () => (() => void) | void
41
+ deps: unknown[] | undefined
42
+ cleanup: (() => void) | undefined
43
+ }
44
+
45
+ let _currentCtx: RenderContext | null = null
46
+ let _hookIndex = 0
47
+
48
+ export function getCurrentCtx(): RenderContext | null {
49
+ return _currentCtx
50
+ }
51
+
52
+ export function getHookIndex(): number {
53
+ return _hookIndex++
54
+ }
55
+
56
+ export function beginRender(ctx: RenderContext): void {
57
+ _currentCtx = ctx
58
+ _hookIndex = 0
59
+ ctx.pendingEffects = []
60
+ ctx.pendingLayoutEffects = []
61
+ }
62
+
63
+ export function endRender(): void {
64
+ _currentCtx = null
65
+ _hookIndex = 0
66
+ }
67
+
68
+ // ─── Effect runners ──────────────────────────────────────────────────────────
69
+
70
+ function runLayoutEffects(entries: EffectEntry[]): void {
71
+ for (const entry of entries) {
72
+ if (entry.cleanup) entry.cleanup()
73
+ const cleanup = entry.fn()
74
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
75
+ }
76
+ }
77
+
78
+ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
79
+ if (entries.length === 0) return
80
+ queueMicrotask(() => {
81
+ for (const entry of entries) {
82
+ if (ctx.unmounted) return
83
+ if (entry.cleanup) entry.cleanup()
84
+ const cleanup = entry.fn()
85
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
86
+ }
87
+ })
88
+ }
89
+
90
+ // ─── Component wrapping ──────────────────────────────────────────────────────
91
+
92
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
93
+ const _wrapperCache = new WeakMap<Function, ComponentFn>()
94
+
95
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
96
+ function wrapCompatComponent(vueComponent: Function): ComponentFn {
97
+ let wrapped = _wrapperCache.get(vueComponent)
98
+ if (wrapped) return wrapped
99
+
100
+ // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
101
+ // mountChild treats as a reactive expression via mountReactive.
102
+ wrapped = ((props: Props) => {
103
+ const ctx: RenderContext = {
104
+ hooks: [],
105
+ scheduleRerender: () => {
106
+ // Will be replaced below after version signal is created
107
+ },
108
+ pendingEffects: [],
109
+ pendingLayoutEffects: [],
110
+ unmounted: false,
111
+ unmountCallbacks: [],
112
+ }
113
+
114
+ const version = signal(0)
115
+ let updateScheduled = false
116
+
117
+ ctx.scheduleRerender = () => {
118
+ if (ctx.unmounted || updateScheduled) return
119
+ updateScheduled = true
120
+ queueMicrotask(() => {
121
+ updateScheduled = false
122
+ if (!ctx.unmounted) version.set(version.peek() + 1)
123
+ })
124
+ }
125
+
126
+ // Register cleanup when component unmounts
127
+ onUnmount(() => {
128
+ ctx.unmounted = true
129
+ for (const cb of ctx.unmountCallbacks) cb()
130
+ })
131
+
132
+ // Return reactive accessor — Pyreon's mountChild calls mountReactive
133
+ return () => {
134
+ version() // tracked read — triggers re-execution when state changes
135
+ beginRender(ctx)
136
+ // runUntracked prevents .value signal reads from being tracked by this accessor —
137
+ // only the version signal should trigger re-renders
138
+ const result = runUntracked(() => (vueComponent as ComponentFn)(props))
139
+ const layoutEffects = ctx.pendingLayoutEffects
140
+ const effects = ctx.pendingEffects
141
+ endRender()
142
+
143
+ runLayoutEffects(layoutEffects)
144
+ scheduleEffects(ctx, effects)
145
+
146
+ return result
147
+ }
148
+ }) as unknown as ComponentFn
149
+
150
+ _wrapperCache.set(vueComponent, wrapped)
151
+ return wrapped
152
+ }
153
+
154
+ // ─── JSX functions ───────────────────────────────────────────────────────────
155
+
156
+ export function jsx(
157
+ type: string | ComponentFn | symbol,
158
+ props: Props & { children?: VNodeChild | VNodeChild[] },
159
+ key?: string | number | null,
160
+ ): VNode {
161
+ const { children, ...rest } = props
162
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
163
+
164
+ if (typeof type === "function") {
165
+ // Wrap Vue-style component for re-render support
166
+ const wrapped = wrapCompatComponent(type)
167
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
168
+ return h(wrapped, componentProps)
169
+ }
170
+
171
+ // DOM element or symbol (Fragment): children go in vnode.children
172
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
173
+
174
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
175
+ }
176
+
177
+ export const jsxs = jsx
178
+ export const jsxDEV = jsx