@pyreon/runtime-dom 0.12.13 → 0.12.15
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 +13 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +167 -26
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +12 -6
- package/src/hydrate.ts +35 -5
- package/src/mount.ts +8 -2
- package/src/props.ts +101 -22
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/dev-gate-pattern.test.ts +40 -0
- package/src/tests/dev-gate-treeshake.test.ts +262 -0
- package/src/tests/mount.test.ts +95 -5
- package/src/tests/props.test.ts +117 -0
- package/src/tests/runtime-dom.browser.test.ts +295 -0
- package/src/tests/ssr-xss-round-trip.browser.test.ts +93 -0
- package/src/tests/style-key-removal.browser.test.ts +54 -0
- package/src/tests/style-key-removal.test.ts +88 -0
- package/src/tests/transition-timeout-leak.test.ts +126 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +46 -3
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { build } from 'vite'
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const SRC = path.resolve(here, '..')
|
|
10
|
+
|
|
11
|
+
// Bundle-level regression test for the T1.1 C-2 finding.
|
|
12
|
+
//
|
|
13
|
+
// Background — the shape of the problem from PR #227 bring-up:
|
|
14
|
+
// Raw `esbuild --minify` preserves chained `__DEV__ && cond &&
|
|
15
|
+
// console.warn(...)` patterns even when `import.meta.env.DEV` is
|
|
16
|
+
// defined to `false`. That tempted a pattern-rewrite across all
|
|
17
|
+
// Pyreon sources.
|
|
18
|
+
//
|
|
19
|
+
// What the C-2 investigation actually found:
|
|
20
|
+
// Pyreon's real consumer path is Vite (which uses Rolldown under the
|
|
21
|
+
// hood plus its own import.meta.env replacement + tree-shake passes).
|
|
22
|
+
// Vite's production build DOES eliminate the chained patterns
|
|
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.
|
|
26
|
+
//
|
|
27
|
+
// This test bundles a runtime-dom entry through Vite's production
|
|
28
|
+
// build and asserts dev-warning strings are GONE. If Vite's handling
|
|
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`).
|
|
33
|
+
// This test is the expensive end-to-end guard for the bundle path.
|
|
34
|
+
|
|
35
|
+
interface FileContract {
|
|
36
|
+
file: string
|
|
37
|
+
/** Dev-warning strings that MUST be eliminated from the prod bundle. */
|
|
38
|
+
devWarningStrings: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Coverage strategy: pick representative files across the runtime-dom
|
|
42
|
+
// dev-gate landscape so a regression in any of the typical patterns is
|
|
43
|
+
// caught. `nodes.ts` covers the chained `&&` form (the original
|
|
44
|
+
// problem). `mount.ts` covers the simple `if (__DEV__)` form across
|
|
45
|
+
// multiple Portal/VNode call sites. `props.ts` covers attribute-validation
|
|
46
|
+
// warnings inside small inline `if (__DEV__) { ... }` blocks.
|
|
47
|
+
// `transition.ts` covers a single `if (__DEV__) { console.warn() }`.
|
|
48
|
+
//
|
|
49
|
+
// These four files exercise every shape of dev gate currently used in
|
|
50
|
+
// runtime-dom; if the contract holds for all of them, it holds for the
|
|
51
|
+
// rest of the file set.
|
|
52
|
+
const FILES_UNDER_TEST: FileContract[] = [
|
|
53
|
+
{
|
|
54
|
+
file: 'nodes.ts',
|
|
55
|
+
devWarningStrings: [
|
|
56
|
+
'[Pyreon] <For> `by` function returned null/undefined',
|
|
57
|
+
'[Pyreon] Duplicate key',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
file: 'mount.ts',
|
|
62
|
+
devWarningStrings: [
|
|
63
|
+
'[Pyreon] <Portal> received a falsy `target`',
|
|
64
|
+
'[Pyreon] <Portal> target must be a DOM node',
|
|
65
|
+
'[Pyreon] Invalid VNode type',
|
|
66
|
+
'is a void element and cannot have children',
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
file: 'props.ts',
|
|
71
|
+
devWarningStrings: [
|
|
72
|
+
'[Pyreon] Event handler',
|
|
73
|
+
'[Pyreon] Blocked unsafe URL',
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
file: 'transition.ts',
|
|
78
|
+
devWarningStrings: [
|
|
79
|
+
'[Pyreon] Transition child is a component',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
|
|
85
|
+
const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
|
|
86
|
+
try {
|
|
87
|
+
// Vite library-mode build with explicit minify. `define` on
|
|
88
|
+
// `import.meta.env` isn't usually needed (Vite sets it automatically
|
|
89
|
+
// based on mode), but `mode: 'production'` flips DEV to false.
|
|
90
|
+
await build({
|
|
91
|
+
mode: dev ? 'development' : 'production',
|
|
92
|
+
logLevel: 'error',
|
|
93
|
+
configFile: false,
|
|
94
|
+
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
|
+
define: {
|
|
98
|
+
'import.meta.env.DEV': JSON.stringify(dev),
|
|
99
|
+
'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
|
|
100
|
+
},
|
|
101
|
+
build: {
|
|
102
|
+
// PINNED minifier: 'esbuild' is what Pyreon's reference consumers
|
|
103
|
+
// (Zero, the example apps) effectively use. If a future Vite
|
|
104
|
+
// version flips the default to oxc-minify or terser, behavior
|
|
105
|
+
// could differ silently — pinning keeps this test honest.
|
|
106
|
+
minify: dev ? false : 'esbuild',
|
|
107
|
+
target: 'esnext',
|
|
108
|
+
write: true,
|
|
109
|
+
outDir,
|
|
110
|
+
emptyOutDir: true,
|
|
111
|
+
lib: {
|
|
112
|
+
entry,
|
|
113
|
+
formats: ['es'],
|
|
114
|
+
fileName: 'out',
|
|
115
|
+
},
|
|
116
|
+
// Bundle everything — we want the tested file's strings visible
|
|
117
|
+
// in the output, not aliased to an external import.
|
|
118
|
+
rollupOptions: {
|
|
119
|
+
external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
const outPath = path.join(outDir, 'out.js')
|
|
124
|
+
const fs = await import('node:fs')
|
|
125
|
+
return fs.readFileSync(outPath, 'utf8')
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('runtime-dom dev-warning gate (Vite production bundle)', () => {
|
|
132
|
+
for (const { file, devWarningStrings } of FILES_UNDER_TEST) {
|
|
133
|
+
it(`${file} → dev warnings eliminated in Vite production bundle`, async () => {
|
|
134
|
+
const code = await bundleWithVite(path.join(SRC, file), false)
|
|
135
|
+
|
|
136
|
+
for (const warn of devWarningStrings) {
|
|
137
|
+
expect(code, `"${warn}" survived prod tree-shake`).not.toContain(warn)
|
|
138
|
+
}
|
|
139
|
+
expect(code.length).toBeGreaterThan(0)
|
|
140
|
+
}, 5000)
|
|
141
|
+
|
|
142
|
+
it(`${file} → dev warnings PRESERVED in Vite dev bundle (sanity)`, async () => {
|
|
143
|
+
// Gate for the eliminated-when-prod test: if the strings were
|
|
144
|
+
// deleted from source entirely, the previous test would pass
|
|
145
|
+
// trivially. Bundling in dev mode should keep them.
|
|
146
|
+
if (devWarningStrings.length === 0) return
|
|
147
|
+
|
|
148
|
+
const code = await bundleWithVite(path.join(SRC, file), true)
|
|
149
|
+
|
|
150
|
+
for (const warn of devWarningStrings) {
|
|
151
|
+
expect(code, `"${warn}" missing from dev bundle (did source change?)`).toContain(warn)
|
|
152
|
+
}
|
|
153
|
+
}, 5000)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ─── Non-Vite consumer runtime correctness ─────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// What the CLAUDE.md doc claims for non-Vite consumers (webpack,
|
|
160
|
+
// bunchee, raw esbuild bundles): the dev-warning STRINGS may stay in
|
|
161
|
+
// the bundle as data, but the warnings themselves don't fire because
|
|
162
|
+
// the `import.meta.env?.DEV === true` gate evaluates to `false` when
|
|
163
|
+
// `import.meta.env.DEV` is undefined at runtime.
|
|
164
|
+
//
|
|
165
|
+
// This block bundles `nodes.ts` with raw esbuild (no `define` for
|
|
166
|
+
// import.meta.env, simulating a less-aware bundler), then asserts:
|
|
167
|
+
//
|
|
168
|
+
// 1. The dev-warning strings DO survive (proving we picked a real
|
|
169
|
+
// bundle to test, not Vite-equivalent behavior).
|
|
170
|
+
// 2. The strings are still gated — they appear next to a check
|
|
171
|
+
// involving `import.meta.env` rather than being unconditional.
|
|
172
|
+
//
|
|
173
|
+
// (2) is what makes the runtime claim true: at runtime `import.meta.env`
|
|
174
|
+
// is `undefined` in non-Vite-aware environments, so `?.DEV` returns
|
|
175
|
+
// `undefined`, `=== true` returns `false`, and the warn never fires.
|
|
176
|
+
// If a future refactor unconditionally calls console.warn (no gate),
|
|
177
|
+
// this assertion catches that the runtime contract regressed.
|
|
178
|
+
|
|
179
|
+
describe('non-Vite consumer runtime correctness', () => {
|
|
180
|
+
it('raw esbuild bundle: warning strings remain in bundle (proves we test the non-Vite path)', async () => {
|
|
181
|
+
const { build } = await import('esbuild')
|
|
182
|
+
const result = await build({
|
|
183
|
+
entryPoints: [path.join(SRC, 'nodes.ts')],
|
|
184
|
+
bundle: true,
|
|
185
|
+
write: false,
|
|
186
|
+
minify: true,
|
|
187
|
+
format: 'esm',
|
|
188
|
+
platform: 'browser',
|
|
189
|
+
external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
|
|
190
|
+
// Intentionally no `define` — simulates a non-Vite-aware bundler.
|
|
191
|
+
})
|
|
192
|
+
const code = result.outputFiles[0]?.text ?? ''
|
|
193
|
+
expect(code).toContain('Duplicate key')
|
|
194
|
+
}, 5000)
|
|
195
|
+
|
|
196
|
+
it('raw esbuild bundle: dev gate evaluates to false at runtime when import.meta.env is undefined', async () => {
|
|
197
|
+
// The real claim is RUNTIME — even when warning strings are in the
|
|
198
|
+
// bundle, the gate stops `console.warn` from firing. This test
|
|
199
|
+
// EXECUTES the bundled module with `import.meta.env` undefined
|
|
200
|
+
// (the non-Vite case) and verifies `console.warn` is never called.
|
|
201
|
+
//
|
|
202
|
+
// Bundle a synthetic harness that exposes the gated callsite as a
|
|
203
|
+
// standalone exported function, replacing the cross-package
|
|
204
|
+
// imports so we don't need a full Pyreon runtime to execute. The
|
|
205
|
+
// harness mirrors the EXACT gate pattern used in nodes.ts.
|
|
206
|
+
const { build } = await import('esbuild')
|
|
207
|
+
const harness = `
|
|
208
|
+
// Same module-scope const pattern used in real Pyreon source.
|
|
209
|
+
// @ts-ignore — \`import.meta.env\` is provided by Vite at build time
|
|
210
|
+
const __DEV__ = import.meta.env?.DEV === true
|
|
211
|
+
export function maybeWarn(seen: Set<string>, key: string): void {
|
|
212
|
+
// Mirrors nodes.ts: a chained \`__DEV__ && cond && warn\` form
|
|
213
|
+
// (Pattern B from the C-2 probe).
|
|
214
|
+
if (seen.has(key)) {
|
|
215
|
+
if (__DEV__) {
|
|
216
|
+
console.warn(\`[Pyreon] Duplicate key "\${String(key)}" in <For> list.\`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
seen.add(key)
|
|
220
|
+
}
|
|
221
|
+
`
|
|
222
|
+
const result = await build({
|
|
223
|
+
stdin: { contents: harness, loader: 'ts', resolveDir: SRC },
|
|
224
|
+
bundle: true,
|
|
225
|
+
write: false,
|
|
226
|
+
minify: true,
|
|
227
|
+
format: 'esm',
|
|
228
|
+
platform: 'browser',
|
|
229
|
+
// No `define` — same as a non-Vite consumer.
|
|
230
|
+
})
|
|
231
|
+
const code = result.outputFiles[0]?.text ?? ''
|
|
232
|
+
|
|
233
|
+
// The string MUST be in the bundle (proves this is the non-Vite path).
|
|
234
|
+
expect(code).toContain('Duplicate key')
|
|
235
|
+
|
|
236
|
+
// Now actually execute the bundled module with `import.meta.env`
|
|
237
|
+
// resembling the non-Vite environment (undefined). Use a data:
|
|
238
|
+
// import to load the bundled ESM module. Bun supports this.
|
|
239
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
|
240
|
+
const mod = (await import(/* @vite-ignore */ dataUrl)) as {
|
|
241
|
+
maybeWarn: (s: Set<string>, k: string) => void
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Spy on console.warn — the real runtime check.
|
|
245
|
+
const calls: unknown[][] = []
|
|
246
|
+
const original = console.warn
|
|
247
|
+
console.warn = (...args: unknown[]) => {
|
|
248
|
+
calls.push(args)
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const seen = new Set<string>()
|
|
252
|
+
mod.maybeWarn(seen, 'foo')
|
|
253
|
+
mod.maybeWarn(seen, 'foo') // second call → seen.has('foo') is true → would warn if gate broken
|
|
254
|
+
} finally {
|
|
255
|
+
console.warn = original
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The runtime contract: warning string is in the bundle (data),
|
|
259
|
+
// but the gate stops it from firing.
|
|
260
|
+
expect(calls).toEqual([])
|
|
261
|
+
}, 5000)
|
|
262
|
+
})
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -219,21 +219,22 @@ describe('mount — refs', () => {
|
|
|
219
219
|
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
-
test('callback ref
|
|
222
|
+
test('callback ref is invoked with null on unmount', () => {
|
|
223
223
|
const el = container()
|
|
224
224
|
let refEl: Element | null = null
|
|
225
225
|
const unmount = mount(
|
|
226
226
|
h('div', {
|
|
227
|
-
ref: (e: Element) => {
|
|
227
|
+
ref: (e: Element | null) => {
|
|
228
228
|
refEl = e
|
|
229
229
|
},
|
|
230
230
|
}),
|
|
231
231
|
el,
|
|
232
232
|
)
|
|
233
|
-
expect(refEl).not.toBeNull()
|
|
234
|
-
unmount()
|
|
235
|
-
// Callback refs don't get called with null on cleanup
|
|
236
233
|
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
234
|
+
unmount()
|
|
235
|
+
// Fixed: callback refs are now called with null on cleanup
|
|
236
|
+
// to match React/Solid/Vue behavior and the RefCallback<T> type.
|
|
237
|
+
expect(refEl).toBeNull()
|
|
237
238
|
})
|
|
238
239
|
|
|
239
240
|
test('ref is not emitted as an HTML attribute', () => {
|
|
@@ -3176,6 +3177,95 @@ describe('TransitionGroup — cleanup', () => {
|
|
|
3176
3177
|
})
|
|
3177
3178
|
})
|
|
3178
3179
|
|
|
3180
|
+
// ─── TransitionGroup — leak regression tests ─────────────────────────────────
|
|
3181
|
+
// Regression for the two fixes:
|
|
3182
|
+
// 1. No safety timeout on applyLeave meant an item whose transition never
|
|
3183
|
+
// fired stayed in the `entries` Map forever (`entries.delete(key)` was
|
|
3184
|
+
// gated on the `done` callback firing).
|
|
3185
|
+
// 2. Unmount during in-flight transition left the 5s safety timer running,
|
|
3186
|
+
// firing `onAfterEnter` / `onAfterLeave` on detached elements.
|
|
3187
|
+
|
|
3188
|
+
describe('TransitionGroup — leak regressions', () => {
|
|
3189
|
+
beforeEach(() => {
|
|
3190
|
+
vi.useFakeTimers()
|
|
3191
|
+
})
|
|
3192
|
+
afterEach(() => {
|
|
3193
|
+
vi.useRealTimers()
|
|
3194
|
+
})
|
|
3195
|
+
|
|
3196
|
+
test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
|
|
3197
|
+
const el = container()
|
|
3198
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
3199
|
+
const onAfterLeave = vi.fn()
|
|
3200
|
+
mount(
|
|
3201
|
+
h(TransitionGroup, {
|
|
3202
|
+
tag: 'div',
|
|
3203
|
+
name: 'fade',
|
|
3204
|
+
items,
|
|
3205
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3206
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3207
|
+
onAfterLeave,
|
|
3208
|
+
}),
|
|
3209
|
+
el,
|
|
3210
|
+
)
|
|
3211
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3212
|
+
items.set([{ id: 1 }])
|
|
3213
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3214
|
+
// transitionend never fires — before the fix this would leak forever.
|
|
3215
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3216
|
+
await vi.advanceTimersByTimeAsync(5100)
|
|
3217
|
+
expect(onAfterLeave).toHaveBeenCalledTimes(1)
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
|
|
3221
|
+
const el = container()
|
|
3222
|
+
const items = signal<{ id: number }[]>([])
|
|
3223
|
+
const onAfterEnter = vi.fn()
|
|
3224
|
+
const dispose = mount(
|
|
3225
|
+
h(TransitionGroup, {
|
|
3226
|
+
tag: 'div',
|
|
3227
|
+
name: 'fade',
|
|
3228
|
+
items,
|
|
3229
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3230
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3231
|
+
onAfterEnter,
|
|
3232
|
+
}),
|
|
3233
|
+
el,
|
|
3234
|
+
)
|
|
3235
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3236
|
+
items.set([{ id: 1 }])
|
|
3237
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3238
|
+
// Mid-transition — unmount. The 5s safety timer must NOT fire the
|
|
3239
|
+
// callback on a detached element.
|
|
3240
|
+
dispose()
|
|
3241
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
3242
|
+
expect(onAfterEnter).not.toHaveBeenCalled()
|
|
3243
|
+
})
|
|
3244
|
+
|
|
3245
|
+
test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
|
|
3246
|
+
const el = container()
|
|
3247
|
+
const items = signal([{ id: 1 }])
|
|
3248
|
+
const onAfterLeave = vi.fn()
|
|
3249
|
+
const dispose = mount(
|
|
3250
|
+
h(TransitionGroup, {
|
|
3251
|
+
tag: 'div',
|
|
3252
|
+
name: 'fade',
|
|
3253
|
+
items,
|
|
3254
|
+
keyFn: (item: { id: number }) => item.id,
|
|
3255
|
+
render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
|
|
3256
|
+
onAfterLeave,
|
|
3257
|
+
}),
|
|
3258
|
+
el,
|
|
3259
|
+
)
|
|
3260
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3261
|
+
items.set([])
|
|
3262
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
3263
|
+
dispose()
|
|
3264
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
3265
|
+
expect(onAfterLeave).not.toHaveBeenCalled()
|
|
3266
|
+
})
|
|
3267
|
+
})
|
|
3268
|
+
|
|
3179
3269
|
// ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
|
|
3180
3270
|
|
|
3181
3271
|
describe('mount — error paths', () => {
|
package/src/tests/props.test.ts
CHANGED
|
@@ -269,6 +269,123 @@ describe('applyProp — innerHTML', () => {
|
|
|
269
269
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
270
270
|
warnSpy.mockRestore()
|
|
271
271
|
})
|
|
272
|
+
|
|
273
|
+
test('reactive innerHTML accessor — function value is called, not stringified', async () => {
|
|
274
|
+
// Regression: the JSX compiler emits `innerHTML={getIcon(props.x ? "a" : "b")}`
|
|
275
|
+
// as a `() => …` accessor. Without function-value handling here, the
|
|
276
|
+
// closure was set as literal text — `() => getIcon(...)` rendered
|
|
277
|
+
// verbatim instead of the SVG.
|
|
278
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
279
|
+
const el = document.createElement('div')
|
|
280
|
+
const which = signal<'a' | 'b'>('a')
|
|
281
|
+
const cleanup = applyProp(el, 'innerHTML', () => `<span data-x="${which()}">x</span>`)
|
|
282
|
+
expect(el.querySelector('[data-x="a"]')).not.toBeNull()
|
|
283
|
+
expect(el.innerHTML).not.toContain('=>')
|
|
284
|
+
which.set('b')
|
|
285
|
+
expect(el.querySelector('[data-x="b"]')).not.toBeNull()
|
|
286
|
+
cleanup?.()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('reactive dangerouslySetInnerHTML accessor — function value is called, not stringified', async () => {
|
|
290
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
291
|
+
const el = document.createElement('div')
|
|
292
|
+
const html = signal('<em>one</em>')
|
|
293
|
+
const cleanup = applyProp(el, 'dangerouslySetInnerHTML', () => ({ __html: html() }))
|
|
294
|
+
expect(el.innerHTML).toBe('<em>one</em>')
|
|
295
|
+
html.set('<em>two</em>')
|
|
296
|
+
expect(el.innerHTML).toBe('<em>two</em>')
|
|
297
|
+
cleanup?.()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('dev warning fires if a function reaches applyStaticProp directly (defensive guard)', () => {
|
|
301
|
+
// applyStaticProp is internal — reachable only if a future special-case
|
|
302
|
+
// branch in applyProp bypasses the reactive-wrap dance. The dev guard
|
|
303
|
+
// catches that regression at first render.
|
|
304
|
+
const el = document.createElement('div')
|
|
305
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
306
|
+
// Indirect: trigger by routing a function through `applyProp` for a
|
|
307
|
+
// key that DOESN'T have a special case — exercises the reactive path,
|
|
308
|
+
// which calls the accessor + passes the result. The accessor itself
|
|
309
|
+
// returning a function would surface the warning.
|
|
310
|
+
applyProp(el, 'innerHTML', () => () => '<em>nested</em>')
|
|
311
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
312
|
+
expect.stringContaining('applyStaticProp received a function for "innerHTML"'),
|
|
313
|
+
)
|
|
314
|
+
warnSpy.mockRestore()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Comprehensive sweep: every string-typed sink must handle reactive
|
|
319
|
+
// (function) values. The original bug was specific to innerHTML, but the
|
|
320
|
+
// structural fix should cover ALL sinks the same way. These tests assert
|
|
321
|
+
// that.
|
|
322
|
+
describe('applyProp — reactive function values across all sink kinds', () => {
|
|
323
|
+
test('reactive href accessor on <a>', async () => {
|
|
324
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
325
|
+
const el = document.createElement('a')
|
|
326
|
+
const path = signal('/one')
|
|
327
|
+
const cleanup = applyProp(el, 'href', () => path())
|
|
328
|
+
expect(el.getAttribute('href')).toBe('/one')
|
|
329
|
+
path.set('/two')
|
|
330
|
+
expect(el.getAttribute('href')).toBe('/two')
|
|
331
|
+
cleanup?.()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('reactive src accessor on <img>', async () => {
|
|
335
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
336
|
+
const el = document.createElement('img')
|
|
337
|
+
const url = signal('/a.png')
|
|
338
|
+
const cleanup = applyProp(el, 'src', () => url())
|
|
339
|
+
// <img> exposes `src` as a normalized absolute URL — assert via getAttribute
|
|
340
|
+
expect(el.getAttribute('src')).toBe('/a.png')
|
|
341
|
+
url.set('/b.png')
|
|
342
|
+
expect(el.getAttribute('src')).toBe('/b.png')
|
|
343
|
+
cleanup?.()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('reactive value accessor on <input>', async () => {
|
|
347
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
348
|
+
const el = document.createElement('input')
|
|
349
|
+
const val = signal('alpha')
|
|
350
|
+
const cleanup = applyProp(el, 'value', () => val())
|
|
351
|
+
expect((el as HTMLInputElement).value).toBe('alpha')
|
|
352
|
+
val.set('beta')
|
|
353
|
+
expect((el as HTMLInputElement).value).toBe('beta')
|
|
354
|
+
cleanup?.()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('reactive title accessor (data attribute pattern)', async () => {
|
|
358
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
359
|
+
const el = document.createElement('div')
|
|
360
|
+
const tip = signal('hello')
|
|
361
|
+
const cleanup = applyProp(el, 'title', () => tip())
|
|
362
|
+
expect(el.getAttribute('title')).toBe('hello')
|
|
363
|
+
tip.set('world')
|
|
364
|
+
expect(el.getAttribute('title')).toBe('world')
|
|
365
|
+
cleanup?.()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('reactive class accessor (string form)', async () => {
|
|
369
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
370
|
+
const el = document.createElement('div')
|
|
371
|
+
const cls = signal('one')
|
|
372
|
+
const cleanup = applyProp(el, 'class', () => cls())
|
|
373
|
+
expect(el.className).toBe('one')
|
|
374
|
+
cls.set('two')
|
|
375
|
+
expect(el.className).toBe('two')
|
|
376
|
+
cleanup?.()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('reactive style accessor (object form)', async () => {
|
|
380
|
+
const { signal } = await import('@pyreon/reactivity')
|
|
381
|
+
const el = document.createElement('div')
|
|
382
|
+
const color = signal('red')
|
|
383
|
+
const cleanup = applyProp(el, 'style', () => ({ color: color() }))
|
|
384
|
+
expect(el.style.color).toBe('red')
|
|
385
|
+
color.set('blue')
|
|
386
|
+
expect(el.style.color).toBe('blue')
|
|
387
|
+
cleanup?.()
|
|
388
|
+
})
|
|
272
389
|
})
|
|
273
390
|
|
|
274
391
|
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|