Skip to content

Anchor-click and form-submit navigations run outside the runtime's interaction frame (observe: NavigationEvent.interaction is undefined) #643

Description

@ryansolid

Summary

Under Solid's observe tier, a navigation performed by clicking an <a> records no interaction: NavigationEvent.interaction is undefined, and the calls the route's data makes carry a navigation origin with no click behind it. A navigation performed by navigate() from an onClick handler is attributed correctly. The difference is where the handler runs: the router attaches its anchor handler to document directly, outside the web runtime's interaction frame.

Where

src/data/events.ts, setupNativeEvents:

// ensure delegated event run first
delegateEvents(["click", "submit"]);
document.addEventListener("click", handleAnchorClick);
if (preload) {
  document.addEventListener("mousemove", handleAnchorMove, { passive: true });
  document.addEventListener("focusin", handleAnchorPreload, { passive: true });
  document.addEventListener("touchstart", handleAnchorPreload, { passive: true });
}
document.addEventListener("submit", handleFormSubmit);

@solidjs/web wraps every handler it dispatches — delegated events via eventHandler, runtime-attached ones via addEvent — in dispatchAsInteraction, i.e. OBSERVE.attribution.withInteraction({ type, target, at }, fn), so every root write inside the handler (the router's location write included) is attributed to that interaction. A raw document.addEventListener goes around that wrapper. handleAnchorClick then calls navigateFromRoute, whose withOrigin(describeNavigation…) frame opens with no enclosing interaction, so the record is an orphan.

handleFormSubmit has the same shape: an action submitted through a native <form> is unattributed, while one submitted from a handler is not.

Observed

With @sentry/solid-2 consuming the records (e2e app on rc.13 / next.32, / → /users/6):

  • <button onClick={() => navigate('/users/6')}>: NavigationEvent.interaction is the click; the click span links to the route-named navigation span.
  • <a href="/users/6">: NavigationEvent.interaction === undefined; there is no click span at all, since no interaction frame ever opened. The navigation and its server-function call stand alone. The INP-relevant facts on the interaction record (inputDelayMs, handlerMs, settledMs) are lost for every link click in the app — which is most navigations.

The consumer documents the caveat for now rather than guessing a parent by time.

Cause

The direct document listeners date from the ordering requirement noted in the comment — Solid's delegated click must run first so a component's onClick with preventDefault() can stop the navigation — and predate the runtime having an interaction frame to run under. That ordering still has to hold; the fix is to run the handler inside the frame, not to change when it runs.

Options

  1. Router wraps its handlers. In setupNativeEvents, when OBSERVE is defined, dispatch handleAnchorClick and handleFormSubmit through OBSERVE.attribution.withInteraction({ type: evt.type, target: …, at: evt.timeStamp }, () => handler(evt)). Smallest change, but it duplicates the web runtime's describeEventTarget (element description, the values privacy gate for text) and interactionStart (the timeStamp clock check), which the router should not own.
  2. Web exposes the frame. @solidjs/web gains a small public entry — a listener attach that wraps under observe and is the identity function otherwise (addEvent(node, name, handler, false) already does exactly this but is marked @internal, compiler-emitted). The router uses it for the click and submit listeners. Preload listeners (mousemove, focusin, touchstart) stay raw: preloading is not a user interaction and should not open frames.
  3. Web stamps document-level listeners itself. Not viable without patching EventTarget.prototype; not proposed.

(2) is the right shape: the web runtime owns interaction description, the router only needs "run this as the event's handler". It is a public-surface addition on @solidjs/web, so it needs a decision there first; (1) can land in the router alone if that is preferred short-term.

Either way the order relative to Solid's delegated handler must not change: attach after delegateEvents(["click", "submit"]) exactly as today, so evt.defaultPrevented still reflects component handlers.

Acceptance

  • An anchor click that navigates produces a NavigationEvent whose interaction is the click's InteractionRef (type click, target a#…), the same object identity a navigate() from onClick gets today.
  • A <form> submitted to an action produces an interaction of type submit on the action's records.
  • A component onClick calling preventDefault() still stops the anchor navigation.
  • Nothing changes in non-observe builds (the wrapper folds to the plain listener).

Related: solidjs/solid#3683 (route declaration to the observe tier), getsentry/sentry-javascript#24517 (consumer; the caveat is documented in docs/solid-2-observe.md there).

— Claude via Cursor

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions