The loop that did nothing
A querySelectorAll in an iframe, a for loop over the results, and an attribute that never changed. No panic. No console error. No exception in the network tab, no red anything. The loop ran, iterated the right number of times, and did nothing at all — which is what this repo's debugging notes record, and what the mechanism below predicts exactly.
If you write Rust that compiles to WASM and cast nodes you pulled out of a same-origin iframe, this is waiting for you. The useful thing I can hand you is not the fix — the fix is one identifier — but the fact that "nothing happened" is a symptom with a small number of causes, and they are separable.
Three of them, in the order they show up:
- A typed cast that returns
Errinstead of panicking, in alet ... elsethat swallows it. - Half the DOM API continuing to work perfectly, which makes the first one look impossible.
- A framework re-mount that reloads the iframe underneath everything, which looks exactly like five other things.
Everything below is traced to code in one repo — a Leptos 0.8 CMS whose editor renders the site being edited inside a same-origin <iframe>. Paths and line numbers are real; I have cited them so you can check me rather than believe me.
Symptom one: the cast that returns Err
Here is the loop, reduced. This is a reduction — the shape, not the code. The real one is clear_selected_dom_state in cms/src/ui/components/responsive_preview/selection_binding.rs:49-64.
// REDUCTION of the broken shape. The real loop is at
// selection_binding.rs:49-64 and does not look like this any more.
let selected = doc.query_selector_all("[data-cms-selected]")?;
for index in 0..selected.length() {
let Some(node) = selected.item(index) else { continue };
let Ok(element) = node.dyn_into::<web_sys::Element>() else { continue };
element.remove_attribute("data-cms-selected")?;
}
Read that let Ok(element) = ... else { continue } again. If the cast fails, this loop does nothing, forever, quietly. It is not an unwrap that panics. It is not a ? that propagates. It is a continue.
Why would the cast fail? Nothing about the query went wrong. The NodeList has the elements in it, selected.length() returns the number you expect, and the loop body runs that many times — none of which is in question, because the cast that fails happens after all of it. (I am reasoning from the mechanism here, not from a screen recording: see the note at the end about what was observed and what was read.)
It fails because doc is the iframe's document and the code doing the cast is the parent frame's WASM.
The mechanism
I traced this through the source rather than inferring it, because "instanceof is weird across frames" is folklore until you can point at the line.
dyn_into is not magic, and it does not panic. From wasm-bindgen-0.2.121/src/cast.rs:47-56:
fn dyn_into<T>(self) -> Result<T, Self>
where
T: JsCast,
{
if self.has_type::<T>() {
Ok(self.unchecked_into())
} else {
Err(self)
}
}
has_type (cast.rs:34-39) calls T::is_type_of, whose default (cast.rs:140-142) is:
fn is_type_of(val: &JsValue) -> bool {
Self::instanceof(val)
}
and instanceof (cast.rs:129) is a trait method whose implementation for each web-sys type is generated JS. Here it is, from the wasm-bindgen glue in this repo's own build output (demo/pkg/editor_demo.js:377-386, generated by wasm-bindgen 0.2.121 — a build artifact, gitignored, reproduce it with a build):
__wbg_instanceof_Element_244fd1e5d45219a6: function(arg0) {
let result;
try {
result = arg0 instanceof Element;
} catch (_) {
result = false;
}
const ret = result;
return ret;
},
There it is: arg0 instanceof Element. That Element is an ordinary identifier, resolved in the scope of this glue module — which was loaded into the parent frame. The parent frame's Element and the iframe's Element are two different function objects, because each browsing context gets its own set of built-in classes. instanceof walks the prototype chain looking for Element.prototype of that realm, does not find it, and returns false.
Note the catch (_) { result = false } while you are here. Even a throwing instanceof becomes a plain false. Nothing about this path is loud.
So dyn_into returns Err, the let ... else hits continue, and the loop completes having done nothing. There is no place in that chain where anyone writes to the console.
Symptom two: half the API keeps working
This is the part that makes the first one hard to believe, and it is worth understanding properly, because it is what will make you look in the wrong place.
Casting across the realm boundary fails. Calling methods across it works fine. set_attribute, remove_attribute, class_list, get_attribute, closest — all fine on an element you got from the iframe.
The asymmetry is not a special case anyone implemented. It falls out of what the generated glue does for each kind of call. Same file, same build, three imports.
One provenance note, since it matters for how much these three prove: the Rust in this post is from the CMS crate, and every JS import quoted below is from the demo crate's bundle, because that is the generated glue that exists on my disk. Both crates pin wasm-bindgen 0.2.121, and the import shape is chosen by the generator per type rather than per crate, so I am relying on those two bundles being generated the same way. That is an inference, not a diff of two files.
// demo/pkg/editor_demo.js:377-386 — a type check
__wbg_instanceof_Element_244fd1e5d45219a6: function(arg0) {
result = arg0 instanceof Element; // <- a name from THIS realm
// demo/pkg/editor_demo.js:529-531 — a method call
__wbg_removeAttribute_69b4f669d167f410: function() { return handleError(function (arg0, arg1, arg2) {
arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); // <- a lookup on the object
}, arguments); },
// demo/pkg/editor_demo.js:211-214 — a property read
__wbg_classList_8e2d38f6fb1576f2: function(arg0) {
const ret = arg0.classList; // <- also a lookup on the object
return ret;
},
The type check names a class from the realm the glue lives in. The method call and the property read do not name a class at all — they go through the object's own prototype chain, which is the iframe's, which has a perfectly good removeAttribute on it.
That gets you halfway: it shows the binding layer contributes no realm-sensitivity. The other half is the DOM's own behaviour, because removeAttribute does check its receiver — every WebIDL operation does. The reason that check passes is that a WebIDL brand check tests the platform object's internal interface type, not whether it was constructed by the caller realm's Element. An element is an element in every realm; only instanceof cares which one made it. I have not put a spec citation next to that here — it is the premise the conclusion rests on, and it is the one thing in this section I am asserting from how the platform is specified to work rather than from a file on this machine.
So: instanceof is realm-bound; calling a method or reading a property on the object is not. Stated that narrowly on purpose — "identity checks are realm-bound" would be too strong, and the post leans on the exception two sections down: typeof, Array.isArray and nodeType are all checks that work perfectly well on a value from another realm, because none of them consults a constructor in the caller's scope. instanceof does, and that is the whole problem.
Rust's type system is not involved in any of this. Every web-sys type is a wrapper around a JsValue handle — JsCast is declared where Self: AsRef<JsValue> + Into<JsValue> (cast.rs:19-22) — so the "type" is a claim you are making about a handle, and the only runtime enforcement of that claim is the instanceof above.
That is why the bug presents as "my mutation didn't happen" rather than "my mutation threw": you never reach the mutation. Every element you did manage to get a hold of behaves normally, so the API looks healthy, so you go looking for your selector, your timing, your reactivity — anywhere but the cast.
The fix, and why it is safe
unchecked_into (wasm-bindgen-0.2.121/src/cast.rs:82-87):
fn unchecked_into<T>(self) -> T
where
T: JsCast,
{
T::unchecked_from_js(self.into())
}
No check. It reinterprets the handle as the target type — you are asserting something the runtime will not verify for you. What happens when you are wrong is worth being exact about, because the obvious answer ("it throws") is not what this environment does.
The setters are handleError-wrapped (editor_demo.js:529-531, :562-564), so a TypeError from calling setAttribute on something that is not an element comes back to Rust as one more silent Err(JsValue) — the same shape as the bug this whole post is about. The getters are not wrapped at all: __wbg_classList_... is const ret = arg0.classList; return ret; (:211-214), with no handleError and no null check, so on a text node it hands Rust a DomTokenList wrapping undefined and reports nothing; __wbg_nodeType_... (:487-490) is a bare property read whose undefined crosses the boundary as 0. So for these two shapes — a handleError-wrapped setter and an unguarded getter — a bad unchecked_into produces the same invisible failure you came here to fix, rather than a throw you can catch.
Do not read that as a general guarantee of silence, and if you open cast.rs:82 you will see why: wasm-bindgen's own doc comment three lines above says that used incorrectly this "may cause runtime exceptions in both Rust and JS". Both are true. Which one you get is decided by the import shape of the call you happen to make next, and there is no rule that says the noisy shape comes first.
What it cannot do is corrupt Rust. unchecked_into is a safe function (cast.rs:82-87; unchecked_from_js is declared without unsafe at :149), and web-sys types are wrappers around a JsValue handle, so the worst case is JS type confusion — a wrong answer or a quiet Err, never undefined behaviour. That is the trade: unchecked_into moves the risk from "won't compile" to "won't complain".
It is safe here, and the reason is specific enough to write down next to it, which is what the repo does (selection_binding.rs:57-59, and again at :75-77):
// unchecked_into skips the instanceof check that fails for cross-frame elements;
// querySelectorAll always returns Elements so this is safe.
let element: web_sys::Element = node.unchecked_into();
The safety argument is the source of the value, not the cast. A NodeList from querySelectorAll holds element nodes — it cannot hand you a text node or a comment. You are not guessing; you are recovering a type the DOM already guaranteed and the binding threw away. (I am taking that API guarantee as given rather than quoting a spec at you; it is also the assumption the comment above is leaning on, which is why the comment says it out loud.)
Read that as "querySelectorAll specifically", not "a NodeList". The type carries no such guarantee: Node::child_nodes returns the same NodeList (web-sys-0.3.98/src/features/gen_Node.rs:78), and its item() will hand you text and comment nodes all day. unchecked_into::<Element>() on one of those gets you the silent nonsense described above — same call, same types, different source, no error.
Which raises the question: why was there a cast at all? Because of where the types are declared. From web-sys 0.3.98, the version this repo locks:
// web-sys-0.3.98/src/features/gen_NodeList.rs:40
pub fn item(this: &NodeList, index: u32) -> Option<Node>;
// web-sys-0.3.98/src/features/gen_Document.rs:2376
pub fn query_selector(this: &Document, selectors: &str) -> Result<Option<Element>, JsValue>;
NodeList::item gives you a Node, because that is what the IDL says, so reaching Element methods needs a cast. Document::query_selector gives you an Element directly — no cast, no instanceof, no bug.
That is not a hypothetical distinction. Both paths are in the same function in this repo. sync_selection_dom_state (selection_binding.rs:102-118) clears state via the query_selector_all path, which needs unchecked_into, and then sets the new attribute via query_selector, which does not:
// selection_binding.rs:112-115
let Some(element) = doc.query_selector(&selection_selector(selection))? else {
return Ok(());
};
element.set_attribute(SELECTED_ATTR, "true")?;
If your cross-frame code happens to use only query_selector, you can go a long way without meeting this — right up until the first time you need HtmlElement or HtmlInputElement and reach for dyn_into. The instanceof import is per-type, not special to Element: the same shape is generated for every type the build casts to (HtmlIFrameElement, ShadowRoot and Window are the others in this one — editor_demo.js:387-396, :397 and :407).
The realm-safe type check
Sometimes you genuinely need to know whether a node is an element — for example when the value came from an event's target, which is typed EventTarget and tells you nothing. instanceof is out. The repo uses the node type instead (selection.rs:32-46):
// The target is an iframe-realm node, so instanceof-based casts (dyn_ref)
// silently fail across the frame boundary. Treat it as a Node and branch on
// node_type instead. See CLAUDE.md and the twin helper in
// drag_controller/dom.rs.
let node: web_sys::Node = target?.unchecked_into();
if node.node_type() == web_sys::Node::ELEMENT_NODE {
Some(node.unchecked_into())
} else {
node.parent_element()
}
node_type() is a property read — realm-agnostic, per the asymmetry above — so it works on a node from any frame. It is a weaker check than instanceof (it tells you the node kind, not the Rust type you are about to claim), but it is a check that actually runs.
Worth being honest about the first line of that snippet, since it is the one place the post's own rule is not met: target?.unchecked_into() claims Node without checking it. Here the claim holds by where the values come from — these are mousedown and click listeners attached to the iframe's document (selection_binding.rs:223-227 and :244-248), so a target is an element or the document, both of which are nodes. If something else ever arrived, the code would not throw: nodeType on a non-node is undefined, the glue reads it bare (editor_demo.js:487-490), it crosses as 0, 0 is not ELEMENT_NODE, and the function falls through to parent_element() and returns None. Correct, by numeric coercion rather than by the rule. I would rather say that than let it stand as an example of careful practice it is not.
The twin of that helper lives in cms/src/ui/components/responsive_preview/drag_controller/dom.rs:163-174. The rule that fell out of this, across the whole preview tree: nothing casts an iframe node with dyn_into, and every occurrence of the name in those files is inside a comment explaining why. That is checkable in one line — grep -rn "dyn_into\|dyn_ref" cms/src/ui/components/responsive_preview returns comments and nothing else.
Symptom three: the iframe that reloads itself
Different bug, same fog. Worth its own section because the diagnosis is what makes it tractable.
The symptom: clicking a block in the editor reloads the entire preview iframe. The page flashes. Any in-frame state is gone.
The obvious suspects — the src changed, something removed and re-inserted the node, a navigation — are all wrong, and knowing they are wrong is most of the work. This is the signature, as recorded in this repo's CLAUDE.md from the debugging session that found it. I did not observe this call stack myself; I am reporting what the repo records, and I have verified that the fix it prescribes is what the code does today.
All of these hold simultaneously:
The iframe's
on:loadfires and the iframe re-hydrates — but there is nosrcchange. If you have a derived-src memo with a log in it, it does not log.There is no
[cms-layout] mount site layout hydratedre-log — the component body does not re-run. Only its view is rebuilt.beforeunloadandunloaddo not fire, and the notes record that the iframe node itself was not seen to be removed. That rules out a navigation. It does not rule out removal, and this is where reading the notes literally sends you the wrong way: the framework removes an ancestor, and the iframe goes with it. Nothing about "the iframe node was not removed" is inconsistent with that — a DOM-removal breakpoint set on the iframe would not fire either, since the node passed toremove()is its ancestor. That last clause is my inference from the source below, not an instrument anyone ran — the notes record no breakpoint.The DevTools "Initiator" / paused call stack reads:
Future::poll → SuspenseBoundary::build → OwnedView::rebuild → EitherKeepAlive::rebuild → ElementState::mount → insert_before
That stack points at the boundary, and the framework source finishes the job.
<Suspense> and <Transition> are one component with one flag, and the flag decides whether a pending resource swaps the fallback in (leptos-0.8.20/src/suspense_component.rs:194):
let show_b = !none_pending.get() && (!TRANSITION || nth_run < 2);
A is the children and B is the fallback — the state type says so at suspense_component.rs:177-179. Under <Suspense>, TRANSITION is false, so !TRANSITION is true and show_b is true whenever anything is pending. Under <Transition> it can only be true while nth_run < 2, i.e. the first render. That single expression is the whole difference between the two, and it is where "the fallback shows only on first load" actually lives.
show_b flipping runs the A→B arm of EitherKeepAlive::rebuild (tachys-0.2.18/src/view/either.rs:493-501):
match (self.show_b, state.showing_b) {
// transition from A to B
(true, false) => match (&mut state.a, &mut state.b) {
(Some(a), Some(b)) => {
a.insert_before_this(b);
a.unmount();
}
unmount on an element state is Rndr::remove(&self.el) (tachys-0.2.18/src/html/element/mod.rs:721-724), and that is node.unchecked_ref::<Element>().remove() (tachys-0.2.18/src/renderer/dom.rs:153-155). A real detach, not a hide.
So under <Suspense> the subtree holding the iframe is taken out of the document and the fallback is put where it was; on resolve the mirror arm at either.rs:503-509 swaps them back. Two things follow, and both are easy to get backwards if you read only the stack:
The removal is the cause, not the re-insertion. Detaching an <iframe> discards its nested browsing context; attaching it again creates a fresh one and navigates to src. So the document dies at unmount and a new one is loaded at mount — re-inserting an iframe that was never detached reloads nothing. (That browser behaviour is the one link in this chain I am taking as given rather than citing; everything above it is code you can open.)
The symptom is a swap, not just a reload. The fallback replaces the children, so under <Suspense> this layout's <p>"Loading site locale configuration..."</p> stands in for the entire editor on every block selection and then hands it back. The swap lasts exactly as long as the resource is pending — one server round trip for get_hero_content — and then reverses itself. I have not measured that interval on this app, but a round-trip-length swap-and-restore is something a person would reasonably write down as a "flash", which is worth knowing when you are matching a bug report to a cause: "flash" does not tell you the DOM stayed put.
The cause: a <Suspense> that encloses the iframe, with descendants that read resources at runtime. In Leptos a resource registers with the nearest enclosing suspense boundary when it is read, not when it is created: ArcAsyncDerived::try_read_untracked looks the boundary up with use_context::<SuspenseContext>() and takes a task handle from it (reactive_graph-0.2.14/src/computed/async_derived/arc_async_derived.rs:633-636). Creating a Resource no component reads registers nothing; reading one in a view under a boundary is what arms this. The block inspector toolbars create one in their component bodies — hero_toolbar.rs:304-314 builds a Resource keyed on (selected.get(), page_path.get()) that calls get_hero_content — and which toolbar mounts is derived straight from the selection (preview_toolbar/context_toolbar/mod.rs:59 derives the panel from selected.get(); :145 maps the hero panel to <HeroToolbar/>). So every block selection puts a resource in the pending state, the boundary re-suspends, and on resolution it rebuilds the view it already built. The iframe is inside that view.
The fix is one word, and the repo comments it as non-negotiable (cms/src/ui/components/site_layout.rs:155-162):
// <Transition>, NOT <Suspense>. The hero inspector creates a `get_hero_content`
// Resource that registers with the nearest suspense boundary — this one, which
// wraps the whole layout INCLUDING the preview <iframe>. With <Suspense>, that
// resource going pending on every hero selection re-suspends this boundary and
// *rebuilds* the already-built view, which re-mounts (and thus reloads) the
// iframe — the "flash". <Transition> keeps the current view mounted while new
// resources load and updates in place, so the iframe is never re-mounted.
<Transition fallback=move || view! { <p>"Loading site locale configuration..."</p> }>
<Transition> is the same machinery with TRANSITION = true. nth_run increments on every run (suspense_component.rs:195), and if the first run registered no tasks it is bumped a second time on the spot (:212-222), so nth_run < 2 is false from then on and show_b stays false however many resources go pending later. The A→B arm never runs again, which means the fallback swap never unmounts the children after the first load — no detach, no new browsing context, no reload.
Scoped deliberately to the swap. It is not a promise that the subtree is immortal: if a parent rebuilds the boundary, SuspenseBoundary::rebuild (suspense_component.rs:228-233) does old.insert_before_this(state); old.unmount(); — it builds a fresh effect with nth_run back at zero and detaches the old subtree, iframe included. That is reachable here: SiteLayout is rendered from a reactive block keyed on the route parameter (ui/app/handlers.rs:328-338, route /sites/:site_id/layout at ui/app/routes.rs:102-104), so moving between sites rebuilds it. <Transition> buys you the selection case, not every case.
The reason that signature earns its space: "the iframe reloaded" has more than one cause, and the other one is a src write. Assigning src — even the same string it already held — reloads the frame. That is browser behaviour I am taking as given rather than citing (it is in the appendix list of such things), and it is worth defending against, which this codebase does by memoising the derived URL (responsive_preview.rs:212-218):
// Memo (not Signal::derive) so the iframe `src` is only rewritten when the
// URL actually changes. A non-memoized derived signal re-notifies on any
// source tick, which rewrites `src` to the same value and reloads the iframe
// — the "whole editor flashes on every selection" bug. `derive_preview_src`
// is deterministic and selection-independent, so a value-diffing Memo makes
// selection physically incapable of reloading the frame.
That comment is right about the danger and wrong about the route to it, and the correction is the sort of thing you can only get by opening the framework. Tachys diffs an attribute value before it touches the DOM. derive_preview_src returns String (responsive_preview/iframe_dom.rs:17-22) and is bound as an ordinary reactive attribute (src=derived_src, responsive_preview.rs:439), so the relevant impl is AttributeValue for String (tachys-0.2.18/src/html/attribute/value.rs:426-432):
fn rebuild(self, key: &str, state: &mut Self::State) {
let (el, prev_value) = state;
if self != *prev_value {
Rndr::set_attribute(el, key, &self);
}
*prev_value = self;
}
A reactive attribute gets there through a RenderEffect that calls value.rebuild(..) on every notification (tachys-0.2.18/src/reactive_graph/mod.rs:416-424) — and rebuild compares before it writes. So a non-memoised derived signal re-notifying with an unchanged URL produces zero setAttribute calls and cannot reload anything. The same guard is in the &str, Cow<'_, str> and Arc<str> impls in that file.
Keep the Memo anyway — it stops the derivation re-running and stops downstream subscribers waking for nothing — but not for the reason the comment gives, and not as a reload defence in this framework. What would reload the frame is a genuine src change: a different locale or page, which is a navigation you asked for.
Which is what the signature is for. A real src write shows up as a src change; the suspense re-mount does not touch src at all. If you cannot tell those apart you cannot fix either, and "the iframe reloaded" is not a diagnosis.
The rule this is an instance of, scoped to Leptos 0.8 / tachys 0.2: an always-mounted, expensive, or stateful element — an iframe, a <video>, a <canvas>, anything holding state the DOM owns rather than your app — should not sit inside a <Suspense> whose descendants read resources at runtime. The detach is invisible in your code; it happens in EitherKeepAlive::rebuild.
The scoping is not hedging. "Suspense" names different machinery in different frameworks, and most readers arriving here will have React's in mind, whose documented behaviour on re-suspension is the opposite: keep the subtree mounted and hide it rather than unmount it. If that is the model you are carrying, the rule above will look wrong — for React it is wrong.
Be precise about what I checked, though, because that comparison is the one claim in this post I did not verify against anything on this machine: I did not read React's source, and I did not re-read its documentation while writing this. I am also not going to tell you why React does it that way; the rationale I would have offered is not something I can quote. Treat the sentence as a prompt to go and check whichever framework you use, not as a report on React. What is checkable here is Leptos: the detach is a.unmount() at either.rs:498.
Why both of these were hard: the errors go into a box
The through-line of this post is that nothing announced itself. That is not carelessness in the code — it is a property of the WASM boundary.
Every fallible DOM import in the generated glue is wrapped like this (demo/pkg/editor_demo.js:876-883):
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
A JS exception thrown inside a binding is caught, stashed in a table, and handed back across the boundary as a value. On the Rust side it becomes Err(JsValue). It is not rethrown, not logged, and does not appear anywhere in DevTools unless you put it there.
So insertBefore, removeAttribute and setAttribute (all wrapped — editor_demo.js:373, :529, :562) fail quietly. Combine that with a dyn_into that returns Err instead of panicking, and a Rust idiom (let ... else { continue }) designed to make skipping cheap, and you get a program that is silent in three independent ways about the same failure.
Two things follow, and they are the practical takeaways:
Do not discard those Errs — and notice how hard that is to hold. The selection path does it properly. clear_selected_dom_state and sync_selection_dom_state propagate with ? (selection_binding.rs:49-64, :102-118), and both callers log once at the top — verbatim from responsive_preview.rs:416-418, with the click path doing the same at selection_binding.rs:95-97:
if let Err(error) = sync_selection_dom_state(&doc, selected) {
web_sys::console::error_1(&error);
}
Now grep the same directory for the opposite habit:
grep -rn "let _ = " cms/src/ui/components/responsive_preview/
Thirty-four hits. One is a tuple binding that silences an unused-variable warning, so thirty-three discard a Result. Five of those are remove_event_listener calls inside Drop impls — selection_binding.rs:264, :269 (impl Drop at :258) and drag_controller.rs:407, :412, :417 (impl Drop at :403) — where nothing downstream could act on a failure anyway.
That leaves twenty-eight discarded DOM Results: class_list().remove_1(..), add_1(..), set_attribute(..), remove_attribute(..), toggle_with_force(..), which return Result in web-sys (gen_DomTokenList.rs:60, :179, :284-288, gen_Element.rs:536, :667). One is selection_binding.rs:79, inside clear_stale_drop_highlight_state — the twin of the very function this post opened with, in the same file as the logging call quoted just above.
Three of the twenty-eight are worth singling out, because they look like the Drop cases and are not. drag_controller.rs:198, :381 and :386 are also remove_event_listener calls, but they sit in rollback paths in a constructor. When adding the second or third capture-phase listener fails, the code removes the ones it already attached and returns Err (:193-204, :376-392). If that removal fails and is dropped, a capture-phase listener stays attached to the iframe document while its Closure is freed — which is the exact failure the same codebase documents one file over (selection_binding.rs:260-262): "Without this, re-binding the preview document (on each iframe load) leaked listeners, so a single click fired mousedown/selection twice (or more)." A silent let _ = on an error path guarding against a known bug is a different animal from one on a CSS class.
I am not going to pretend all twenty-eight are bugs. Losing the result of class_list().remove_1("ring-2") on a drag highlight costs a stale outline, and a log line per class removal would be worse than the problem. But let _ = draws the line between "cosmetic, let it go" and "the mutation this feature is made of" by hand, silently, at every call site — in an environment where being wrong produces no output at all. The instrumentation is not exempt either: emit_preview_debug below drops the result of its own post_message (responsive_preview.rs:462).
If you take one rule from this: in cross-frame WASM code, an ignored Result should carry a comment saying what you are content to lose. let _ = on its own cannot tell you, later, whether you decided or forgot.
Instrument the boundary itself, not the browser. This repo's rules open with "there is no way to attach a Rust debugger to running WASM" (CLAUDE.md:69), and I have not found one either. Its approach is a small helper that logs to the console and posts the same line to the parent window (responsive_preview.rs:453-466), so a cross-frame sequence can be read in one place:
pub(super) fn emit_preview_debug(stage: &str, detail: &str) {
use wasm_bindgen::JsValue;
let message = preview_debug_message(stage, detail);
web_sys::console::log_1(&JsValue::from_str(&message));
if let Some(window) = web_sys::window() {
if let Ok(Some(parent)) = window.parent() {
if let Some(target_origin) = current_window_origin() {
let _ = parent.post_message(&JsValue::from_str(&message), &target_origin);
}
}
}
}
One anti-pattern, written into the same rules after it cost someone here a session (CLAUDE.md:75): do not install defineProperty overrides on DOM prototypes to intercept what WASM is calling. The repo's account is that this "causes infinite recursion in the WASM binding layer, which handleError catches silently, making it appear the operation was never attempted" — i.e. the instrument fails in exactly the same silent way as the bug you are chasing. That one is the repo's finding, not something I re-derived from the glue.
The short version
dyn_into::<T>()across an iframe boundary returnsErr, because theinstanceofbehind it names a class from the caller's realm.- It returns
Err— it does not panic.let ... else { continue }turns that into a loop that does nothing. - Method calls and property reads cross realms fine — two reasons, and you need both: the glue names no class from its own realm (read off the glue), and the DOM's own receiver check is a WebIDL brand check on the platform object's interface type rather than on a realm's constructor (taken as given; see the appendix). That asymmetry is why the bug looks impossible.
unchecked_intois the fix when the value came from a query that already guarantees the type —querySelectorAll, not "aNodeList":childNodeshas the same Rust type and yields text nodes. Write the guarantee down next to the cast.- Need a real check?
node_type()is a property read, so it works cross-realm. - In Leptos 0.8, a
<Suspense>enclosing an iframe detaches it whenever a descendant resource goes pending (that part is read off the source), and detaching an iframe destroys its document (that part I take as given, see the note in the body). Use<Transition>. Do not carry this rule to another framework's<Suspense>without reading it — I did not read React's, and its documentation describes the opposite behaviour. - Every fallible DOM call from WASM returns its exception as a value. If you drop it, the failure is silent.
How the claims here were checked
These are source readings, not measurements. No build was run while writing this, and there are no benchmarks or timings anywhere in it — deliberately.
- Rust semantics of
dyn_into/unchecked_into/is_type_of: read inwasm-bindgen-0.2.121/src/cast.rs(the version this repo locks). - The
instanceof,handleError,removeAttribute,setAttributeandclassListglue: read in this repo's generated output,demo/pkg/editor_demo.js, produced by wasm-bindgen 0.2.121. - web-sys return types and
Resultsignatures: read inweb-sys-0.3.98/src/features/gen_NodeList.rs,gen_Document.rs,gen_Node.rs,gen_DomTokenList.rsandgen_Element.rs. - Where a resource registers with a suspense boundary: read in
reactive_graph-0.2.14/src/computed/async_derived/arc_async_derived.rs— it istry_read_untracked, i.e. on read, which is why the body says "read" and not "create". - The
nodeTypeandinsertBeforeimports (editor_demo.js:487-490,:373): read in the same generated bundle as the others, with the provenance caveat above — the Rust is the CMS crate, the glue is the demo crate's. - The
<Suspense>detach: read inleptos-0.8.20/src/suspense_component.rs(show_b, and the state type that fixes which branch is the fallback) andtachys-0.2.18/src/view/either.rs,src/html/element/mod.rs,src/renderer/dom.rs. This replaced an earlier draft that had the causality backwards — it blamed the re-insertion rather than the detach. - The attribute diffing that makes a same-value
srcre-notify a no-op: read intachys-0.2.18/src/html/attribute/value.rs(theStringimpl) andsrc/reactive_graph/mod.rs. The repo comment quoted in that section states a different mechanism, and the code contradicts it. - The
let _ =count:grep -rn "let _ = " cms/src/ui/components/responsive_preview/, classified by hand against each call's web-sys signature — one tuple binding is not aResultat all, five are listener removals insideDropimpls, and the remaining twenty-eight are discarded DOMResults. Three of those twenty-eight areremove_event_listenercalls too, but in constructor rollback paths rather than inDrop; the body says why that distinction matters. - Every Rust snippet: quoted verbatim from the file, with the line range cited beside it and no silent elisions.
- Taken as given rather than cited, in one place so you can weigh them together: (a) assigning
srcreloads an iframe even when the string is unchanged; (b) detaching an iframe discards its nested browsing context, so re-attaching loads a fresh document; (c)querySelectorAllyields only element nodes; (d) WebIDL brand checks test the platform object's interface type rather than a realm's constructor. Each is load-bearing somewhere above. None was verified against a specification document on this machine. - React's re-suspension behaviour: not verified at all. No React source or documentation was read while writing this; the sentence about it exists to stop you carrying a Leptos-specific rule to a framework where it is wrong, and it is the one comparison here you should check yourself before repeating.
- The DevTools call stack and the rest of that signature: taken from the debugging notes already in this repo's CLAUDE.md, not re-observed while writing this. What was re-checked is that the fix those notes prescribe (
<Transition>) is whatsite_layout.rs:162does today. - The claim that realm-crossing property access "works": two premises, held to different standards. That the binding layer adds no realm-sensitivity is read off the generated glue, in which no realm-bound identifier appears. That the DOM's own receiver check passes — WebIDL brand checks testing the platform object's interface type rather than the caller realm's constructor — is asserted from how the platform is specified, not verified against a document on this machine. Consistent with the shipping code calling
remove_attributeandclass_liston iframe elements; the browser experiment was not re-run for this post.
This came out of building an embeddable email editor. The editor renders the site you are editing inside a same-origin iframe, which is why any of the above came up at all — the whole product is one frame talking to another.
Demo: the editor itself, which is the thing this post is about.