@pyreon/core 0.1.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/lazy.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { h } from "./h"
3
+ import type { LazyComponent } from "./suspense"
4
+ import type { ComponentFn, Props } from "./types"
5
+
6
+ export function lazy<P extends Props>(
7
+ load: () => Promise<{ default: ComponentFn<P> }>,
8
+ ): LazyComponent<P> {
9
+ const loaded = signal<ComponentFn<P> | null>(null)
10
+ const error = signal<Error | null>(null)
11
+
12
+ load()
13
+ .then((m) => loaded.set(m.default))
14
+ .catch((e) => error.set(e instanceof Error ? e : new Error(String(e))))
15
+
16
+ const wrapper = ((props: P) => {
17
+ const err = error()
18
+ if (err) throw err
19
+ const comp = loaded()
20
+ return comp ? h(comp as ComponentFn, props as Props) : null
21
+ }) as LazyComponent<P>
22
+
23
+ wrapper.__loading = () => loaded() === null && error() === null
24
+ return wrapper
25
+ }
@@ -0,0 +1,52 @@
1
+ import type { CleanupFn, LifecycleHooks } from "./types"
2
+
3
+ // The currently-executing component's hook storage, set by the renderer
4
+ // before calling the component function, cleared immediately after.
5
+ let _current: LifecycleHooks | null = null
6
+
7
+ export function setCurrentHooks(hooks: LifecycleHooks | null) {
8
+ _current = hooks
9
+ }
10
+
11
+ export function getCurrentHooks(): LifecycleHooks | null {
12
+ return _current
13
+ }
14
+
15
+ /**
16
+ * Register a callback to run after the component is mounted to the DOM.
17
+ * Optionally return a cleanup function — it will run on unmount.
18
+ */
19
+ export function onMount(fn: () => CleanupFn | undefined) {
20
+ _current?.mount.push(fn)
21
+ }
22
+
23
+ /**
24
+ * Register a callback to run when the component is removed from the DOM.
25
+ */
26
+ export function onUnmount(fn: () => void) {
27
+ _current?.unmount.push(fn)
28
+ }
29
+
30
+ /**
31
+ * Register a callback to run after each reactive update.
32
+ */
33
+ export function onUpdate(fn: () => void) {
34
+ _current?.update.push(fn)
35
+ }
36
+
37
+ /**
38
+ * Register an error handler for this component subtree.
39
+ *
40
+ * When an error is thrown during rendering or in a child component,
41
+ * the nearest `onErrorCaptured` handler is called with the error.
42
+ * Return `true` to mark the error as handled and stop propagation.
43
+ *
44
+ * @example
45
+ * onErrorCaptured((err) => {
46
+ * setError(String(err))
47
+ * return true // handled — don't propagate
48
+ * })
49
+ */
50
+ export function onErrorCaptured(fn: (err: unknown) => boolean | undefined) {
51
+ _current?.error.push(fn)
52
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * mapArray — keyed reactive list mapping.
3
+ *
4
+ * Creates each mapped item exactly once per key, then reuses it across
5
+ * updates. When the source array is reordered or partially changed, only
6
+ * new keys invoke `map()`; existing entries return the cached result.
7
+ *
8
+ * This makes structural list operations (swap, sort, filter) O(k) in
9
+ * allocations where k is the number of new/removed keys, not O(n).
10
+ *
11
+ * The returned accessor reads `source()` reactively, so it can be passed
12
+ * directly to the keyed-list reconciler.
13
+ */
14
+ export function mapArray<T, U>(
15
+ source: () => T[],
16
+ getKey: (item: T) => string | number,
17
+ map: (item: T) => U,
18
+ ): () => U[] {
19
+ const cache = new Map<string | number, U>()
20
+
21
+ return () => {
22
+ const items = source()
23
+ const result: U[] = []
24
+ const newKeys = new Set<string | number>()
25
+
26
+ for (const item of items) {
27
+ const key = getKey(item)
28
+ newKeys.add(key)
29
+ if (!cache.has(key)) {
30
+ cache.set(key, map(item))
31
+ }
32
+ result.push(cache.get(key) as U)
33
+ }
34
+
35
+ // Evict entries whose keys are no longer present
36
+ for (const key of cache.keys()) {
37
+ if (!newKeys.has(key)) cache.delete(key)
38
+ }
39
+
40
+ return result
41
+ }
42
+ }
package/src/portal.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Props, VNode, VNodeChild } from "./types"
2
+
3
+ /**
4
+ * Symbol used as the VNode type for a Portal — runtime-dom mounts the
5
+ * children into `target` instead of the normal parent.
6
+ */
7
+ export const PortalSymbol: unique symbol = Symbol("pyreon.Portal")
8
+
9
+ export interface PortalProps {
10
+ /** DOM element to render children into (e.g. document.body). */
11
+ target: Element
12
+ children: VNodeChild
13
+ }
14
+
15
+ /**
16
+ * Portal — renders `children` into a different DOM node than the
17
+ * current parent tree.
18
+ *
19
+ * Useful for modals, tooltips, dropdowns, and any overlay that needs to
20
+ * escape CSS overflow/stacking context restrictions.
21
+ *
22
+ * @example
23
+ * // Render a modal at document.body level regardless of where in the
24
+ * // component tree <Modal> is used:
25
+ * Portal({ target: document.body, children: h(Modal, { onClose }) })
26
+ *
27
+ * // JSX:
28
+ * <Portal target={document.body}>
29
+ * <Modal onClose={close} />
30
+ * </Portal>
31
+ */
32
+ export function Portal(props: PortalProps): VNode {
33
+ return {
34
+ type: PortalSymbol as unknown as string,
35
+ props: props as unknown as Props,
36
+ children: [],
37
+ key: null,
38
+ }
39
+ }
package/src/ref.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * createRef — mutable container for a DOM element or component value.
3
+ *
4
+ * Usage:
5
+ * const inputRef = createRef<HTMLInputElement>()
6
+ * onMount(() => { inputRef.current?.focus() })
7
+ * return <input ref={inputRef} />
8
+ *
9
+ * The runtime sets `ref.current` after the element is inserted into the DOM
10
+ * and clears it to `null` when the element is removed.
11
+ */
12
+
13
+ export interface Ref<T = unknown> {
14
+ current: T | null
15
+ }
16
+
17
+ export function createRef<T = unknown>(): Ref<T> {
18
+ return { current: null }
19
+ }
package/src/show.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { Props, VNode, VNodeChild, VNodeChildAtom } from "./types"
2
+
3
+ // ─── Show ─────────────────────────────────────────────────────────────────────
4
+
5
+ export interface ShowProps extends Props {
6
+ /** Accessor — children render when truthy, fallback when falsy. */
7
+ when: () => unknown
8
+ fallback?: VNodeChild
9
+ children?: VNodeChild
10
+ }
11
+
12
+ /**
13
+ * Conditionally render children based on a reactive condition.
14
+ *
15
+ * @example
16
+ * h(Show, { when: () => isLoggedIn() },
17
+ * h(Dashboard, null)
18
+ * )
19
+ *
20
+ * // With fallback:
21
+ * h(Show, { when: () => user(), fallback: h(Login, null) },
22
+ * h(Dashboard, null)
23
+ * )
24
+ */
25
+ export function Show(props: ShowProps): VNode | null {
26
+ // Returns a reactive accessor; the renderer unwraps it at mount time.
27
+ return ((): VNodeChildAtom =>
28
+ (props.when()
29
+ ? (props.children ?? null)
30
+ : (props.fallback ?? null)) as VNodeChildAtom) as unknown as VNode
31
+ }
32
+
33
+ // ─── Switch / Match ───────────────────────────────────────────────────────────
34
+
35
+ export interface MatchProps extends Props {
36
+ /** Accessor — this branch renders when truthy. */
37
+ when: () => unknown
38
+ children?: VNodeChild
39
+ }
40
+
41
+ /**
42
+ * A branch inside `<Switch>`. Renders when `when()` is truthy.
43
+ * Must be used as a direct child of `Switch`.
44
+ *
45
+ * `Match` acts as a pure type/identity marker — Switch identifies it by checking
46
+ * `vnode.type === Match` rather than by the runtime return value.
47
+ */
48
+ export function Match(_props: MatchProps): VNode | null {
49
+ // Match is never mounted directly — Switch inspects Match VNodes by type identity.
50
+ return null
51
+ }
52
+
53
+ export interface SwitchProps extends Props {
54
+ /** Rendered when no Match branch is truthy. */
55
+ fallback?: VNodeChild
56
+ children?: VNodeChild | VNodeChild[]
57
+ }
58
+
59
+ /**
60
+ * Multi-branch conditional rendering. Evaluates each `Match` child in order,
61
+ * renders the first whose `when()` is truthy, or `fallback` if none match.
62
+ *
63
+ * @example
64
+ * h(Switch, { fallback: h("p", null, "404") },
65
+ * h(Match, { when: () => route() === "/" }, h(Home, null)),
66
+ * h(Match, { when: () => route() === "/about" }, h(About, null)),
67
+ * )
68
+ */
69
+ function isMatchVNode(branch: VNodeChild): branch is VNode {
70
+ return (
71
+ branch !== null &&
72
+ typeof branch === "object" &&
73
+ !Array.isArray(branch) &&
74
+ (branch as VNode).type === Match
75
+ )
76
+ }
77
+
78
+ function resolveMatchChildren(matchVNode: VNode): VNodeChildAtom {
79
+ if (matchVNode.children.length === 0) {
80
+ return ((matchVNode.props as unknown as MatchProps).children ?? null) as VNodeChildAtom
81
+ }
82
+ if (matchVNode.children.length === 1) return matchVNode.children[0] as VNodeChildAtom
83
+ return matchVNode.children as unknown as VNodeChildAtom
84
+ }
85
+
86
+ function normalizeBranches(children: SwitchProps["children"]): VNodeChild[] {
87
+ if (Array.isArray(children)) return children
88
+ if (children != null) return [children]
89
+ return []
90
+ }
91
+
92
+ export function Switch(props: SwitchProps): VNode | null {
93
+ // Returns a reactive accessor; the renderer unwraps it at mount time.
94
+ return ((): VNodeChildAtom => {
95
+ const branches = normalizeBranches(props.children)
96
+
97
+ for (const branch of branches) {
98
+ if (!isMatchVNode(branch)) continue
99
+ const matchProps = branch.props as unknown as MatchProps
100
+ if (matchProps.when()) return resolveMatchChildren(branch)
101
+ }
102
+
103
+ return (props.fallback ?? null) as VNodeChildAtom
104
+ }) as unknown as VNode
105
+ }
106
+
107
+ // Keep MatchSymbol export for any code that was using it
108
+ export const MatchSymbol: unique symbol = Symbol("pyreon.Match")
@@ -0,0 +1,41 @@
1
+ import { Fragment, h } from "./h"
2
+ import type { Props, VNode, VNodeChild } from "./types"
3
+
4
+ /** Internal marker attached to lazy()-wrapped components */
5
+ export type LazyComponent<P extends Props = Props> = ((props: P) => VNode | null) & {
6
+ __loading: () => boolean
7
+ }
8
+
9
+ /**
10
+ * Suspense — shows `fallback` while a lazy child component is still loading.
11
+ *
12
+ * Works in tandem with `lazy()` from `@pyreon/react-compat` (or `@pyreon/core/lazy`).
13
+ * The child VNode's `.type.__loading()` signal drives the switch.
14
+ *
15
+ * Usage:
16
+ * const Page = lazy(() => import("./Page"))
17
+ *
18
+ * h(Suspense, { fallback: h(Spinner, null) }, h(Page, null))
19
+ * // or with JSX:
20
+ * <Suspense fallback={<Spinner />}><Page /></Suspense>
21
+ */
22
+ export function Suspense(props: { fallback: VNodeChild; children?: VNodeChild }): VNode {
23
+ return h(Fragment, null, () => {
24
+ const ch = props.children
25
+ const childNode = typeof ch === "function" ? ch() : ch
26
+
27
+ // Check if the child is a VNode whose type is a lazy component still loading
28
+ const isLoading =
29
+ childNode != null &&
30
+ typeof childNode === "object" &&
31
+ !Array.isArray(childNode) &&
32
+ typeof (childNode as VNode).type === "function" &&
33
+ ((childNode as VNode).type as unknown as LazyComponent).__loading?.()
34
+
35
+ if (isLoading) {
36
+ const fb = props.fallback
37
+ return typeof fb === "function" ? fb() : fb
38
+ }
39
+ return childNode
40
+ })
41
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
3
+ *
4
+ * @example
5
+ * import { registerErrorHandler } from "@pyreon/core"
6
+ * import * as Sentry from "@sentry/browser"
7
+ *
8
+ * registerErrorHandler(ctx => {
9
+ * Sentry.captureException(ctx.error, {
10
+ * extra: { component: ctx.component, phase: ctx.phase },
11
+ * })
12
+ * })
13
+ */
14
+
15
+ export interface ErrorContext {
16
+ /** Component function name, or "Anonymous" */
17
+ component: string
18
+ /** Lifecycle phase where the error occurred */
19
+ phase: "setup" | "render" | "mount" | "unmount" | "effect"
20
+ /** The thrown value */
21
+ error: unknown
22
+ /** Unix timestamp (ms) */
23
+ timestamp: number
24
+ /** Component props at the time of the error */
25
+ props?: Record<string, unknown>
26
+ }
27
+
28
+ export type ErrorHandler = (ctx: ErrorContext) => void
29
+
30
+ let _handlers: ErrorHandler[] = []
31
+
32
+ /**
33
+ * Register a global error handler. Called whenever a component throws in any
34
+ * lifecycle phase. Returns an unregister function.
35
+ */
36
+ export function registerErrorHandler(handler: ErrorHandler): () => void {
37
+ _handlers.push(handler)
38
+ return () => {
39
+ _handlers = _handlers.filter((h) => h !== handler)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Internal — called by the runtime whenever a component error is caught.
45
+ * Existing console.error calls are preserved; this is additive.
46
+ */
47
+ export function reportError(ctx: ErrorContext): void {
48
+ for (const h of _handlers) {
49
+ try {
50
+ h(ctx)
51
+ } catch {
52
+ // handler errors must never propagate back into the framework
53
+ }
54
+ }
55
+ }