Skip to content

Add addEventListener/removeEventListener with DOM-model on* semantics#573

Merged
ochafik merged 2 commits intomainfrom
claude/dom-event-model
Mar 30, 2026
Merged

Add addEventListener/removeEventListener with DOM-model on* semantics#573
ochafik merged 2 commits intomainfrom
claude/dom-event-model

Conversation

@ochafik
Copy link
Copy Markdown
Contributor

@ochafik ochafik commented Mar 29, 2026

Adds DOM-model event semantics to on* setters and introduces addEventListener/removeEventListener for multi-listener support. The on* setters now have replace semantics (like el.onclick) with getters, and coexist with addEventListener listeners — both fire on dispatch.

How it works

New ProtocolWithEvents base class between Protocol and App/AppBridge. Each notification event gets a slot with two independent channels:

Feature on* setter addEventListener
Semantics Replace (single slot) Append (listener array)
Clear app.ontoolinput = undefined removeEventListener(event, fn)
Getter app.ontoolinput returns handler N/A

Dispatch order: onEventDispatch() (side-effects) → on* handler → addEventListener listeners.

Other changes

  • Getters for all on* properties on both App and AppBridge
  • replaceRequestHandler for request-handler on* setters (replace semantics, no double-set throw)
  • Reentrancy-safe dispatch (listener array snapshot-copied)
  • useHostStyleVariables and useHostFonts now use addEventListener with proper cleanup

Test plan

  • Build + all unit tests pass
  • All examples build
  • on* replace semantics, coexistence with addEventListener, getters, clearing
  • Double-set protection on direct setRequestHandler/setNotificationHandler
  • E2E tests

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 29, 2026

Preview

Preview deployments for this PR have been cleaned up.

@ochafik ochafik requested a review from idosal March 29, 2026 02:46
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 29, 2026

Open in StackBlitz

@modelcontextprotocol/ext-apps

npm i https://pkg.pr.new/@modelcontextprotocol/ext-apps@573

@modelcontextprotocol/server-basic-preact

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-preact@573

@modelcontextprotocol/server-basic-react

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-react@573

@modelcontextprotocol/server-basic-solid

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-solid@573

@modelcontextprotocol/server-basic-svelte

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-svelte@573

@modelcontextprotocol/server-basic-vanillajs

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-vanillajs@573

@modelcontextprotocol/server-basic-vue

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-vue@573

@modelcontextprotocol/server-budget-allocator

npm i https://pkg.pr.new/@modelcontextprotocol/server-budget-allocator@573

@modelcontextprotocol/server-cohort-heatmap

npm i https://pkg.pr.new/@modelcontextprotocol/server-cohort-heatmap@573

@modelcontextprotocol/server-customer-segmentation

npm i https://pkg.pr.new/@modelcontextprotocol/server-customer-segmentation@573

@modelcontextprotocol/server-debug

npm i https://pkg.pr.new/@modelcontextprotocol/server-debug@573

@modelcontextprotocol/server-map

npm i https://pkg.pr.new/@modelcontextprotocol/server-map@573

@modelcontextprotocol/server-pdf

npm i https://pkg.pr.new/@modelcontextprotocol/server-pdf@573

@modelcontextprotocol/server-scenario-modeler

npm i https://pkg.pr.new/@modelcontextprotocol/server-scenario-modeler@573

@modelcontextprotocol/server-shadertoy

npm i https://pkg.pr.new/@modelcontextprotocol/server-shadertoy@573

@modelcontextprotocol/server-sheet-music

npm i https://pkg.pr.new/@modelcontextprotocol/server-sheet-music@573

@modelcontextprotocol/server-system-monitor

npm i https://pkg.pr.new/@modelcontextprotocol/server-system-monitor@573

@modelcontextprotocol/server-threejs

npm i https://pkg.pr.new/@modelcontextprotocol/server-threejs@573

@modelcontextprotocol/server-transcript

npm i https://pkg.pr.new/@modelcontextprotocol/server-transcript@573

@modelcontextprotocol/server-video-resource

npm i https://pkg.pr.new/@modelcontextprotocol/server-video-resource@573

@modelcontextprotocol/server-wiki-explorer

npm i https://pkg.pr.new/@modelcontextprotocol/server-wiki-explorer@573

commit: d64adb8

@ochafik ochafik force-pushed the claude/dom-event-model branch from f121180 to a1ebc1b Compare March 29, 2026 02:50
@ochafik ochafik requested review from antonpk1 and localden March 29, 2026 03:12
@antonpk1
Copy link
Copy Markdown
Contributor

love it! I think we should keep app.onhostcontextchanged as deprecated to avoid surprises with backward compatibility for folks who update

@mel-anthropic
Copy link
Copy Markdown
Contributor

love it! I think we should keep app.onhostcontextchanged as deprecated to avoid surprises with backward compatibility for folks who update

If we keep the old handler, would it be possible to add a console warning when it's being overwritten and that it's deprecated?

params: McpUiResourceTeardownRequest["params"],
extra: RequestHandlerExtra,
) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>)
| undefined,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any issues with this being undefined? Wouldn't if (callback) skip on undefined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. The closure now reads from the backing field (this._onteardown) at call time instead of capturing callback. When cleared with undefined, the backing field is undefined and the next request throws "No onteardown handler set". The old callback is never called again.

set onteardown(callback: ... | undefined) {
  this._onteardown = callback;
  this.replaceRequestHandler(Schema, (request, extra) => {
    if (!this._onteardown) throw new Error("No onteardown handler set");
    return this._onteardown(request.params, extra);
  });
}

Same pattern applied to all 13 request handler setters.

| ((
params: ListToolsRequest["params"],
extra: RequestHandlerExtra,
) => Promise<ListToolsResult>)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous implementations like this would break, right?

app.onlisttools = () => ({ tools: ["foo"]})

Do we need to document this?

Copy link
Copy Markdown
Contributor Author

@ochafik ochafik Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, app.onlisttools = () => ({ tools: ["foo"]}) would be a TypeScript error with the new type. The old type Promise<{ tools: string[] }> was incorrect though — tools/list returns Tool objects (with name, inputSchema, etc.), not strings. This fixes the type to match the MCP protocol. Will mention as a type fix in the changelog.

@ochafik ochafik force-pushed the claude/dom-event-model branch from a1ebc1b to 9aee887 Compare March 30, 2026 14:05
…ntListener

Fixes #551 (useHostStyles broken: fonts clobber theme/CSS variables)
Fixes #225 (user onhostcontextchanged and SDK hooks mutually exclusive)

The on* setters now follow the DOM event model:
- Replace semantics (like el.onclick) with getters
- Coexist with addEventListener/removeEventListener listeners
- Both channels fire on dispatch: on* handler then listeners

Introduces ProtocolWithEvents base class between Protocol and
App/AppBridge. Each notification event gets a slot with two independent
channels (singular on* handler + addEventListener listener array).

useHostStyles hooks now use addEventListener with proper cleanup,
so they compose correctly with user on* handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ochafik ochafik force-pushed the claude/dom-event-model branch from 9aee887 to 618d252 Compare March 30, 2026 14:26
@ochafik
Copy link
Copy Markdown
Contributor Author

ochafik commented Mar 30, 2026

Done in latest push:

  • Notification on* setters deprecated — all 10 (5 on App, 5 on AppBridge) now have @deprecated JSDoc directing users to addEventListener.
  • Replacement warningconsole.warn fires when an on* handler replaces a previously-set one (both notification events via setEventHandler and request handlers via warnIfRequestHandlerReplaced). Helps catch accidental overwrites at dev time.
  • Request handler clearing fixed — closures now read from the backing field at call time instead of capturing the callback, so app.onteardown = undefined truly prevents the old handler from being called.
  • Bot test fix — added getter assertion in the clearing test so the first assignment is observed.

antonpk1
antonpk1 previously approved these changes Mar 30, 2026
- Export AppEventMap, AppBridgeEventMap, ProtocolWithEvents so they
  appear in generated docs (they're part of public addEventListener
  signatures)
- Move AppBridgeEventMap above the AppBridge class JSDoc — it had been
  inserted between the doc block and the class, orphaning the AppBridge
  docs onto the wrong type
- Drop @param callback tags from on* getter/setter pairs — typedoc
  attaches the doc block to the getter signature which has no params
- Add MethodSchema to intentionallyNotExported (internal zod helper
  referenced by the now-documented eventSchemas field)
@ochafik ochafik merged commit 0266171 into main Mar 30, 2026
22 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.

useHostStyles internals override each other bug: app.onhostcontextchanged handlers silently overwrite each other

4 participants