Widgets: introduce IridWidget framework with CodeMirror example#13
Open
khusmann wants to merge 37 commits into
Open
Widgets: introduce IridWidget framework with CodeMirror example#13khusmann wants to merge 37 commits into
khusmann wants to merge 37 commits into
Conversation
…an_accept_write()
…eactivity via is.function
…per; immediate as widget default
…rid.react section
…r and plotly examples
…, tightened wording
…s, defensive checks
…emantically split, always explicit
…dget value lands in Commit 1
…plemented design doc
…echo gate makes it unnecessary)
…* spelling (0.3.0)
…rappers stay declarative; NULL props preserve as JS null on the wire)
…ets via write_back/autobind; events only echo bindings they own) — fixes cursor-during-typing clobber in codemirror; remaining wire-batching design in dev/
… list and IridWidget .event slot; event_defaults removed (event_pick now local to wrappers, slated for 0.3.0 removal alongside scalar-broadcast .event); dom-events design doc renamed and reframed with custom_tag/on: optional follow-ons, listener-opts §5 flags .event → .timing rename for 0.3.0
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
Adds
IridWidget, a process-tags citizen for wrapping arbitrary JS libraries (CodeMirror, Plotly, Leaflet, ...) as reactive components inside irid's control flow (When,Each,Match). One example — CodeMirror via inline ES module from esm.sh — vets the framework against a real library. A follow-up PR (irid-plotly) stacks on top to landPlotlyOutputas a second consumer.Inspiration
Builds on #11, which first implemented the widget mechanism. The vision (reactive props in, events out, init/update/destroy on the JS side) and several specific patterns (deferred init queue, data-attribute lifecycle marker) carry through. A few load-bearing protocol decisions land differently — motivated below — informed by what #11 turned up in practice.
Design choices that differ from #11
1. Wire protocol: extend existing channels rather than add dedicated ones
irid-widget-channelmessageirid-attrwithtarget = "widget"irid.sendEvent()primitiveirid-eventswithsource = "widget"irid-widget-destroymessageirid-widget-init)targetonirid-attralready had two values ("dom"/"text") —"widget"as a third is the natural extension. Same for the newsourcefield onirid-events.The payoff isn't fewer message types so much as everything sharing those channels works for widgets without widget-specific code in the transport: the optimistic-update sequence counter, per-event timing config, the stale-UI indicator, the force-send-on-no-op echo, the cross-element seq gating. #11 landed a follow-up commit (the
s.submit(payload)refactor) after finding.eventwas dead code for widget events — threading widget events throughmanaged[inputId]from the start avoids the analogous refactor in this implementation.2. Client-driven destroy via the detach walker
#11 sends
irid-widget-destroyfrom the server. This PR has the client'sdetachRangewalker find[data-irid-widget]elements in the detached fragment and call each widget'sdestroy()beforeShiny.unbindAll. Rationale:destroy(); the DOM detach is what the client needs to react to, and it already has that event.3. Deps: register as Shiny static resources, not inline in swap HTML
#11's
render_tag_html()callsfindDependencies()+renderDependencies()and prepends<script>/<link>tags into the HTML shipped viairid-swap/irid-mutate(required becausehtmltools::as.character()strips dep metadata).This PR's
register_widget_dep()runs each widget dep throughshiny::createWebDependency()— which callsaddResourcePathand rewritessrcto anhref— before sendingirid-widget-init. The client callsShiny.renderDependencies(deps)to inject the tags into<head>.When/Each/Match, widgets insiderenderIrid— same code path. Mount sends init; init carries deps.createContextualFragmentdoesn't guarantee order), and URL-encoding issues with@and,in jsdelivr'scombineendpoint. Going through Shiny's resource path side-steps both.4. Explicit
propsandeventslists, not...with auto-classification#11 has
IridWidget(dep, container, ..., .config, .event, .render, .widget_name), with...auto-classified: callable → channel,on*prefix → event, everything else → static config.This PR has
IridWidget(name, props = list(), events = list(), deps = NULL, container = NULL).propsis an explicit named list; per-keyis.function()decides callable (observer-registered) vs static (init-only).eventsis a list ofwidget_event()records bundling each event's wire name, handler, and timing — see §5.on*is a DOM-event convention. Widget event names follow the web'sCustomEventconvention — lowercase kebab-case likecursor-changed,relayout. Auto-classifying byon*makes those unreachable through.... Explicitevents = list(widget_event(name = "cursor-changed", handler = ...))works cleanly.CodeMirror(content = doc, onChange = h)works identically. The classification just moves inside the wrapper..eventslot onIridWidget. Per-event timing lives in eachwidget_event(timing = ...)record. Widget event names are library-specific so the framework has no per-name intuition like DOM events have (input→event_debounce(200)); per-event timing has to be declared somewhere, and the per-event record is the natural place. Container-level.event(for DOM events on the container) is still set on the container tag directly.5. Wrapper helpers shipped from irid
Three helpers ship with the framework — together they turn the canonical wrapper into one declarative call per round-trip key:
can_accept_write(callable)— writability predicate.write_back(callable, field, then = NULL)— handler factory that writes through the callable (gated bycan_accept_write), then calls the optionalthenhook.widget_event(name, handler, timing)— per-event record bundling wire-name, handler, and timing config (defaults toevent_immediate()) into one call site. ReturnsNULLwhen handler isNULLso wrappers forward optional handlers declaratively without conditional list-building;IridWidgetdropsNULLentries.The canonical line for a round-trip key with per-event timing:
Wrappers that want to surface caller-side
.eventoverrides typically define a small localevent_pick(user, key, default)helper inline; seeexamples/codemirror.R. Bothevent_pickand scalar-broadcast.eventare slated for removal in 0.3.0 in favor of inline.event$key %||% default(tracked indev/dom-events-design.md).6. Factory returns
{update, destroy}, not CustomEvent listeners#11 has widget code listen for
CustomEvent('irid-widget-channel')andCustomEvent('irid-widget-destroy')on its element. This PR has the factory return{update, destroy}, stored in a per-id widget map; irid calls the hooks directly.Smaller contract, no event-listener bookkeeping in widget code. The factory shape (
(el, props, send) → {update, destroy}) echoes prior art (htmlwidgetsfactory, Mithril/Solid component shapes) so the mental model is familiar.7. Snap-back via force-send-on-no-op, no client-side tracker
#11 has
irid.trackChannel(el)withrecordSent()/receiveChannel(). This PR reuses the existing force-send-on-no-op loop inmount.R— after every event, every binding on the source element isisolate-evaluated and echoed viairid-attr. The widget'supdatehook compares against its current state and snaps back on mismatch.Idempotence is library-specific — CodeMirror's "current value" is
view.state.doc; Plotly's is in_fullLayout. The framework can't track meaningfully without knowing the library's native state representation; leaving the comparison to the widget keeps the framework free of library assumptions.Documentation
ARCHITECTURE.md gains a Widgets section covering the constructor, the wire protocol (target/source extensions + the new
irid-widget-init), the JS factory contract, lifecycle and identity across re-renders, andregister_widget_dep. Existing protocol sections are updated to mention the newtarget/sourcevalues.The old design docs (
dev/irid-widget-design-v2.md,dev/plans/irid-widgets-plan.md) are removed — the design lives in code and in ARCHITECTURE.md now. References fromdev/plotly-output-design.mdanddev/dom-events-design.mdwere folded inline or redirected to ARCHITECTURE.md.Out of scope
iridbeyond the framework itself. CodeMirror lives inexamples/;PlotlyOutputlands in the follow-up.tags$*— covered by the open follow-up indev/dom-events-design.md(§5 optional follow-ons:on:verbatim escape hatch +custom_tag()for Web Components)..event→.timingrename, plain-tag.eventon*keys, drop scalar broadcast — slated for 0.3.0; tracked indev/dom-events-design.md§4 and coordinated withdev/listener-opts-design.md§5.Follow-up
irid-plotlystacks on top of this branch and addsPlotlyOutput— a chart wrapper with multi-write relayout fan-out and reactive-proxy snap-back, validating the framework against a different shape of widget than CodeMirror exercises.Test plan
~150 widget-related tests across
test-widget.R,test-widget-helpers.R,test-widget-deps.R; 602 total tests pass.examples/codemirror.R: editor materializes; typing round-trips to the<pre>mirror with the 200ms debounce; toggle off/on rebuilds with preserved doc text; Reset button replaces contentsdevtools::test()cleandevtools::check()clean