Skip to content

fix(web): support delegated listener objects - #3209

Merged
ryansolid merged 15 commits into
solidjs:nextfrom
nickshiro:fix/delegated-event-listener-object
Sep 2, 2026
Merged

fix(web): support delegated listener objects#3209
ryansolid merged 15 commits into
solidjs:nextfrom
nickshiro:fix/delegated-event-listener-object

Conversation

@nickshiro

Copy link
Copy Markdown

Summary

addEvent() accepts both functions and EventListenerObject handlers, but the delegated event dispatcher always invoked handlers with .call(). As a result, delegated EventListenerObject handlers threw instead of invoking their handleEvent() method. This change dispatches listener objects through handleEvent() while preserving the existing behavior for function and tuple handlers.

How did you test this change?

Added a regression test that registers a delegated listener object and verifies that:

  • handleEvent() is called once
  • it receives the dispatched event
  • its this value is the listener object

Commands run:

  • pnpm build
  • pnpm types
  • pnpm test-types
  • vitest run test/delegated-event-listener-object.spec.ts
  • prettier --check src/client.ts test/delegated-event-listener-object.spec.ts ../../.changeset/support-delegated-event-listener-objects.md

frenzzy and others added 5 commits September 2, 2026 11:44
Seven defects across the server-function guards, five of them introduced by
the guards themselves (solidjs#3168, solidjs#3170, solidjs#3175, solidjs#3176). Every fix removes a
special case rather than adding one — 45 lines of code in, 28 out.

- The argument walk stops for no prototype (solidjs#3200) and strips the whole
  unsafe key set (solidjs#3202). It mutates in place and rebuilds nothing, so the
  non-plain-prototype `continue` was never guarding a rebuild; it only hid a
  payload under a carrier the codec revives with own properties. `constructor`
  joins `__proto__` because a recursive merge reaches Object.prototype
  through it.

- The guard shell carries only the flag that reaches the wire (solidjs#3196, solidjs#3198).
  Replicating a frozen source's `writable`/`configurable` made the write-back
  illegal; pinning them true lost `enumerable: false` and put a deliberately
  hidden field on the wire. One descriptor shape replaces the two-branch
  conditional.

- The scheme floor asks the URL parser instead of a regex (solidjs#3201). A parser
  strips ASCII tab and newline before it begins, so `java<TAB>script:` read
  as scheme-less to the grammar and as `javascript:` to every consumer. The
  masked and no-JS roads already resolved, which is why neither was fooled.

- The event is awaited only when it is genuinely a promise, and a failure is
  answered rather than thrown (solidjs#3199). Awaiting anything wearing a `then`
  parked the request forever on a lazy-locals proxy and starved the event
  loop on a self-resolving one. An `async createEvent` still works.

- `Content-Length` is never forwarded onto a body the transport composed
  (solidjs#3197). The declared length described the source; the body is ours, so the
  answer arrived truncated at the socket — 13 of 815 bytes over a real
  connection. RFC 9110 §8.6.

Tests are table-driven over the adjacent shapes each fix must close and the
ones it must leave alone, so a later change cannot move a hole sideways: every
descriptor combination, every carrier the codec revives, every whitespace a
URL parser strips in every position on every road, every thenable spelling,
and every producer that merges author headers onto an encoded body.

Suite: 586 -> 626 passing.
… ran

Three gaps a mutation pass found in the tests, plus one simplification.

The solidjs#3200 fix had NO coverage: restoring the removed `continue` left the
whole suite green. The reachable shape is a plain object one level UNDER a
non-plain carrier — an unsafe key ON an `Error` is dropped by the codec at
encode time, so the obvious test cannot fail. The added row encodes a real
`Error` carrying the payload; with the fix reverted it reports
`payloadKeys=["constructor","n"]` and `Object.prototype.polluted="viaCarrier"`.

Two of the five tables passed when the handler dispatched nothing: `ship()`
collapsed "threw", "500" and "no header" into one "refused", and the
content-length rows never mentioned status, so `absent / 0 bytes` was a pass.
Both now carry the observation that makes them fail — `ran=1` and the
expected status. With a handler that answers 500 as its first statement, all
five files now go red (41 tests) instead of two staying green.

`Headers` rejects CR/LF in a value before the scheme floor is reached, so
that protection comes from the platform rather than from the code under
test. The helper now reports it as its own outcome instead of counting it as
a refusal the floor made.

Also `Object.prototype.hasOwnProperty.call` -> `Object.hasOwn` in the strip
walk: same shadow-proofing (verified against a payload that shadows
`hasOwnProperty`), one line shorter, and well inside the platform floor this
package already assumes elsewhere.
… params

Implementation review, all three measured against the branch.

`Content-Length` was one name in three hand-placed checks, and the class it
belongs to is wider: `Content-Encoding` still rode onto every body the
transport composed and never compressed — measured `gzip` surviving on both
`respond()` and a returned Response. One `COMPOSED_BODY_FRAMING` set in
`response.ts`, the leaf module all three sites already import, replaces the
three checks and covers `transfer-encoding` too. It also makes the
`content-length` clause in `fillsStubGap` reachable-but-redundant, so that
special case goes.

The `base` threaded into `refusedTargetScheme` was doing nothing: across 19
targets x 3 real bases the verdict is identical to a constant stand-in,
because an absolute scheme always beats the base and a relative target always
inherits an http(s) one. That removes a parameter, a signature change and a
threaded argument, so the fix stops touching the dispatch tail entirely.

`Object.hasOwn(v, key)` before `delete v[key]` changes no outcome — delete on
an absent or inherited key is a no-op, and on a non-configurable own key both
spellings throw the same TypeError. The guard was ceremony.

The Content-Encoding rows are pinned: dropping the name from the set reddens
them.
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c9ab9b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Seven defects across the server-function guards, five of them introduced by
the guards themselves (solidjs#3168, solidjs#3170, solidjs#3175, solidjs#3176). Every fix removes a
special case rather than adding one — 45 lines of code in, 28 out.

- The argument walk stops for no prototype (solidjs#3200) and strips the whole
  unsafe key set (solidjs#3202). It mutates in place and rebuilds nothing, so the
  non-plain-prototype `continue` was never guarding a rebuild; it only hid a
  payload under a carrier the codec revives with own properties. `constructor`
  joins `__proto__` because a recursive merge reaches Object.prototype
  through it.

- The guard shell carries only the flag that reaches the wire (solidjs#3196, solidjs#3198).
  Replicating a frozen source's `writable`/`configurable` made the write-back
  illegal; pinning them true lost `enumerable: false` and put a deliberately
  hidden field on the wire. One descriptor shape replaces the two-branch
  conditional.

- The scheme floor asks the URL parser instead of a regex (solidjs#3201). A parser
  strips ASCII tab and newline before it begins, so `java<TAB>script:` read
  as scheme-less to the grammar and as `javascript:` to every consumer. The
  masked and no-JS roads already resolved, which is why neither was fooled.

- The event is awaited only when it is genuinely a promise, and a failure is
  answered rather than thrown (solidjs#3199). Awaiting anything wearing a `then`
  parked the request forever on a lazy-locals proxy and starved the event
  loop on a self-resolving one. An `async createEvent` still works.

- `Content-Length` is never forwarded onto a body the transport composed
  (solidjs#3197). The declared length described the source; the body is ours, so the
  answer arrived truncated at the socket — 13 of 815 bytes over a real
  connection. RFC 9110 §8.6.

Tests are table-driven over the adjacent shapes each fix must close and the
ones it must leave alone, so a later change cannot move a hole sideways: every
descriptor combination, every carrier the codec revives, every whitespace a
URL parser strips in every position on every road, every thenable spelling,
and every producer that merges author headers onto an encoded body.

Suite: 586 -> 626 passing.
… ran

Three gaps a mutation pass found in the tests, plus one simplification.

The solidjs#3200 fix had NO coverage: restoring the removed `continue` left the
whole suite green. The reachable shape is a plain object one level UNDER a
non-plain carrier — an unsafe key ON an `Error` is dropped by the codec at
encode time, so the obvious test cannot fail. The added row encodes a real
`Error` carrying the payload; with the fix reverted it reports
`payloadKeys=["constructor","n"]` and `Object.prototype.polluted="viaCarrier"`.

Two of the five tables passed when the handler dispatched nothing: `ship()`
collapsed "threw", "500" and "no header" into one "refused", and the
content-length rows never mentioned status, so `absent / 0 bytes` was a pass.
Both now carry the observation that makes them fail — `ran=1` and the
expected status. With a handler that answers 500 as its first statement, all
five files now go red (41 tests) instead of two staying green.

`Headers` rejects CR/LF in a value before the scheme floor is reached, so
that protection comes from the platform rather than from the code under
test. The helper now reports it as its own outcome instead of counting it as
a refusal the floor made.

Also `Object.prototype.hasOwnProperty.call` -> `Object.hasOwn` in the strip
walk: same shadow-proofing (verified against a payload that shadows
`hasOwnProperty`), one line shorter, and well inside the platform floor this
package already assumes elsewhere.
… params

Implementation review, all three measured against the branch.

`Content-Length` was one name in three hand-placed checks, and the class it
belongs to is wider: `Content-Encoding` still rode onto every body the
transport composed and never compressed — measured `gzip` surviving on both
`respond()` and a returned Response. One `COMPOSED_BODY_FRAMING` set in
`response.ts`, the leaf module all three sites already import, replaces the
three checks and covers `transfer-encoding` too. It also makes the
`content-length` clause in `fillsStubGap` reachable-but-redundant, so that
special case goes.

The `base` threaded into `refusedTargetScheme` was doing nothing: across 19
targets x 3 real bases the verdict is identical to a constant stand-in,
because an absolute scheme always beats the base and a relative target always
inherits an http(s) one. That removes a parameter, a signature change and a
threaded argument, so the fix stops touching the dispatch tail entirely.

`Object.hasOwn(v, key)` before `delete v[key]` changes no outcome — delete on
an absent or inherited key is a no-op, and on a non-configurable own key both
spellings throw the same TypeError. The guard was ceremony.

The Content-Encoding rows are pinned: dropping the name from the set reddens
them.
@codspeed-hq

codspeed-hq Bot commented Sep 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 136 untouched benchmarks
⏩ 132 skipped benchmarks1


Comparing nickshiro:fix/delegated-event-listener-object (1c9ab9b) with next (321fd16)

Open in CodSpeed

Footnotes

  1. 132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

ryansolid and others added 7 commits September 2, 2026 00:31
Close cross-realm, descriptor, argument, and SSR response gaps found while auditing the server-function transport.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	.changeset/server-function-guard-simplification.md
#	packages/web/server-functions/src/server.ts
#	packages/web/test/server/server-functions-event-hook.spec.tsx
#	packages/web/test/server/server-functions-proto-keys.spec.tsx
#	packages/web/test/server/server-functions-result-descriptors.spec.tsx
Avoid exposing the shared framing-header set while preserving the audited server behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
…d-simplification

fix(web): judge arguments, results and redirect targets by what they are
…-removal

fix(web): remove capturing event listeners
Prevent a stale bound-tuple payload from changing how replacement listener objects are dispatched.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 9158a01 into solidjs:next Sep 2, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants