Skip to content

Widgets: introduce IridWidget framework with CodeMirror example#13

Open
khusmann wants to merge 37 commits into
mainfrom
irid-widgets2
Open

Widgets: introduce IridWidget framework with CodeMirror example#13
khusmann wants to merge 37 commits into
mainfrom
irid-widgets2

Conversation

@khusmann
Copy link
Copy Markdown
Owner

@khusmann khusmann commented May 26, 2026

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 land PlotlyOutput as 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

#11 This PR
Server → client prop updates new irid-widget-channel message extend irid-attr with target = "widget"
Client → server events new irid.sendEvent() primitive extend irid-events with source = "widget"
Server → client destroy new irid-widget-destroy message client detach walker (see #2)
New messages on the wire 3 1 (irid-widget-init)

target on irid-attr already had two values ("dom" / "text") — "widget" as a third is the natural extension. Same for the new source field on irid-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 .event was dead code for widget events — threading widget events through managed[inputId] from the start avoids the analogous refactor in this implementation.

2. Client-driven destroy via the detach walker

#11 sends irid-widget-destroy from the server. This PR has the client's detachRange walker find [data-irid-widget] elements in the detached fragment and call each widget's destroy() before Shiny.unbindAll. Rationale:

  • The DOM is the source of truth for what's mounted. A server-side message leaves a window where a widget can leak if the server crashes between observer teardown and the swap.
  • No extra wire traffic. Server-side observers are already torn down by the enclosing mount's 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() calls findDependencies() + renderDependencies() and prepends <script> / <link> tags into the HTML shipped via irid-swap / irid-mutate (required because htmltools::as.character() strips dep metadata).

This PR's register_widget_dep() runs each widget dep through shiny::createWebDependency() — which calls addResourcePath and rewrites src to an href — before sending irid-widget-init. The client calls Shiny.renderDependencies(deps) to inject the tags into <head>.

  • One channel for deps regardless of mount location. Top-level widgets, widgets inside When/Each/Match, widgets inside renderIrid — same code path. Mount sends init; init carries deps.
  • Shiny's static-resource pipeline serves files in correct load order. Irid-native Widget mechanism for third-party JS libs #11 surfaced two bugs from inlining dep scripts in swap HTML: CodeMirror mode scripts loading before the main library (because createContextualFragment doesn't guarantee order), and URL-encoding issues with @ and , in jsdelivr's combine endpoint. Going through Shiny's resource path side-steps both.

4. Explicit props and events lists, 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). props is an explicit named list; per-key is.function() decides callable (observer-registered) vs static (init-only). events is a list of widget_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's CustomEvent convention — lowercase kebab-case like cursor-changed, relayout. Auto-classifying by on* makes those unreachable through .... Explicit events = list(widget_event(name = "cursor-changed", handler = ...)) works cleanly.
  • End users don't see the difference. Wrapper signatures stay the same shape either way — CodeMirror(content = doc, onChange = h) works identically. The classification just moves inside the wrapper.
  • No .event slot on IridWidget. Per-event timing lives in each widget_event(timing = ...) record. Widget event names are library-specific so the framework has no per-name intuition like DOM events have (inputevent_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 by can_accept_write), then calls the optional then hook.
  • widget_event(name, handler, timing) — per-event record bundling wire-name, handler, and timing config (defaults to event_immediate()) into one call site. Returns NULL when handler is NULL so wrappers forward optional handlers declaratively without conditional list-building; IridWidget drops NULL entries.

The canonical line for a round-trip key with per-event timing:

widget_event(
  name    = "change",
  handler = write_back(content, "content", then = onChange),
  timing  = event_debounce(200, coalesce = TRUE)
)

Wrappers that want to surface caller-side .event overrides typically define a small local event_pick(user, key, default) helper inline; see examples/codemirror.R. Both event_pick and scalar-broadcast .event are slated for removal in 0.3.0 in favor of inline .event$key %||% default (tracked in dev/dom-events-design.md).

6. Factory returns {update, destroy}, not CustomEvent listeners

#11 has widget code listen for CustomEvent('irid-widget-channel') and CustomEvent('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 (htmlwidgets factory, 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) with recordSent() / receiveChannel(). This PR reuses the existing force-send-on-no-op loop in mount.R — after every event, every binding on the source element is isolate-evaluated and echoed via irid-attr. The widget's update hook 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, and register_widget_dep. Existing protocol sections are updated to mention the new target / source values.

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 from dev/plotly-output-design.md and dev/dom-events-design.md were folded inline or redirected to ARCHITECTURE.md.

Out of scope

  • Built-in widgets shipped from irid beyond the framework itself. CodeMirror lives in examples/; PlotlyOutput lands in the follow-up.
  • Cross-widget client-side messaging. Widgets compose via shared R-side reactives.
  • Server-side widget rendering (htmlwidgets-style static knitr output). Always client-rendered.
  • Custom DOM events on regular tags$* — covered by the open follow-up in dev/dom-events-design.md (§5 optional follow-ons: on: verbatim escape hatch + custom_tag() for Web Components).
  • .event.timing rename, plain-tag .event on* keys, drop scalar broadcast — slated for 0.3.0; tracked in dev/dom-events-design.md §4 and coordinated with dev/listener-opts-design.md §5.

Follow-up

irid-plotly stacks on top of this branch and adds PlotlyOutput — 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.

  • Inspect ARCHITECTURE.md Widgets section reads cleanly
  • Run 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 contents
  • devtools::test() clean
  • devtools::check() clean

khusmann added 28 commits May 25, 2026 13:40
@khusmann khusmann changed the title IridWidget framework + CodeMirror example Widgets: introduce IridWidget framework with CodeMirror example May 26, 2026
khusmann added 8 commits May 25, 2026 22:21
…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
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