@pyreon/preact-compat 0.13.1 → 0.14.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.
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
13
- import { Fragment, h } from '@pyreon/core'
13
+ import { Fragment, h, onUnmount } from '@pyreon/core'
14
14
  import { signal } from '@pyreon/reactivity'
15
+ import type { Component } from './index'
15
16
 
16
17
  export { Fragment }
17
18
 
@@ -79,6 +80,84 @@ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
79
80
  })
80
81
  }
81
82
 
83
+ // ─── Native component marker ────────────────────────────────────────────────
84
+
85
+ const NATIVE_COMPONENT = Symbol.for('pyreon:native-compat')
86
+
87
+ // ─── Class component detection ──────────────────────────────────────────────
88
+
89
+ function isClassComponent(type: Function): boolean {
90
+ return type.prototype != null && typeof type.prototype.render === 'function'
91
+ }
92
+
93
+ // ─── Class component wrapping ───────────────────────────────────────────────
94
+
95
+ function wrapClassComponent(ClassComp: Function): ComponentFn {
96
+ const wrapped = ((props: Props) => {
97
+ const instance = new (ClassComp as new (props: Props) => Component)(props)
98
+ const version = signal(0)
99
+ let updateScheduled = false
100
+
101
+ // Override setState to trigger re-render via version signal
102
+ const origSetState = instance.setState.bind(instance)
103
+ instance.setState = (partial: Partial<Record<string, unknown>>) => {
104
+ origSetState(partial)
105
+ if (!updateScheduled) {
106
+ updateScheduled = true
107
+ queueMicrotask(() => {
108
+ updateScheduled = false
109
+ version.set(version.peek() + 1)
110
+ })
111
+ }
112
+ }
113
+
114
+ // Override forceUpdate
115
+ instance.forceUpdate = () => {
116
+ version.set(version.peek() + 1)
117
+ }
118
+
119
+ // Lifecycle: componentWillUnmount
120
+ let didMountFired = false
121
+ onUnmount(() => {
122
+ if (typeof instance.componentWillUnmount === 'function') {
123
+ instance.componentWillUnmount()
124
+ }
125
+ })
126
+
127
+ // Return reactive accessor for re-renders
128
+ return () => {
129
+ const ver = version() // track for re-renders
130
+ instance.props = props // update props on re-render
131
+
132
+ // shouldComponentUpdate only applies after mount (ver > 0 means setState/forceUpdate)
133
+ if (didMountFired && ver > 0 && typeof instance.shouldComponentUpdate === 'function') {
134
+ if (!instance.shouldComponentUpdate(props, instance.state)) {
135
+ return instance._lastResult // skip render
136
+ }
137
+ }
138
+
139
+ const result = instance.render()
140
+ instance._lastResult = result
141
+
142
+ // componentDidMount fires once after the initial render settles
143
+ if (!didMountFired) {
144
+ didMountFired = true
145
+ if (typeof instance.componentDidMount === 'function') {
146
+ queueMicrotask(() => instance.componentDidMount!())
147
+ }
148
+ } else if (ver > 0) {
149
+ // componentDidUpdate only fires on explicit re-renders (setState/forceUpdate)
150
+ if (typeof instance.componentDidUpdate === 'function') {
151
+ queueMicrotask(() => instance.componentDidUpdate!())
152
+ }
153
+ }
154
+
155
+ return result
156
+ }
157
+ }) as unknown as ComponentFn
158
+ return wrapped
159
+ }
160
+
82
161
  // ─── Component wrapping ──────────────────────────────────────────────────────
83
162
 
84
163
  const _wrapperCache = new WeakMap<Function, ComponentFn>()
@@ -87,6 +166,13 @@ function wrapCompatComponent(preactComponent: Function): ComponentFn {
87
166
  let wrapped = _wrapperCache.get(preactComponent)
88
167
  if (wrapped) return wrapped
89
168
 
169
+ // Handle class components (those with prototype.render)
170
+ if (isClassComponent(preactComponent)) {
171
+ wrapped = wrapClassComponent(preactComponent)
172
+ _wrapperCache.set(preactComponent, wrapped)
173
+ return wrapped
174
+ }
175
+
90
176
  // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
91
177
  // mountChild treats as a reactive expression via mountReactive.
92
178
  wrapped = ((props: Props) => {
@@ -112,6 +198,17 @@ function wrapCompatComponent(preactComponent: Function): ComponentFn {
112
198
  })
113
199
  }
114
200
 
201
+ // Register cleanup for all hooks on unmount
202
+ onUnmount(() => {
203
+ ctx.unmounted = true
204
+ for (const hook of ctx.hooks) {
205
+ if (hook && typeof hook === 'object' && 'cleanup' in hook) {
206
+ const entry = hook as EffectEntry
207
+ if (typeof entry.cleanup === 'function') entry.cleanup()
208
+ }
209
+ }
210
+ })
211
+
115
212
  // Return reactive accessor — Pyreon's mountChild calls mountReactive
116
213
  return () => {
117
214
  version() // tracked read — triggers re-execution when state changes
@@ -143,15 +240,63 @@ export function jsx(
143
240
  const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
144
241
 
145
242
  if (typeof type === 'function') {
243
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
244
+ // Native Pyreon components (e.g. context Provider) skip compat wrapping
245
+ if ((type as unknown as Record<symbol, boolean>)[NATIVE_COMPONENT]) {
246
+ return h(type as ComponentFn, componentProps)
247
+ }
146
248
  // Wrap Preact-style component for re-render support
147
249
  const wrapped = wrapCompatComponent(type)
148
- const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
149
250
  return h(wrapped, componentProps)
150
251
  }
151
252
 
152
253
  // DOM element or symbol (Fragment): children go in vnode.children
153
254
  const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
154
255
 
256
+ // Map Preact-style attributes to standard HTML attributes
257
+ if (typeof type === 'string') {
258
+ if (propsWithKey.className !== undefined) {
259
+ propsWithKey.class = propsWithKey.className
260
+ delete propsWithKey.className
261
+ }
262
+ if (propsWithKey.htmlFor !== undefined) {
263
+ propsWithKey.for = propsWithKey.htmlFor
264
+ delete propsWithKey.htmlFor
265
+ }
266
+
267
+ // Preact's onChange fires on every keystroke for form elements (like onInput)
268
+ if (
269
+ (type === 'input' || type === 'textarea' || type === 'select') &&
270
+ propsWithKey.onChange !== undefined
271
+ ) {
272
+ if (propsWithKey.onInput === undefined) {
273
+ propsWithKey.onInput = propsWithKey.onChange
274
+ }
275
+ delete propsWithKey.onChange
276
+ }
277
+
278
+ // autoFocus → autofocus
279
+ if (propsWithKey.autoFocus !== undefined) {
280
+ propsWithKey.autofocus = propsWithKey.autoFocus
281
+ delete propsWithKey.autoFocus
282
+ }
283
+
284
+ // defaultValue / defaultChecked → value / checked when no controlled value
285
+ if (type === 'input' || type === 'textarea') {
286
+ if (propsWithKey.defaultValue !== undefined && propsWithKey.value === undefined) {
287
+ propsWithKey.value = propsWithKey.defaultValue
288
+ delete propsWithKey.defaultValue
289
+ }
290
+ if (propsWithKey.defaultChecked !== undefined && propsWithKey.checked === undefined) {
291
+ propsWithKey.checked = propsWithKey.defaultChecked
292
+ delete propsWithKey.defaultChecked
293
+ }
294
+ }
295
+
296
+ // Strip Preact-only props that have no DOM equivalent
297
+ delete propsWithKey.suppressHydrationWarning
298
+ }
299
+
155
300
  return h(type, propsWithKey, ...(childArray as VNodeChild[]))
156
301
  }
157
302
 
package/src/signals.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  batch as pyreonBatch,
11
11
  computed as pyreonComputed,
12
12
  effect as pyreonEffect,
13
+ runUntracked as pyreonRunUntracked,
13
14
  signal as pyreonSignal,
14
15
  } from '@pyreon/reactivity'
15
16
 
@@ -63,8 +64,7 @@ export function computed<T>(fn: () => T): ReadonlySignal<T> {
63
64
  return c()
64
65
  },
65
66
  peek(): T {
66
- // computed doesn't have peek — just read the value untracked
67
- return c()
67
+ return pyreonRunUntracked(() => c())
68
68
  },
69
69
  }
70
70
  }