@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/lib/types/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ReactiveTraceEntry } from "@pyreon/reactivity";
|
|
2
|
+
|
|
1
3
|
//#region src/types.d.ts
|
|
2
4
|
type VNodeChildAtom = VNode | string | number | boolean | null | undefined;
|
|
3
5
|
/** Reactive accessor — TS checks this arm FIRST so `{() => cond && <X />}` resolves correctly */
|
|
@@ -176,6 +178,34 @@ declare function nativeCompat<T>(fn: T): T;
|
|
|
176
178
|
*/
|
|
177
179
|
declare function isNativeCompat(fn: unknown): boolean;
|
|
178
180
|
//#endregion
|
|
181
|
+
//#region src/compat-shared.d.ts
|
|
182
|
+
/**
|
|
183
|
+
* Code shared by the framework-compat JSX runtimes
|
|
184
|
+
* (`@pyreon/react-compat`, `@pyreon/preact-compat`).
|
|
185
|
+
*
|
|
186
|
+
* These helpers were previously copy-pasted byte-for-byte into both
|
|
187
|
+
* packages. `@pyreon/core` is the correct single home — it's already a
|
|
188
|
+
* dependency of every compat package and already hosts the sibling
|
|
189
|
+
* cross-compat module `compat-marker.ts` (`nativeCompat` / `isNativeCompat`).
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* Shallow props comparison used by compat `memo()` / `useState` bailout.
|
|
193
|
+
* Same-length key sets with `Object.is`-equal values → equal.
|
|
194
|
+
*/
|
|
195
|
+
declare function shallowEqualProps<P extends Record<string, unknown>>(a: P, b: P): boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Map React/Preact-style DOM attributes to standard HTML attributes,
|
|
198
|
+
* mutating `props` in place. No-op when `type` is not a host string
|
|
199
|
+
* (component vnodes keep their props untouched).
|
|
200
|
+
*
|
|
201
|
+
* The React and Preact variants were identical apart from React also
|
|
202
|
+
* stripping `suppressContentEditableWarning`. Both keys are React/Preact
|
|
203
|
+
* authoring-only and never valid DOM attributes, so always stripping
|
|
204
|
+
* both is behavior-preserving for Preact (the key is never set there;
|
|
205
|
+
* `delete` of an absent key is a no-op) and removes the only divergence.
|
|
206
|
+
*/
|
|
207
|
+
declare function mapCompatDomProps(props: Record<string, unknown>, type: unknown): void;
|
|
208
|
+
//#endregion
|
|
179
209
|
//#region src/context.d.ts
|
|
180
210
|
/**
|
|
181
211
|
* Provide / inject — like React context or Vue provide/inject.
|
|
@@ -1011,6 +1041,102 @@ declare global {
|
|
|
1011
1041
|
}
|
|
1012
1042
|
} //# sourceMappingURL=jsx-runtime.d.ts.map
|
|
1013
1043
|
//#endregion
|
|
1044
|
+
//#region src/defer.d.ts
|
|
1045
|
+
/**
|
|
1046
|
+
* Module shape `<Defer>` accepts from `chunk()`. Mirrors `lazy()`'s
|
|
1047
|
+
* contract — either an ES module with `default` export, OR a raw
|
|
1048
|
+
* `ComponentFn` returned directly (rare; covers re-export patterns).
|
|
1049
|
+
*/
|
|
1050
|
+
type ChunkResult<P extends Props> = {
|
|
1051
|
+
default: ComponentFn<P>;
|
|
1052
|
+
} | ComponentFn<P>;
|
|
1053
|
+
/**
|
|
1054
|
+
* Trigger discriminant. Exactly ONE shape is provided:
|
|
1055
|
+
* - `when={() => signal()}` — load when the accessor becomes truthy
|
|
1056
|
+
* - `on="visible"` — load when the wrapper enters the viewport
|
|
1057
|
+
* - `on="idle"` — load during browser idle time
|
|
1058
|
+
*/
|
|
1059
|
+
type DeferTrigger = {
|
|
1060
|
+
when: () => boolean;
|
|
1061
|
+
} | {
|
|
1062
|
+
on: 'visible' | 'idle';
|
|
1063
|
+
};
|
|
1064
|
+
/**
|
|
1065
|
+
* Set up the `on="idle"` trigger. Returns a teardown function the
|
|
1066
|
+
* caller must invoke on unmount. Browser-API access is gated by
|
|
1067
|
+
* `typeof` checks so SSR / jsdom environments fall back to a
|
|
1068
|
+
* `setTimeout(1)` shim. Extracted as a standalone helper so it's
|
|
1069
|
+
* directly testable without going through `onMount` (core tests
|
|
1070
|
+
* don't run in happy-dom; runtime-dom is where the lifecycle hooks
|
|
1071
|
+
* live).
|
|
1072
|
+
*
|
|
1073
|
+
* @internal Exported for tests; not part of the stable public API.
|
|
1074
|
+
*/
|
|
1075
|
+
type DeferProps<P extends Props> = DeferTrigger & {
|
|
1076
|
+
/**
|
|
1077
|
+
* Dynamic import to lazy-load. The literal `import('./X')` is what
|
|
1078
|
+
* Rolldown / Vite see when emitting chunks — using a variable here
|
|
1079
|
+
* defeats code splitting.
|
|
1080
|
+
*
|
|
1081
|
+
* Typed as optional ONLY because the compiler-driven inline form
|
|
1082
|
+
* (`<Defer when={x}><Modal /></Defer>`) doesn't include a `chunk`
|
|
1083
|
+
* prop at source level — `@pyreon/compiler`'s `transformDeferInline`
|
|
1084
|
+
* synthesizes it before runtime. Authors using the explicit form
|
|
1085
|
+
* must pass `chunk` — runtime throws a clear dev-mode error when
|
|
1086
|
+
* the trigger fires and `chunk` is missing.
|
|
1087
|
+
*/
|
|
1088
|
+
chunk?: () => Promise<ChunkResult<P>>;
|
|
1089
|
+
/**
|
|
1090
|
+
* Children accept TWO shapes:
|
|
1091
|
+
* 1. Render-prop `(Component) => VNodeChild` — the explicit form.
|
|
1092
|
+
* Receives the loaded component, lets the author pass props.
|
|
1093
|
+
* 2. Inline JSX (`<Defer when={x}><Modal /></Defer>`) — the compiler-
|
|
1094
|
+
* driven form. The compiler extracts the subtree into a chunk
|
|
1095
|
+
* and rewrites this to the render-prop form before runtime.
|
|
1096
|
+
*
|
|
1097
|
+
* Type widening is necessary because TypeScript checks the raw source
|
|
1098
|
+
* BEFORE the compiler pass runs — both shapes must typecheck.
|
|
1099
|
+
*/
|
|
1100
|
+
children?: ((Component: ComponentFn<P>) => VNodeChild) | VNodeChild; /** Shown while the chunk is loading. Default: `null`. */
|
|
1101
|
+
fallback?: VNodeChild;
|
|
1102
|
+
/**
|
|
1103
|
+
* IntersectionObserver `rootMargin` for `on="visible"` mode. Default
|
|
1104
|
+
* `'200px'` — start loading the chunk before the wrapper is fully in
|
|
1105
|
+
* view so it's typically ready by the time the user scrolls to it.
|
|
1106
|
+
*/
|
|
1107
|
+
rootMargin?: string;
|
|
1108
|
+
};
|
|
1109
|
+
/**
|
|
1110
|
+
* Lazy-load a chunk when a trigger condition is met.
|
|
1111
|
+
*
|
|
1112
|
+
* Three trigger modes:
|
|
1113
|
+
* - `when={() => signal()}` — load when condition flips truthy (modal pattern)
|
|
1114
|
+
* - `on="visible"` — load when the wrapper scrolls into view
|
|
1115
|
+
* - `on="idle"` — load during browser idle time
|
|
1116
|
+
*
|
|
1117
|
+
* The chunk fetch is fired exactly once per `Defer` instance — repeated
|
|
1118
|
+
* trigger firings after the chunk loads are no-ops.
|
|
1119
|
+
*
|
|
1120
|
+
* @example
|
|
1121
|
+
* // Signal-driven (modal):
|
|
1122
|
+
* <Defer chunk={() => import('./ConfirmDeleteModal')} when={open}>
|
|
1123
|
+
* {Modal => <Modal onClose={() => setOpen(false)} />}
|
|
1124
|
+
* </Defer>
|
|
1125
|
+
*
|
|
1126
|
+
* @example
|
|
1127
|
+
* // Viewport-driven (below-fold):
|
|
1128
|
+
* <Defer chunk={() => import('./Comments')} on="visible">
|
|
1129
|
+
* {Comments => <Comments postId={id} />}
|
|
1130
|
+
* </Defer>
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* // Idle-driven (non-critical):
|
|
1134
|
+
* <Defer chunk={() => import('./Analytics')} on="idle">
|
|
1135
|
+
* {Dashboard => <Dashboard />}
|
|
1136
|
+
* </Defer>
|
|
1137
|
+
*/
|
|
1138
|
+
declare function Defer<P extends Props>(props: DeferProps<P>): VNode;
|
|
1139
|
+
//#endregion
|
|
1014
1140
|
//#region src/suspense.d.ts
|
|
1015
1141
|
/** Internal marker attached to lazy()-wrapped components */
|
|
1016
1142
|
type LazyComponent<P extends Props = Props> = ((props: P) => VNodeChild) & {
|
|
@@ -1144,6 +1270,34 @@ declare const REACTIVE_PROP: unique symbol;
|
|
|
1144
1270
|
* Called by the compiler for component prop expressions containing signal reads.
|
|
1145
1271
|
*/
|
|
1146
1272
|
declare function _rp<T>(fn: () => T): () => T;
|
|
1273
|
+
/**
|
|
1274
|
+
* Wrap a JSX spread source so its getter-shaped reactive props survive
|
|
1275
|
+
* the JS-level object spread that esbuild's automatic JSX runtime emits
|
|
1276
|
+
* for `<Comp {...source}>`.
|
|
1277
|
+
*
|
|
1278
|
+
* Without this wrapper, esbuild compiles `<Comp {...source}>` to
|
|
1279
|
+
* `jsx(Comp, { ...source })` — and JS spread fires every getter on
|
|
1280
|
+
* `source`, storing the resolved values as plain data properties. Any
|
|
1281
|
+
* compiler-emitted reactive prop (`_rp(() => signal())` converted to a
|
|
1282
|
+
* getter by `makeReactiveProps`) on `source` is collapsed to its
|
|
1283
|
+
* initial value before the receiving component ever sees it.
|
|
1284
|
+
*
|
|
1285
|
+
* `_wrapSpread(source)` walks `source`'s own keys via `Reflect.ownKeys`
|
|
1286
|
+
* (no getter firing) and returns a new object whose values are
|
|
1287
|
+
* `_rp`-branded thunks `() => source[key]`. When `{ ..._wrapSpread(s) }`
|
|
1288
|
+
* is spread by esbuild, the thunks are stored as plain data property
|
|
1289
|
+
* values (no getters to fire), then `makeReactiveProps` in `mount.ts`
|
|
1290
|
+
* converts the brands back into getters that lazily read from the
|
|
1291
|
+
* original `source` — preserving the reactive subscription end-to-end.
|
|
1292
|
+
*
|
|
1293
|
+
* Fast path: when `source` has no getter descriptors, return the
|
|
1294
|
+
* source object unchanged. JS spread will work correctly in that case
|
|
1295
|
+
* because there's nothing reactive to preserve. Saves N thunk
|
|
1296
|
+
* allocations per component render in the 99% case.
|
|
1297
|
+
*
|
|
1298
|
+
* Emitted by the compiler — not generally meant for hand-written code.
|
|
1299
|
+
*/
|
|
1300
|
+
declare function _wrapSpread(source: Record<string, unknown> | null | undefined): Record<string, unknown> | null | undefined;
|
|
1147
1301
|
/**
|
|
1148
1302
|
* Convert compiler-emitted `_rp(() => expr)` prop values into getter properties.
|
|
1149
1303
|
*
|
|
@@ -1216,23 +1370,6 @@ declare function Switch(props: SwitchProps): VNode | null;
|
|
|
1216
1370
|
declare const MatchSymbol: unique symbol;
|
|
1217
1371
|
//#endregion
|
|
1218
1372
|
//#region src/telemetry.d.ts
|
|
1219
|
-
/**
|
|
1220
|
-
* Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
|
|
1221
|
-
*
|
|
1222
|
-
* Captures errors from ALL lifecycle phases including reactive effects.
|
|
1223
|
-
* `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
|
|
1224
|
-
* globalThis sink (no upward import — reactivity doesn't depend on core).
|
|
1225
|
-
*
|
|
1226
|
-
* @example
|
|
1227
|
-
* import { registerErrorHandler } from "@pyreon/core"
|
|
1228
|
-
* import * as Sentry from "@sentry/browser"
|
|
1229
|
-
*
|
|
1230
|
-
* registerErrorHandler(ctx => {
|
|
1231
|
-
* Sentry.captureException(ctx.error, {
|
|
1232
|
-
* extra: { component: ctx.component, phase: ctx.phase },
|
|
1233
|
-
* })
|
|
1234
|
-
* })
|
|
1235
|
-
*/
|
|
1236
1373
|
interface ErrorContext {
|
|
1237
1374
|
/** Component function name, "Anonymous", or "Effect" for reactive effects */
|
|
1238
1375
|
component: string;
|
|
@@ -1244,6 +1381,22 @@ interface ErrorContext {
|
|
|
1244
1381
|
timestamp: number;
|
|
1245
1382
|
/** Component props at the time of the error */
|
|
1246
1383
|
props?: Record<string, unknown>;
|
|
1384
|
+
/**
|
|
1385
|
+
* The last N signal writes (chronological, oldest → newest) leading
|
|
1386
|
+
* up to the error — the causal sequence of reactive state changes,
|
|
1387
|
+
* not a point-in-time snapshot. Each entry is `{ name, prev, next,
|
|
1388
|
+
* timestamp }` with `prev` / `next` as bounded string previews.
|
|
1389
|
+
*
|
|
1390
|
+
* Populated automatically in development from `@pyreon/reactivity`'s
|
|
1391
|
+
* dev-only ring buffer. **`undefined` in production** — the recorder
|
|
1392
|
+
* feeding the buffer tree-shakes out of prod bundles, so the cost is
|
|
1393
|
+
* zero and the field is simply absent.
|
|
1394
|
+
*
|
|
1395
|
+
* For a signal framework this answers the first question a crash
|
|
1396
|
+
* raises — "what reactive state changed in the run-up?" — that the
|
|
1397
|
+
* thrown value + stack alone can't.
|
|
1398
|
+
*/
|
|
1399
|
+
reactiveTrace?: ReactiveTraceEntry[];
|
|
1247
1400
|
}
|
|
1248
1401
|
type ErrorHandler = (ctx: ErrorContext) => void;
|
|
1249
1402
|
/**
|
|
@@ -1263,5 +1416,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
|
|
|
1263
1416
|
*/
|
|
1264
1417
|
declare function reportError(ctx: ErrorContext): void;
|
|
1265
1418
|
//#endregion
|
|
1266
|
-
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, splitProps, toKebabCase, useContext, withContext };
|
|
1419
|
+
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1267
1420
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Core component model and lifecycle for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/core#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"prepublishOnly": "bun run build"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
56
|
+
"@pyreon/reactivity": "^0.19.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@pyreon/manifest": "0.13.1"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code shared by the framework-compat JSX runtimes
|
|
3
|
+
* (`@pyreon/react-compat`, `@pyreon/preact-compat`).
|
|
4
|
+
*
|
|
5
|
+
* These helpers were previously copy-pasted byte-for-byte into both
|
|
6
|
+
* packages. `@pyreon/core` is the correct single home — it's already a
|
|
7
|
+
* dependency of every compat package and already hosts the sibling
|
|
8
|
+
* cross-compat module `compat-marker.ts` (`nativeCompat` / `isNativeCompat`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shallow props comparison used by compat `memo()` / `useState` bailout.
|
|
13
|
+
* Same-length key sets with `Object.is`-equal values → equal.
|
|
14
|
+
*/
|
|
15
|
+
export function shallowEqualProps<P extends Record<string, unknown>>(a: P, b: P): boolean {
|
|
16
|
+
const keysA = Object.keys(a)
|
|
17
|
+
const keysB = Object.keys(b)
|
|
18
|
+
if (keysA.length !== keysB.length) return false
|
|
19
|
+
for (const k of keysA) {
|
|
20
|
+
if (!Object.is(a[k], b[k])) return false
|
|
21
|
+
}
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map React/Preact-style DOM attributes to standard HTML attributes,
|
|
27
|
+
* mutating `props` in place. No-op when `type` is not a host string
|
|
28
|
+
* (component vnodes keep their props untouched).
|
|
29
|
+
*
|
|
30
|
+
* The React and Preact variants were identical apart from React also
|
|
31
|
+
* stripping `suppressContentEditableWarning`. Both keys are React/Preact
|
|
32
|
+
* authoring-only and never valid DOM attributes, so always stripping
|
|
33
|
+
* both is behavior-preserving for Preact (the key is never set there;
|
|
34
|
+
* `delete` of an absent key is a no-op) and removes the only divergence.
|
|
35
|
+
*/
|
|
36
|
+
export function mapCompatDomProps(props: Record<string, unknown>, type: unknown): void {
|
|
37
|
+
if (typeof type !== 'string') return
|
|
38
|
+
|
|
39
|
+
if (props.className !== undefined) {
|
|
40
|
+
props.class = props.className
|
|
41
|
+
delete props.className
|
|
42
|
+
}
|
|
43
|
+
if (props.htmlFor !== undefined) {
|
|
44
|
+
props.for = props.htmlFor
|
|
45
|
+
delete props.htmlFor
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// React/Preact onChange fires on every keystroke for form elements (like onInput)
|
|
49
|
+
if (
|
|
50
|
+
(type === 'input' || type === 'textarea' || type === 'select') &&
|
|
51
|
+
props.onChange !== undefined
|
|
52
|
+
) {
|
|
53
|
+
if (props.onInput === undefined) {
|
|
54
|
+
props.onInput = props.onChange
|
|
55
|
+
}
|
|
56
|
+
delete props.onChange
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// autoFocus → autofocus
|
|
60
|
+
if (props.autoFocus !== undefined) {
|
|
61
|
+
props.autofocus = props.autoFocus
|
|
62
|
+
delete props.autoFocus
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// defaultValue / defaultChecked → value / checked when no controlled value
|
|
66
|
+
if (type === 'input' || type === 'textarea') {
|
|
67
|
+
if (props.defaultValue !== undefined && props.value === undefined) {
|
|
68
|
+
props.value = props.defaultValue
|
|
69
|
+
delete props.defaultValue
|
|
70
|
+
}
|
|
71
|
+
if (props.defaultChecked !== undefined && props.checked === undefined) {
|
|
72
|
+
props.checked = props.defaultChecked
|
|
73
|
+
delete props.defaultChecked
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip authoring-only props that have no DOM equivalent
|
|
78
|
+
delete props.suppressHydrationWarning
|
|
79
|
+
delete props.suppressContentEditableWarning
|
|
80
|
+
}
|
package/src/defer.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
* Typed as optional ONLY because the compiler-driven inline form
|
|
93
|
+
* (`<Defer when={x}><Modal /></Defer>`) doesn't include a `chunk`
|
|
94
|
+
* prop at source level — `@pyreon/compiler`'s `transformDeferInline`
|
|
95
|
+
* synthesizes it before runtime. Authors using the explicit form
|
|
96
|
+
* must pass `chunk` — runtime throws a clear dev-mode error when
|
|
97
|
+
* the trigger fires and `chunk` is missing.
|
|
98
|
+
*/
|
|
99
|
+
chunk?: () => Promise<ChunkResult<P>>
|
|
100
|
+
/**
|
|
101
|
+
* Children accept TWO shapes:
|
|
102
|
+
* 1. Render-prop `(Component) => VNodeChild` — the explicit form.
|
|
103
|
+
* Receives the loaded component, lets the author pass props.
|
|
104
|
+
* 2. Inline JSX (`<Defer when={x}><Modal /></Defer>`) — the compiler-
|
|
105
|
+
* driven form. The compiler extracts the subtree into a chunk
|
|
106
|
+
* and rewrites this to the render-prop form before runtime.
|
|
107
|
+
*
|
|
108
|
+
* Type widening is necessary because TypeScript checks the raw source
|
|
109
|
+
* BEFORE the compiler pass runs — both shapes must typecheck.
|
|
110
|
+
*/
|
|
111
|
+
children?: ((Component: ComponentFn<P>) => VNodeChild) | VNodeChild
|
|
112
|
+
/** Shown while the chunk is loading. Default: `null`. */
|
|
113
|
+
fallback?: VNodeChild
|
|
114
|
+
/**
|
|
115
|
+
* IntersectionObserver `rootMargin` for `on="visible"` mode. Default
|
|
116
|
+
* `'200px'` — start loading the chunk before the wrapper is fully in
|
|
117
|
+
* view so it's typically ready by the time the user scrolls to it.
|
|
118
|
+
*/
|
|
119
|
+
rootMargin?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Lazy-load a chunk when a trigger condition is met.
|
|
124
|
+
*
|
|
125
|
+
* Three trigger modes:
|
|
126
|
+
* - `when={() => signal()}` — load when condition flips truthy (modal pattern)
|
|
127
|
+
* - `on="visible"` — load when the wrapper scrolls into view
|
|
128
|
+
* - `on="idle"` — load during browser idle time
|
|
129
|
+
*
|
|
130
|
+
* The chunk fetch is fired exactly once per `Defer` instance — repeated
|
|
131
|
+
* trigger firings after the chunk loads are no-ops.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* // Signal-driven (modal):
|
|
135
|
+
* <Defer chunk={() => import('./ConfirmDeleteModal')} when={open}>
|
|
136
|
+
* {Modal => <Modal onClose={() => setOpen(false)} />}
|
|
137
|
+
* </Defer>
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* // Viewport-driven (below-fold):
|
|
141
|
+
* <Defer chunk={() => import('./Comments')} on="visible">
|
|
142
|
+
* {Comments => <Comments postId={id} />}
|
|
143
|
+
* </Defer>
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* // Idle-driven (non-critical):
|
|
147
|
+
* <Defer chunk={() => import('./Analytics')} on="idle">
|
|
148
|
+
* {Dashboard => <Dashboard />}
|
|
149
|
+
* </Defer>
|
|
150
|
+
*/
|
|
151
|
+
export function Defer<P extends Props>(props: DeferProps<P>): VNode {
|
|
152
|
+
const Loaded = signal<ComponentFn<P> | null>(null)
|
|
153
|
+
const Failed = signal<Error | null>(null)
|
|
154
|
+
// Module-scope flag prevents repeat fetches when the trigger condition
|
|
155
|
+
// oscillates (e.g. modal opens / closes / opens again). The chunk only
|
|
156
|
+
// loads once per Defer mount.
|
|
157
|
+
let loadStarted = false
|
|
158
|
+
|
|
159
|
+
const startLoad = (): void => {
|
|
160
|
+
if (loadStarted) return
|
|
161
|
+
loadStarted = true
|
|
162
|
+
if (!props.chunk) {
|
|
163
|
+
// Missing chunk = either the user is hand-writing the inline form
|
|
164
|
+
// without the compiler pass running, or they wrote the explicit
|
|
165
|
+
// form and forgot to pass chunk. Either way, error early with an
|
|
166
|
+
// actionable message instead of crashing later inside the `.then`.
|
|
167
|
+
const err = new Error(
|
|
168
|
+
'[Pyreon] <Defer> has no `chunk` prop. Either pass `chunk={() => import("...")}` ' +
|
|
169
|
+
'(explicit form), or use the inline form `<Defer when={...}><Component /></Defer>` ' +
|
|
170
|
+
'with `@pyreon/vite-plugin` enabled — the compiler rewrites inline JSX to ' +
|
|
171
|
+
'an explicit chunk-prop call.',
|
|
172
|
+
)
|
|
173
|
+
Failed.set(err)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
props
|
|
177
|
+
.chunk()
|
|
178
|
+
.then((mod) => {
|
|
179
|
+
// Accept both ES-module-default and bare ComponentFn shapes.
|
|
180
|
+
const Comp =
|
|
181
|
+
typeof mod === 'function'
|
|
182
|
+
? mod
|
|
183
|
+
: (mod as { default: ComponentFn<P> }).default
|
|
184
|
+
if (__DEV__ && typeof Comp !== 'function') {
|
|
185
|
+
// oxlint-disable-next-line no-console
|
|
186
|
+
console.warn(
|
|
187
|
+
'[Pyreon] <Defer> chunk() resolved without a default-exported component. Make sure your module exports default.',
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
Loaded.set(Comp)
|
|
192
|
+
})
|
|
193
|
+
.catch((err) => {
|
|
194
|
+
const wrapped = err instanceof Error ? err : new Error(String(err))
|
|
195
|
+
if (__DEV__) {
|
|
196
|
+
// oxlint-disable-next-line no-console
|
|
197
|
+
console.error('[Pyreon] <Defer> chunk() rejected:', wrapped)
|
|
198
|
+
}
|
|
199
|
+
Failed.set(wrapped)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Trigger wiring — exactly one branch fires per instance.
|
|
204
|
+
if ('when' in props) {
|
|
205
|
+
// Signal-driven. Subscribe to the accessor; load when it transitions
|
|
206
|
+
// to truthy. Repeat truthy emissions are no-ops via `loadStarted`.
|
|
207
|
+
effect(() => {
|
|
208
|
+
if (props.when() && !loadStarted) startLoad()
|
|
209
|
+
})
|
|
210
|
+
} else if (props.on === 'idle') {
|
|
211
|
+
// Idle-driven. Delegated to `_setupIdleTrigger` so the browser-API
|
|
212
|
+
// branching is testable as a pure function. Wrapped in onMount so
|
|
213
|
+
// SSR / non-browser environments don't fire the callback at all.
|
|
214
|
+
onMount(() => _setupIdleTrigger(startLoad))
|
|
215
|
+
}
|
|
216
|
+
// Note: `on === 'visible'` is wired below alongside the wrapper element
|
|
217
|
+
// because it needs a DOM target to observe.
|
|
218
|
+
|
|
219
|
+
// Inline accessor — type annotation deliberately omitted so the
|
|
220
|
+
// inferred return type narrows to `VNodeChildAtom | VNodeChildAtom[]`
|
|
221
|
+
// (what `h()`'s rest-args expect). Annotating as `VNodeChild` widens
|
|
222
|
+
// to include `VNodeChildAccessor`, which can't be returned from another
|
|
223
|
+
// accessor.
|
|
224
|
+
const renderContent = () => {
|
|
225
|
+
const err = Failed()
|
|
226
|
+
if (err) throw err
|
|
227
|
+
const Comp = Loaded()
|
|
228
|
+
if (!Comp) return props.fallback ?? null
|
|
229
|
+
// children is widened to `VNodeChild | render-prop` so the compiler-
|
|
230
|
+
// driven inline form (where author writes `<Defer ...><Modal /></Defer>`)
|
|
231
|
+
// typechecks at source level. At RUNTIME children is always either
|
|
232
|
+
// undefined OR the render-prop — the compiler rewrites the inline
|
|
233
|
+
// form's JSX children to a render-prop before this code runs.
|
|
234
|
+
// A non-function children at runtime means the user is invoking the
|
|
235
|
+
// inline form without the compiler pass (e.g. running tests through
|
|
236
|
+
// a bundler that doesn't include `@pyreon/vite-plugin`) — in that
|
|
237
|
+
// case we render `<Comp />` with no props as a best-effort fallback.
|
|
238
|
+
const ch = props.children
|
|
239
|
+
if (typeof ch === 'function') return ch(Comp)
|
|
240
|
+
return h(Comp as ComponentFn, {})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if ('on' in props && props.on === 'visible') {
|
|
244
|
+
// Visible-mode needs a DOM target for IntersectionObserver. A
|
|
245
|
+
// wrapper `<div data-pyreon-defer="visible">` carries the ref and
|
|
246
|
+
// styles `display: contents` so it's transparent to layout (the
|
|
247
|
+
// fallback / loaded component render as direct children of Defer's
|
|
248
|
+
// parent).
|
|
249
|
+
const containerRef = createRef<HTMLElement>()
|
|
250
|
+
// Visible-mode trigger is wired via `_setupVisibleTrigger` so the
|
|
251
|
+
// observer-construction + intersection-detection logic is
|
|
252
|
+
// independently testable. onMount keeps the browser-API access
|
|
253
|
+
// out of the SSR path.
|
|
254
|
+
onMount(() =>
|
|
255
|
+
_setupVisibleTrigger(
|
|
256
|
+
containerRef.current,
|
|
257
|
+
startLoad,
|
|
258
|
+
props.rootMargin ?? '200px',
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
// Cast renderContent to VNodeChildAccessor — its inferred return type
|
|
262
|
+
// is `VNodeChild` (broader than the accessor's `atom | atom[]`) because
|
|
263
|
+
// `props.children` itself may return any VNodeChild. The runtime
|
|
264
|
+
// unwraps nested accessors via the same mountChild path that handles
|
|
265
|
+
// <Show>'s thunk shape; the type system doesn't model the unwrap so
|
|
266
|
+
// the cast bridges. See <Show>'s `as unknown as VNode` for prior art.
|
|
267
|
+
return h(
|
|
268
|
+
'div',
|
|
269
|
+
{
|
|
270
|
+
'data-pyreon-defer': 'visible',
|
|
271
|
+
ref: containerRef,
|
|
272
|
+
style: 'display: contents',
|
|
273
|
+
},
|
|
274
|
+
renderContent as VNodeChildAccessor,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return h(Fragment, null, renderContent as VNodeChildAccessor)
|
|
279
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
export { defineComponent, dispatchToErrorBoundary, propagateError, runWithHooks } from './component'
|
|
4
4
|
export { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from './compat-marker'
|
|
5
|
+
export { mapCompatDomProps, shallowEqualProps } from './compat-shared'
|
|
5
6
|
export type { Context, ContextSnapshot, ReactiveContext } from './context'
|
|
6
7
|
export {
|
|
7
8
|
captureContextStack,
|
|
@@ -35,12 +36,22 @@ export type {
|
|
|
35
36
|
TargetedEvent,
|
|
36
37
|
TextareaAttributes,
|
|
37
38
|
} from './jsx-runtime'
|
|
39
|
+
export type { DeferProps } from './defer'
|
|
40
|
+
export { Defer } from './defer'
|
|
38
41
|
export { lazy } from './lazy'
|
|
39
42
|
export { onErrorCaptured, onMount, onUnmount, onUpdate } from './lifecycle'
|
|
40
43
|
export { mapArray } from './map-array'
|
|
41
44
|
export type { PortalProps } from './portal'
|
|
42
45
|
export { Portal, PortalSymbol } from './portal'
|
|
43
|
-
export {
|
|
46
|
+
export {
|
|
47
|
+
_rp,
|
|
48
|
+
_wrapSpread,
|
|
49
|
+
createUniqueId,
|
|
50
|
+
makeReactiveProps,
|
|
51
|
+
mergeProps,
|
|
52
|
+
REACTIVE_PROP,
|
|
53
|
+
splitProps,
|
|
54
|
+
} from './props'
|
|
44
55
|
export type { Ref, RefCallback, RefProp } from './ref'
|
|
45
56
|
export { createRef } from './ref'
|
|
46
57
|
export type { MatchProps, ShowProps, SwitchProps } from './show'
|
|
@@ -49,7 +60,7 @@ export type { ClassValue } from './style'
|
|
|
49
60
|
export { CSS_UNITLESS, cx, normalizeStyleValue, toKebabCase } from './style'
|
|
50
61
|
export type { LazyComponent } from './suspense'
|
|
51
62
|
export { Suspense } from './suspense'
|
|
52
|
-
export type { ErrorContext, ErrorHandler } from './telemetry'
|
|
63
|
+
export type { ErrorContext, ErrorHandler, ReactiveTraceEntry } from './telemetry'
|
|
53
64
|
export { registerErrorHandler, reportError } from './telemetry'
|
|
54
65
|
export type {
|
|
55
66
|
CleanupFn,
|