@pyreon/core 0.16.0 → 0.18.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/src/defer.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { effect, signal } from '@pyreon/reactivity'
2
+ import { Fragment, h } from './h'
3
+ import { onMount } from './lifecycle'
4
+ import { createRef } from './ref'
5
+ import type { ComponentFn, Props, VNode, VNodeChild, VNodeChildAccessor } from './types'
6
+
7
+ // Dev-mode gate (bundler-agnostic, see pyreon/no-process-dev-gate).
8
+ const __DEV__ = process.env.NODE_ENV !== 'production'
9
+
10
+ /**
11
+ * Module shape `<Defer>` accepts from `chunk()`. Mirrors `lazy()`'s
12
+ * contract — either an ES module with `default` export, OR a raw
13
+ * `ComponentFn` returned directly (rare; covers re-export patterns).
14
+ */
15
+ type ChunkResult<P extends Props> = { default: ComponentFn<P> } | ComponentFn<P>
16
+
17
+ /**
18
+ * Trigger discriminant. Exactly ONE shape is provided:
19
+ * - `when={() => signal()}` — load when the accessor becomes truthy
20
+ * - `on="visible"` — load when the wrapper enters the viewport
21
+ * - `on="idle"` — load during browser idle time
22
+ */
23
+ type DeferTrigger = { when: () => boolean } | { on: 'visible' | 'idle' }
24
+
25
+ /**
26
+ * Set up the `on="idle"` trigger. Returns a teardown function the
27
+ * caller must invoke on unmount. Browser-API access is gated by
28
+ * `typeof` checks so SSR / jsdom environments fall back to a
29
+ * `setTimeout(1)` shim. Extracted as a standalone helper so it's
30
+ * directly testable without going through `onMount` (core tests
31
+ * don't run in happy-dom; runtime-dom is where the lifecycle hooks
32
+ * live).
33
+ *
34
+ * @internal Exported for tests; not part of the stable public API.
35
+ */
36
+ export function _setupIdleTrigger(startLoad: () => void): () => void {
37
+ const ric = (
38
+ globalThis as { requestIdleCallback?: (cb: () => void) => number }
39
+ ).requestIdleCallback
40
+ const cic = (
41
+ globalThis as { cancelIdleCallback?: (id: number) => void }
42
+ ).cancelIdleCallback
43
+ if (typeof ric === 'function') {
44
+ const id = ric(startLoad)
45
+ return () => cic?.(id)
46
+ }
47
+ const t = setTimeout(startLoad, 1)
48
+ return () => clearTimeout(t)
49
+ }
50
+
51
+ /**
52
+ * Set up the `on="visible"` trigger. Observes `el` via an
53
+ * `IntersectionObserver` and fires `startLoad` once on the first
54
+ * intersection. If `IntersectionObserver` is unavailable (jsdom)
55
+ * or `el` is null (SSR), falls back to loading immediately.
56
+ *
57
+ * Returns a teardown function — call to disconnect the observer.
58
+ *
59
+ * @internal Exported for tests; not part of the stable public API.
60
+ */
61
+ export function _setupVisibleTrigger(
62
+ el: HTMLElement | null,
63
+ startLoad: () => void,
64
+ rootMargin: string,
65
+ ): () => void {
66
+ if (!el || typeof IntersectionObserver === 'undefined') {
67
+ // Observer unavailable or no DOM target — load eagerly so the
68
+ // user still sees the component in environments where the
69
+ // viewport-detection mechanism can't run.
70
+ startLoad()
71
+ return () => {}
72
+ }
73
+ const obs = new IntersectionObserver(
74
+ (entries) => {
75
+ if (entries.some((e) => e.isIntersecting)) {
76
+ startLoad()
77
+ obs.disconnect()
78
+ }
79
+ },
80
+ { rootMargin },
81
+ )
82
+ obs.observe(el)
83
+ return () => obs.disconnect()
84
+ }
85
+
86
+ export type DeferProps<P extends Props> = DeferTrigger & {
87
+ /**
88
+ * Dynamic import to lazy-load. The literal `import('./X')` is what
89
+ * Rolldown / Vite see when emitting chunks — using a variable here
90
+ * defeats code splitting.
91
+ */
92
+ chunk: () => Promise<ChunkResult<P>>
93
+ /**
94
+ * Render-prop for the loaded component. Receives the resolved component
95
+ * and returns its JSX with whatever props the parent needs to pass.
96
+ * Optional — omitting it renders `<Comp />` with no props.
97
+ */
98
+ children?: (Component: ComponentFn<P>) => VNodeChild
99
+ /** Shown while the chunk is loading. Default: `null`. */
100
+ fallback?: VNodeChild
101
+ /**
102
+ * IntersectionObserver `rootMargin` for `on="visible"` mode. Default
103
+ * `'200px'` — start loading the chunk before the wrapper is fully in
104
+ * view so it's typically ready by the time the user scrolls to it.
105
+ */
106
+ rootMargin?: string
107
+ }
108
+
109
+ /**
110
+ * Lazy-load a chunk when a trigger condition is met.
111
+ *
112
+ * Three trigger modes:
113
+ * - `when={() => signal()}` — load when condition flips truthy (modal pattern)
114
+ * - `on="visible"` — load when the wrapper scrolls into view
115
+ * - `on="idle"` — load during browser idle time
116
+ *
117
+ * The chunk fetch is fired exactly once per `Defer` instance — repeated
118
+ * trigger firings after the chunk loads are no-ops.
119
+ *
120
+ * @example
121
+ * // Signal-driven (modal):
122
+ * <Defer chunk={() => import('./ConfirmDeleteModal')} when={open}>
123
+ * {Modal => <Modal onClose={() => setOpen(false)} />}
124
+ * </Defer>
125
+ *
126
+ * @example
127
+ * // Viewport-driven (below-fold):
128
+ * <Defer chunk={() => import('./Comments')} on="visible">
129
+ * {Comments => <Comments postId={id} />}
130
+ * </Defer>
131
+ *
132
+ * @example
133
+ * // Idle-driven (non-critical):
134
+ * <Defer chunk={() => import('./Analytics')} on="idle">
135
+ * {Dashboard => <Dashboard />}
136
+ * </Defer>
137
+ */
138
+ export function Defer<P extends Props>(props: DeferProps<P>): VNode {
139
+ const Loaded = signal<ComponentFn<P> | null>(null)
140
+ const Failed = signal<Error | null>(null)
141
+ // Module-scope flag prevents repeat fetches when the trigger condition
142
+ // oscillates (e.g. modal opens / closes / opens again). The chunk only
143
+ // loads once per Defer mount.
144
+ let loadStarted = false
145
+
146
+ const startLoad = (): void => {
147
+ if (loadStarted) return
148
+ loadStarted = true
149
+ props
150
+ .chunk()
151
+ .then((mod) => {
152
+ // Accept both ES-module-default and bare ComponentFn shapes.
153
+ const Comp =
154
+ typeof mod === 'function'
155
+ ? mod
156
+ : (mod as { default: ComponentFn<P> }).default
157
+ if (__DEV__ && typeof Comp !== 'function') {
158
+ // oxlint-disable-next-line no-console
159
+ console.warn(
160
+ '[Pyreon] <Defer> chunk() resolved without a default-exported component. Make sure your module exports default.',
161
+ )
162
+ return
163
+ }
164
+ Loaded.set(Comp)
165
+ })
166
+ .catch((err) => {
167
+ const wrapped = err instanceof Error ? err : new Error(String(err))
168
+ if (__DEV__) {
169
+ // oxlint-disable-next-line no-console
170
+ console.error('[Pyreon] <Defer> chunk() rejected:', wrapped)
171
+ }
172
+ Failed.set(wrapped)
173
+ })
174
+ }
175
+
176
+ // Trigger wiring — exactly one branch fires per instance.
177
+ if ('when' in props) {
178
+ // Signal-driven. Subscribe to the accessor; load when it transitions
179
+ // to truthy. Repeat truthy emissions are no-ops via `loadStarted`.
180
+ effect(() => {
181
+ if (props.when() && !loadStarted) startLoad()
182
+ })
183
+ } else if (props.on === 'idle') {
184
+ // Idle-driven. Delegated to `_setupIdleTrigger` so the browser-API
185
+ // branching is testable as a pure function. Wrapped in onMount so
186
+ // SSR / non-browser environments don't fire the callback at all.
187
+ onMount(() => _setupIdleTrigger(startLoad))
188
+ }
189
+ // Note: `on === 'visible'` is wired below alongside the wrapper element
190
+ // because it needs a DOM target to observe.
191
+
192
+ // Inline accessor — type annotation deliberately omitted so the
193
+ // inferred return type narrows to `VNodeChildAtom | VNodeChildAtom[]`
194
+ // (what `h()`'s rest-args expect). Annotating as `VNodeChild` widens
195
+ // to include `VNodeChildAccessor`, which can't be returned from another
196
+ // accessor.
197
+ const renderContent = () => {
198
+ const err = Failed()
199
+ if (err) throw err
200
+ const Comp = Loaded()
201
+ if (!Comp) return props.fallback ?? null
202
+ return props.children ? props.children(Comp) : h(Comp as ComponentFn, {})
203
+ }
204
+
205
+ if ('on' in props && props.on === 'visible') {
206
+ // Visible-mode needs a DOM target for IntersectionObserver. A
207
+ // wrapper `<div data-pyreon-defer="visible">` carries the ref and
208
+ // styles `display: contents` so it's transparent to layout (the
209
+ // fallback / loaded component render as direct children of Defer's
210
+ // parent).
211
+ const containerRef = createRef<HTMLElement>()
212
+ // Visible-mode trigger is wired via `_setupVisibleTrigger` so the
213
+ // observer-construction + intersection-detection logic is
214
+ // independently testable. onMount keeps the browser-API access
215
+ // out of the SSR path.
216
+ onMount(() =>
217
+ _setupVisibleTrigger(
218
+ containerRef.current,
219
+ startLoad,
220
+ props.rootMargin ?? '200px',
221
+ ),
222
+ )
223
+ // Cast renderContent to VNodeChildAccessor — its inferred return type
224
+ // is `VNodeChild` (broader than the accessor's `atom | atom[]`) because
225
+ // `props.children` itself may return any VNodeChild. The runtime
226
+ // unwraps nested accessors via the same mountChild path that handles
227
+ // <Show>'s thunk shape; the type system doesn't model the unwrap so
228
+ // the cast bridges. See <Show>'s `as unknown as VNode` for prior art.
229
+ return h(
230
+ 'div',
231
+ {
232
+ 'data-pyreon-defer': 'visible',
233
+ ref: containerRef,
234
+ style: 'display: contents',
235
+ },
236
+ renderContent as VNodeChildAccessor,
237
+ )
238
+ }
239
+
240
+ return h(Fragment, null, renderContent as VNodeChildAccessor)
241
+ }
package/src/index.ts CHANGED
@@ -35,12 +35,22 @@ export type {
35
35
  TargetedEvent,
36
36
  TextareaAttributes,
37
37
  } from './jsx-runtime'
38
+ export type { DeferProps } from './defer'
39
+ export { Defer } from './defer'
38
40
  export { lazy } from './lazy'
39
41
  export { onErrorCaptured, onMount, onUnmount, onUpdate } from './lifecycle'
40
42
  export { mapArray } from './map-array'
41
43
  export type { PortalProps } from './portal'
42
44
  export { Portal, PortalSymbol } from './portal'
43
- export { _rp, createUniqueId, makeReactiveProps, mergeProps, REACTIVE_PROP, splitProps } from './props'
45
+ export {
46
+ _rp,
47
+ _wrapSpread,
48
+ createUniqueId,
49
+ makeReactiveProps,
50
+ mergeProps,
51
+ REACTIVE_PROP,
52
+ splitProps,
53
+ } from './props'
44
54
  export type { Ref, RefCallback, RefProp } from './ref'
45
55
  export { createRef } from './ref'
46
56
  export type { MatchProps, ShowProps, SwitchProps } from './show'
@@ -25,19 +25,57 @@ export function jsx(
25
25
  props: Props & { children?: VNodeChild | VNodeChild[] },
26
26
  key?: string | number | null,
27
27
  ): VNode {
28
- const { children, ...rest } = props
29
- const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
28
+ // Build the destructured props object by copying own property
29
+ // DESCRIPTORS, not values. Compiler-emitted reactive props (`_rp(() =>
30
+ // signal())` wrappers converted to getter properties by
31
+ // `makeReactiveProps` in mount.ts) MUST survive the destructure with
32
+ // their getters intact. A plain `{ children, ...rest } = props`
33
+ // destructure fires every getter on `props` and stores the resolved
34
+ // value, breaking signal-driven reactivity for any downstream
35
+ // consumer that reads `props.x` in a tracking scope.
36
+ //
37
+ // Fast path: if `props` has no own property descriptors with `get`
38
+ // accessors, we can use the original value-copy shape (cheap object
39
+ // literal allocation). This is the 99% case — only framework wrappers
40
+ // (rocketstyle attrs HOC, Wrapper, styled) and direct signal props
41
+ // produce getter-shaped descriptors.
42
+ const descriptors = Object.getOwnPropertyDescriptors(props)
43
+ let hasGetter = false
44
+ for (const k in descriptors) {
45
+ if (descriptors[k]!.get) {
46
+ hasGetter = true
47
+ break
48
+ }
49
+ }
50
+ const children = props.children
51
+
52
+ if (!hasGetter) {
53
+ const { children: _ignored, ...rest } = props
54
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
55
+ if (typeof type === 'function') {
56
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
57
+ return h(type, componentProps)
58
+ }
59
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
60
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
61
+ }
62
+
63
+ // Slow path: at least one getter descriptor present — preserve
64
+ // descriptors during the destructure.
65
+ const propsWithKey: Record<string, unknown> = {}
66
+ for (const k in descriptors) {
67
+ if (k === 'children') continue
68
+ Object.defineProperty(propsWithKey, k, descriptors[k]!)
69
+ }
70
+ if (key != null) propsWithKey.key = key as unknown
30
71
 
31
72
  if (typeof type === 'function') {
32
- // Component: keep children in props.children so the component function can access them.
33
- // Children must NOT be spread as h() rest args because mountComponent only passes vnode.props.
34
- const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
35
- return h(type, componentProps)
73
+ if (children !== undefined) propsWithKey.children = children
74
+ return h(type, propsWithKey as Props)
36
75
  }
37
76
 
38
- // DOM element or symbol (Fragment, ForSymbol): children go in vnode.children
39
77
  const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
40
- return h(type, propsWithKey, ...(childArray as VNodeChild[]))
78
+ return h(type, propsWithKey as Props, ...(childArray as VNodeChild[]))
41
79
  }
42
80
 
43
81
  // jsxs is called when there are multiple static children — same signature
package/src/props.ts CHANGED
@@ -140,6 +140,65 @@ export function _rp<T>(fn: () => T): () => T {
140
140
  return fn
141
141
  }
142
142
 
143
+ /**
144
+ * Wrap a JSX spread source so its getter-shaped reactive props survive
145
+ * the JS-level object spread that esbuild's automatic JSX runtime emits
146
+ * for `<Comp {...source}>`.
147
+ *
148
+ * Without this wrapper, esbuild compiles `<Comp {...source}>` to
149
+ * `jsx(Comp, { ...source })` — and JS spread fires every getter on
150
+ * `source`, storing the resolved values as plain data properties. Any
151
+ * compiler-emitted reactive prop (`_rp(() => signal())` converted to a
152
+ * getter by `makeReactiveProps`) on `source` is collapsed to its
153
+ * initial value before the receiving component ever sees it.
154
+ *
155
+ * `_wrapSpread(source)` walks `source`'s own keys via `Reflect.ownKeys`
156
+ * (no getter firing) and returns a new object whose values are
157
+ * `_rp`-branded thunks `() => source[key]`. When `{ ..._wrapSpread(s) }`
158
+ * is spread by esbuild, the thunks are stored as plain data property
159
+ * values (no getters to fire), then `makeReactiveProps` in `mount.ts`
160
+ * converts the brands back into getters that lazily read from the
161
+ * original `source` — preserving the reactive subscription end-to-end.
162
+ *
163
+ * Fast path: when `source` has no getter descriptors, return the
164
+ * source object unchanged. JS spread will work correctly in that case
165
+ * because there's nothing reactive to preserve. Saves N thunk
166
+ * allocations per component render in the 99% case.
167
+ *
168
+ * Emitted by the compiler — not generally meant for hand-written code.
169
+ */
170
+ export function _wrapSpread(
171
+ source: Record<string, unknown> | null | undefined,
172
+ ): Record<string, unknown> | null | undefined {
173
+ if (!source || typeof source !== 'object') return source
174
+ const descriptors = Object.getOwnPropertyDescriptors(source)
175
+ let hasGetter = false
176
+ for (const k in descriptors) {
177
+ if (descriptors[k]!.get) {
178
+ hasGetter = true
179
+ break
180
+ }
181
+ }
182
+ if (!hasGetter) return source
183
+
184
+ const result: Record<string, unknown> = {}
185
+ // Reflect.ownKeys covers symbol keys too — REACTIVE_PROP brands and
186
+ // other framework symbols must round-trip through the wrap.
187
+ for (const key of Reflect.ownKeys(source)) {
188
+ const desc = descriptors[key as string]
189
+ if (!desc) continue
190
+ if (desc.get) {
191
+ const fn: () => unknown = () => source[key as string]
192
+ ;(fn as unknown as Record<symbol, boolean>)[REACTIVE_PROP] = true
193
+ result[key as string] = fn
194
+ } else {
195
+ // Static data property — copy through as-is.
196
+ result[key as string] = desc.value
197
+ }
198
+ }
199
+ return result
200
+ }
201
+
143
202
  /**
144
203
  * Convert compiler-emitted `_rp(() => expr)` prop values into getter properties.
145
204
  *