@pyreon/runtime-dom 0.13.1 → 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/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +156 -57
- package/lib/keep-alive-entry.js +1342 -0
- package/lib/transition-entry.js +168 -0
- package/lib/types/index.d.ts +54 -5
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/package.json +17 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +23 -14
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +30 -6
- package/src/keep-alive-entry.ts +3 -0
- package/src/keep-alive.ts +5 -1
- package/src/mount.ts +160 -56
- package/src/nodes.ts +62 -13
- package/src/props.ts +1 -2
- package/src/template.ts +57 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- 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/lis-prepend.browser.test.ts +99 -0
- 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 +121 -7
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +135 -1
- package/src/transition-entry.ts +7 -0
- package/src/transition-group.ts +6 -1
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { For, h, Portal } from '@pyreon/core'
|
|
1
|
+
import { For, h, Portal, _rp } from '@pyreon/core'
|
|
2
2
|
import { signal } from '@pyreon/reactivity'
|
|
3
3
|
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
@@ -63,6 +63,68 @@ describe('runtime-dom in real browser', () => {
|
|
|
63
63
|
unmount()
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
it('keyed <For> with _rp-wrapped each (compiled JSX shape) renders + reacts to signal updates', async () => {
|
|
67
|
+
// Regression for the `<For each={signal}>` JSX form. The compiler emits
|
|
68
|
+
// `h(For, { each: _rp(() => rows()), ... })`. `makeReactiveProps` (via
|
|
69
|
+
// mountComponent) converts the `_rp`-branded function to a getter on
|
|
70
|
+
// `props.each`. `For()` then forwards those props onto a `ForSymbol`
|
|
71
|
+
// VNode, which reaches `mountChild`'s ForSymbol branch.
|
|
72
|
+
//
|
|
73
|
+
// Before the fix, that branch destructured `{ each, by, children }`
|
|
74
|
+
// eagerly — firing the getter and binding `each` to the *resolved
|
|
75
|
+
// array*, not the function. `mountFor` then crashed on `source()`
|
|
76
|
+
// (calling an array) and the list never rendered. This test produces
|
|
77
|
+
// the exact same vnode shape the compiler emits, so it catches the
|
|
78
|
+
// regression without needing a JSX transform pipeline in the test.
|
|
79
|
+
type Row = { id: number; label: string }
|
|
80
|
+
const rows = signal<Row[]>([
|
|
81
|
+
{ id: 1, label: 'a' },
|
|
82
|
+
{ id: 2, label: 'b' },
|
|
83
|
+
{ id: 3, label: 'c' },
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
const { container, unmount } = mountInBrowser(
|
|
87
|
+
h(
|
|
88
|
+
'ul',
|
|
89
|
+
{ id: 'rp-list' },
|
|
90
|
+
h(For, {
|
|
91
|
+
each: _rp(() => rows()),
|
|
92
|
+
by: (r: Row) => r.id,
|
|
93
|
+
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
let items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
99
|
+
expect(items).toHaveLength(3)
|
|
100
|
+
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '2', '3'])
|
|
101
|
+
|
|
102
|
+
// Signal-driven update — replace + reorder the list.
|
|
103
|
+
rows.set([
|
|
104
|
+
{ id: 2, label: 'b' },
|
|
105
|
+
{ id: 4, label: 'd' },
|
|
106
|
+
{ id: 1, label: 'a' },
|
|
107
|
+
])
|
|
108
|
+
await flush()
|
|
109
|
+
|
|
110
|
+
items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
111
|
+
expect(items).toHaveLength(3)
|
|
112
|
+
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['2', '4', '1'])
|
|
113
|
+
expect(Array.from(items).map((el) => el.textContent)).toEqual(['b', 'd', 'a'])
|
|
114
|
+
|
|
115
|
+
// Append — confirms reactivity persists across multiple updates.
|
|
116
|
+
rows.set([
|
|
117
|
+
{ id: 2, label: 'b' },
|
|
118
|
+
{ id: 4, label: 'd' },
|
|
119
|
+
{ id: 1, label: 'a' },
|
|
120
|
+
{ id: 5, label: 'e' },
|
|
121
|
+
])
|
|
122
|
+
await flush()
|
|
123
|
+
items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
124
|
+
expect(items).toHaveLength(4)
|
|
125
|
+
unmount()
|
|
126
|
+
})
|
|
127
|
+
|
|
66
128
|
it('creates SVG with the SVG namespace and updates reactive attributes via setAttribute', async () => {
|
|
67
129
|
const x = signal(10)
|
|
68
130
|
const { container, unmount } = mountInBrowser(
|
|
@@ -87,6 +149,57 @@ describe('runtime-dom in real browser', () => {
|
|
|
87
149
|
unmount()
|
|
88
150
|
})
|
|
89
151
|
|
|
152
|
+
it('delegated event handler sees `currentTarget` as the bound element, not the listener root', async () => {
|
|
153
|
+
// Regression for a real Pyreon framework bug found via PR #329's form
|
|
154
|
+
// section. Pyreon's TargetedEvent<E> type promises `currentTarget` is
|
|
155
|
+
// the per-element type (e.g. HTMLInputElement), but native event
|
|
156
|
+
// delegation leaves `currentTarget` as the container (where the
|
|
157
|
+
// listener is registered). User code that writes
|
|
158
|
+
// `(ev.currentTarget as HTMLInputElement).value` would silently read
|
|
159
|
+
// from a <div> and get undefined.
|
|
160
|
+
//
|
|
161
|
+
// The fix is in delegate.ts: per-handler Object.defineProperty
|
|
162
|
+
// override of currentTarget, matching React/Vue/Solid behavior.
|
|
163
|
+
const { container, unmount } = mountInBrowser(
|
|
164
|
+
h(
|
|
165
|
+
'div',
|
|
166
|
+
{ id: 'wrap' },
|
|
167
|
+
h('input', {
|
|
168
|
+
id: 'inp',
|
|
169
|
+
type: 'text',
|
|
170
|
+
'data-marker': 'real-input',
|
|
171
|
+
onInput: (ev: Event) => {
|
|
172
|
+
const ct = ev.currentTarget as HTMLInputElement | null
|
|
173
|
+
// Capture observable signals so the test can assert on them
|
|
174
|
+
;(globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag = ct?.tagName
|
|
175
|
+
;(globalThis as { __test_ct_value?: string | undefined }).__test_ct_value =
|
|
176
|
+
ct?.value
|
|
177
|
+
;(globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker =
|
|
178
|
+
ct?.getAttribute('data-marker') ?? null
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const inp = container.querySelector<HTMLInputElement>('#inp')!
|
|
185
|
+
inp.value = 'hello'
|
|
186
|
+
inp.dispatchEvent(new Event('input', { bubbles: true }))
|
|
187
|
+
await flush()
|
|
188
|
+
|
|
189
|
+
// Without the fix: tagName would be 'DIV' (or whatever container is),
|
|
190
|
+
// value would be undefined, marker would be null.
|
|
191
|
+
expect((globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag).toBe('INPUT')
|
|
192
|
+
expect((globalThis as { __test_ct_value?: string | undefined }).__test_ct_value).toBe('hello')
|
|
193
|
+
expect((globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker).toBe(
|
|
194
|
+
'real-input',
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
unmount()
|
|
198
|
+
delete (globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag
|
|
199
|
+
delete (globalThis as { __test_ct_value?: string | undefined }).__test_ct_value
|
|
200
|
+
delete (globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker
|
|
201
|
+
})
|
|
202
|
+
|
|
90
203
|
it('dispatches a real PointerEvent and fires the onClick handler', async () => {
|
|
91
204
|
const clicks = signal(0)
|
|
92
205
|
const { container, unmount } = mountInBrowser(
|
|
@@ -115,12 +228,13 @@ describe('runtime-dom in real browser', () => {
|
|
|
115
228
|
unmount()
|
|
116
229
|
})
|
|
117
230
|
|
|
118
|
-
it('emits the duplicate-key
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// proves the same code path
|
|
123
|
-
|
|
231
|
+
it('emits the duplicate-key dev warning under non-production NODE_ENV', async () => {
|
|
232
|
+
// process.env.NODE_ENV !== 'production' in this dev browser run — the
|
|
233
|
+
// bundler-agnostic gate that every modern bundler auto-replaces at
|
|
234
|
+
// consumer build time. The warning must fire here. The companion
|
|
235
|
+
// `runtime-dom.prod-bundle.test.ts` Node test proves the same code path
|
|
236
|
+
// is dead in a prod bundle (NODE_ENV='production').
|
|
237
|
+
expect(process.env.NODE_ENV).not.toBe('production')
|
|
124
238
|
|
|
125
239
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
126
240
|
const dupes = signal([
|
|
@@ -174,4 +174,97 @@ describe('context inheritance through reactive boundaries', () => {
|
|
|
174
174
|
expect(collected.length).toBe(3)
|
|
175
175
|
expect(collected.every((v) => v === 'parent-provided')).toBe(true)
|
|
176
176
|
})
|
|
177
|
+
|
|
178
|
+
// ── Lock-in for the context-truncation fix (PR #406) ──────────────────────
|
|
179
|
+
//
|
|
180
|
+
// Pre-fix `mountReactive`'s `restoreContextStack` did
|
|
181
|
+
// `stack.length = savedLength` in its finally block — which destroyed
|
|
182
|
+
// every provider frame that the synchronous mount had pushed via
|
|
183
|
+
// `provide()`. Signal-driven re-runs of `_bind` / `renderEffect` inside
|
|
184
|
+
// the mounted subtree later saw a half-empty stack and `useContext()`
|
|
185
|
+
// silently fell back to the default. The original symptom was
|
|
186
|
+
// `<PyreonUI mode={signal()}>` toggling not propagating to consumers
|
|
187
|
+
// — discovered while writing PR #406's regression e2e and traced back
|
|
188
|
+
// through the binding subscription chain to this stack truncation.
|
|
189
|
+
//
|
|
190
|
+
// The fix has two cooperating layers; each provides defense-in-depth
|
|
191
|
+
// for the other, so this assertion would still pass if you revert
|
|
192
|
+
// EITHER alone — but reverting BOTH layers together fails it. To
|
|
193
|
+
// bisect-verify cleanly, revert both:
|
|
194
|
+
// 1. `packages/core/core/src/context.ts:restoreContextStack` — change
|
|
195
|
+
// the finally block back to `stack.length = savedLength` (truncate
|
|
196
|
+
// everything fn() pushed).
|
|
197
|
+
// 2. `packages/core/reactivity/src/effect.ts:_bind` — remove the
|
|
198
|
+
// `_snapshotCapture` capture/restore wiring so re-runs call fn()
|
|
199
|
+
// against whatever the live stack happens to be at re-run time.
|
|
200
|
+
//
|
|
201
|
+
// With both reverted, this test fails with `seen[1] === 'default'`.
|
|
202
|
+
//
|
|
203
|
+
// What the test exercises: a `_bind` text binding inside a child mounted
|
|
204
|
+
// through a reactive accessor (which goes through `mountReactive`). The
|
|
205
|
+
// binding subscribes to a signal and reads `useContext(Ctx)`. After
|
|
206
|
+
// initial mount, the provider frame is at risk of being truncated by
|
|
207
|
+
// `mountReactive`'s cleanup — toggling the signal forces the binding to
|
|
208
|
+
// re-run, which re-reads context. If either fix is in place, the
|
|
209
|
+
// re-read finds the provider frame and returns the provided value.
|
|
210
|
+
it('binding re-runs preserve context lookup across mountReactive cleanup boundary (PR #406 splice + snapshot capture)', async () => {
|
|
211
|
+
const Ctx = createContext('default')
|
|
212
|
+
const trigger = signal(0)
|
|
213
|
+
let lastSeen: string | undefined
|
|
214
|
+
let runCount = 0
|
|
215
|
+
|
|
216
|
+
function Inner() {
|
|
217
|
+
// JSX text accessor compiles to a `_bind` / renderEffect text binding
|
|
218
|
+
// that subscribes to `trigger` (signal read inside the body) AND
|
|
219
|
+
// captures the external context snapshot at setup time.
|
|
220
|
+
return h('span', null, () => {
|
|
221
|
+
trigger()
|
|
222
|
+
const v = useContext(Ctx)
|
|
223
|
+
lastSeen = v
|
|
224
|
+
runCount++
|
|
225
|
+
return v
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function Provider() {
|
|
230
|
+
// CRITICAL for exercising the bug: `provide()` runs INSIDE the
|
|
231
|
+
// reactive child fn (the accessor `() => h(Provider)` returned by
|
|
232
|
+
// App below). That puts Ctx on the stack DURING `mountReactive`'s
|
|
233
|
+
// restoreContextStack(snapshot, fn) execution — and pre-fix the
|
|
234
|
+
// truncating finally block (`stack.length = savedLength`) destroyed
|
|
235
|
+
// the frame the moment fn returned. If Outer pushed Ctx in its OWN
|
|
236
|
+
// body BEFORE returning the accessor, the frame would already be on
|
|
237
|
+
// the stack at snapshot-capture time and survive truncation
|
|
238
|
+
// unrelated — so the test wouldn't actually exercise the bug.
|
|
239
|
+
provide(Ctx, 'provider-value')
|
|
240
|
+
return h(Inner, null)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function App() {
|
|
244
|
+
// No provide() here — the provider frame must be pushed strictly
|
|
245
|
+
// inside the reactive accessor body (= inside `mountReactive`'s fn)
|
|
246
|
+
// so the truncation-vs-splice path is reached.
|
|
247
|
+
return () => h(Provider, null)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const container = document.createElement('div')
|
|
251
|
+
mount(h(App, null), container)
|
|
252
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
253
|
+
expect(lastSeen).toBe('provider-value')
|
|
254
|
+
const initialRunCount = runCount
|
|
255
|
+
|
|
256
|
+
// Force the binding's effect to re-run AFTER the synchronous mount
|
|
257
|
+
// has fully unwound. With the broken pre-fix shape, mountReactive's
|
|
258
|
+
// `stack.length = savedLength` finally block has already destroyed
|
|
259
|
+
// the Ctx frame Provider pushed, so this re-run reads useContext
|
|
260
|
+
// against a stack that no longer contains the provider — and
|
|
261
|
+
// `lastSeen` becomes `'default'`.
|
|
262
|
+
trigger.set(1)
|
|
263
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
264
|
+
expect(lastSeen).toBe('provider-value')
|
|
265
|
+
// Sanity: the re-run actually happened. Otherwise the test could
|
|
266
|
+
// pass for the wrong reason (e.g. if the trigger subscription wasn't
|
|
267
|
+
// established because the accessor didn't read `trigger()` reactively).
|
|
268
|
+
expect(runCount).toBeGreaterThan(initialRunCount)
|
|
269
|
+
})
|
|
177
270
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, signal } from '@pyreon/reactivity'
|
|
2
|
-
import { _bindDirect, _bindText } from '../template'
|
|
2
|
+
import { _bindDirect, _bindText, _clearTplCache, _tpl, _tplCacheSize } from '../template'
|
|
3
3
|
|
|
4
4
|
// ─── _bindText ──────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -121,6 +121,70 @@ describe('_bindText', () => {
|
|
|
121
121
|
|
|
122
122
|
dispose()
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
test('skips DOM write when value unchanged (fast path)', () => {
|
|
126
|
+
const s = signal('same')
|
|
127
|
+
const node = document.createTextNode('')
|
|
128
|
+
|
|
129
|
+
const dispose = _bindText(s, node)
|
|
130
|
+
expect(node.data).toBe('same')
|
|
131
|
+
|
|
132
|
+
// Set same value — should skip the DOM write (next !== node.data is false)
|
|
133
|
+
s.set('same')
|
|
134
|
+
expect(node.data).toBe('same')
|
|
135
|
+
|
|
136
|
+
dispose()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('fallback path: null/false/undefined → empty string', () => {
|
|
140
|
+
const s = signal<string | null | false | undefined>('text')
|
|
141
|
+
const getter = () => s()
|
|
142
|
+
const node = document.createTextNode('')
|
|
143
|
+
|
|
144
|
+
const dispose = _bindText(getter as unknown as Parameters<typeof _bindText>[0], node)
|
|
145
|
+
expect(node.data).toBe('text')
|
|
146
|
+
|
|
147
|
+
s.set(null)
|
|
148
|
+
expect(node.data).toBe('')
|
|
149
|
+
|
|
150
|
+
s.set(false)
|
|
151
|
+
expect(node.data).toBe('')
|
|
152
|
+
|
|
153
|
+
s.set(undefined)
|
|
154
|
+
expect(node.data).toBe('')
|
|
155
|
+
|
|
156
|
+
s.set('restored')
|
|
157
|
+
expect(node.data).toBe('restored')
|
|
158
|
+
|
|
159
|
+
dispose()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('fallback path: skips DOM write when value unchanged', () => {
|
|
163
|
+
const s = signal('x')
|
|
164
|
+
const getter = () => s()
|
|
165
|
+
const node = document.createTextNode('')
|
|
166
|
+
|
|
167
|
+
const dispose = _bindText(getter as unknown as Parameters<typeof _bindText>[0], node)
|
|
168
|
+
expect(node.data).toBe('x')
|
|
169
|
+
|
|
170
|
+
s.set('x') // same value — skip
|
|
171
|
+
expect(node.data).toBe('x')
|
|
172
|
+
|
|
173
|
+
dispose()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('number coercion via String()', () => {
|
|
177
|
+
const s = signal<number>(42)
|
|
178
|
+
const node = document.createTextNode('')
|
|
179
|
+
|
|
180
|
+
const dispose = _bindText(s, node)
|
|
181
|
+
expect(node.data).toBe('42')
|
|
182
|
+
|
|
183
|
+
s.set(0)
|
|
184
|
+
expect(node.data).toBe('0')
|
|
185
|
+
|
|
186
|
+
dispose()
|
|
187
|
+
})
|
|
124
188
|
})
|
|
125
189
|
|
|
126
190
|
// ─── _bindDirect ────────────────────────────────────────────────────────────
|
|
@@ -247,3 +311,73 @@ describe('_mountSlot', async () => {
|
|
|
247
311
|
expect(parent.childNodes.length).toBe(0)
|
|
248
312
|
})
|
|
249
313
|
})
|
|
314
|
+
|
|
315
|
+
// ─── Audit bug #5: _tplCache LRU eviction ───────────────────────────────────
|
|
316
|
+
//
|
|
317
|
+
// The cache is an LRU-bounded Map keyed on the HTML string. Typed JSX
|
|
318
|
+
// produces a small bounded set of unique HTML strings — most apps stay in
|
|
319
|
+
// the dozens-to-hundreds. But an app that constructs JSX from user input
|
|
320
|
+
// or compiles many large dynamic templates could grow this unbounded
|
|
321
|
+
// pre-fix. The cap at 1024 entries keeps memory predictable while being
|
|
322
|
+
// generous enough that no realistic codebase hits it.
|
|
323
|
+
|
|
324
|
+
describe('_tpl cache — LRU eviction (audit bug #5)', () => {
|
|
325
|
+
const TPL_CACHE_MAX = 1024
|
|
326
|
+
|
|
327
|
+
test('cache stays bounded when more than MAX unique templates are emitted', () => {
|
|
328
|
+
_clearTplCache()
|
|
329
|
+
const noBind = (): null => null
|
|
330
|
+
|
|
331
|
+
// Emit 1.5x the cap of unique templates — without LRU bound, cache
|
|
332
|
+
// would grow to 1536 entries.
|
|
333
|
+
const overshoot = Math.floor(TPL_CACHE_MAX * 1.5)
|
|
334
|
+
for (let i = 0; i < overshoot; i++) {
|
|
335
|
+
_tpl(`<div data-i="${i}">${i}</div>`, noBind)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
expect(_tplCacheSize()).toBeLessThanOrEqual(TPL_CACHE_MAX)
|
|
339
|
+
expect(_tplCacheSize()).toBeGreaterThan(0)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('eviction is oldest-first; recently-touched entries survive', () => {
|
|
343
|
+
_clearTplCache()
|
|
344
|
+
const noBind = (): null => null
|
|
345
|
+
|
|
346
|
+
// Fill the cache to the cap.
|
|
347
|
+
const baseHtml = (i: number): string => `<span data-i="${i}">${i}</span>`
|
|
348
|
+
for (let i = 0; i < TPL_CACHE_MAX; i++) _tpl(baseHtml(i), noBind)
|
|
349
|
+
expect(_tplCacheSize()).toBe(TPL_CACHE_MAX)
|
|
350
|
+
|
|
351
|
+
// Touch entry 0 (the oldest). Map insertion-order semantics mean a
|
|
352
|
+
// re-insert after delete moves it to the most-recent position.
|
|
353
|
+
_tpl(baseHtml(0), noBind)
|
|
354
|
+
|
|
355
|
+
// Add ONE new entry — the OLDEST untouched entry should evict, NOT entry 0.
|
|
356
|
+
_tpl('<p>brand-new</p>', noBind)
|
|
357
|
+
|
|
358
|
+
expect(_tplCacheSize()).toBe(TPL_CACHE_MAX)
|
|
359
|
+
|
|
360
|
+
// Touch entry 0 again — if eviction policy were broken and entry 0
|
|
361
|
+
// had been evicted, this re-creates it. We need a way to assert it
|
|
362
|
+
// was retained. Approach: count cache misses by checking size delta.
|
|
363
|
+
// Adding the brand-new entry above evicted ONE; the cache stayed at
|
|
364
|
+
// cap. If we now add 1 more brand-new entry without re-using existing
|
|
365
|
+
// keys, size stays at cap. If we re-touch entry 0, size also stays at
|
|
366
|
+
// cap (already cached). The assertion: re-emitting entry 0 must NOT
|
|
367
|
+
// grow the cache (cache hit, not miss).
|
|
368
|
+
const sizeBeforeReHit = _tplCacheSize()
|
|
369
|
+
_tpl(baseHtml(0), noBind)
|
|
370
|
+
expect(_tplCacheSize()).toBe(sizeBeforeReHit) // re-emit was a hit
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('repeated emit of same template produces ONE cached entry', () => {
|
|
374
|
+
_clearTplCache()
|
|
375
|
+
const noBind = (): null => null
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < 100; i++) {
|
|
378
|
+
_tpl('<div class="static"></div>', noBind)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
expect(_tplCacheSize()).toBe(1)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Subpath entry for @pyreon/runtime-dom/transition
|
|
2
|
+
// Apps that don't use animations can avoid importing this entirely.
|
|
3
|
+
|
|
4
|
+
export type { TransitionProps } from './transition'
|
|
5
|
+
export { Transition } from './transition'
|
|
6
|
+
export type { TransitionGroupProps } from './transition-group'
|
|
7
|
+
export { TransitionGroup } from './transition-group'
|
package/src/transition-group.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Props, VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, onMount, onUnmount } from '@pyreon/core'
|
|
2
|
+
import { createRef, h, nativeCompat, onMount, onUnmount } from '@pyreon/core'
|
|
3
3
|
import { effect, runUntracked, signal } from '@pyreon/reactivity'
|
|
4
4
|
import { mountChild } from './mount'
|
|
5
5
|
|
|
@@ -333,3 +333,8 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
|
|
|
333
333
|
|
|
334
334
|
return h(tag, { ref: containerRef })
|
|
335
335
|
}
|
|
336
|
+
|
|
337
|
+
// Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
|
|
338
|
+
// TransitionGroup uses signal/effect/onMount/onUnmount + mountChild that
|
|
339
|
+
// need Pyreon's setup frame.
|
|
340
|
+
nativeCompat(TransitionGroup)
|
package/src/transition.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Props, VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createRef, Fragment, h, onUnmount } from '@pyreon/core'
|
|
2
|
+
import { createRef, Fragment, h, nativeCompat, onUnmount } from '@pyreon/core'
|
|
3
3
|
import { effect, runUntracked, signal } from '@pyreon/reactivity'
|
|
4
4
|
|
|
5
5
|
// Dev-mode gate: `import.meta.env.DEV` is the Vite/Rolldown standard,
|
|
@@ -7,8 +7,7 @@ import { effect, runUntracked, signal } from '@pyreon/reactivity'
|
|
|
7
7
|
// pattern was dead code in real Vite browser bundles because Vite does not
|
|
8
8
|
// polyfill `process` for the client — every wrapped warning silently never
|
|
9
9
|
// fired in dev. Enforced by the `pyreon/no-process-dev-gate` lint rule.
|
|
10
|
-
|
|
11
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
10
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
12
11
|
|
|
13
12
|
export interface TransitionProps {
|
|
14
13
|
/**
|
|
@@ -189,6 +188,11 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
189
188
|
applyLeave(el)
|
|
190
189
|
}
|
|
191
190
|
|
|
191
|
+
// queueMicrotask defers the appear-animation to after the DOM has
|
|
192
|
+
// committed the initial mount — that scheduling IS the reactive
|
|
193
|
+
// subscription's job here (it tracks `props.show()` and `props.appear`
|
|
194
|
+
// and schedules the visual transition). Not setup work.
|
|
195
|
+
// pyreon-lint-disable-next-line pyreon/no-imperative-effect-on-create
|
|
192
196
|
effect(() => {
|
|
193
197
|
const visible = props.show()
|
|
194
198
|
if (!initialized) {
|
|
@@ -235,3 +239,7 @@ export function Transition(props: TransitionProps): VNodeChild {
|
|
|
235
239
|
return { ...vnode, props: { ...vnode.props, ref } as Props }
|
|
236
240
|
}) as unknown as VNode
|
|
237
241
|
}
|
|
242
|
+
|
|
243
|
+
// Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
|
|
244
|
+
// Transition uses signal/effect/onUnmount that need Pyreon's setup frame.
|
|
245
|
+
nativeCompat(Transition)
|