@pyreon/preact-compat 0.2.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/preact-compat",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Preact-compatible API shim for Pyreon — write Preact-style code that runs on Pyreon's reactive engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,16 @@
38
38
  "bun": "./src/signals.ts",
39
39
  "import": "./lib/signals.js",
40
40
  "types": "./lib/types/signals.d.ts"
41
+ },
42
+ "./jsx-runtime": {
43
+ "bun": "./src/jsx-runtime.ts",
44
+ "import": "./lib/jsx-runtime.js",
45
+ "types": "./lib/types/jsx-runtime.d.ts"
46
+ },
47
+ "./jsx-dev-runtime": {
48
+ "bun": "./src/jsx-runtime.ts",
49
+ "import": "./lib/jsx-runtime.js",
50
+ "types": "./lib/types/jsx-runtime.d.ts"
41
51
  }
42
52
  },
43
53
  "scripts": {
@@ -49,9 +59,9 @@
49
59
  "prepublishOnly": "bun run build"
50
60
  },
51
61
  "dependencies": {
52
- "@pyreon/core": "^0.2.1",
53
- "@pyreon/reactivity": "^0.2.1",
54
- "@pyreon/runtime-dom": "^0.2.1"
62
+ "@pyreon/core": "^0.3.0",
63
+ "@pyreon/reactivity": "^0.3.0",
64
+ "@pyreon/runtime-dom": "^0.3.0"
55
65
  },
56
66
  "devDependencies": {
57
67
  "@happy-dom/global-registrator": "^20.8.3",
package/src/hooks.ts CHANGED
@@ -1,129 +1,247 @@
1
1
  /**
2
2
  * @pyreon/preact-compat/hooks
3
3
  *
4
- * Preact hooks — separate import like `preact/hooks`.
5
- * All hooks run on Pyreon's reactive engine under the hood.
4
+ * Preact-compatible hooks — separate import like `preact/hooks`.
5
+ *
6
+ * Components re-render on state change — just like Preact. Hooks return plain
7
+ * values and use deps arrays for memoization. Existing Preact code works
8
+ * unchanged when paired with `pyreon({ compat: "preact" })` in your vite config.
6
9
  */
7
10
 
8
- import type { CleanupFn } from "@pyreon/core"
9
- import { createRef, onErrorCaptured, onMount, onUnmount, useContext } from "@pyreon/core"
10
- import { computed, effect, getCurrentScope, runUntracked, signal } from "@pyreon/reactivity"
11
+ import type { VNodeChild } from "@pyreon/core"
12
+ import { onErrorCaptured, useContext } from "@pyreon/core"
13
+ import type { EffectEntry } from "./jsx-runtime"
14
+ import { getCurrentCtx, getHookIndex } from "./jsx-runtime"
11
15
 
12
16
  export { useContext }
13
17
 
18
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
19
+
20
+ function requireCtx() {
21
+ const ctx = getCurrentCtx()
22
+ if (!ctx) throw new Error("Hook called outside of a component render")
23
+ return ctx
24
+ }
25
+
26
+ function depsChanged(a: unknown[] | undefined, b: unknown[] | undefined): boolean {
27
+ if (a === undefined || b === undefined) return true
28
+ if (a.length !== b.length) return true
29
+ for (let i = 0; i < a.length; i++) {
30
+ if (!Object.is(a[i], b[i])) return true
31
+ }
32
+ return false
33
+ }
34
+
14
35
  // ─── useState ────────────────────────────────────────────────────────────────
15
36
 
16
37
  /**
17
- * Drop-in for Preact's `useState`.
18
- * Returns `[getter, setter]` call `getter()` to read, `setter(v)` to write.
38
+ * Preact-compatible `useState` returns `[value, setter]`.
39
+ * Triggers a component re-render when the setter is called.
19
40
  */
20
- export function useState<T>(initial: T | (() => T)): [() => T, (v: T | ((prev: T) => T)) => void] {
21
- const s = signal<T>(typeof initial === "function" ? (initial as () => T)() : initial)
41
+ export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T)) => void] {
42
+ const ctx = requireCtx()
43
+ const idx = getHookIndex()
44
+
45
+ if (ctx.hooks.length <= idx) {
46
+ ctx.hooks.push(typeof initial === "function" ? (initial as () => T)() : initial)
47
+ }
48
+
49
+ const value = ctx.hooks[idx] as T
22
50
  const setter = (v: T | ((prev: T) => T)) => {
23
- if (typeof v === "function") s.update(v as (prev: T) => T)
24
- else s.set(v)
51
+ const current = ctx.hooks[idx] as T
52
+ const next = typeof v === "function" ? (v as (prev: T) => T)(current) : v
53
+ if (Object.is(current, next)) return
54
+ ctx.hooks[idx] = next
55
+ ctx.scheduleRerender()
25
56
  }
26
- return [s, setter]
57
+
58
+ return [value, setter]
27
59
  }
28
60
 
29
61
  // ─── useEffect ───────────────────────────────────────────────────────────────
30
62
 
31
63
  /**
32
- * Drop-in for Preact's `useEffect`.
33
- * The `deps` array is IGNORED Pyreon tracks dependencies automatically.
64
+ * Preact-compatible `useEffect` runs after render when deps change.
65
+ * Returns cleanup on unmount and before re-running.
34
66
  */
35
- // biome-ignore lint/suspicious/noConfusingVoidType: void is intentional — callers may return void
36
- export function useEffect(fn: () => CleanupFn | void, deps?: unknown[]): void {
37
- if (deps !== undefined && deps.length === 0) {
38
- onMount((): undefined => {
39
- const cleanup = runUntracked(fn)
40
- if (typeof cleanup === "function") onUnmount(cleanup)
41
- })
67
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches Preact's useEffect signature
68
+ export function useEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
69
+ const ctx = requireCtx()
70
+ const idx = getHookIndex()
71
+
72
+ if (ctx.hooks.length <= idx) {
73
+ // First render — always run
74
+ const entry: EffectEntry = { fn, deps, cleanup: undefined }
75
+ ctx.hooks.push(entry)
76
+ ctx.pendingEffects.push(entry)
42
77
  } else {
43
- // effect() natively supports cleanup: if fn() returns a function,
44
- // it's called before re-runs and on dispose.
45
- const e = effect(fn)
46
- onUnmount(() => {
47
- e.dispose()
48
- })
78
+ const entry = ctx.hooks[idx] as EffectEntry
79
+ if (depsChanged(entry.deps, deps)) {
80
+ entry.fn = fn
81
+ entry.deps = deps
82
+ ctx.pendingEffects.push(entry)
83
+ }
49
84
  }
50
85
  }
51
86
 
52
87
  // ─── useLayoutEffect ─────────────────────────────────────────────────────────
53
88
 
54
89
  /**
55
- * Drop-in for Preact's `useLayoutEffect`.
56
- * No distinction from useEffect in Pyreon — same implementation.
90
+ * Preact-compatible `useLayoutEffect` runs synchronously after DOM mutations.
57
91
  */
58
- export const useLayoutEffect = useEffect
92
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches Preact's useLayoutEffect signature
93
+ export function useLayoutEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
94
+ const ctx = requireCtx()
95
+ const idx = getHookIndex()
96
+
97
+ if (ctx.hooks.length <= idx) {
98
+ const entry: EffectEntry = { fn, deps, cleanup: undefined }
99
+ ctx.hooks.push(entry)
100
+ ctx.pendingLayoutEffects.push(entry)
101
+ } else {
102
+ const entry = ctx.hooks[idx] as EffectEntry
103
+ if (depsChanged(entry.deps, deps)) {
104
+ entry.fn = fn
105
+ entry.deps = deps
106
+ ctx.pendingLayoutEffects.push(entry)
107
+ }
108
+ }
109
+ }
59
110
 
60
111
  // ─── useMemo ─────────────────────────────────────────────────────────────────
61
112
 
62
113
  /**
63
- * Drop-in for Preact's `useMemo`.
64
- * Returns a getter — call `value()` to read.
114
+ * Preact-compatible `useMemo` returns the cached value, recomputed when deps change.
65
115
  */
66
- export function useMemo<T>(fn: () => T, _deps?: unknown[]): () => T {
67
- return computed(fn)
116
+ export function useMemo<T>(fn: () => T, deps: unknown[]): T {
117
+ const ctx = requireCtx()
118
+ const idx = getHookIndex()
119
+
120
+ if (ctx.hooks.length <= idx) {
121
+ const value = fn()
122
+ ctx.hooks.push({ value, deps })
123
+ return value
124
+ }
125
+
126
+ const entry = ctx.hooks[idx] as { value: T; deps: unknown[] }
127
+ if (depsChanged(entry.deps, deps)) {
128
+ entry.value = fn()
129
+ entry.deps = deps
130
+ }
131
+ return entry.value
68
132
  }
69
133
 
70
134
  // ─── useCallback ─────────────────────────────────────────────────────────────
71
135
 
72
136
  /**
73
- * Drop-in for Preact's `useCallback`.
74
- * Components run once in Pyreon — returns `fn` as-is.
137
+ * Preact-compatible `useCallback` — returns the cached function when deps haven't changed.
75
138
  */
76
- // biome-ignore lint/suspicious/noExplicitAny: any is needed for contravariant function params
77
- export function useCallback<T extends (...args: any[]) => any>(fn: T, _deps?: unknown[]): T {
78
- return fn
139
+ export function useCallback<T extends (...args: never[]) => unknown>(fn: T, deps: unknown[]): T {
140
+ return useMemo(() => fn, deps)
79
141
  }
80
142
 
81
143
  // ─── useRef ──────────────────────────────────────────────────────────────────
82
144
 
83
145
  /**
84
- * Drop-in for Preact's `useRef`.
85
- * Returns `{ current: T }`.
146
+ * Preact-compatible `useRef` returns `{ current }` persisted across re-renders.
86
147
  */
87
148
  export function useRef<T>(initial?: T): { current: T | null } {
88
- const ref = createRef<T>()
89
- if (initial !== undefined) ref.current = initial as T
90
- return ref
149
+ const ctx = requireCtx()
150
+ const idx = getHookIndex()
151
+
152
+ if (ctx.hooks.length <= idx) {
153
+ const ref = { current: initial !== undefined ? (initial as T) : null }
154
+ ctx.hooks.push(ref)
155
+ }
156
+
157
+ return ctx.hooks[idx] as { current: T | null }
91
158
  }
92
159
 
93
160
  // ─── useReducer ──────────────────────────────────────────────────────────────
94
161
 
95
162
  /**
96
- * Drop-in for Preact's `useReducer`.
163
+ * Preact-compatible `useReducer` returns `[state, dispatch]`.
97
164
  */
98
165
  export function useReducer<S, A>(
99
166
  reducer: (state: S, action: A) => S,
100
167
  initial: S | (() => S),
101
- ): [() => S, (action: A) => void] {
102
- const s = signal<S>(typeof initial === "function" ? (initial as () => S)() : initial)
103
- const dispatch = (action: A) => s.update((prev) => reducer(prev, action))
104
- return [s, dispatch]
168
+ ): [S, (action: A) => void] {
169
+ const ctx = requireCtx()
170
+ const idx = getHookIndex()
171
+
172
+ if (ctx.hooks.length <= idx) {
173
+ ctx.hooks.push(typeof initial === "function" ? (initial as () => S)() : initial)
174
+ }
175
+
176
+ const state = ctx.hooks[idx] as S
177
+ const dispatch = (action: A) => {
178
+ const current = ctx.hooks[idx] as S
179
+ const next = reducer(current, action)
180
+ if (Object.is(current, next)) return
181
+ ctx.hooks[idx] = next
182
+ ctx.scheduleRerender()
183
+ }
184
+
185
+ return [state, dispatch]
105
186
  }
106
187
 
107
188
  // ─── useId ───────────────────────────────────────────────────────────────────
108
189
 
109
- const _idCounters = new WeakMap<object, number>()
190
+ let _idCounter = 0
110
191
 
111
192
  /**
112
- * Drop-in for Preact's `useId`.
113
- * Returns a stable unique string per component instance.
193
+ * Preact-compatible `useId` returns a stable unique string per hook call.
114
194
  */
115
195
  export function useId(): string {
116
- const scope = getCurrentScope()
117
- if (!scope) return `:r${Math.random().toString(36).slice(2, 9)}:`
118
- const count = _idCounters.get(scope) ?? 0
119
- _idCounters.set(scope, count + 1)
120
- return `:r${count.toString(36)}:`
196
+ const ctx = requireCtx()
197
+ const idx = getHookIndex()
198
+
199
+ if (ctx.hooks.length <= idx) {
200
+ ctx.hooks.push(`:r${(_idCounter++).toString(36)}:`)
201
+ }
202
+
203
+ return ctx.hooks[idx] as string
204
+ }
205
+
206
+ // ─── Optimization ────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Preact-compatible `memo` — wraps a component to skip re-render when props
210
+ * are shallowly equal.
211
+ */
212
+ export function memo<P extends Record<string, unknown>>(
213
+ component: (props: P) => VNodeChild,
214
+ areEqual?: (prevProps: P, nextProps: P) => boolean,
215
+ ): (props: P) => VNodeChild {
216
+ const compare =
217
+ areEqual ??
218
+ ((a: P, b: P) => {
219
+ const keysA = Object.keys(a)
220
+ const keysB = Object.keys(b)
221
+ if (keysA.length !== keysB.length) return false
222
+ for (const k of keysA) {
223
+ if (!Object.is(a[k], b[k])) return false
224
+ }
225
+ return true
226
+ })
227
+
228
+ let prevProps: P | null = null
229
+ let prevResult: VNodeChild = null
230
+
231
+ return (props: P) => {
232
+ if (prevProps !== null && compare(prevProps, props)) {
233
+ return prevResult
234
+ }
235
+ prevProps = props
236
+ prevResult = (component as (p: P) => VNodeChild)(props)
237
+ return prevResult
238
+ }
121
239
  }
122
240
 
123
241
  // ─── useErrorBoundary ────────────────────────────────────────────────────────
124
242
 
125
243
  /**
126
- * Drop-in for Preact's `useErrorBoundary`.
244
+ * Preact-compatible `useErrorBoundary`.
127
245
  * Wraps Pyreon's `onErrorCaptured`.
128
246
  */
129
247
  export { onErrorCaptured as useErrorBoundary }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Compat JSX runtime for Preact compatibility mode.
3
+ *
4
+ * When `jsxImportSource` is redirected to `@pyreon/preact-compat` (via the vite
5
+ * plugin's `compat: "preact"` 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 Preact-style re-renders on state change while Pyreon's
9
+ * existing renderer handles all DOM work.
10
+ */
11
+
12
+ import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
13
+ import { Fragment, h } from "@pyreon/core"
14
+ import { signal } from "@pyreon/reactivity"
15
+
16
+ export { Fragment }
17
+
18
+ // ─── Render context (used by hooks) ──────────────────────────────────────────
19
+
20
+ export interface RenderContext {
21
+ hooks: unknown[]
22
+ scheduleRerender: () => void
23
+ /** Effect entries pending execution after render */
24
+ pendingEffects: EffectEntry[]
25
+ /** Layout effect entries pending execution after render */
26
+ pendingLayoutEffects: EffectEntry[]
27
+ /** Set to true when the component is unmounted */
28
+ unmounted: boolean
29
+ }
30
+
31
+ export interface EffectEntry {
32
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches Preact's effect signature
33
+ fn: () => (() => void) | void
34
+ deps: unknown[] | undefined
35
+ cleanup: (() => void) | undefined
36
+ }
37
+
38
+ let _currentCtx: RenderContext | null = null
39
+ let _hookIndex = 0
40
+
41
+ export function getCurrentCtx(): RenderContext | null {
42
+ return _currentCtx
43
+ }
44
+
45
+ export function getHookIndex(): number {
46
+ return _hookIndex++
47
+ }
48
+
49
+ export function beginRender(ctx: RenderContext): void {
50
+ _currentCtx = ctx
51
+ _hookIndex = 0
52
+ ctx.pendingEffects = []
53
+ ctx.pendingLayoutEffects = []
54
+ }
55
+
56
+ export function endRender(): void {
57
+ _currentCtx = null
58
+ _hookIndex = 0
59
+ }
60
+
61
+ // ─── Effect runners ──────────────────────────────────────────────────────────
62
+
63
+ function runLayoutEffects(entries: EffectEntry[]): void {
64
+ for (const entry of entries) {
65
+ if (entry.cleanup) entry.cleanup()
66
+ const cleanup = entry.fn()
67
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
68
+ }
69
+ }
70
+
71
+ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
72
+ if (entries.length === 0) return
73
+ queueMicrotask(() => {
74
+ for (const entry of entries) {
75
+ if (ctx.unmounted) return
76
+ if (entry.cleanup) entry.cleanup()
77
+ const cleanup = entry.fn()
78
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
79
+ }
80
+ })
81
+ }
82
+
83
+ // ─── Component wrapping ──────────────────────────────────────────────────────
84
+
85
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
86
+ const _wrapperCache = new WeakMap<Function, ComponentFn>()
87
+
88
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
89
+ function wrapCompatComponent(preactComponent: Function): ComponentFn {
90
+ let wrapped = _wrapperCache.get(preactComponent)
91
+ if (wrapped) return wrapped
92
+
93
+ // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
94
+ // mountChild treats as a reactive expression via mountReactive.
95
+ wrapped = ((props: Props) => {
96
+ const ctx: RenderContext = {
97
+ hooks: [],
98
+ scheduleRerender: () => {
99
+ // Will be replaced below after version signal is created
100
+ },
101
+ pendingEffects: [],
102
+ pendingLayoutEffects: [],
103
+ unmounted: false,
104
+ }
105
+
106
+ const version = signal(0)
107
+ let updateScheduled = false
108
+
109
+ ctx.scheduleRerender = () => {
110
+ if (ctx.unmounted || updateScheduled) return
111
+ updateScheduled = true
112
+ queueMicrotask(() => {
113
+ updateScheduled = false
114
+ if (!ctx.unmounted) version.set(version.peek() + 1)
115
+ })
116
+ }
117
+
118
+ // Return reactive accessor — Pyreon's mountChild calls mountReactive
119
+ return () => {
120
+ version() // tracked read — triggers re-execution when state changes
121
+ beginRender(ctx)
122
+ const result = (preactComponent as ComponentFn)(props)
123
+ const layoutEffects = ctx.pendingLayoutEffects
124
+ const effects = ctx.pendingEffects
125
+ endRender()
126
+
127
+ runLayoutEffects(layoutEffects)
128
+ scheduleEffects(ctx, effects)
129
+
130
+ return result
131
+ }
132
+ }) as unknown as ComponentFn
133
+
134
+ _wrapperCache.set(preactComponent, wrapped)
135
+ return wrapped
136
+ }
137
+
138
+ // ─── JSX functions ───────────────────────────────────────────────────────────
139
+
140
+ export function jsx(
141
+ type: string | ComponentFn | symbol,
142
+ props: Props & { children?: VNodeChild | VNodeChild[] },
143
+ key?: string | number | null,
144
+ ): VNode {
145
+ const { children, ...rest } = props
146
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
147
+
148
+ if (typeof type === "function") {
149
+ // Wrap Preact-style component for re-render support
150
+ const wrapped = wrapCompatComponent(type)
151
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
152
+ return h(wrapped, componentProps)
153
+ }
154
+
155
+ // DOM element or symbol (Fragment): children go in vnode.children
156
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
157
+
158
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
159
+ }
160
+
161
+ export const jsxs = jsx
162
+ export const jsxDEV = jsx