@pyreon/runtime-dom 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/props.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Props } from "@pyreon/core"
2
+ import { normalizeStyleValue, toKebabCase } from "@pyreon/core"
3
+
2
4
  import { batch, renderEffect } from "@pyreon/reactivity"
3
5
  import { DELEGATED_EVENTS, delegatedPropName } from "./delegate"
4
6
 
@@ -187,23 +189,26 @@ const EVENT_RE = /^on[A-Z]/
187
189
  * Uses for-in instead of Object.keys() to avoid allocating a keys array.
188
190
  */
189
191
  export function applyProps(el: Element, props: Props): Cleanup | null {
190
- let cleanup: Cleanup | null = null
192
+ let first: Cleanup | null = null
193
+ let cleanups: Cleanup[] | null = null
191
194
  for (const key in props) {
192
195
  if (key === "key" || key === "ref") continue
193
196
  const c = applyProp(el, key, props[key])
194
197
  if (c) {
195
- if (!cleanup) {
196
- cleanup = c
198
+ if (!first) {
199
+ first = c
200
+ } else if (!cleanups) {
201
+ cleanups = [first, c]
197
202
  } else {
198
- const prev = cleanup
199
- cleanup = () => {
200
- prev()
201
- c()
202
- }
203
+ cleanups.push(c)
203
204
  }
204
205
  }
205
206
  }
206
- return cleanup
207
+ if (cleanups)
208
+ return () => {
209
+ for (const c of cleanups) c()
210
+ }
211
+ return first
207
212
  }
208
213
 
209
214
  /**
@@ -213,28 +218,37 @@ export function applyProps(el: Element, props: Props): Cleanup | null {
213
218
  * - `() => value` (non-event function) → reactive via effect
214
219
  * - anything else → static attribute / DOM property
215
220
  */
216
- export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
217
- // Event listener: onClick → "click"
218
- // Delegated events use expando properties (picked up by the container's delegated listener).
219
- // Non-delegated events use addEventListener directly.
220
- // Both paths wrap in batch() so multiple signal writes coalesce into one DOM update.
221
- if (EVENT_RE.test(key)) {
222
- const eventName = key[2]?.toLowerCase() + key.slice(3)
223
- const handler = value as EventListener
224
-
225
- if (DELEGATED_EVENTS.has(eventName)) {
226
- const prop = delegatedPropName(eventName)
227
- ;(el as unknown as Record<string, unknown>)[prop] = (e: Event) => batch(() => handler(e))
228
- return () => {
229
- ;(el as unknown as Record<string, unknown>)[prop] = undefined
230
- }
221
+ /**
222
+ * Bind an event handler (onClick → "click") with batching + delegation support.
223
+ */
224
+ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | null {
225
+ if (__DEV__ && typeof value !== "function") {
226
+ console.warn(
227
+ `[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). ` +
228
+ `Expected a function. Did you mean ${key}={() => ...}?`,
229
+ )
230
+ return null
231
+ }
232
+ const eventName = key[2]?.toLowerCase() + key.slice(3)
233
+ const handler = value as EventListener
234
+
235
+ if (DELEGATED_EVENTS.has(eventName)) {
236
+ const prop = delegatedPropName(eventName)
237
+ ;(el as unknown as Record<string, unknown>)[prop] = (e: Event) => batch(() => handler(e))
238
+ return () => {
239
+ ;(el as unknown as Record<string, unknown>)[prop] = undefined
231
240
  }
232
-
233
- const batched: EventListener = (e) => batch(() => handler(e))
234
- el.addEventListener(eventName, batched)
235
- return () => el.removeEventListener(eventName, batched)
236
241
  }
237
242
 
243
+ const batched: EventListener = (e) => batch(() => handler(e))
244
+ el.addEventListener(eventName, batched)
245
+ return () => el.removeEventListener(eventName, batched)
246
+ }
247
+
248
+ export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
249
+ // Event listener: onClick → "click"
250
+ if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
251
+
238
252
  // innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
239
253
  if (key === "innerHTML") {
240
254
  if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === "function") {
@@ -297,7 +311,11 @@ function applyStyleProp(el: HTMLElement, value: unknown): void {
297
311
  if (typeof value === "string") {
298
312
  el.style.cssText = value
299
313
  } else if (value != null && typeof value === "object") {
300
- Object.assign(el.style, value)
314
+ const obj = value as Record<string, unknown>
315
+ for (const k in obj) {
316
+ const css = normalizeStyleValue(k, obj[k])
317
+ el.style.setProperty(k.startsWith("--") ? k : toKebabCase(k), css)
318
+ }
301
319
  }
302
320
  }
303
321
 
@@ -13,6 +13,7 @@ import {
13
13
  onMount,
14
14
  onUnmount,
15
15
  onUpdate,
16
+ Portal,
16
17
  } from "@pyreon/core"
17
18
  import { signal } from "@pyreon/reactivity"
18
19
  import { installDevTools, registerComponent, unregisterComponent } from "../devtools"
@@ -360,6 +361,37 @@ describe("mount.ts — uncovered branches", () => {
360
361
  warnSpy.mockRestore()
361
362
  })
362
363
 
364
+ test("component returning Promise triggers dev warning", () => {
365
+ const el = container()
366
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
367
+
368
+ const AsyncComp = (() => Promise.resolve(null)) as unknown as ComponentFn
369
+ mount(h(AsyncComp, null), el)
370
+
371
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("returned a Promise"))
372
+ warnSpy.mockRestore()
373
+ })
374
+
375
+ test("void element with children triggers dev warning", () => {
376
+ const el = container()
377
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
378
+
379
+ mount(h("img", null, "child text"), el)
380
+
381
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("void element"))
382
+ warnSpy.mockRestore()
383
+ })
384
+
385
+ test("Portal with falsy target warns", () => {
386
+ const el = container()
387
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
388
+
389
+ mount(h(Portal, { target: null }), el)
390
+
391
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Portal"))
392
+ warnSpy.mockRestore()
393
+ })
394
+
363
395
  test("component subtree mount error with propagateError (lines 298-309)", () => {
364
396
  const el = container()
365
397
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
@@ -914,6 +946,16 @@ describe("props.ts — uncovered branches", () => {
914
946
  unmount()
915
947
  })
916
948
 
949
+ test("non-function event handler triggers dev warning", () => {
950
+ const el = container()
951
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
952
+
953
+ mount(h("button", { onClick: "not a function" }), el)
954
+
955
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("non-function value"))
956
+ warnSpy.mockRestore()
957
+ })
958
+
917
959
  test("n-show prop toggles display (lines 273-277)", () => {
918
960
  const el = container()
919
961
  const visible = signal(true)
@@ -975,6 +975,23 @@ describe("mount — props (extended)", () => {
975
975
  expect(div.style.marginTop).toBe("10px")
976
976
  })
977
977
 
978
+ test("style object auto-appends px to numeric values", () => {
979
+ const el = container()
980
+ mount(h("div", { style: { height: 100, marginTop: 20, opacity: 0.5, zIndex: 10 } }), el)
981
+ const div = el.querySelector("div") as HTMLElement
982
+ expect(div.style.height).toBe("100px")
983
+ expect(div.style.marginTop).toBe("20px")
984
+ expect(div.style.opacity).toBe("0.5")
985
+ expect(div.style.zIndex).toBe("10")
986
+ })
987
+
988
+ test("style object handles CSS custom properties", () => {
989
+ const el = container()
990
+ mount(h("div", { style: { "--my-color": "red" } }), el)
991
+ const div = el.querySelector("div") as HTMLElement
992
+ expect(div.style.getPropertyValue("--my-color")).toBe("red")
993
+ })
994
+
978
995
  test("className sets class attribute", () => {
979
996
  const el = container()
980
997
  mount(h("div", { className: "my-class" }), el)
@@ -1841,7 +1858,6 @@ describe("hydrateRoot — extended", () => {
1841
1858
  const Comp = defineComponent(() => {
1842
1859
  onMount(() => {
1843
1860
  mountCalled = true
1844
- return undefined
1845
1861
  })
1846
1862
  return h("span", null, "mounted")
1847
1863
  })
@@ -251,7 +251,6 @@ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VN
251
251
  // Fire the effect once the container is in the DOM
252
252
  onMount(() => {
253
253
  ready.set(true)
254
- return undefined
255
254
  })
256
255
 
257
256
  onUnmount(() => {