@pyreon/core 0.16.0 → 0.19.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/README.md +1 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-dev-runtime.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +278 -16
- package/lib/jsx-dev-runtime.js +29 -9
- package/lib/jsx-runtime.js +29 -9
- package/lib/types/index.d.ts +171 -18
- package/package.json +2 -2
- package/src/compat-shared.ts +80 -0
- package/src/defer.ts +279 -0
- package/src/index.ts +13 -2
- package/src/jsx-runtime.ts +46 -8
- package/src/lifecycle.ts +4 -2
- package/src/props.ts +59 -0
- package/src/telemetry.ts +37 -0
- package/src/tests/compat-shared.test.ts +99 -0
- package/src/tests/defer.test.ts +359 -0
- package/src/tests/reactive-props.test.ts +71 -1
- package/src/tests/telemetry.test.ts +94 -0
package/src/jsx-runtime.ts
CHANGED
|
@@ -25,19 +25,57 @@ export function jsx(
|
|
|
25
25
|
props: Props & { children?: VNodeChild | VNodeChild[] },
|
|
26
26
|
key?: string | number | null,
|
|
27
27
|
): VNode {
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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/lifecycle.ts
CHANGED
|
@@ -59,12 +59,14 @@ function captureCallSite(): string {
|
|
|
59
59
|
function warnOutsideSetup(hookName: string): void {
|
|
60
60
|
if (__DEV__ && !_current) {
|
|
61
61
|
const callSite = captureCallSite()
|
|
62
|
-
|
|
62
|
+
// Local name must NOT shadow the `location` browser global (poor
|
|
63
|
+
// hygiene + trips SSR static analysis into a false positive).
|
|
64
|
+
const callSiteSuffix = callSite ? `\n Called from: ${callSite}` : ''
|
|
63
65
|
// oxlint-disable-next-line no-console
|
|
64
66
|
console.warn(
|
|
65
67
|
`[Pyreon] ${hookName}() called outside component setup. ` +
|
|
66
68
|
"Lifecycle hooks must be called synchronously during a component's setup function." +
|
|
67
|
-
|
|
69
|
+
callSiteSuffix +
|
|
68
70
|
(hookName === 'onUnmount'
|
|
69
71
|
? '\n Hint: `provide()` internally calls onUnmount(). If you use provide(), ensure it runs during synchronous component setup — not inside effects, callbacks, or after awaits.'
|
|
70
72
|
: ''),
|
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
|
*
|
package/src/telemetry.ts
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* })
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { getReactiveTrace, type ReactiveTraceEntry } from '@pyreon/reactivity'
|
|
20
|
+
|
|
21
|
+
// Bundler-agnostic dev gate (see pyreon/no-process-dev-gate).
|
|
22
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
23
|
+
|
|
24
|
+
export type { ReactiveTraceEntry }
|
|
25
|
+
|
|
19
26
|
export interface ErrorContext {
|
|
20
27
|
/** Component function name, "Anonymous", or "Effect" for reactive effects */
|
|
21
28
|
component: string
|
|
@@ -27,6 +34,22 @@ export interface ErrorContext {
|
|
|
27
34
|
timestamp: number
|
|
28
35
|
/** Component props at the time of the error */
|
|
29
36
|
props?: Record<string, unknown>
|
|
37
|
+
/**
|
|
38
|
+
* The last N signal writes (chronological, oldest → newest) leading
|
|
39
|
+
* up to the error — the causal sequence of reactive state changes,
|
|
40
|
+
* not a point-in-time snapshot. Each entry is `{ name, prev, next,
|
|
41
|
+
* timestamp }` with `prev` / `next` as bounded string previews.
|
|
42
|
+
*
|
|
43
|
+
* Populated automatically in development from `@pyreon/reactivity`'s
|
|
44
|
+
* dev-only ring buffer. **`undefined` in production** — the recorder
|
|
45
|
+
* feeding the buffer tree-shakes out of prod bundles, so the cost is
|
|
46
|
+
* zero and the field is simply absent.
|
|
47
|
+
*
|
|
48
|
+
* For a signal framework this answers the first question a crash
|
|
49
|
+
* raises — "what reactive state changed in the run-up?" — that the
|
|
50
|
+
* thrown value + stack alone can't.
|
|
51
|
+
*/
|
|
52
|
+
reactiveTrace?: ReactiveTraceEntry[]
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
export type ErrorHandler = (ctx: ErrorContext) => void
|
|
@@ -56,6 +79,20 @@ export function registerErrorHandler(handler: ErrorHandler): () => void {
|
|
|
56
79
|
* Existing console.error calls are preserved; this is additive.
|
|
57
80
|
*/
|
|
58
81
|
export function reportError(ctx: ErrorContext): void {
|
|
82
|
+
// Enrich with the recent-signal-write trace so every handler (Sentry,
|
|
83
|
+
// Datadog, console) gets the causal reactive sequence for free. Only
|
|
84
|
+
// when the caller didn't already supply one, and only in dev — the
|
|
85
|
+
// gate lets the `getReactiveTrace` call (and the buffer behind it)
|
|
86
|
+
// tree-shake out of production. A throwing/empty trace must never
|
|
87
|
+
// block error reporting, so it's best-effort.
|
|
88
|
+
if (__DEV__ && ctx.reactiveTrace === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
const trace = getReactiveTrace()
|
|
91
|
+
if (trace.length > 0) ctx.reactiveTrace = trace
|
|
92
|
+
} catch {
|
|
93
|
+
// Trace capture is diagnostic — never let it swallow the real error.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
59
96
|
for (const h of _handlers) {
|
|
60
97
|
try {
|
|
61
98
|
h(ctx)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mapCompatDomProps, shallowEqualProps } from '../compat-shared'
|
|
3
|
+
|
|
4
|
+
describe('shallowEqualProps', () => {
|
|
5
|
+
it('equal for same-key same-value objects', () => {
|
|
6
|
+
expect(shallowEqualProps({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('not equal when a value differs', () => {
|
|
10
|
+
expect(shallowEqualProps({ a: 1 }, { a: 2 })).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('not equal when key counts differ', () => {
|
|
14
|
+
expect(shallowEqualProps({ a: 1 }, { a: 1, b: 2 })).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('uses Object.is semantics (NaN equal, ±0 distinct)', () => {
|
|
18
|
+
expect(shallowEqualProps({ n: NaN }, { n: NaN })).toBe(true)
|
|
19
|
+
expect(shallowEqualProps({ z: 0 }, { z: -0 })).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('empty objects are equal', () => {
|
|
23
|
+
expect(shallowEqualProps({}, {})).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('mapCompatDomProps', () => {
|
|
28
|
+
it('no-op for component (non-string) type', () => {
|
|
29
|
+
const Comp = () => null
|
|
30
|
+
const p: Record<string, unknown> = { className: 'x', htmlFor: 'y' }
|
|
31
|
+
mapCompatDomProps(p, Comp)
|
|
32
|
+
expect(p).toEqual({ className: 'x', htmlFor: 'y' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('className → class, htmlFor → for', () => {
|
|
36
|
+
const p: Record<string, unknown> = { className: 'btn', htmlFor: 'email' }
|
|
37
|
+
mapCompatDomProps(p, 'label')
|
|
38
|
+
expect(p).toEqual({ class: 'btn', for: 'email' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('onChange → onInput on input/textarea/select', () => {
|
|
42
|
+
for (const tag of ['input', 'textarea', 'select']) {
|
|
43
|
+
const fn = () => {}
|
|
44
|
+
const p: Record<string, unknown> = { onChange: fn }
|
|
45
|
+
mapCompatDomProps(p, tag)
|
|
46
|
+
expect(p).toEqual({ onInput: fn })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('onChange does not clobber an explicit onInput', () => {
|
|
51
|
+
const onChange = () => {}
|
|
52
|
+
const onInput = () => {}
|
|
53
|
+
const p: Record<string, unknown> = { onChange, onInput }
|
|
54
|
+
mapCompatDomProps(p, 'input')
|
|
55
|
+
expect(p).toEqual({ onInput })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('onChange left alone on non-form elements', () => {
|
|
59
|
+
const onChange = () => {}
|
|
60
|
+
const p: Record<string, unknown> = { onChange }
|
|
61
|
+
mapCompatDomProps(p, 'div')
|
|
62
|
+
expect(p).toEqual({ onChange })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('autoFocus → autofocus', () => {
|
|
66
|
+
const p: Record<string, unknown> = { autoFocus: true }
|
|
67
|
+
mapCompatDomProps(p, 'input')
|
|
68
|
+
expect(p).toEqual({ autofocus: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('defaultValue/defaultChecked → value/checked only when uncontrolled', () => {
|
|
72
|
+
const a: Record<string, unknown> = { defaultValue: 'd', defaultChecked: true }
|
|
73
|
+
mapCompatDomProps(a, 'input')
|
|
74
|
+
expect(a).toEqual({ value: 'd', checked: true })
|
|
75
|
+
|
|
76
|
+
const b: Record<string, unknown> = {
|
|
77
|
+
defaultValue: 'd',
|
|
78
|
+
value: 'controlled',
|
|
79
|
+
defaultChecked: true,
|
|
80
|
+
checked: false,
|
|
81
|
+
}
|
|
82
|
+
mapCompatDomProps(b, 'input')
|
|
83
|
+
expect(b).toEqual({
|
|
84
|
+
defaultValue: 'd',
|
|
85
|
+
value: 'controlled',
|
|
86
|
+
defaultChecked: true,
|
|
87
|
+
checked: false,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('strips authoring-only props with no DOM equivalent', () => {
|
|
92
|
+
const p: Record<string, unknown> = {
|
|
93
|
+
suppressHydrationWarning: true,
|
|
94
|
+
suppressContentEditableWarning: true,
|
|
95
|
+
}
|
|
96
|
+
mapCompatDomProps(p, 'div')
|
|
97
|
+
expect(p).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { _setupIdleTrigger, _setupVisibleTrigger, Defer } from '../defer'
|
|
3
|
+
import { Fragment, h } from '../h'
|
|
4
|
+
import type { ComponentFn, Props, VNode } from '../types'
|
|
5
|
+
|
|
6
|
+
// Helper: pull the render-callback out of Defer's returned VNode shape.
|
|
7
|
+
// `when` and `idle` modes return a Fragment whose `children[0]` is a
|
|
8
|
+
// thunk. `visible` mode returns a div whose `children[0]` is the same
|
|
9
|
+
// thunk. Both shapes return the same `renderContent` accessor.
|
|
10
|
+
function getRenderThunk(vnode: VNode): () => unknown {
|
|
11
|
+
const children = vnode.children as unknown[]
|
|
12
|
+
const thunk = children[0]
|
|
13
|
+
if (typeof thunk !== 'function') throw new Error('Expected render thunk')
|
|
14
|
+
return thunk as () => unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Defer — common shape', () => {
|
|
18
|
+
test('returns a VNode (Fragment or wrapper div per trigger mode)', () => {
|
|
19
|
+
const result = Defer({
|
|
20
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
21
|
+
when: () => false,
|
|
22
|
+
})
|
|
23
|
+
expect(result).toBeDefined()
|
|
24
|
+
expect((result as VNode).type).toBe(Fragment)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('renders fallback before chunk resolves', () => {
|
|
28
|
+
const fallback = h('span', null, 'loading…')
|
|
29
|
+
const vnode = Defer<Props>({
|
|
30
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}), // never resolves
|
|
31
|
+
when: () => true,
|
|
32
|
+
fallback,
|
|
33
|
+
})
|
|
34
|
+
expect(getRenderThunk(vnode)()).toBe(fallback)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('renders null when no fallback and chunk has not resolved', () => {
|
|
38
|
+
const vnode = Defer<Props>({
|
|
39
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
40
|
+
when: () => true,
|
|
41
|
+
})
|
|
42
|
+
expect(getRenderThunk(vnode)()).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Defer — when (signal-driven)', () => {
|
|
47
|
+
test('does NOT load chunk while when is false', () => {
|
|
48
|
+
let calls = 0
|
|
49
|
+
const chunkFn = () => {
|
|
50
|
+
calls++
|
|
51
|
+
return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
|
|
52
|
+
}
|
|
53
|
+
const flag = signal(false)
|
|
54
|
+
Defer<Props>({ chunk: chunkFn, when: flag })
|
|
55
|
+
expect(calls).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('loads chunk when when flips to true', async () => {
|
|
59
|
+
const Inner: ComponentFn<{ msg: string }> = (p) => h('div', null, p.msg)
|
|
60
|
+
let calls = 0
|
|
61
|
+
const chunkFn = () => {
|
|
62
|
+
calls++
|
|
63
|
+
return Promise.resolve({ default: Inner })
|
|
64
|
+
}
|
|
65
|
+
const flag = signal(false)
|
|
66
|
+
const vnode = Defer<{ msg: string }>({
|
|
67
|
+
chunk: chunkFn,
|
|
68
|
+
when: flag,
|
|
69
|
+
children: (Comp) => h(Comp, { msg: 'hi' }),
|
|
70
|
+
})
|
|
71
|
+
expect(calls).toBe(0)
|
|
72
|
+
flag.set(true)
|
|
73
|
+
// Effect schedules synchronously; chunk fetch is microtask-resolved.
|
|
74
|
+
expect(calls).toBe(1)
|
|
75
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
76
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
77
|
+
expect(result.type).toBe(Inner)
|
|
78
|
+
expect(result.props).toEqual({ msg: 'hi' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('loads chunk EXACTLY ONCE when signal oscillates', async () => {
|
|
82
|
+
let calls = 0
|
|
83
|
+
const chunkFn = () => {
|
|
84
|
+
calls++
|
|
85
|
+
return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
|
|
86
|
+
}
|
|
87
|
+
const flag = signal(false)
|
|
88
|
+
Defer<Props>({ chunk: chunkFn, when: flag })
|
|
89
|
+
flag.set(true)
|
|
90
|
+
flag.set(false)
|
|
91
|
+
flag.set(true)
|
|
92
|
+
flag.set(false)
|
|
93
|
+
flag.set(true)
|
|
94
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
95
|
+
expect(calls).toBe(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('accepts component re-exports without default wrapper', async () => {
|
|
99
|
+
const Inner: ComponentFn = () => h('span', null, 'ok')
|
|
100
|
+
const flag = signal(true)
|
|
101
|
+
const vnode = Defer<Props>({
|
|
102
|
+
chunk: () => Promise.resolve(Inner), // bare ComponentFn
|
|
103
|
+
when: flag,
|
|
104
|
+
children: (Comp) => h(Comp, {}),
|
|
105
|
+
})
|
|
106
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
107
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
108
|
+
expect(result.type).toBe(Inner)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('throws when chunk() rejects (Suspense-style error propagation)', async () => {
|
|
112
|
+
const consoleSpy = (() => {
|
|
113
|
+
const orig = console.error
|
|
114
|
+
console.error = () => {} // silence dev-mode error log
|
|
115
|
+
return () => {
|
|
116
|
+
console.error = orig
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
try {
|
|
120
|
+
const flag = signal(true)
|
|
121
|
+
const vnode = Defer<Props>({
|
|
122
|
+
chunk: () => Promise.reject(new Error('chunk boom')),
|
|
123
|
+
when: flag,
|
|
124
|
+
})
|
|
125
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
126
|
+
expect(() => getRenderThunk(vnode)()).toThrow('chunk boom')
|
|
127
|
+
} finally {
|
|
128
|
+
consoleSpy()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('renders default <Comp /> when children render-prop omitted', async () => {
|
|
133
|
+
const Inner: ComponentFn = () => h('div', null, 'no-children-prop')
|
|
134
|
+
const flag = signal(true)
|
|
135
|
+
const vnode = Defer<Props>({
|
|
136
|
+
chunk: () => Promise.resolve({ default: Inner }),
|
|
137
|
+
when: flag,
|
|
138
|
+
})
|
|
139
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
140
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
141
|
+
expect(result.type).toBe(Inner)
|
|
142
|
+
expect(result.props).toEqual({})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('Defer — on="visible"', () => {
|
|
147
|
+
test('returns a div wrapper with data-pyreon-defer="visible"', () => {
|
|
148
|
+
const vnode = Defer<Props>({
|
|
149
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
150
|
+
on: 'visible',
|
|
151
|
+
})
|
|
152
|
+
expect((vnode as VNode).type).toBe('div')
|
|
153
|
+
expect((vnode as VNode).props['data-pyreon-defer']).toBe('visible')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('uses display: contents so wrapper is layout-transparent', () => {
|
|
157
|
+
const vnode = Defer<Props>({
|
|
158
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
159
|
+
on: 'visible',
|
|
160
|
+
})
|
|
161
|
+
expect((vnode as VNode).props.style).toBe('display: contents')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('default rootMargin is 200px (not exposed via prop spread)', () => {
|
|
165
|
+
// The rootMargin is consumed by onMount; we can't directly observe
|
|
166
|
+
// it from the returned VNode. This test documents the default and
|
|
167
|
+
// would catch a regression in the constant.
|
|
168
|
+
const vnode = Defer<Props>({
|
|
169
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
170
|
+
on: 'visible',
|
|
171
|
+
})
|
|
172
|
+
// Wrapper has the structural attrs but no rootMargin leak to DOM.
|
|
173
|
+
expect((vnode as VNode).props.rootMargin).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('Defer — on="idle"', () => {
|
|
178
|
+
test('returns a Fragment (no wrapper element)', () => {
|
|
179
|
+
const vnode = Defer<Props>({
|
|
180
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
181
|
+
on: 'idle',
|
|
182
|
+
})
|
|
183
|
+
expect((vnode as VNode).type).toBe(Fragment)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Browser-API helpers extracted from the onMount callbacks so they're
|
|
188
|
+
// directly testable without happy-dom (core tests run in Node). The
|
|
189
|
+
// onMount wrappers in `defer.ts` just delegate to these.
|
|
190
|
+
describe('_setupIdleTrigger', () => {
|
|
191
|
+
// Use `Reflect.has` + property descriptors instead of plain assignment
|
|
192
|
+
// so the restore is symmetric — if the global wasn't defined at test
|
|
193
|
+
// start (Node), `delete` cleanly returns to the original state.
|
|
194
|
+
const orig = {
|
|
195
|
+
ric: (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback,
|
|
196
|
+
cic: (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback,
|
|
197
|
+
}
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
if (orig.ric === undefined) delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
200
|
+
else (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = orig.ric
|
|
201
|
+
if (orig.cic === undefined) delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
|
|
202
|
+
else (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = orig.cic
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('uses requestIdleCallback when available', () => {
|
|
206
|
+
let captured: (() => void) | null = null
|
|
207
|
+
;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
|
|
208
|
+
cb: () => void,
|
|
209
|
+
): number => {
|
|
210
|
+
captured = cb
|
|
211
|
+
return 42
|
|
212
|
+
}
|
|
213
|
+
let cancelledId: number | null = null
|
|
214
|
+
;(globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = (
|
|
215
|
+
id: number,
|
|
216
|
+
): void => {
|
|
217
|
+
cancelledId = id
|
|
218
|
+
}
|
|
219
|
+
const startLoad = () => {}
|
|
220
|
+
const teardown = _setupIdleTrigger(startLoad)
|
|
221
|
+
expect(captured).toBe(startLoad)
|
|
222
|
+
teardown()
|
|
223
|
+
expect(cancelledId).toBe(42)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('teardown is no-op when cancelIdleCallback is missing', () => {
|
|
227
|
+
;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
|
|
228
|
+
_cb: () => void,
|
|
229
|
+
): number => 99
|
|
230
|
+
delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
|
|
231
|
+
const teardown = _setupIdleTrigger(() => {})
|
|
232
|
+
expect(() => teardown()).not.toThrow()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('falls back to setTimeout when requestIdleCallback is absent', async () => {
|
|
236
|
+
delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
237
|
+
let loaded = false
|
|
238
|
+
const teardown = _setupIdleTrigger(() => {
|
|
239
|
+
loaded = true
|
|
240
|
+
})
|
|
241
|
+
// setTimeout(fn, 1) fires after a tick — await to observe.
|
|
242
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
243
|
+
expect(loaded).toBe(true)
|
|
244
|
+
teardown() // safe to call after the timer fired
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('setTimeout fallback cancels via clearTimeout when teardown fires before tick', () => {
|
|
248
|
+
delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
249
|
+
let loaded = false
|
|
250
|
+
const teardown = _setupIdleTrigger(() => {
|
|
251
|
+
loaded = true
|
|
252
|
+
})
|
|
253
|
+
teardown() // cancel before the timer fires
|
|
254
|
+
return new Promise<void>((r) =>
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
expect(loaded).toBe(false)
|
|
257
|
+
r()
|
|
258
|
+
}, 10),
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('_setupVisibleTrigger', () => {
|
|
264
|
+
const origObs = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
if (origObs === undefined) delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
267
|
+
else (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = origObs
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('loads immediately when el is null', () => {
|
|
271
|
+
let loaded = false
|
|
272
|
+
const teardown = _setupVisibleTrigger(null, () => {
|
|
273
|
+
loaded = true
|
|
274
|
+
}, '200px')
|
|
275
|
+
expect(loaded).toBe(true)
|
|
276
|
+
expect(typeof teardown).toBe('function')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('loads immediately when IntersectionObserver is unavailable', () => {
|
|
280
|
+
delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
281
|
+
// Pass a stub element; without the global the trigger should bail early.
|
|
282
|
+
const stubEl = {} as unknown as HTMLElement
|
|
283
|
+
let loaded = false
|
|
284
|
+
_setupVisibleTrigger(stubEl, () => {
|
|
285
|
+
loaded = true
|
|
286
|
+
}, '200px')
|
|
287
|
+
expect(loaded).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('creates an observer with the configured rootMargin', () => {
|
|
291
|
+
let capturedOptions: IntersectionObserverInit | undefined
|
|
292
|
+
let observed: Element | null = null
|
|
293
|
+
let disconnected = false
|
|
294
|
+
class StubObserver {
|
|
295
|
+
callback: IntersectionObserverCallback
|
|
296
|
+
constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) {
|
|
297
|
+
this.callback = cb
|
|
298
|
+
capturedOptions = opts
|
|
299
|
+
}
|
|
300
|
+
observe(el: Element) {
|
|
301
|
+
observed = el
|
|
302
|
+
}
|
|
303
|
+
disconnect() {
|
|
304
|
+
disconnected = true
|
|
305
|
+
}
|
|
306
|
+
unobserve() {}
|
|
307
|
+
takeRecords() {
|
|
308
|
+
return []
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
|
|
312
|
+
const stubEl = { tagName: 'DIV' } as unknown as HTMLElement
|
|
313
|
+
const teardown = _setupVisibleTrigger(stubEl, () => {}, '300px')
|
|
314
|
+
expect(observed).toBe(stubEl)
|
|
315
|
+
expect(capturedOptions?.rootMargin).toBe('300px')
|
|
316
|
+
teardown()
|
|
317
|
+
expect(disconnected).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('fires startLoad on intersection, then disconnects', () => {
|
|
321
|
+
let captured: IntersectionObserverCallback | null = null
|
|
322
|
+
let disconnected = false
|
|
323
|
+
class StubObserver {
|
|
324
|
+
constructor(cb: IntersectionObserverCallback) {
|
|
325
|
+
captured = cb
|
|
326
|
+
}
|
|
327
|
+
observe() {}
|
|
328
|
+
disconnect() {
|
|
329
|
+
disconnected = true
|
|
330
|
+
}
|
|
331
|
+
unobserve() {}
|
|
332
|
+
takeRecords() {
|
|
333
|
+
return []
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
|
|
337
|
+
let loaded = false
|
|
338
|
+
const stubEl = {} as unknown as HTMLElement
|
|
339
|
+
_setupVisibleTrigger(stubEl, () => {
|
|
340
|
+
loaded = true
|
|
341
|
+
}, '0px')
|
|
342
|
+
|
|
343
|
+
// Simulate non-intersecting entry — should NOT fire.
|
|
344
|
+
captured!(
|
|
345
|
+
[{ isIntersecting: false } as unknown as IntersectionObserverEntry],
|
|
346
|
+
{} as IntersectionObserver,
|
|
347
|
+
)
|
|
348
|
+
expect(loaded).toBe(false)
|
|
349
|
+
expect(disconnected).toBe(false)
|
|
350
|
+
|
|
351
|
+
// Simulate intersecting entry — fires and disconnects.
|
|
352
|
+
captured!(
|
|
353
|
+
[{ isIntersecting: true } as unknown as IntersectionObserverEntry],
|
|
354
|
+
{} as IntersectionObserver,
|
|
355
|
+
)
|
|
356
|
+
expect(loaded).toBe(true)
|
|
357
|
+
expect(disconnected).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
})
|