Skip to content

Wire VNode event delivery + wikilink intercept (post-v1.2 followup)#142

Merged
thetechjon merged 1 commit into
devfrom
fix/plugin-vnode-event-delivery
Jun 7, 2026
Merged

Wire VNode event delivery + wikilink intercept (post-v1.2 followup)#142
thetechjon merged 1 commit into
devfrom
fix/plugin-vnode-event-delivery

Conversation

@thetechjon
Copy link
Copy Markdown
Collaborator

Summary

Plugin API v1.2 (PRs A–F + integration #141) shipped the VNode event ENVELOPE shape and the VNode shapes (button / input / radio / clickable svg) that emit events, but the dispatch + routing pipeline was never wired. Four feature plugins (#70 / #71 / #72 / #73) hit the same wall trying to use the API today. This PR closes two distinct gaps in a single piece of plumbing.

Gap 1 — VNode events did not reach the plugin

  • PluginFullscreenView.handleEvent was a documented no-op.
  • PluginsPanel's <PluginNode> mount had no onEvent prop.
  • PluginHost had no sendVNodeEvent method.
  • workerEntry.ts had no inbound handler for host:vnodeEvent.
  • PluginCtx had no onVNodeEvent registration API.

The wire envelope (HostVNodeEvent in src/plugins/protocol.ts) was already correct with the source.kind: 'panel' | 'codeBlock' | 'fullscreen' discriminator — PR A pre-shipped the shape. This PR wires both ends.

Gap 2 — wikilink:// clicks on plugin-rendered note links did nothing

A plugin emits { tag: 'link', href: { kind: 'note', noteId } }; the renderer produces <a href="wikilink://<encodedId>">. Browser navigation to an unrecognised scheme is a no-op, so the click was inert.

What landed

Dispatch + routing

  • PluginHost.sendVNodeEvent(pluginId, source, event, payload) constructs the envelope and posts to the worker.
  • workerEntry.ts handles host:vnodeEvent by fanning out to every handler registered via ctx.onVNodeEvent. Handlers live in a worker-local Set; unload() calls worker.terminate(), which drops the entire module and every handler with it.
  • PluginsPanel, PluginFullscreenView, and PluginCodeBlock now pass an onEvent callback to <PluginNode> with the right source descriptor ({ kind: 'panel', panelId } / { kind: 'fullscreen', viewId } / { kind: 'codeBlock', blockId }).

SDK addition

ctx.onVNodeEvent(handler): Unsubscribe ships in BOTH src/plugins/sdk.ts and packages/noteser-plugin-sdk/src/sdk.ts.

Handler signature:

(args: {
  event: string
  payload: unknown
  source:
    | { kind: 'panel'; panelId: string }
    | { kind: 'codeBlock'; blockId: string }
    | { kind: 'fullscreen'; viewId: string }
}) => void

ONE handler shape with the source discriminator rather than the plan-section-6 (event, cb) pair so plugins that reuse an event name across surfaces can disambiguate without renaming.

Wikilink intercept

The renderer's renderLink now attaches an onClick:

  • If href starts with wikilink:// AND the click is unmodified (no meta / ctrl / shift / alt, primary button), call e.preventDefault() and useWorkspaceStore.getState().openNote(noteId). The noteId comes from the typed VNodeLink.href.kind === 'note' branch — NOT URL parsing — so the parsing surface stays at one chokepoint.
  • Modifier-clicks pass through (native semantics, no-op for an unknown scheme).
  • Anchor (#fragment) links are untouched (native fragment-scroll owns that case).

Rate-limit choice

MAX_VNODE_EVENTS_PER_SECOND = 16 events / sec / plugin, sliding 1-second window. Tighter than the general MAX_MESSAGES_PER_SECOND ceiling of 60.

  • 16 events/sec is one event per repaint at 60Hz with three frames of headroom — plenty for interactive controls.
  • Tight enough to bottom out a runaway loop fast (a render that emits one onClick is typically one event per repaint; a real workload never approaches the cap).
  • A drop within a window emits a single vnodeEventRateLimited PluginHostEvent (one per window) so a runaway loop surfaces in the dev console without spamming the toast layer.

The event family has no acknowledged use case for high-rate streaming — the high-rate paths are setPanelContent + vault.events, both already capped elsewhere.

Backwards compatibility

Plugins that never call ctx.onVNodeEvent see no behaviour change. Their rendered controls fire events into an empty handler set in the worker, which silently drops them — exactly the pre-PR behaviour.

The PluginVNode.tsx renderer adds an onClick to every <a> it emits, but the handler is a pass-through for any non-wikilink:// href, so existing tests of the link shape still pass.

Reference plugin

public/plugins/noteser-vnode-demo bumps to 0.3.0:

  • Subscribes via ctx.onVNodeEvent from onActivate — toasts every event with the source kind, and console.logs every event for the manual smoke check.
  • Tracks the active note via onActiveNoteChange and renders a link VNode to it, so the wikilink intercept can be smoke-tested by clicking the link in the panel.
  • No new permissions.

Test plan

  • npm run lint is clean.
  • npx tsc --noEmit reports zero errors.
  • npm test -- --ci is green (207 suites pass, 2564 tests).
  • Install the updated noteser-vnode-demo plugin, open the demo panel, click the button — devtools shows the [noteser-vnode-demo] vnodeEvent log line with source.kind: 'panel'.
  • In the demo panel, click the "Open the current note" link — the host opens that note (wikilink intercept fires).
  • Run the "VNode demo: show fullscreen" command, click the button inside the modal — devtools logs the event with source.kind: 'fullscreen'.

🤖 Generated with Claude Code

Plugin API v1.2 (PRs A-F + integration #141) shipped the VNode event
envelope and the VNode shapes that emit events, but the dispatch +
routing pipeline was never wired. Four feature plugins (#70 / #71 /
#72 / #73) hit the same wall trying to use the API. Two gaps closed
here:

Gap 1 — VNode event delivery. Added PluginHost.sendVNodeEvent which
constructs the host:vnodeEvent envelope and posts to the worker,
rate-limited per plugin at MAX_VNODE_EVENTS_PER_SECOND (16/s/plugin).
workerEntry handles host:vnodeEvent by fanning out to handlers
registered through the new ctx.onVNodeEvent SDK method. PluginsPanel,
PluginFullscreenView, and PluginCodeBlock now wrap PluginNode's
onEvent with the right source descriptor.

Gap 2 — wikilink:// click intercept. The renderer's renderLink now
attaches an onClick that calls workspaceStore.openNote(noteId) when
the href starts with wikilink://, taking the noteId off the typed
VNodeLink.href shape (not URL parsing). Modifier-clicks and fragment
anchors pass through to native browser behaviour.

Reference plugin noteser-vnode-demo bumped to 0.3.0 — subscribes to
every VNode event via the new API and renders a wikilink to the
active note so the intercept can be smoke-tested manually.

Backwards compatible: plugins that never call ctx.onVNodeEvent see
no behaviour change.

Docs appended to docs/plugins-v1.2-impl-notes.md under "Post-v1.2:
VNode event delivery + wikilink intercept" with the rate-limit
rationale and a deviation note on the (event, cb) vs
(handler) SDK shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@thetechjon thetechjon merged commit 2acb1a1 into dev Jun 7, 2026
3 checks passed
thetechjon added a commit that referenced this pull request Jun 7, 2026
Adds `noteser-properties` under public/plugins/ — closes issue #72 by
implementing both halves of the Obsidian Bases-parity ask as a plugin
rather than core code. PR #142 just landed the VNode event delivery
that makes interactive plugin UIs work end-to-end; this is the first
feature plugin that exercises the full v1.2 stack.

Surfaces:

- Sidebar `Properties+` panel. Renders the active note's frontmatter
  as a key/value list with one `input` VNode per property. "+ Add
  property" opens a key + value draft row. Each commit calls
  `ctx.vault.write.updateNote(id, { frontmatter })`. Subscribes to
  `ctx.vault.events.onActiveNoteChange` and `onNoteSaved` so the
  panel re-pulls the host-parsed frontmatter on every relevant
  change.

- Fullscreen `Vault tables` view. `ctx.vault.read.getAllNotes()`
  (falls back to `stream()` for vault-too-large), columns inferred
  as the union of every note's frontmatter keys, every row rendered
  as a `box` with a `link` VNode for the title (clicking dispatches
  the PR #142 wikilink intercept). Top input filters across title,
  folder path, every stringified frontmatter value. Column headers
  are `button` VNodes — click to sort, click again to flip
  direction. "Save current view as note" calls
  `ctx.vault.write.createNote` with the rendered markdown table.

Frontmatter handling:

- Source: `NoteWithBody.frontmatter` straight from the host's
  parser. The plugin never re-parses YAML; js-yaml is not a
  dependency (the plan's check confirmed it's not in package.json).
- Type inference per column: `string` / `number` / `date` (ISO
  date) / `tag-array` / `boolean`. Heterogeneous columns fall back
  to `string` so sort stays sane.

Test plan:
- 44 unit tests cover inference (string/number/date/tag-array/
  boolean/empty/heterogeneous), filter (title, folder, tags, status,
  numbers, case-insensitivity, empty filter), sort (every column
  type, asc + desc, empties last, _title / _folder special keys,
  immutability), value coercion (back to numbers / arrays / booleans
  on edit), and the markdown table serialiser (header + separator,
  pipe escaping). Mirror lives at
  src/plugins/propertiesPluginLogic.ts; production logic at
  public/plugins/noteser-properties/main.js (must move in lock
  step).

Coexistence: the existing core `PropertiesPanel` is unchanged — the
plugin adds a second, richer surface alongside it.

`npm run lint`, `npx tsc --noEmit`, `npm test -- --ci` all green.

Closes #72.

Co-authored-by: noteser-agent <noteser-agent@claude>
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.

1 participant