@pyreon/runtime-dom 0.14.0 → 0.15.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/keep-alive-entry.js.html +1 -1
- package/lib/analysis/transition-entry.js.html +1 -1
- package/lib/index.js +62 -14
- package/lib/keep-alive-entry.js +5 -4
- package/lib/transition-entry.js +3 -2
- package/lib/types/index.d.ts +54 -5
- package/package.json +6 -5
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +9 -2
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +11 -3
- package/src/keep-alive.ts +5 -1
- package/src/mount.ts +1 -2
- package/src/nodes.ts +1 -2
- package/src/props.ts +1 -2
- package/src/template.ts +46 -2
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/mount.test.ts +91 -0
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +58 -6
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +71 -1
- package/src/transition-group.ts +6 -1
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/keep-alive-entry.js.map +0 -1
- package/lib/transition-entry.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/keep-alive-entry.d.ts.map +0 -1
- package/lib/types/transition-entry.d.ts.map +0 -1
package/src/hydration-debug.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hydration mismatch warnings.
|
|
2
|
+
* Hydration mismatch warnings + telemetry hook.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Can be toggled manually for testing or verbose production debugging.
|
|
4
|
+
* Two complementary surfaces:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* 1. **Dev-mode console.warn** — enabled automatically when
|
|
7
|
+
* `NODE_ENV !== "production"` (and silent otherwise, matching React /
|
|
8
|
+
* Vue / Solid). Toggle manually with `enableHydrationWarnings()` /
|
|
9
|
+
* `disableHydrationWarnings()` if you need verbose production debugging.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Telemetry callback** — register a handler with
|
|
12
|
+
* `onHydrationMismatch(handler)` to forward every mismatch into your
|
|
13
|
+
* error-tracking pipeline (Sentry, Datadog, etc.). Fires on EVERY
|
|
14
|
+
* mismatch, in development AND production, regardless of the warn
|
|
15
|
+
* toggle. Returns an unregister function.
|
|
16
|
+
*
|
|
17
|
+
* The dev warn and the telemetry callback are independent: a production
|
|
18
|
+
* deployment can install Sentry forwarding via `onHydrationMismatch`
|
|
19
|
+
* WITHOUT enabling the noisy console output.
|
|
20
|
+
*
|
|
21
|
+
* @example — dev console
|
|
8
22
|
* import { enableHydrationWarnings } from "@pyreon/runtime-dom"
|
|
9
23
|
* enableHydrationWarnings()
|
|
24
|
+
*
|
|
25
|
+
* @example — production telemetry
|
|
26
|
+
* import { onHydrationMismatch } from "@pyreon/runtime-dom"
|
|
27
|
+
* import * as Sentry from "@sentry/browser"
|
|
28
|
+
*
|
|
29
|
+
* onHydrationMismatch(ctx => {
|
|
30
|
+
* Sentry.captureMessage(`Hydration mismatch (${ctx.type})`, {
|
|
31
|
+
* extra: { expected: ctx.expected, actual: ctx.actual, path: ctx.path },
|
|
32
|
+
* level: 'warning',
|
|
33
|
+
* })
|
|
34
|
+
* })
|
|
10
35
|
*/
|
|
11
36
|
|
|
12
37
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
13
38
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
14
|
-
|
|
15
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
39
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
16
40
|
|
|
17
41
|
let _enabled = __DEV__
|
|
18
42
|
|
|
@@ -24,6 +48,43 @@ export function disableHydrationWarnings(): void {
|
|
|
24
48
|
_enabled = false
|
|
25
49
|
}
|
|
26
50
|
|
|
51
|
+
// ─── Telemetry callback ─────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export type HydrationMismatchType = 'tag' | 'text' | 'missing'
|
|
54
|
+
|
|
55
|
+
export interface HydrationMismatchContext {
|
|
56
|
+
/** Kind of mismatch */
|
|
57
|
+
type: HydrationMismatchType
|
|
58
|
+
/** What the VNode expected */
|
|
59
|
+
expected: unknown
|
|
60
|
+
/** What the DOM had */
|
|
61
|
+
actual: unknown
|
|
62
|
+
/** Human-readable path in the tree, e.g. "root > div > span" */
|
|
63
|
+
path: string
|
|
64
|
+
/** Unix timestamp (ms) */
|
|
65
|
+
timestamp: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type HydrationMismatchHandler = (ctx: HydrationMismatchContext) => void
|
|
69
|
+
|
|
70
|
+
let _handlers: HydrationMismatchHandler[] = []
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a hydration mismatch handler. Called on every mismatch in BOTH
|
|
74
|
+
* development and production, independent of the dev-mode warn toggle.
|
|
75
|
+
*
|
|
76
|
+
* Mirrors `@pyreon/core`'s `registerErrorHandler` pattern — multiple
|
|
77
|
+
* handlers can be registered; each is called in registration order;
|
|
78
|
+
* handler errors are swallowed so they don't propagate into the
|
|
79
|
+
* framework. Returns an unregister function.
|
|
80
|
+
*/
|
|
81
|
+
export function onHydrationMismatch(handler: HydrationMismatchHandler): () => void {
|
|
82
|
+
_handlers.push(handler)
|
|
83
|
+
return () => {
|
|
84
|
+
_handlers = _handlers.filter((h) => h !== handler)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
27
88
|
/**
|
|
28
89
|
* Emit a hydration mismatch warning.
|
|
29
90
|
* @param type - Kind of mismatch
|
|
@@ -32,13 +93,37 @@ export function disableHydrationWarnings(): void {
|
|
|
32
93
|
* @param path - Human-readable path in the tree, e.g. "root > div > span"
|
|
33
94
|
*/
|
|
34
95
|
export function warnHydrationMismatch(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
96
|
+
type: HydrationMismatchType,
|
|
97
|
+
expected: unknown,
|
|
98
|
+
actual: unknown,
|
|
99
|
+
path: string,
|
|
39
100
|
): void {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
101
|
+
// Dev-mode console.warn — gated on _enabled (default __DEV__).
|
|
102
|
+
if (_enabled) {
|
|
103
|
+
// oxlint-disable-next-line no-console
|
|
104
|
+
console.warn(
|
|
105
|
+
`[Pyreon] Hydration mismatch (${type}): expected ${String(expected)}, got ${String(actual)} at ${path}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Telemetry callbacks — fire in BOTH dev and prod, independent of the
|
|
110
|
+
// warn toggle. This is the production observability hook (Sentry,
|
|
111
|
+
// Datadog, etc.) that pre-fix was missing entirely.
|
|
112
|
+
if (_handlers.length > 0) {
|
|
113
|
+
const ctx: HydrationMismatchContext = {
|
|
114
|
+
type,
|
|
115
|
+
expected,
|
|
116
|
+
actual,
|
|
117
|
+
path,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
}
|
|
120
|
+
for (const h of _handlers) {
|
|
121
|
+
try {
|
|
122
|
+
h(ctx)
|
|
123
|
+
} catch {
|
|
124
|
+
// handler errors must never propagate back into the hydration
|
|
125
|
+
// pipeline — a broken Sentry SDK shouldn't crash the app.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
44
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
export { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from './delegate'
|
|
4
4
|
export type { DevtoolsComponentEntry, PyreonDevtools } from './devtools'
|
|
5
5
|
export { hydrateRoot } from './hydrate'
|
|
6
|
-
export {
|
|
6
|
+
export type {
|
|
7
|
+
HydrationMismatchContext,
|
|
8
|
+
HydrationMismatchHandler,
|
|
9
|
+
HydrationMismatchType,
|
|
10
|
+
} from './hydration-debug'
|
|
11
|
+
export {
|
|
12
|
+
disableHydrationWarnings,
|
|
13
|
+
enableHydrationWarnings,
|
|
14
|
+
onHydrationMismatch,
|
|
15
|
+
} from './hydration-debug'
|
|
7
16
|
export type { KeepAliveProps } from './keep-alive'
|
|
8
17
|
export { KeepAlive } from './keep-alive'
|
|
9
18
|
export { mountChild } from './mount'
|
|
@@ -28,8 +37,7 @@ import { mountChild } from './mount'
|
|
|
28
37
|
|
|
29
38
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
30
39
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
31
|
-
|
|
32
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
40
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
33
41
|
|
|
34
42
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
35
43
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
package/src/keep-alive.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Props, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, onMount } from '@pyreon/core'
|
|
2
|
+
import { createRef, h, nativeCompat, onMount } from '@pyreon/core'
|
|
3
3
|
import { effect } from '@pyreon/reactivity'
|
|
4
4
|
import { mountChild } from './mount'
|
|
5
5
|
|
|
@@ -70,3 +70,7 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
|
|
|
70
70
|
// (children appear as if directly in the parent flow)
|
|
71
71
|
return h('div', { ref: containerRef, style: 'display: contents' })
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
|
|
75
|
+
// KeepAlive uses onMount + effect + mountChild that need Pyreon's setup frame.
|
|
76
|
+
nativeCompat(KeepAlive)
|
package/src/mount.ts
CHANGED
|
@@ -25,8 +25,7 @@ import { applyProps } from './props'
|
|
|
25
25
|
|
|
26
26
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
27
27
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
28
|
-
|
|
29
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
28
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
30
29
|
|
|
31
30
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
32
31
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
package/src/nodes.ts
CHANGED
|
@@ -7,8 +7,7 @@ import { effect, runUntracked } from '@pyreon/reactivity'
|
|
|
7
7
|
|
|
8
8
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
9
9
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
10
|
-
|
|
11
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
10
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
12
11
|
|
|
13
12
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
14
13
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
package/src/props.ts
CHANGED
|
@@ -8,8 +8,7 @@ type Cleanup = () => void
|
|
|
8
8
|
|
|
9
9
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
10
10
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
11
|
-
|
|
12
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
11
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
13
12
|
|
|
14
13
|
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
15
14
|
|
package/src/template.ts
CHANGED
|
@@ -4,8 +4,7 @@ import { mountChild } from './mount'
|
|
|
4
4
|
|
|
5
5
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
6
6
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
7
|
-
|
|
8
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
7
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
9
8
|
|
|
10
9
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
11
10
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
@@ -126,6 +125,22 @@ export function _bindDirect(
|
|
|
126
125
|
// ─── Compiler-facing template API ─────────────────────────────────────────────
|
|
127
126
|
|
|
128
127
|
// Cache parsed <template> elements by HTML string — parse once, clone many.
|
|
128
|
+
//
|
|
129
|
+
// LRU bound (audit bug #5): typical apps emit a small bounded set of unique
|
|
130
|
+
// HTML strings (one per JSX element tree the compiler hoists), so the cache
|
|
131
|
+
// stays in the dozens-to-hundreds in practice. But an app that constructs
|
|
132
|
+
// JSX from user input (or compiles many large dynamic templates) could grow
|
|
133
|
+
// this unbounded — every unique string holds a parsed <template> alive.
|
|
134
|
+
//
|
|
135
|
+
// Map preserves insertion order; on overflow we evict the OLDEST entry (the
|
|
136
|
+
// least-recently-inserted). Common HTML strings hit the cache before
|
|
137
|
+
// eviction; pathological inputs cycle through the cap without leaking.
|
|
138
|
+
//
|
|
139
|
+
// 1024 chosen as a balance: ~1024 unique templates × ~1KB parsed = ~1MB
|
|
140
|
+
// worst case — well within memory budget for any realistic app, and
|
|
141
|
+
// generous enough that no real codebase will hit the cap. Apps that
|
|
142
|
+
// genuinely need a different cap can swap their own _tpl wrapper.
|
|
143
|
+
const TPL_CACHE_MAX = 1024
|
|
129
144
|
const _tplCache = new Map<string, HTMLTemplateElement>()
|
|
130
145
|
|
|
131
146
|
/**
|
|
@@ -157,6 +172,18 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
|
|
|
157
172
|
if (!tpl) {
|
|
158
173
|
tpl = document.createElement('template')
|
|
159
174
|
tpl.innerHTML = html
|
|
175
|
+
// LRU eviction — drop the oldest entry once we hit the cap. Map
|
|
176
|
+
// iteration is insertion-order so the first key is always the
|
|
177
|
+
// oldest. delete() is O(1).
|
|
178
|
+
if (_tplCache.size >= TPL_CACHE_MAX) {
|
|
179
|
+
const oldest = _tplCache.keys().next().value
|
|
180
|
+
if (oldest !== undefined) _tplCache.delete(oldest)
|
|
181
|
+
}
|
|
182
|
+
_tplCache.set(html, tpl)
|
|
183
|
+
} else {
|
|
184
|
+
// LRU touch — re-insert moves to most-recent position so frequently
|
|
185
|
+
// used templates survive eviction.
|
|
186
|
+
_tplCache.delete(html)
|
|
160
187
|
_tplCache.set(html, tpl)
|
|
161
188
|
}
|
|
162
189
|
const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
|
|
@@ -164,6 +191,23 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
|
|
|
164
191
|
return { __isNative: true, el, cleanup }
|
|
165
192
|
}
|
|
166
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Test-only: clear the template cache. Used by tests that assert on
|
|
196
|
+
* cache size; never called by runtime code. Not exported from the
|
|
197
|
+
* package's public index.
|
|
198
|
+
*/
|
|
199
|
+
export function _clearTplCache(): void {
|
|
200
|
+
_tplCache.clear()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Test-only: read current cache size. Used by tests that assert
|
|
205
|
+
* eviction. Not exported from the package's public index.
|
|
206
|
+
*/
|
|
207
|
+
export function _tplCacheSize(): number {
|
|
208
|
+
return _tplCache.size
|
|
209
|
+
}
|
|
210
|
+
|
|
167
211
|
/**
|
|
168
212
|
* Mount a children slot inside a template.
|
|
169
213
|
*
|
|
@@ -8,22 +8,27 @@ const SRC = path.resolve(here, '..')
|
|
|
8
8
|
|
|
9
9
|
// Source-pattern regression test for the dev-mode warning gate. Pairs with
|
|
10
10
|
// the browser test in `runtime-dom.browser.test.ts` (which proves the gate
|
|
11
|
-
// fires in dev) — this asserts the gate is written using the
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// fires in dev) — this asserts the gate is written using the bundler-agnostic
|
|
12
|
+
// pattern (`process.env.NODE_ENV !== 'production'`) that every modern bundler
|
|
13
|
+
// (Vite, Webpack/Next.js, esbuild, Rollup, Parcel, Bun) literal-replaces at
|
|
14
|
+
// consumer build time. The two previously-shipped broken patterns must not
|
|
15
|
+
// appear:
|
|
16
|
+
// 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
|
|
17
|
+
// — dead in Vite browser bundles.
|
|
18
|
+
// 2. `import.meta.env.DEV` — Vite/Rolldown-only; undefined and silent in
|
|
19
|
+
// Webpack/Next.js, esbuild, Rollup, Parcel, Bun.
|
|
14
20
|
//
|
|
15
21
|
// Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
|
|
16
22
|
//
|
|
17
|
-
// The lint rule `pyreon/no-process-dev-gate`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// configuration drifts.
|
|
23
|
+
// The lint rule `pyreon/no-process-dev-gate` is the CI-wide enforcement for
|
|
24
|
+
// this. This test is the narrow, package-local safety net so a regression in
|
|
25
|
+
// runtime-dom is caught even if the lint configuration drifts.
|
|
21
26
|
|
|
22
27
|
const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
|
|
23
28
|
|
|
24
29
|
describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
25
30
|
for (const file of FILES_WITH_DEV_GATE) {
|
|
26
|
-
it(`${file} uses
|
|
31
|
+
it(`${file} uses bundler-agnostic process.env.NODE_ENV`, async () => {
|
|
27
32
|
const source = await readFile(path.join(SRC, file), 'utf8')
|
|
28
33
|
// Strip line + block comments so referencing the broken pattern in
|
|
29
34
|
// documentation doesn't false-positive.
|
|
@@ -31,10 +36,11 @@ describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
|
31
36
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
32
37
|
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
33
38
|
|
|
34
|
-
// The gate
|
|
35
|
-
expect(code).toMatch(/
|
|
36
|
-
//
|
|
39
|
+
// The bundler-agnostic gate must appear (bare `process.env.NODE_ENV`).
|
|
40
|
+
expect(code).toMatch(/process\.env\.NODE_ENV/)
|
|
41
|
+
// Neither broken pattern may appear in executable code.
|
|
37
42
|
expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
|
|
43
|
+
expect(code).not.toMatch(/import\.meta\.env\??\.DEV/)
|
|
38
44
|
})
|
|
39
45
|
}
|
|
40
46
|
})
|
|
@@ -8,28 +8,24 @@ import { build } from 'vite'
|
|
|
8
8
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
9
9
|
const SRC = path.resolve(here, '..')
|
|
10
10
|
|
|
11
|
-
// Bundle-level regression test for the
|
|
11
|
+
// Bundle-level regression test for the dev-warning gate.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// runtime-dom uses bundler-agnostic `process.env.NODE_ENV !== 'production'`
|
|
14
|
+
// for dev gates — the cross-bundler library convention used by React, Vue,
|
|
15
|
+
// Preact, Solid, MobX, Redux. Every modern bundler (Vite, Webpack/Next.js,
|
|
16
|
+
// esbuild, Rollup, Parcel, Bun) auto-replaces `process.env.NODE_ENV` at
|
|
17
|
+
// consumer build time. This test bundles each representative runtime-dom
|
|
18
|
+
// file through Vite's production build and asserts dev-warning strings
|
|
19
|
+
// are GONE from the output — proving literal-replacement + dead-code
|
|
20
|
+
// elimination work end-to-end.
|
|
18
21
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// correctly — the raw esbuild baseline was misleading. Raw Rolldown
|
|
24
|
-
// alone also doesn't replicate Vite's behavior because Rolldown's
|
|
25
|
-
// `define` doesn't rewrite optional-chain access paths.
|
|
22
|
+
// The test uses Vite because that's Pyreon's reference consumer pipeline
|
|
23
|
+
// today; the same files under Webpack / esbuild / Rollup etc. tree-shake
|
|
24
|
+
// equivalently because they all replace `process.env.NODE_ENV`. Vite is
|
|
25
|
+
// just the most-tested path.
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// ever regresses, this catches it.
|
|
30
|
-
//
|
|
31
|
-
// Scope note: the existing `dev-gate-pattern.test.ts` is the cheap
|
|
32
|
-
// source-level guard (grep for `typeof process`, require `import.meta.env.DEV`).
|
|
27
|
+
// Scope note: `dev-gate-pattern.test.ts` is the cheap source-level guard
|
|
28
|
+
// (grep for the broken patterns, require bare `process.env.NODE_ENV`).
|
|
33
29
|
// This test is the expensive end-to-end guard for the bundle path.
|
|
34
30
|
|
|
35
31
|
interface FileContract {
|
|
@@ -84,19 +80,17 @@ const FILES_UNDER_TEST: FileContract[] = [
|
|
|
84
80
|
async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
|
|
85
81
|
const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
|
|
86
82
|
try {
|
|
87
|
-
// Vite library-mode build with explicit minify.
|
|
88
|
-
// `
|
|
89
|
-
//
|
|
83
|
+
// Vite library-mode build with explicit minify. The bundler-agnostic
|
|
84
|
+
// gate uses `process.env.NODE_ENV` — Vite's library mode doesn't apply
|
|
85
|
+
// the default replacement automatically, so we set it ourselves to
|
|
86
|
+
// match what every modern bundler does at consumer build time.
|
|
90
87
|
await build({
|
|
91
88
|
mode: dev ? 'development' : 'production',
|
|
92
89
|
logLevel: 'error',
|
|
93
90
|
configFile: false,
|
|
94
91
|
resolve: { conditions: ['bun'] },
|
|
95
|
-
// Explicit define — Vite in lib mode doesn't always apply the
|
|
96
|
-
// default production env replacement, so we set it ourselves.
|
|
97
92
|
define: {
|
|
98
|
-
'
|
|
99
|
-
'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
|
|
93
|
+
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
|
|
100
94
|
},
|
|
101
95
|
build: {
|
|
102
96
|
// PINNED minifier: 'esbuild' is what Pyreon's reference consumers
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* hydrate on client, verify signals work and DOM is reused.
|
|
6
6
|
*/
|
|
7
7
|
import type { VNodeChild } from '@pyreon/core'
|
|
8
|
-
import { For, Fragment, h, Show } from '@pyreon/core'
|
|
8
|
+
import { _rp, For, Fragment, h, Show } from '@pyreon/core'
|
|
9
9
|
import { signal } from '@pyreon/reactivity'
|
|
10
10
|
import { renderToString } from '@pyreon/runtime-server'
|
|
11
11
|
import { disableHydrationWarnings, enableHydrationWarnings, hydrateRoot } from '../index'
|
|
@@ -373,3 +373,168 @@ describe('hydration integration — mismatch recovery', () => {
|
|
|
373
373
|
cleanup()
|
|
374
374
|
})
|
|
375
375
|
})
|
|
376
|
+
|
|
377
|
+
// ─── onHydrationMismatch telemetry hook ────────────────────────────────────
|
|
378
|
+
//
|
|
379
|
+
// Pre-fix: runtime-dom emitted hydration mismatches via console.warn ONLY,
|
|
380
|
+
// gated on __DEV__. Production deployments (Sentry, Datadog) had no
|
|
381
|
+
// integration point — mismatches surfaced as silent recovery (text
|
|
382
|
+
// rewritten or DOM remounted) with no telemetry signal. The asymmetry
|
|
383
|
+
// with `@pyreon/core`'s `registerErrorHandler` (which captures component
|
|
384
|
+
// + reactivity errors via the `__pyreon_report_error__` bridge) was the
|
|
385
|
+
// gap.
|
|
386
|
+
//
|
|
387
|
+
// Post-fix: `onHydrationMismatch(handler)` registers a callback fired on
|
|
388
|
+
// EVERY mismatch in dev AND prod, independent of the warn toggle.
|
|
389
|
+
// Mirrors core's `registerErrorHandler` shape.
|
|
390
|
+
describe('hydration integration — onHydrationMismatch telemetry hook', () => {
|
|
391
|
+
test('handler fires with full mismatch context on tag mismatch', async () => {
|
|
392
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
393
|
+
const captured: Array<{ type: string; expected: unknown; actual: unknown; path: string; timestamp: number }> = []
|
|
394
|
+
const unsub = onHydrationMismatch((ctx) => {
|
|
395
|
+
captured.push({
|
|
396
|
+
type: ctx.type,
|
|
397
|
+
expected: ctx.expected,
|
|
398
|
+
actual: ctx.actual,
|
|
399
|
+
path: ctx.path,
|
|
400
|
+
timestamp: ctx.timestamp,
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
const el = container()
|
|
405
|
+
el.innerHTML = '<div>server content</div>'
|
|
406
|
+
|
|
407
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
408
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
409
|
+
|
|
410
|
+
expect(captured.length).toBeGreaterThan(0)
|
|
411
|
+
const tagMismatch = captured.find((c) => c.type === 'tag')
|
|
412
|
+
expect(tagMismatch).toBeDefined()
|
|
413
|
+
expect(tagMismatch?.expected).toBe('span')
|
|
414
|
+
expect(typeof tagMismatch?.path).toBe('string')
|
|
415
|
+
expect(typeof tagMismatch?.timestamp).toBe('number')
|
|
416
|
+
|
|
417
|
+
cleanup()
|
|
418
|
+
unsub()
|
|
419
|
+
warnSpy.mockRestore()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('handler fires for tag mismatch in production-style silence (warn disabled)', () => {
|
|
423
|
+
const el = container()
|
|
424
|
+
el.innerHTML = '<div>server content</div>'
|
|
425
|
+
|
|
426
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
427
|
+
disableHydrationWarnings() // simulate production: warns silenced
|
|
428
|
+
|
|
429
|
+
return import('../hydration-debug').then(({ onHydrationMismatch }) => {
|
|
430
|
+
const captured: Array<{ type: string }> = []
|
|
431
|
+
const unsub = onHydrationMismatch((ctx) => {
|
|
432
|
+
captured.push({ type: ctx.type })
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
436
|
+
|
|
437
|
+
// Telemetry hook fired even with warn disabled — independent.
|
|
438
|
+
expect(captured.length).toBeGreaterThan(0)
|
|
439
|
+
expect(captured.some((c) => c.type === 'tag')).toBe(true)
|
|
440
|
+
// console.warn was NOT called (production-style silence).
|
|
441
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
442
|
+
|
|
443
|
+
cleanup()
|
|
444
|
+
unsub()
|
|
445
|
+
warnSpy.mockRestore()
|
|
446
|
+
enableHydrationWarnings()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('multiple handlers all receive forwarded mismatches; unsub stops one cleanly', async () => {
|
|
451
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
452
|
+
let count1 = 0
|
|
453
|
+
let count2 = 0
|
|
454
|
+
const unsub1 = onHydrationMismatch(() => count1++)
|
|
455
|
+
const unsub2 = onHydrationMismatch(() => count2++)
|
|
456
|
+
|
|
457
|
+
const el = container()
|
|
458
|
+
el.innerHTML = '<div>server</div>'
|
|
459
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
460
|
+
|
|
461
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
462
|
+
|
|
463
|
+
expect(count1).toBeGreaterThan(0)
|
|
464
|
+
expect(count1).toBe(count2)
|
|
465
|
+
|
|
466
|
+
// Unsubscribe one — only the other fires next time.
|
|
467
|
+
unsub1()
|
|
468
|
+
const before2 = count2
|
|
469
|
+
const el2 = container()
|
|
470
|
+
el2.innerHTML = '<p>foo</p>'
|
|
471
|
+
const cleanup2 = hydrateRoot(el2, h('article', null, 'bar'))
|
|
472
|
+
|
|
473
|
+
expect(count2).toBeGreaterThan(before2)
|
|
474
|
+
|
|
475
|
+
cleanup()
|
|
476
|
+
cleanup2()
|
|
477
|
+
unsub2()
|
|
478
|
+
warnSpy.mockRestore()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('handler errors do not propagate into hydration', async () => {
|
|
482
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
483
|
+
let goodHandlerFired = false
|
|
484
|
+
const unsubBad = onHydrationMismatch(() => {
|
|
485
|
+
throw new Error('telemetry SDK exploded')
|
|
486
|
+
})
|
|
487
|
+
const unsubGood = onHydrationMismatch(() => {
|
|
488
|
+
goodHandlerFired = true
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const el = container()
|
|
492
|
+
el.innerHTML = '<div>server</div>'
|
|
493
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
494
|
+
disableHydrationWarnings()
|
|
495
|
+
|
|
496
|
+
// Hydration must complete without throwing despite bad handler.
|
|
497
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
498
|
+
expect(goodHandlerFired).toBe(true)
|
|
499
|
+
// Client content still rendered — recovery worked.
|
|
500
|
+
expect(el.textContent).toContain('client')
|
|
501
|
+
|
|
502
|
+
cleanup()
|
|
503
|
+
unsubBad()
|
|
504
|
+
unsubGood()
|
|
505
|
+
warnSpy.mockRestore()
|
|
506
|
+
enableHydrationWarnings()
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// ─── _rp prop forwarding through SSR -> hydrate ─────────────────────────────
|
|
511
|
+
|
|
512
|
+
describe('hydration integration — `_rp`-wrapped component props (regression)', () => {
|
|
513
|
+
// Pre-fix, hydrate.ts skipped `makeReactiveProps` on the way into a
|
|
514
|
+
// component, so `props.x` returned the raw `_rp` function instead of the
|
|
515
|
+
// resolved value. mount.ts already did the right thing, so the failure mode
|
|
516
|
+
// surfaced only on cold-start SSR/hydrate (the fundamentals NavItem layout
|
|
517
|
+
// shape — see e2e/fundamentals/playground.spec.ts). Lock in BOTH the SSR
|
|
518
|
+
// emit and the post-hydration value.
|
|
519
|
+
test('SSR emits resolved string from `_rp` prop, hydration preserves it', async () => {
|
|
520
|
+
const Link = (props: { to: string }) =>
|
|
521
|
+
h('a', { href: `#${props.to}`, id: 'lnk' }, () => props.to)
|
|
522
|
+
|
|
523
|
+
const html = await renderToString(
|
|
524
|
+
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
525
|
+
)
|
|
526
|
+
expect(html).toBe('<a href="#/about" id="lnk">/about</a>')
|
|
527
|
+
expect(html).not.toContain('=>')
|
|
528
|
+
|
|
529
|
+
const el = container()
|
|
530
|
+
el.innerHTML = html
|
|
531
|
+
const cleanup = hydrateRoot(
|
|
532
|
+
el,
|
|
533
|
+
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
534
|
+
)
|
|
535
|
+
const link = el.querySelector<HTMLAnchorElement>('#lnk')!
|
|
536
|
+
expect(link.getAttribute('href')).toBe('#/about')
|
|
537
|
+
expect(link.textContent).toBe('/about')
|
|
538
|
+
cleanup()
|
|
539
|
+
})
|
|
540
|
+
})
|