@pyreon/core 0.14.0 → 0.16.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/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 +144 -17
- package/lib/jsx-dev-runtime.js +23 -2
- package/lib/jsx-runtime.js +23 -2
- package/lib/types/index.d.ts +169 -15
- package/lib/types/jsx-dev-runtime.d.ts +19 -4
- package/lib/types/jsx-runtime.d.ts +19 -4
- package/package.json +3 -2
- package/src/compat-marker.ts +79 -0
- package/src/context.ts +38 -7
- package/src/dynamic.ts +16 -5
- package/src/error-boundary.ts +15 -2
- package/src/for.ts +13 -1
- package/src/h.ts +16 -2
- package/src/index.ts +1 -0
- package/src/jsx-runtime.ts +20 -2
- package/src/lifecycle.ts +1 -2
- package/src/manifest.ts +55 -7
- package/src/show.ts +19 -6
- package/src/suspense.ts +1 -2
- package/src/telemetry.ts +30 -2
- package/src/tests/compat-marker.test.ts +96 -0
- package/src/tests/core.test.ts +1 -1
- package/src/tests/dynamic.test.ts +33 -1
- package/src/tests/extract-props-overloads.types.test.ts +135 -0
- package/src/tests/for.test.ts +23 -0
- package/src/tests/h.test.ts +21 -0
- package/src/tests/manifest-snapshot.test.ts +6 -1
- package/src/tests/native-marker-error-boundary.test.ts +12 -0
- package/src/tests/show.test.ts +76 -0
- package/src/tests/telemetry.test.ts +61 -0
- package/src/types.ts +45 -2
- package/lib/index.js.map +0 -1
- package/lib/jsx-dev-runtime.js.map +0 -1
- package/lib/jsx-runtime.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/jsx-dev-runtime.d.ts.map +0 -1
- package/lib/types/jsx-runtime.d.ts.map +0 -1
package/src/h.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import type { ComponentFn, Props, VNode, VNodeChild } from './types'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Marker for fragment nodes — renders children without a wrapper element.
|
|
5
|
+
*
|
|
6
|
+
* MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
|
|
7
|
+
* `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
|
|
8
|
+
* main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
|
|
9
|
+
* each bundle's evaluation of a bare `Symbol(...)` would produce a
|
|
10
|
+
* DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
|
|
11
|
+
* resolves to jsx-runtime's identity; `runtime-server` checks
|
|
12
|
+
* `vnode.type === Fragment` against the main-entry identity. Mismatch
|
|
13
|
+
* fell through to `renderElement` and crashed SSG with
|
|
14
|
+
* `TypeError: Cannot convert a Symbol value to a string`.
|
|
15
|
+
* `Symbol.for()` keys by string in a global registry shared across all
|
|
16
|
+
* bundle evaluations — same identity everywhere.
|
|
17
|
+
*/
|
|
18
|
+
export const Fragment: symbol = Symbol.for('Pyreon.Fragment')
|
|
5
19
|
|
|
6
20
|
/**
|
|
7
21
|
* Hyperscript function — the compiled output of JSX.
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @pyreon/core — component model, VNode types, lifecycle hooks
|
|
2
2
|
|
|
3
3
|
export { defineComponent, dispatchToErrorBoundary, propagateError, runWithHooks } from './component'
|
|
4
|
+
export { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from './compat-marker'
|
|
4
5
|
export type { Context, ContextSnapshot, ReactiveContext } from './context'
|
|
5
6
|
export {
|
|
6
7
|
captureContextStack,
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
1
2
|
/**
|
|
2
3
|
* JSX automatic runtime.
|
|
3
4
|
*
|
|
4
5
|
* When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
|
|
5
6
|
* rewrites JSX to imports from this file automatically:
|
|
6
7
|
* <div class="x" /> → jsx("div", { class: "x" })
|
|
8
|
+
*
|
|
9
|
+
* The triple-slash reference above makes this file self-declare its DOM-lib
|
|
10
|
+
* dependency. Without it, any consumer whose tsconfig has `lib: ["ESNext"]`
|
|
11
|
+
* (no DOM) — e.g. backend-only packages like @pyreon/cli — fails to typecheck
|
|
12
|
+
* once `@pyreon/core` becomes resolvable from their dependency graph (e.g. via
|
|
13
|
+
* a transitive devDep), because tsc auto-resolves jsxImportSource and pulls
|
|
14
|
+
* jsx-runtime.ts into the consumer's compilation unit.
|
|
7
15
|
*/
|
|
8
16
|
import { Fragment, h } from './h'
|
|
9
17
|
import type { RefProp } from './ref'
|
|
@@ -132,6 +140,8 @@ export interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
|
|
|
132
140
|
// Events — typed currentTarget via generic E
|
|
133
141
|
onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
134
142
|
onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
143
|
+
// React-compat alias for onDblClick — compiler maps to `dblclick` DOM event.
|
|
144
|
+
onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
135
145
|
onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
136
146
|
onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
137
147
|
onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
@@ -197,7 +207,11 @@ export interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement>
|
|
|
197
207
|
defaultChecked?: boolean | undefined
|
|
198
208
|
placeholder?: string | (() => string) | undefined
|
|
199
209
|
disabled?: boolean | (() => boolean) | undefined
|
|
200
|
-
readOnly
|
|
210
|
+
// `readOnly` is paired with `disabled` semantically — both accept a
|
|
211
|
+
// reactive callable so consumers can spread `useForm.register()`'s
|
|
212
|
+
// return value (which produces `readOnly: Accessor<boolean>`) directly
|
|
213
|
+
// onto `<input>` / `<textarea>` without losing reactivity.
|
|
214
|
+
readOnly?: boolean | (() => boolean) | undefined
|
|
201
215
|
required?: boolean | (() => boolean) | undefined
|
|
202
216
|
min?: string | number | undefined
|
|
203
217
|
max?: string | number | undefined
|
|
@@ -249,7 +263,11 @@ export interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaEle
|
|
|
249
263
|
defaultValue?: string | undefined
|
|
250
264
|
placeholder?: string | (() => string) | undefined
|
|
251
265
|
disabled?: boolean | (() => boolean) | undefined
|
|
252
|
-
readOnly
|
|
266
|
+
// `readOnly` is paired with `disabled` semantically — both accept a
|
|
267
|
+
// reactive callable so consumers can spread `useForm.register()`'s
|
|
268
|
+
// return value (which produces `readOnly: Accessor<boolean>`) directly
|
|
269
|
+
// onto `<input>` / `<textarea>` without losing reactivity.
|
|
270
|
+
readOnly?: boolean | (() => boolean) | undefined
|
|
253
271
|
required?: boolean | (() => boolean) | undefined
|
|
254
272
|
rows?: number | undefined
|
|
255
273
|
cols?: number | undefined
|
package/src/lifecycle.ts
CHANGED
|
@@ -2,8 +2,7 @@ import type { CleanupFn, LifecycleHooks } from './types'
|
|
|
2
2
|
|
|
3
3
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
4
4
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
5
|
-
|
|
6
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
5
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
7
6
|
|
|
8
7
|
// The currently-executing component's hook storage, set by the renderer
|
|
9
8
|
// before calling the component function, cleared immediately after.
|
package/src/manifest.ts
CHANGED
|
@@ -249,14 +249,14 @@ const getMode = useContext(ModeCtx) // reactive: returns () => T`,
|
|
|
249
249
|
kind: 'component',
|
|
250
250
|
signature: '<Show when={condition} fallback={alternative}>{children}</Show>',
|
|
251
251
|
summary:
|
|
252
|
-
'Reactive conditional rendering. Mounts children when `when` is truthy, unmounts and shows `fallback` when falsy. More efficient than ternary for signal-driven conditions because it avoids re-evaluating the entire branch expression on every signal change — `Show` only transitions between mounted/unmounted when the boolean flips.',
|
|
252
|
+
'Reactive conditional rendering. Mounts children when `when` is truthy, unmounts and shows `fallback` when falsy. More efficient than ternary for signal-driven conditions because it avoids re-evaluating the entire branch expression on every signal change — `Show` only transitions between mounted/unmounted when the boolean flips. `when` accepts BOTH a value (`when={true}`, `when={signal()}`) and an accessor (`when={() => signal()}`) — the framework normalizes via `typeof === "function"`. The accessor form is required for true reactivity (the framework re-evaluates it on signal change); a bare `when={signal}` reference works because the compiler\'s signal auto-call rewrites it to `when={signal()}`.',
|
|
253
253
|
example: `<Show when={isLoggedIn()} fallback={<LoginForm />}>
|
|
254
254
|
<Dashboard />
|
|
255
255
|
</Show>`,
|
|
256
256
|
mistakes: [
|
|
257
257
|
'`{cond() ? <A /> : <B />}` — works but less efficient than `<Show>` for signal-driven conditions',
|
|
258
258
|
'`<Show when={items().length}>` — works (truthy check), but be explicit: `<Show when={items().length > 0}>`',
|
|
259
|
-
'`<Show when={
|
|
259
|
+
'`<Show when={signal}>` (bare reference) — relies on the compiler\'s signal auto-call to rewrite to `when={signal()}`. Works defensively but use `when={() => signal()}` for explicit accessor semantics across the entire reactive lifecycle.',
|
|
260
260
|
],
|
|
261
261
|
seeAlso: ['Switch', 'Match', 'For'],
|
|
262
262
|
},
|
|
@@ -284,7 +284,7 @@ const getMode = useContext(ModeCtx) // reactive: returns () => T`,
|
|
|
284
284
|
kind: 'component',
|
|
285
285
|
signature: '<Match when={condition}>{children}</Match>',
|
|
286
286
|
summary:
|
|
287
|
-
'A branch inside a `<Switch>`. Renders its children when `when` is truthy and it is the first truthy `<Match>` in the parent `<Switch>`. Must be a direct child of `<Switch>`.',
|
|
287
|
+
'A branch inside a `<Switch>`. Renders its children when `when` is truthy and it is the first truthy `<Match>` in the parent `<Switch>`. Must be a direct child of `<Switch>`. `when` accepts both a value and an accessor (same normalization as `<Show>`).',
|
|
288
288
|
example: `<Switch>
|
|
289
289
|
<Match when={tab() === "home"}><Home /></Match>
|
|
290
290
|
<Match when={tab() === "settings"}><Settings /></Match>
|
|
@@ -481,14 +481,62 @@ return <input ref={inputRef} />`,
|
|
|
481
481
|
})`,
|
|
482
482
|
seeAlso: ['@pyreon/reactivity'],
|
|
483
483
|
},
|
|
484
|
+
{
|
|
485
|
+
name: 'nativeCompat',
|
|
486
|
+
kind: 'function',
|
|
487
|
+
signature: '<T>(fn: T) => T',
|
|
488
|
+
summary:
|
|
489
|
+
'Mark a Pyreon framework component as "self-managing" so compat layers (`@pyreon/{react,preact,vue,solid}-compat`) skip their wrapping and route the component through Pyreon\'s mount path. Use on every `@pyreon/*` JSX component whose setup body uses `provide()` / lifecycle hooks / signal subscriptions — wrapping breaks those by running the body inside the compat layer\'s render context instead of Pyreon\'s. Idempotent; non-function inputs pass through unchanged. The marker is a registry symbol (`Symbol.for("pyreon:native-compat")`), so framework and compat sides share it without an import dependency between them.',
|
|
490
|
+
example: `// In a framework package:
|
|
491
|
+
export const RouterView = nativeCompat(function RouterView(props) {
|
|
492
|
+
provide(RouterContext, ...)
|
|
493
|
+
return <div>{children}</div>
|
|
494
|
+
})`,
|
|
495
|
+
seeAlso: ['isNativeCompat', 'NATIVE_COMPAT_MARKER'],
|
|
496
|
+
mistakes: [
|
|
497
|
+
'Forgetting to mark a new framework JSX export — under compat mode, the component\'s `provide()` / `onMount()` calls fail with "called outside component setup" warnings and the rendered DOM silently breaks.',
|
|
498
|
+
'Marking user-app components — only `@pyreon/*` framework components that already manage their own reactivity should be marked. User components in compat mode are SUPPOSED to be wrapped (that\'s how they get re-render-on-state-change semantics).',
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: 'isNativeCompat',
|
|
503
|
+
kind: 'function',
|
|
504
|
+
signature: '(fn: unknown) => boolean',
|
|
505
|
+
summary:
|
|
506
|
+
'Compat-layer-side: read whether a function has been marked as a Pyreon native framework component via `nativeCompat()`. Compat `jsx()` calls this to decide whether to skip the React/Vue/Solid/Preact-style wrapping. Always returns `false` for non-function inputs.',
|
|
507
|
+
example: `// In a compat layer's jsx-runtime:
|
|
508
|
+
if (isNativeCompat(type)) return h(type, props)
|
|
509
|
+
return wrapCompatComponent(type)(props)`,
|
|
510
|
+
seeAlso: ['nativeCompat', 'NATIVE_COMPAT_MARKER'],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: 'NATIVE_COMPAT_MARKER',
|
|
514
|
+
kind: 'constant',
|
|
515
|
+
signature: 'symbol',
|
|
516
|
+
summary:
|
|
517
|
+
'The well-known registry symbol (`Symbol.for("pyreon:native-compat")`) used to mark a component as a Pyreon native framework component. Most callers should use `nativeCompat()` / `isNativeCompat()` instead of touching the symbol directly; exported for advanced cases (e.g., a compat layer that wants to inspect the property without going through the helper).',
|
|
518
|
+
example: `import { NATIVE_COMPAT_MARKER } from '@pyreon/core'
|
|
519
|
+
|
|
520
|
+
// Equivalent to nativeCompat(MyComponent):
|
|
521
|
+
;(MyComponent as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER] = true`,
|
|
522
|
+
seeAlso: ['nativeCompat', 'isNativeCompat'],
|
|
523
|
+
},
|
|
484
524
|
{
|
|
485
525
|
name: 'ExtractProps',
|
|
486
526
|
kind: 'type',
|
|
487
|
-
signature:
|
|
527
|
+
signature:
|
|
528
|
+
'type ExtractProps<T> = /* matches up to 4 overloads, unions the props */ T extends ComponentFn<infer P> ? P : T',
|
|
488
529
|
summary:
|
|
489
|
-
|
|
490
|
-
example: `
|
|
491
|
-
|
|
530
|
+
"Extracts the props type from a `ComponentFn`. Passes through unchanged if `T` is not a `ComponentFn`. **Multi-overload aware** — matches up to 4 call signatures and produces the UNION of their first-argument types. Critical for multi-overload primitives (Iterator, List, Element) whose loosest overload is last; without overload-aware extraction, HOC wrapping (`rocketstyle()`, `attrs()`) silently downgraded their public prop surface. Single-overload functions still work — the union of 4 copies of the same props type dedupes back to the single shape.",
|
|
531
|
+
example: `function Iterator<T extends SimpleValue>(p: { data: T[]; valueName?: string }): VNodeChild
|
|
532
|
+
function Iterator<T extends ObjectValue>(p: { data: T[]; component: ComponentFn<T> }): VNodeChild
|
|
533
|
+
type Props = ExtractProps<typeof Iterator>
|
|
534
|
+
// → { data: SimpleValue[]; valueName?: string }
|
|
535
|
+
// | { data: ObjectValue[]; component: ComponentFn<ObjectValue> }`,
|
|
536
|
+
mistakes: [
|
|
537
|
+
'Assuming `ExtractProps<T>` returns only the LAST overload — pre-fix it did, post-fix it returns the UNION of up to 4 overloads. Functions with more than 4 overloads still drop the extras.',
|
|
538
|
+
'Using `T extends (props: infer P) => any ? P : never` directly in user code — that pattern captures only the LAST overload of a multi-overload function. Use `ExtractProps<T>` to get the full union.',
|
|
539
|
+
],
|
|
492
540
|
seeAlso: ['HigherOrderComponent'],
|
|
493
541
|
},
|
|
494
542
|
{
|
package/src/show.ts
CHANGED
|
@@ -3,12 +3,25 @@ import type { Props, VNode, VNodeChild, VNodeChildAtom } from './types'
|
|
|
3
3
|
// ─── Show ─────────────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
export interface ShowProps extends Props {
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Truthy condition. Accepts a value or an accessor.
|
|
8
|
+
*
|
|
9
|
+
* Use an accessor (`() => signal()`) for reactive conditions.
|
|
10
|
+
* Bare values are accepted for static cases and as a defensive normalization
|
|
11
|
+
* for cases where the compiler's signal auto-call has already invoked
|
|
12
|
+
* a signal at the prop site (e.g. `when={mySignal}` becomes `when={mySignal()}`).
|
|
13
|
+
*/
|
|
14
|
+
when: unknown | (() => unknown)
|
|
8
15
|
fallback?: VNodeChild
|
|
9
16
|
children?: VNodeChild
|
|
10
17
|
}
|
|
11
18
|
|
|
19
|
+
// Normalize a value-or-accessor `when` into a single accessor.
|
|
20
|
+
// Same shape used by Match — kept inline (one branch) to stay zero-cost.
|
|
21
|
+
function callWhen(when: unknown): unknown {
|
|
22
|
+
return typeof when === 'function' ? (when as () => unknown)() : when
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
/**
|
|
13
26
|
* Conditionally render children based on a reactive condition.
|
|
14
27
|
*
|
|
@@ -25,7 +38,7 @@ export interface ShowProps extends Props {
|
|
|
25
38
|
export function Show(props: ShowProps): VNode | null {
|
|
26
39
|
// Returns a reactive accessor; the renderer unwraps it at mount time.
|
|
27
40
|
return ((): VNodeChildAtom =>
|
|
28
|
-
(props.when
|
|
41
|
+
(callWhen(props.when)
|
|
29
42
|
? (props.children ?? null)
|
|
30
43
|
: (props.fallback ?? null)) as VNodeChildAtom) as unknown as VNode
|
|
31
44
|
}
|
|
@@ -33,8 +46,8 @@ export function Show(props: ShowProps): VNode | null {
|
|
|
33
46
|
// ─── Switch / Match ───────────────────────────────────────────────────────────
|
|
34
47
|
|
|
35
48
|
export interface MatchProps extends Props {
|
|
36
|
-
/**
|
|
37
|
-
when: () => unknown
|
|
49
|
+
/** Truthy condition. Accepts a value or an accessor — see {@link ShowProps.when}. */
|
|
50
|
+
when: unknown | (() => unknown)
|
|
38
51
|
children?: VNodeChild
|
|
39
52
|
}
|
|
40
53
|
|
|
@@ -97,7 +110,7 @@ export function Switch(props: SwitchProps): VNode | null {
|
|
|
97
110
|
for (const branch of branches) {
|
|
98
111
|
if (!isMatchVNode(branch)) continue
|
|
99
112
|
const matchProps = branch.props as unknown as MatchProps
|
|
100
|
-
if (matchProps.when
|
|
113
|
+
if (callWhen(matchProps.when)) return resolveMatchChildren(branch)
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
return (props.fallback ?? null) as VNodeChildAtom
|
package/src/suspense.ts
CHANGED
|
@@ -3,8 +3,7 @@ import type { Props, VNode, VNodeChild } from './types'
|
|
|
3
3
|
|
|
4
4
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
5
5
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
6
|
-
|
|
7
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
6
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
8
7
|
|
|
9
8
|
/** Internal marker attached to lazy()-wrapped components */
|
|
10
9
|
export type LazyComponent<P extends Props = Props> = ((props: P) => VNodeChild) & {
|
package/src/telemetry.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
|
|
3
3
|
*
|
|
4
|
+
* Captures errors from ALL lifecycle phases including reactive effects.
|
|
5
|
+
* `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
|
|
6
|
+
* globalThis sink (no upward import — reactivity doesn't depend on core).
|
|
7
|
+
*
|
|
4
8
|
* @example
|
|
5
9
|
* import { registerErrorHandler } from "@pyreon/core"
|
|
6
10
|
* import * as Sentry from "@sentry/browser"
|
|
@@ -13,7 +17,7 @@
|
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
export interface ErrorContext {
|
|
16
|
-
/** Component function name, or "
|
|
20
|
+
/** Component function name, "Anonymous", or "Effect" for reactive effects */
|
|
17
21
|
component: string
|
|
18
22
|
/** Lifecycle phase where the error occurred */
|
|
19
23
|
phase: 'setup' | 'render' | 'mount' | 'unmount' | 'effect'
|
|
@@ -31,10 +35,17 @@ let _handlers: ErrorHandler[] = []
|
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Register a global error handler. Called whenever a component throws in any
|
|
34
|
-
* lifecycle phase
|
|
38
|
+
* lifecycle phase, OR an effect throws in `@pyreon/reactivity`. Returns an
|
|
39
|
+
* unregister function.
|
|
40
|
+
*
|
|
41
|
+
* Also installs a `globalThis.__pyreon_report_error__` bridge so the
|
|
42
|
+
* reactivity package (which can't depend on core) can forward effect errors
|
|
43
|
+
* into the same telemetry pipeline. Pre-fix the two surfaces were
|
|
44
|
+
* disconnected — Sentry/Datadog wiring missed effect-thrown errors.
|
|
35
45
|
*/
|
|
36
46
|
export function registerErrorHandler(handler: ErrorHandler): () => void {
|
|
37
47
|
_handlers.push(handler)
|
|
48
|
+
_installReactivityBridge()
|
|
38
49
|
return () => {
|
|
39
50
|
_handlers = _handlers.filter((h) => h !== handler)
|
|
40
51
|
}
|
|
@@ -53,3 +64,20 @@ export function reportError(ctx: ErrorContext): void {
|
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
}
|
|
67
|
+
|
|
68
|
+
// ─── Reactivity bridge ──────────────────────────────────────────────────────
|
|
69
|
+
// Installs `globalThis.__pyreon_report_error__` so `@pyreon/reactivity`
|
|
70
|
+
// effect-error path can forward into reportError. Idempotent — multiple
|
|
71
|
+
// `registerErrorHandler` calls install once.
|
|
72
|
+
|
|
73
|
+
interface PyreonErrorBridge {
|
|
74
|
+
__pyreon_report_error__?: (err: unknown, phase: 'effect') => void
|
|
75
|
+
}
|
|
76
|
+
const _bridgeHost = globalThis as PyreonErrorBridge
|
|
77
|
+
|
|
78
|
+
function _installReactivityBridge(): void {
|
|
79
|
+
if (_bridgeHost.__pyreon_report_error__) return
|
|
80
|
+
_bridgeHost.__pyreon_report_error__ = (err, phase) => {
|
|
81
|
+
reportError({ component: 'Effect', phase, error: err, timestamp: Date.now() })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from '../compat-marker'
|
|
3
|
+
|
|
4
|
+
describe('NATIVE_COMPAT_MARKER', () => {
|
|
5
|
+
it('is the same registry symbol regardless of how it is referenced', () => {
|
|
6
|
+
// Symbol.for(...) registry contract — every consumer that uses the same
|
|
7
|
+
// string key (compat layers reading it, framework packages writing it)
|
|
8
|
+
// gets the SAME symbol identity. Changing the string is a breaking
|
|
9
|
+
// change to the marker contract.
|
|
10
|
+
expect(NATIVE_COMPAT_MARKER).toBe(Symbol.for('pyreon:native-compat'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('is a `symbol`-typed value', () => {
|
|
14
|
+
expect(typeof NATIVE_COMPAT_MARKER).toBe('symbol')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('nativeCompat', () => {
|
|
19
|
+
it('attaches the marker to a function and returns the same reference', () => {
|
|
20
|
+
function RouterView() {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
const marked = nativeCompat(RouterView)
|
|
24
|
+
expect(marked).toBe(RouterView)
|
|
25
|
+
expect((RouterView as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is idempotent — applying twice yields the same property state', () => {
|
|
29
|
+
const Component = () => null
|
|
30
|
+
nativeCompat(Component)
|
|
31
|
+
nativeCompat(Component)
|
|
32
|
+
expect((Component as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('passes non-function values through unchanged', () => {
|
|
36
|
+
// Defensive: callers may pipe variables of unknown shape (e.g. lazy
|
|
37
|
+
// imports that resolve to objects, or null during HMR boundary
|
|
38
|
+
// teardown). The helper must be safe regardless.
|
|
39
|
+
expect(nativeCompat(null as unknown)).toBe(null)
|
|
40
|
+
expect(nativeCompat(undefined as unknown)).toBe(undefined)
|
|
41
|
+
const obj = { foo: 'bar' }
|
|
42
|
+
expect(nativeCompat(obj)).toBe(obj)
|
|
43
|
+
expect((obj as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('preserves the function signature for typed callers', () => {
|
|
47
|
+
// The generic `T` flows through unchanged so framework component
|
|
48
|
+
// exports keep their typed callable shape after wrapping.
|
|
49
|
+
const Typed = (props: { name: string }): string => `hello ${props.name}`
|
|
50
|
+
const marked: typeof Typed = nativeCompat(Typed)
|
|
51
|
+
expect(marked({ name: 'world' })).toBe('hello world')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('isNativeCompat', () => {
|
|
56
|
+
it('returns true for a marked function', () => {
|
|
57
|
+
const Comp = nativeCompat(() => null)
|
|
58
|
+
expect(isNativeCompat(Comp)).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns false for an unmarked function', () => {
|
|
62
|
+
expect(isNativeCompat(() => null)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns false for non-function inputs', () => {
|
|
66
|
+
expect(isNativeCompat(null)).toBe(false)
|
|
67
|
+
expect(isNativeCompat(undefined)).toBe(false)
|
|
68
|
+
expect(isNativeCompat('string')).toBe(false)
|
|
69
|
+
expect(isNativeCompat(42)).toBe(false)
|
|
70
|
+
expect(isNativeCompat({ [NATIVE_COMPAT_MARKER]: true })).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns false when the marker is set to a non-true value', () => {
|
|
74
|
+
// Defensive against accidental shape mismatch — only `=== true` qualifies.
|
|
75
|
+
function Comp() {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
;(Comp as unknown as Record<symbol, unknown>)[NATIVE_COMPAT_MARKER] = 1
|
|
79
|
+
expect(isNativeCompat(Comp)).toBe(false)
|
|
80
|
+
;(Comp as unknown as Record<symbol, unknown>)[NATIVE_COMPAT_MARKER] = 'yes'
|
|
81
|
+
expect(isNativeCompat(Comp)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reads the same registry symbol that nativeCompat writes', () => {
|
|
85
|
+
// Cross-side contract: a function marked here is detectable by
|
|
86
|
+
// someone who looked up the symbol via `Symbol.for('pyreon:native-compat')`
|
|
87
|
+
// independently — without importing NATIVE_COMPAT_MARKER from this module.
|
|
88
|
+
const Comp = nativeCompat(function Comp() {
|
|
89
|
+
return null
|
|
90
|
+
})
|
|
91
|
+
const externallyDiscoveredSymbol = Symbol.for('pyreon:native-compat')
|
|
92
|
+
expect(
|
|
93
|
+
(Comp as unknown as Record<symbol, boolean>)[externallyDiscoveredSymbol],
|
|
94
|
+
).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/tests/core.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Dynamic } from '../dynamic'
|
|
2
2
|
import { h } from '../h'
|
|
3
|
-
import type { ComponentFn, VNode } from '../types'
|
|
3
|
+
import type { ComponentFn, VNode, VNodeChild } from '../types'
|
|
4
4
|
|
|
5
5
|
describe('Dynamic', () => {
|
|
6
6
|
test('renders component function', () => {
|
|
@@ -52,4 +52,36 @@ describe('Dynamic', () => {
|
|
|
52
52
|
expect(result).not.toBeNull()
|
|
53
53
|
expect((result as VNode).type).toBe('br')
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
test('does not leak children as a prop on string-tag mount', () => {
|
|
57
|
+
// Regression: for string `component`, runtime-dom forwards every prop
|
|
58
|
+
// key to setAttribute. If `children` stayed in props it crashed at
|
|
59
|
+
// mount with `setAttribute('children', ...)`. The fix re-emits them
|
|
60
|
+
// as h() rest args, landing them in vnode.children.
|
|
61
|
+
const result = Dynamic({ component: 'h3', children: 'hello' })
|
|
62
|
+
expect((result as VNode).type).toBe('h3')
|
|
63
|
+
expect((result as VNode).props.children).toBeUndefined()
|
|
64
|
+
expect((result as VNode).children).toEqual(['hello'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('flattens array children to vnode.children', () => {
|
|
68
|
+
const a = h('span', null, 'a')
|
|
69
|
+
const b = h('span', null, 'b')
|
|
70
|
+
const result = Dynamic({ component: 'div', children: [a, b] })
|
|
71
|
+
expect((result as VNode).props.children).toBeUndefined()
|
|
72
|
+
expect((result as VNode).children).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('component children still reach props.children at mount', () => {
|
|
76
|
+
// For component (not string), the merge happens at mount via
|
|
77
|
+
// mergeChildrenIntoProps — verified end-to-end by mount tests in
|
|
78
|
+
// runtime-dom. Here we just confirm the vnode shape is correct so the
|
|
79
|
+
// merge will fire (children must be on vnode.children, not props).
|
|
80
|
+
const Comp: ComponentFn = (props) =>
|
|
81
|
+
h('div', null, (props as { children?: VNodeChild }).children ?? null)
|
|
82
|
+
const result = Dynamic({ component: Comp, children: 'hi' })
|
|
83
|
+
expect((result as VNode).type).toBe(Comp)
|
|
84
|
+
expect((result as VNode).props.children).toBeUndefined()
|
|
85
|
+
expect((result as VNode).children).toEqual(['hi'])
|
|
86
|
+
})
|
|
55
87
|
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time type tests for `ExtractProps` multi-overload narrowing.
|
|
3
|
+
*
|
|
4
|
+
* Regression: pre-fix, `ExtractProps<T>` collapsed multi-overload functions
|
|
5
|
+
* to the LAST overload's props — TS's overload-resolution-against-conditional-
|
|
6
|
+
* types semantics. Multi-overload primitives (Iterator / List / Element in
|
|
7
|
+
* `@pyreon/elements`) silently downgraded their public prop surface to the
|
|
8
|
+
* loosest overload when wrapped through `rocketstyle()` / `attrs()`. The
|
|
9
|
+
* fix matches up to 4 call signatures via pattern matching and produces the
|
|
10
|
+
* UNION of every overload's first-argument type.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors vitus-labs PR #222. Kept in sync across the 4 copies in
|
|
13
|
+
* `@pyreon/core`, `@pyreon/elements`, `@pyreon/attrs`, and `@pyreon/rocketstyle`
|
|
14
|
+
* — the canonical reference test lives here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expectTypeOf, it } from 'vitest'
|
|
18
|
+
import type { ComponentFn, ExtractProps, VNodeChild } from '../index'
|
|
19
|
+
|
|
20
|
+
describe('ExtractProps — single-overload functions still work', () => {
|
|
21
|
+
it('extracts props from a ComponentFn<P>', () => {
|
|
22
|
+
type Greet = ComponentFn<{ name: string }>
|
|
23
|
+
expectTypeOf<ExtractProps<Greet>>().toEqualTypeOf<{ name: string }>()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('extracts props from a bare (props: P) => any signature', () => {
|
|
27
|
+
type Fn = (props: { count: number }) => string
|
|
28
|
+
expectTypeOf<ExtractProps<Fn>>().toEqualTypeOf<{ count: number }>()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('passes through a non-function shape unchanged', () => {
|
|
32
|
+
type Props = { id: string; value: number }
|
|
33
|
+
expectTypeOf<ExtractProps<Props>>().toEqualTypeOf<Props>()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('ExtractProps — multi-overload narrowing (load-bearing assertions)', () => {
|
|
38
|
+
it('unions both arms of a 2-overload function', () => {
|
|
39
|
+
interface TwoOverloads {
|
|
40
|
+
(props: { kind: 'a'; value: number }): VNodeChild
|
|
41
|
+
(props: { kind: 'b'; value: string }): VNodeChild
|
|
42
|
+
}
|
|
43
|
+
type Props = ExtractProps<TwoOverloads>
|
|
44
|
+
// Both shapes appear in the extracted union.
|
|
45
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
46
|
+
{ kind: 'a'; value: number } | { kind: 'b'; value: string }
|
|
47
|
+
>()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('unions all three arms of a 3-overload function (Iterator/List/Element shape)', () => {
|
|
51
|
+
interface ThreeOverloads {
|
|
52
|
+
(props: { mode: 'simple'; data: string[] }): VNodeChild
|
|
53
|
+
(props: { mode: 'object'; data: { id: number }[] }): VNodeChild
|
|
54
|
+
(props: { mode: 'children'; children: unknown }): VNodeChild
|
|
55
|
+
}
|
|
56
|
+
type Props = ExtractProps<ThreeOverloads>
|
|
57
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
58
|
+
| { mode: 'simple'; data: string[] }
|
|
59
|
+
| { mode: 'object'; data: { id: number }[] }
|
|
60
|
+
| { mode: 'children'; children: unknown }
|
|
61
|
+
>()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('unions all four arms of a 4-overload function', () => {
|
|
65
|
+
interface FourOverloads {
|
|
66
|
+
(props: { variant: 'a' }): VNodeChild
|
|
67
|
+
(props: { variant: 'b' }): VNodeChild
|
|
68
|
+
(props: { variant: 'c' }): VNodeChild
|
|
69
|
+
(props: { variant: 'd' }): VNodeChild
|
|
70
|
+
}
|
|
71
|
+
type Props = ExtractProps<FourOverloads>
|
|
72
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
73
|
+
{ variant: 'a' } | { variant: 'b' } | { variant: 'c' } | { variant: 'd' }
|
|
74
|
+
>()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('ExtractProps — bisect-load-bearing: pre-fix shape would FAIL these', () => {
|
|
79
|
+
/**
|
|
80
|
+
* If `ExtractProps<T>` were reverted to `T extends ComponentFn<infer P> ? P : T`,
|
|
81
|
+
* each of these would extract only the LAST overload's props and the
|
|
82
|
+
* `toEqualTypeOf<union>` check would fail at compile time. This is the
|
|
83
|
+
* structural anchor — the load-bearing regression guard.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
it('a 2-overload function MUST extract BOTH arms (not just the last)', () => {
|
|
87
|
+
interface OverloadedComp {
|
|
88
|
+
(props: { mode: 'a'; valueA: number }): VNodeChild
|
|
89
|
+
(props: { mode: 'b'; valueB: string }): VNodeChild
|
|
90
|
+
}
|
|
91
|
+
// The first arm `{ mode: 'a'; valueA: number }` must be present in the
|
|
92
|
+
// union. Pre-fix, the conditional collapsed to just the LAST arm.
|
|
93
|
+
type Props = ExtractProps<OverloadedComp>
|
|
94
|
+
// Assignability check: both shapes must be assignable to the extracted type.
|
|
95
|
+
const a: Props = { mode: 'a', valueA: 1 }
|
|
96
|
+
const b: Props = { mode: 'b', valueB: 'x' }
|
|
97
|
+
void a
|
|
98
|
+
void b
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("a 3-overload Iterator-shaped surface MUST surface SimpleProps + ObjectProps + ChildrenProps", () => {
|
|
102
|
+
// Synthetic Iterator overload-shape — mirrors the real
|
|
103
|
+
// `@pyreon/elements` Iterator. The structural failure mode pre-fix:
|
|
104
|
+
// `ExtractProps<typeof Iterator>` returned just `ChildrenProps`, so any
|
|
105
|
+
// HOC wrapping (rocketstyle, attrs) lost the SimpleProps + ObjectProps
|
|
106
|
+
// surfaces from the public typed API.
|
|
107
|
+
type SimpleItem = ComponentFn<{ value: string }>
|
|
108
|
+
type ObjectItem = ComponentFn<{ id: number }>
|
|
109
|
+
interface IteratorLike {
|
|
110
|
+
<T extends string | number>(props: {
|
|
111
|
+
data: T[]
|
|
112
|
+
component: SimpleItem
|
|
113
|
+
valueName?: string
|
|
114
|
+
}): VNodeChild
|
|
115
|
+
<T extends { id: number }>(props: {
|
|
116
|
+
data: T[]
|
|
117
|
+
component: ObjectItem
|
|
118
|
+
}): VNodeChild
|
|
119
|
+
(props: { children: VNodeChild }): VNodeChild
|
|
120
|
+
}
|
|
121
|
+
type Props = ExtractProps<IteratorLike>
|
|
122
|
+
|
|
123
|
+
const noopSimple: SimpleItem = () => null
|
|
124
|
+
const noopObject: ObjectItem = () => null
|
|
125
|
+
// SimpleProps arm assignable:
|
|
126
|
+
const simple: Props = { data: ['a', 'b'], component: noopSimple, valueName: 'text' }
|
|
127
|
+
// ObjectProps arm assignable:
|
|
128
|
+
const obj: Props = { data: [{ id: 1 }], component: noopObject }
|
|
129
|
+
// ChildrenProps arm assignable:
|
|
130
|
+
const ch: Props = { children: null }
|
|
131
|
+
void simple
|
|
132
|
+
void obj
|
|
133
|
+
void ch
|
|
134
|
+
})
|
|
135
|
+
})
|
package/src/tests/for.test.ts
CHANGED
|
@@ -91,4 +91,27 @@ describe('For', () => {
|
|
|
91
91
|
expect((result as VNode).type).toBe('li')
|
|
92
92
|
expect((result as VNode).key).toBe(1)
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
// Regression: `ForProps.each` previously typed as `() => T[]` only.
|
|
96
|
+
// Users writing `<For each={items}>` (with `items: T[]` directly) hit
|
|
97
|
+
// a confusing TS error: `Type 'T[]' is not assignable to type
|
|
98
|
+
// '() => T[]'`. The runtime in `runtime-dom/src/mount.ts:144-147`
|
|
99
|
+
// already accepted both shapes — only the type was forcing the
|
|
100
|
+
// accessor form. Type now accepts `T[] | (() => T[])` so users with
|
|
101
|
+
// already-resolved arrays don't need to wrap them in a thunk just to
|
|
102
|
+
// satisfy the type.
|
|
103
|
+
test('each accepts T[] directly (not just () => T[])', () => {
|
|
104
|
+
// TypeScript-level test: this would not compile pre-fix.
|
|
105
|
+
const items = [1, 2, 3]
|
|
106
|
+
const childFn = (n: number): VNode => h('li', { key: n }, String(n))
|
|
107
|
+
const node = For<number>({ each: items, by: (n) => n, children: childFn })
|
|
108
|
+
expect(node.type).toBe(ForSymbol as unknown as string)
|
|
109
|
+
// Both shapes still work — function form continues to typecheck.
|
|
110
|
+
const node2 = For<number>({
|
|
111
|
+
each: () => items,
|
|
112
|
+
by: (n) => n,
|
|
113
|
+
children: childFn,
|
|
114
|
+
})
|
|
115
|
+
expect(node2.type).toBe(ForSymbol as unknown as string)
|
|
116
|
+
})
|
|
94
117
|
})
|