Skip to content

Irid-native Widget mechanism for third-party JS libs#11

Open
rmvegasm wants to merge 7 commits into
khusmann:mainfrom
rmvegasm:irid-widgets
Open

Irid-native Widget mechanism for third-party JS libs#11
rmvegasm wants to merge 7 commits into
khusmann:mainfrom
rmvegasm:irid-widgets

Conversation

@rmvegasm
Copy link
Copy Markdown

Summary

Implements the complete irid widget system: a protocol for wrapping
third-party JS libraries as reactive components inside irid's control flow
(When, Each, Match). Delivered in four slices, followed by a test
audit and widget event timing fix.


What was built

Slice 1 — irid.sendEvent() JS primitive

  • inst/js/irid.jsirid.sendEvent(elementId, eventName, payload)
    shares the sequences counter and sendPayload() machinery with DOM
    events, so sequence-based optimistic-update tracking and stale indicators
    work identically for programmatic events.
  • 22 tests covering payload construction, sequence incrementing, R-side
    handler dispatch, force-send, null payload, unknown input.

Slice 2 — Client-side init, channel, destroy handlers

  • inst/js/irid.js additions:
    • irid.widgets registry + irid.registerWidget(name, initFn)
    • deepEqual() helper for nested-object comparison
    • Shiny.addCustomMessageHandler('irid-widget-init', …) — dispatches to
      registered init function; queues if not yet registered (handles race
      with dynamically-inserted scripts)
    • Shiny.addCustomMessageHandler('irid-widget-channel', …) — dispatches
      CustomEvent('irid-widget-channel') with detail.channel,
      detail.value, detail.isRender
    • Shiny.addCustomMessageHandler('irid-widget-destroy', …) — dispatches
      CustomEvent('irid-widget-destroy')
    • irid.trackChannel(el) — per-element tracker with recordSent() /
      receiveChannel() for snap-back correction
  • 76 tests (JS syntax, deep_equal algorithm, message contract shapes,
    widget lifecycle ordering, TrackChannel state machine).

Slice 3 — IridWidget() R-side constructor and mount wiring

  • R/irid_widget.RIridWidget(dep, container, ..., .config, .event, .render, .widget_name) constructor. Validates deps, events, config.
  • R/process_tags.Ririd_widget branch in tag walker: splits named
    args into channels (callable reactives), events (on* prefix), and
    static config (everything else).
  • R/mount.Ririd_mount_processed: sends init message, creates one
    observe() per reactive channel (with isRender flag for the render
    channel), sends destroy message on unmount.
  • 74 tests covering constructor validation, process_tags extraction, mount
    messages, channel observers, destroy lifecycle, end-to-end counter widget.

Slice 4 — CodeMirror example

  • New examples/codemirror/ directory with three files:
    • codemirror.R — R-side component: codemirror_dep() (CDN via jsdelivr
      combine endpoint for correct script ordering), widget_js_dep() (local
      binding), CodeMirror() constructor wrapping IridWidget().
    • codemirror.js — Client-side binding: irid.registerWidget('codemirror', …),
      CDN polling, echo suppression (skip content updates while editor has
      focus or content matches lastSentContent).
    • app.R — Shiny app registering resource path, driving widget inside
      When with reactive code, show_editor, language.
  • 33 tests (component construction, channel/event splitting, init/channel
    message shapes, event dispatch, When lifecycle, JS syntax, multi-instance).

Bugs found and fixed during Slice 4

Bug 1: htmlDependency scripts stripped by as.character()

htmltools::as.character() strips html_dependency metadata. irid's
control flow sends raw HTML over custom messages (irid-swap, irid-mutate),
bypassing Shiny's output pipeline. Widget scripts were silently lost.

Fix: Added render_tag_html() helper in R/mount.R that calls
findDependencies() + renderDependencies() and prepends the resulting
<script> / <link> tags to the tag HTML. Applied at all four
serialization sites (When observer, Each keyed inserts, Each positional
inserts, Match observer).

Bug 2: irid-widget-init races with widget script loading

Init message fires synchronously in the same Shiny message batch as
irid-swap, before the widget JS script has loaded. Widget init was a
one-shot: if the script wasn't registered yet, the message was lost.

Fix: Added deferred init queue in irid.js. When irid-widget-init
fires and the widget isn't registered, the message is stored in
irid._pendingInits[name]. When irid.registerWidget() is called (script
loaded), it flushes all queued inits for that widget name.

Bug 3: Mode scripts crash because they load before codemirror.min.js

Dynamically-inserted <script> tags via createContextualFragment load
and execute in arbitrary order. Mode scripts all do
CodeMirror.defineMode(...) at the top level, requiring the CodeMirror
global. When they load before codemirror.min.js, they crash, and the main
library also crashes from inconsistent internal state.

Fix: Combined all scripts into a single request using jsdelivr's
combine endpoint (concatenates files in order into one response). Used
the head field of htmlDependency to avoid URL encoding issues with @
and , characters that renderDependencies would escape.

Bug 4: Codemirror content echo causes cursor jumping

Content channel echoes the value back from the server and calls
editor.setValue(), snapping the cursor during active typing.

Fix: Two guards in the irid-widget-channel listener:

  • Skip content updates while editor.hasFocus() (user is actively editing)
  • Skip content updates matching lastSentContent (echo from our own
    irid.sendEvent call)

Bug 5: Mode read from msg.config instead of msg.channels

mode is passed as a reactive channel (mode = language), so it appears
in msg.channels.mode, not msg.config.mode. The init code read
msg.config.mode, so the initial mode was always the default 'javascript'
regardless of the language reactiveVal.

Fix: Changed to msg.channels.mode || msg.config.mode || 'javascript'.


Test audit and widget event timing fix (22 May 2026)

Test suite audit

Reviewed all 205 existing widget tests. Found 3 false positives:

File Issue
test-widget-mount.R When-deactivation destroy test captured msgs before deactivation — assertion checked stale data. Assertion was commented out, test passed vacuously.
test-widget-example.R Codemirror.js syntax check — all four system.file() paths resolved to "" because examples/ is at repo root, not inst/examples/. Test silently skipped.
test-widget-example.R CodeMirror When destroy test called handle$destroy() manually after deactivation instead of testing the automatic When-deactivation destroy path.

Added 12 new tests:

  • render_tag_html() dependency script rendering
  • .config vs static ... arg merge precedence
  • Multi-widget destroy sends all IDs
  • Channel message ID targeting (isolation between instances)
  • Widget inside keyed Each (add/keep/remove lifecycle)
  • Static mode value goes to config, not channels (covers Bug 5 root cause)
  • irid-events message structure verification for widget events
  • Managed-state dispatch contract (R mirror of JS sendEvent routing)
  • sendEvent with throttle/debounce config reaches R handler

Final tally: 256 tests, 0 failures.

Widget event timing config fix

Problem: Widget JS calls irid.sendEvent() directly, which called
sendPayload() directly — completely bypassing the throttle/debounce/
immediate managed state set up by the irid-events message. The .event
timing config on IridWidget was dead code for widget events.

Fix: Extracted first-send timing logic from DOM listener closures into
a s.submit(payload) method on each managed state object (throttle,
debounce, immediate-with-coalesce). Then:

  • DOM listeners call s.submit(buildPayload(e, el, msg.id)) — a one-liner
  • irid.sendEvent() checks managed[inputId] and routes through
    s.submit(payload) when managed state exists; falls back to direct
    sendPayload when no managed state

Stored timing parameters (inputId, ms, leading, coalesce, mode)
on the state object itself (previously captured via closure). No new timing
logic — same code, repositioned to be callable from both paths.

Files changed:

  • inst/js/irid.js — 4 functions refactored
  • tests/testthat/test-widget-client.R — +5 tests (dispatch contract)
  • tests/testthat/test-widget-mount.R — +3 tests (irid-events message shape)
  • tests/testthat/test-sendEvent.R — +2 tests (integration)

Key architectural insight

htmltools::as.character() strips html_dependency metadata — by design
(Shiny's output pipeline handles them separately), but irid sends raw HTML
over custom messages. Every tag rendered for irid-swap or irid-mutate
must have its dependencies manually rendered. The render_tag_html()
helper exists for this reason.

The init-deferred queue pattern (_pendingInits + registerWidget flush)
is the canonical solution for the race between Shiny custom message
processing and dynamically-inserted script loading.

The s.submit(payload) refactoring ensures widget events (from
irid.sendEvent) route through the same throttle/debounce/immediate
machinery as DOM events. This is also a preparatory step for the
client-side event slot queue design: when slot-claiming is added,
irid.sendEvent will naturally participate in per-element ordering because
it feeds through the same submit entry point.

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