Irid-native Widget mechanism for third-party JS libs#11
Open
rmvegasm wants to merge 7 commits into
Open
Conversation
…s and steps ordering
…first-send timing logic for DOM listeners so that widget can share the path
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 testaudit and widget event timing fix.
What was built
Slice 1 —
irid.sendEvent()JS primitiveinst/js/irid.js—irid.sendEvent(elementId, eventName, payload)shares the
sequencescounter andsendPayload()machinery with DOMevents, so sequence-based optimistic-update tracking and stale indicators
work identically for programmatic events.
handler dispatch, force-send, null payload, unknown input.
Slice 2 — Client-side init, channel, destroy handlers
inst/js/irid.jsadditions:irid.widgetsregistry +irid.registerWidget(name, initFn)deepEqual()helper for nested-object comparisonShiny.addCustomMessageHandler('irid-widget-init', …)— dispatches toregistered init function; queues if not yet registered (handles race
with dynamically-inserted scripts)
Shiny.addCustomMessageHandler('irid-widget-channel', …)— dispatchesCustomEvent('irid-widget-channel')withdetail.channel,detail.value,detail.isRenderShiny.addCustomMessageHandler('irid-widget-destroy', …)— dispatchesCustomEvent('irid-widget-destroy')irid.trackChannel(el)— per-element tracker withrecordSent()/receiveChannel()for snap-back correctionwidget lifecycle ordering, TrackChannel state machine).
Slice 3 —
IridWidget()R-side constructor and mount wiringR/irid_widget.R—IridWidget(dep, container, ..., .config, .event, .render, .widget_name)constructor. Validates deps, events, config.R/process_tags.R—irid_widgetbranch in tag walker: splits namedargs into channels (callable reactives), events (
on*prefix), andstatic config (everything else).
R/mount.R—irid_mount_processed: sends init message, creates oneobserve()per reactive channel (withisRenderflag for the renderchannel), sends destroy message on unmount.
messages, channel observers, destroy lifecycle, end-to-end counter widget.
Slice 4 — CodeMirror example
examples/codemirror/directory with three files:codemirror.R— R-side component:codemirror_dep()(CDN via jsdelivrcombine endpoint for correct script ordering),
widget_js_dep()(localbinding),
CodeMirror()constructor wrappingIridWidget().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 insideWhenwith reactivecode,show_editor,language.message shapes, event dispatch, When lifecycle, JS syntax, multi-instance).
Bugs found and fixed during Slice 4
Bug 1:
htmlDependencyscripts stripped byas.character()htmltools::as.character()stripshtml_dependencymetadata. irid'scontrol 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 inR/mount.Rthat callsfindDependencies()+renderDependencies()and prepends the resulting<script>/<link>tags to the tag HTML. Applied at all fourserialization sites (When observer, Each keyed inserts, Each positional
inserts, Match observer).
Bug 2:
irid-widget-initraces with widget script loadingInit message fires synchronously in the same Shiny message batch as
irid-swap, before the widget JS script has loaded. Widget init was aone-shot: if the script wasn't registered yet, the message was lost.
Fix: Added deferred init queue in
irid.js. Whenirid-widget-initfires and the widget isn't registered, the message is stored in
irid._pendingInits[name]. Whenirid.registerWidget()is called (scriptloaded), it flushes all queued inits for that widget name.
Bug 3: Mode scripts crash because they load before
codemirror.min.jsDynamically-inserted
<script>tags viacreateContextualFragmentloadand execute in arbitrary order. Mode scripts all do
CodeMirror.defineMode(...)at the top level, requiring theCodeMirrorglobal. When they load before
codemirror.min.js, they crash, and the mainlibrary also crashes from inconsistent internal state.
Fix: Combined all scripts into a single request using jsdelivr's
combineendpoint (concatenates files in order into one response). Usedthe
headfield ofhtmlDependencyto avoid URL encoding issues with@and
,characters thatrenderDependencieswould 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-channellistener:editor.hasFocus()(user is actively editing)lastSentContent(echo from our ownirid.sendEventcall)Bug 5: Mode read from
msg.configinstead ofmsg.channelsmodeis passed as a reactive channel (mode = language), so it appearsin
msg.channels.mode, notmsg.config.mode. The init code readmsg.config.mode, so the initial mode was always the default'javascript'regardless of the
languagereactiveVal.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:
test-widget-mount.Rmsgsbefore deactivation — assertion checked stale data. Assertion was commented out, test passed vacuously.test-widget-example.Rsystem.file()paths resolved to""becauseexamples/is at repo root, notinst/examples/. Test silently skipped.test-widget-example.Rhandle$destroy()manually after deactivation instead of testing the automatic When-deactivation destroy path.Added 12 new tests:
render_tag_html()dependency script rendering.configvs static...arg merge precedenceEach(add/keep/remove lifecycle)config, notchannels(covers Bug 5 root cause)irid-eventsmessage structure verification for widget eventssendEventrouting)sendEventwith throttle/debounce config reaches R handlerFinal tally: 256 tests, 0 failures.
Widget event timing config fix
Problem: Widget JS calls
irid.sendEvent()directly, which calledsendPayload()directly — completely bypassing the throttle/debounce/immediate managed state set up by the
irid-eventsmessage. The.eventtiming config on
IridWidgetwas 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:
s.submit(buildPayload(e, el, msg.id))— a one-lineririd.sendEvent()checksmanaged[inputId]and routes throughs.submit(payload)when managed state exists; falls back to directsendPayloadwhen no managed stateStored 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 refactoredtests/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()stripshtml_dependencymetadata — by design(Shiny's output pipeline handles them separately), but irid sends raw HTML
over custom messages. Every tag rendered for
irid-swaporirid-mutatemust have its dependencies manually rendered. The
render_tag_html()helper exists for this reason.
The init-deferred queue pattern (
_pendingInits+registerWidgetflush)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 (fromirid.sendEvent) route through the same throttle/debounce/immediatemachinery as DOM events. This is also a preparatory step for the
client-side event slot queue design: when slot-claiming is added,
irid.sendEventwill naturally participate in per-element ordering becauseit feeds through the same
submitentry point.