@pyreon/vue-compat 0.13.1 → 0.15.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.
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
18
- import { Fragment, h, onUnmount } from '@pyreon/core'
18
+ import { Fragment, h, isNativeCompat, onUnmount } from '@pyreon/core'
19
19
  import { runUntracked, signal } from '@pyreon/reactivity'
20
20
 
21
21
  export { Fragment }
@@ -159,12 +159,36 @@ export function jsx(
159
159
  const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
160
160
 
161
161
  if (typeof type === 'function') {
162
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
163
+ // Native Pyreon framework components (context Provider, RouterView,
164
+ // QueryClientProvider, etc.) skip compat wrapping — they manage their
165
+ // own reactivity via signals + Pyreon's lifecycle, and wrapping them
166
+ // would run their setup body inside the compat layer's render context
167
+ // instead of Pyreon's, breaking `provide()` / `onMount()` / `onUnmount()`.
168
+ // See `@pyreon/core`'s `nativeCompat()` for the contract.
169
+ if (isNativeCompat(type)) {
170
+ return h(type as ComponentFn, componentProps)
171
+ }
162
172
  // Wrap Vue-style component for re-render support
163
173
  const wrapped = wrapCompatComponent(type)
164
- const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
165
174
  return h(wrapped, componentProps)
166
175
  }
167
176
 
177
+ // DOM element: convert Vue ref ({ value }) to callback ref for Pyreon's runtime-dom
178
+ if (typeof type === 'string' && propsWithKey.ref != null) {
179
+ const r = propsWithKey.ref
180
+ if (
181
+ typeof r === 'object' &&
182
+ r !== null &&
183
+ (r as Record<symbol, unknown>)[Symbol.for('__v_isRef')] === true
184
+ ) {
185
+ const vueRef = r as { value: unknown }
186
+ propsWithKey.ref = (el: Element | null) => {
187
+ vueRef.value = el
188
+ }
189
+ }
190
+ }
191
+
168
192
  // DOM element or symbol (Fragment): children go in vnode.children
169
193
  const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
170
194
 
@@ -0,0 +1,158 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { createContext, h, nativeCompat, provide, useContext } from '@pyreon/core'
3
+ import { mount } from '@pyreon/runtime-dom'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { onMounted, onUnmounted } from '../index'
6
+ import { jsx } from '../jsx-runtime'
7
+
8
+ // Coverage gap closed in PR #323. Exercises the Vue-compat wrapper
9
+ // (`wrapCompatComponent`) end-to-end: JSX → mount → lifecycle hooks
10
+ // → unmount cleanup. Pins the wrapper's setup-and-teardown contract;
11
+ // fine-grained reactivity behavior is covered by the broader
12
+ // vue-compat.test.ts integration suite.
13
+
14
+ function container(): HTMLElement {
15
+ const el = document.createElement('div')
16
+ document.body.appendChild(el)
17
+ return el
18
+ }
19
+
20
+ const tick = () => new Promise<void>((r) => queueMicrotask(() => r()))
21
+
22
+ describe('vue-compat — wrapCompatComponent (jsx-runtime)', () => {
23
+ it('caches the wrapper per source-fn identity (same wrapper on repeat jsx calls)', () => {
24
+ const Comp = () => jsx('div', { children: 'hi' })
25
+ const a = jsx(Comp, {})
26
+ const b = jsx(Comp, {})
27
+ // Both vnodes should reference the same wrapped component (the
28
+ // _wrapperCache WeakMap reuses by source-fn identity)
29
+ expect(a.type).toBe(b.type)
30
+ })
31
+
32
+ it('produces distinct wrappers for distinct source functions', () => {
33
+ const A = () => jsx('div', { children: 'a' })
34
+ const B = () => jsx('div', { children: 'b' })
35
+ expect(jsx(A, {}).type).not.toBe(jsx(B, {}).type)
36
+ })
37
+
38
+ it('mounts a Vue-style component and renders into the container', () => {
39
+ const Comp = () => jsx('div', { children: 'mounted-via-vue-compat' })
40
+ const c = container()
41
+ mount(jsx(Comp, {}), c)
42
+ expect(c.textContent).toContain('mounted-via-vue-compat')
43
+ })
44
+
45
+ it('runs onMounted callback after first render', async () => {
46
+ const mountedSpy = vi.fn()
47
+ const Comp = () => {
48
+ onMounted(mountedSpy)
49
+ return jsx('div', { children: 'hi' })
50
+ }
51
+
52
+ const c = container()
53
+ mount(jsx(Comp, {}), c)
54
+ await tick()
55
+ await tick()
56
+ expect(mountedSpy).toHaveBeenCalledTimes(1)
57
+ })
58
+
59
+ it('runs onUnmounted callback after disposal', async () => {
60
+ const unmountedSpy = vi.fn()
61
+ const Comp = () => {
62
+ onUnmounted(unmountedSpy)
63
+ return jsx('div', { children: 'hi' })
64
+ }
65
+
66
+ const c = container()
67
+ const dispose = mount(jsx(Comp, {}), c)
68
+ await tick()
69
+ expect(unmountedSpy).not.toHaveBeenCalled()
70
+
71
+ dispose()
72
+ expect(unmountedSpy).toHaveBeenCalledTimes(1)
73
+ })
74
+
75
+ it('handles components with children prop (passes children through to wrapped fn)', () => {
76
+ const Wrapper = (props: { children?: string }) =>
77
+ jsx('section', { children: props.children ?? '' })
78
+ const c = container()
79
+ mount(jsx(Wrapper, { children: 'inner' }), c)
80
+ expect(c.textContent).toContain('inner')
81
+ })
82
+
83
+ it('handles components with no props', () => {
84
+ const Comp = () => jsx('div', { children: 'noprops' })
85
+ const c = container()
86
+ mount(jsx(Comp, {}), c)
87
+ expect(c.textContent).toContain('noprops')
88
+ })
89
+
90
+ it('skips wrapping for components marked via nativeCompat() (Vue-side parity with React/Preact/Solid compat)', () => {
91
+ // Pre-PR-2 vue-compat's jsx() had no NATIVE marker check — even marked
92
+ // components got wrapCompatComponent applied, which broke Pyreon framework
93
+ // components composed inside Vue-style code (their `provide()` /
94
+ // `onMount()` calls fired outside Pyreon's setup frame). PR 2 added the
95
+ // missing check at the same site as react/preact/solid compat.
96
+ //
97
+ // Bisect-verified: removing the `if (isNativeCompat(type))` branch from
98
+ // jsx-runtime.ts puts vue-compat back in the broken state — this test
99
+ // fails because the wrapped component's identity no longer matches the
100
+ // native source fn.
101
+ const Native = () => jsx('div', { children: 'native' })
102
+ nativeCompat(Native)
103
+ const vnode = jsx(Native, {})
104
+ // Marker hit: jsx() routes through h() directly with the SOURCE fn,
105
+ // never through wrapCompatComponent. So vnode.type === Native.
106
+ expect(vnode.type).toBe(Native)
107
+ })
108
+
109
+ it('jsx() wraps UNMARKED components (control — bypass is selective)', () => {
110
+ // Sibling assertion to "skips wrapping for marked components": proves the
111
+ // marker check is selective (only marked components bypass), not blanket.
112
+ // Without this, a regression that accidentally wraps EVERYTHING would
113
+ // still pass the "marked component" test as a coincidence.
114
+ const Unmarked = () => jsx('div', { children: 'wrapped' })
115
+ const vnode = jsx(Unmarked, {})
116
+ // Wrapper: vnode.type is the cached wrapCompatComponent, NOT the source fn.
117
+ expect(vnode.type).not.toBe(Unmarked)
118
+ expect(typeof vnode.type).toBe('function')
119
+ })
120
+
121
+ it('marked Provider mounts cleanly through compat-mode jsx() — provide() reaches descendants', () => {
122
+ // PR 5 mount-integration smoke (parity with react/preact/solid-compat
123
+ // suites). Sanity check that a marked Provider mounts cleanly through
124
+ // compat-mode jsx() and its `provide()` reaches the descendant Consumer.
125
+ //
126
+ // Note: this assertion is NOT bisect-load-bearing for the marker check
127
+ // itself. Synchronous mount preserves provide() context even WITH the
128
+ // wrapper (provide() pushes onto the global context stack regardless),
129
+ // so removing `nativeCompat(Provider)` here won't fail this test. The
130
+ // genuine multi-render-cycle bug PR #425 prevents (signal change re-fires
131
+ // the wrapper's accessor → provide() in re-run lands in stale stack) is
132
+ // covered by PR #427's e2e gate against real router state.
133
+ //
134
+ // The bisect-load-bearing assertion is the bypass-identity test above
135
+ // (`vnode.type === Native`) — that one fails when the marker check is
136
+ // removed from `jsx-runtime.ts`.
137
+ const Ctx = createContext<string>('default')
138
+
139
+ const Provider: ComponentFn = (props) => {
140
+ provide(Ctx, props.value as string)
141
+ return props.children as never
142
+ }
143
+ nativeCompat(Provider)
144
+
145
+ const Consumer: ComponentFn = () => {
146
+ const value = useContext(Ctx)
147
+ return h('span', { 'data-value': value }, value)
148
+ }
149
+ nativeCompat(Consumer)
150
+
151
+ const el = container()
152
+ mount(jsx(Provider, { value: 'native', children: jsx(Consumer, {}) }), el)
153
+
154
+ const span = el.querySelector('span')
155
+ expect(span?.getAttribute('data-value')).toBe('native')
156
+ expect(span?.textContent).toBe('native')
157
+ })
158
+ })