Skip to main content

adam_web_ui/graph/
view.rs

1//! [`GraphView`] — renders the D3 force graph inside a `<div>`.
2//!
3//! Mount [`GraphView`] with a container id, graph-data signal and source id.
4//! Ordinary data updates preserve layout. Source/container changes retire the
5//! old evaluation channel and initialize a fresh graph; unmount cancels both
6//! Rust reception and JS polling/subscriptions. The shared component owns this
7//! lifecycle on desktop and web, independently of any Begin-only integration.
8//! D3 submits geometry and absolute gesture proposals; only Adam-approved
9//! frames reach the visible zoom layer.
10
11use std::{
12    cell::RefCell,
13    future::{Future, poll_fn},
14    pin::Pin,
15    rc::Rc,
16    task::Poll,
17};
18
19use dioxus::core::Task;
20use dioxus::prelude::*;
21
22use super::{BridgeState, data::GraphData};
23
24/// Owns the Rust receiver and its matching cancellable JavaScript evaluation.
25struct ChannelSession {
26    /// An identity retained by one-shot updates to reject delayed old failures.
27    identity: Rc<()>,
28    /// The live bidirectional evaluation.
29    eval: document::Eval,
30    /// The component-scoped Rust receiver.
31    task: Task,
32}
33
34/// Returns whether a one-shot update still belongs to the current channel owner.
35fn same_owner(current: Option<&Rc<()>>, expected: Option<&Rc<()>>) -> bool {
36    match (current, expected) {
37        (Some(current), Some(expected)) => Rc::ptr_eq(current, expected),
38        (None, None) => true,
39        _ => false,
40    }
41}
42
43/// Selects a message before channel completion when both are ready.
44///
45/// A pending message never hides a completed/failed evaluation. This preserves
46/// queued diagnostics while preventing a broken JS sender from hanging Rust.
47async fn prefer_message<M: Future, E: Future>(
48    message: M,
49    mut ended: Pin<&mut E>,
50) -> Result<M::Output, E::Output> {
51    let mut message = std::pin::pin!(message);
52    poll_fn(|context| {
53        if let Poll::Ready(message) = message.as_mut().poll(context) {
54            return Poll::Ready(Ok(message));
55        }
56        ended.as_mut().poll(context).map(Err)
57    })
58    .await
59}
60
61impl ChannelSession {
62    /// Closes JS polling/subscriptions/resources before aborting the Rust receiver.
63    fn stop(self) {
64        let _ = self.eval.send(serde_json::json!({ "kind": "close" }));
65        self.task.cancel();
66    }
67}
68
69/// Embeds the tested shared channel runtime and safely encoded initial arguments.
70///
71/// - Complexity: O(n) time and space in serialized graph data.
72fn graph_channel_script(container_id: &str, data: &GraphData, external_container: bool) -> String {
73    let id = serde_json::to_string(container_id).expect("strings serialize");
74    let data = serde_json::to_string(data).expect("graph data serializes");
75    format!(
76        "{}\nawait runGraphChannel(window, {id}, {data}, dioxus, {external_container});",
77        include_str!("channel.js")
78    )
79}
80
81/// Reports a terminal channel failure and requests JS resource disposal.
82fn channel_failed(eval: document::Eval, mut failure: Signal<Option<String>>, message: String) {
83    crate::diagnostics::report_error(&message);
84    failure.set(Some(message.clone()));
85    let _ = eval.send(serde_json::json!({ "kind": "fatal", "message": message }));
86}
87
88/// Processes typed messages sequentially through the generation's cached model.
89async fn receive_views(mut eval: document::Eval, failure: Signal<Option<String>>) {
90    let mut bridge = BridgeState::default();
91    let mut ended = std::pin::pin!(eval.join::<serde_json::Value>());
92    loop {
93        let result = async {
94            let payload =
95                match prefer_message(eval.recv::<serde_json::Value>(), ended.as_mut()).await {
96                    Ok(message) => message?,
97                    Err(completion) => {
98                        completion?;
99                        anyhow::bail!("graph channel ended unexpectedly");
100                    }
101                };
102            if let Some(reply) = bridge.receive(payload)? {
103                eval.send(reply)?;
104            }
105            Ok::<(), anyhow::Error>(())
106        }
107        .await;
108        if let Err(error) = result {
109            channel_failed(eval, failure, format!("Graph view failed: {error}"));
110            return;
111        }
112    }
113}
114
115/// Which `window.beginGraph` driver method a [`graph_drive_script`] invokes.
116#[derive(Clone, Copy, PartialEq, Eq, Debug)]
117pub enum GraphDrive {
118    /// `window.beginGraph.init` — build a fresh D3 instance for the container, replacing any
119    /// existing one (a new sheet, or the first mount).
120    Init,
121    /// `window.beginGraph.update` — push new data into the container's existing instance.
122    Update,
123}
124
125impl GraphDrive {
126    /// The `window.beginGraph` method name this variant invokes.
127    fn method(self) -> &'static str {
128        match self {
129            GraphDrive::Init => "init",
130            GraphDrive::Update => "update",
131        }
132    }
133}
134
135/// Builds the JavaScript that stores `data` (serialized) as container `container_id`'s entry in
136/// `window.__beginGraphData` and invokes the [`GraphDrive`] method `call` on `window.beginGraph`
137/// when the driver script (`begin/assets/graph.js`) is loaded.
138///
139/// `container_id` is embedded as a JSON-encoded JS string literal, so any characters it contains
140/// (quotes, backslashes, newlines) are escaped rather than breaking or injecting into the emitted
141/// JS — `graph_id` is a public [`GraphView`] prop, so the id is not assumed to be a bare DOM id.
142/// The invoked method name comes from the [`GraphDrive`] enum, never a free string, so it can
143/// never be an arbitrary (injecting) value.
144///
145/// - Complexity: O(n) in the size of `data` (serialization plus string formatting).
146pub fn graph_drive_script(container_id: &str, data: &GraphData, call: GraphDrive) -> String {
147    let id = serde_json::to_string(container_id).unwrap_or_else(|_| "\"\"".to_string());
148    // Fall back to a valid JS literal (`null`), never an empty string, so the emitted assignment
149    // stays syntactically valid even in the (unreachable for `GraphData`) serialization-failure case.
150    let json = serde_json::to_string(data).unwrap_or_else(|_| "null".to_string());
151    let method = call.method();
152    format!(
153        "window.__beginGraphData = window.__beginGraphData || Object.create(null); \
154         window.__beginGraphData[{id}] = {json}; \
155         if (typeof window.beginGraph !== 'undefined') \
156         window.beginGraph.{method}({id}, window.__beginGraphData[{id}]); \
157         dioxus.close(); return null;"
158    )
159}
160
161/// Renders the property model bipartite graph using D3.
162///
163/// Installs a container-scoped bidirectional evaluation channel before D3
164/// initialization on desktop and web (including book hosts). Each generation
165/// owns one cached Adam model. Source/container replacement and unmount close
166/// JavaScript subscriptions/polling and abort the Rust receiver. Errors are
167/// reported through the platform diagnostics channel and a visible alert;
168/// no JavaScript view policy substitutes for a failed model.
169///
170/// With `external_container`, the host supplies an existing container outside
171/// this Dioxus root (as mdBook does). This component renders only its lifecycle
172/// anchor and fallback diagnostic, never a second element with the same id.
173/// The shared channel also displays failures inside that external container.
174///
175/// The zoom controls and the "Show inactive" toggle live in `App`'s top bar
176/// (not here) — they only ever call `window.beginGraph.*`/set a signal `App`
177/// owns, so they don't need to be inside this component to work.
178///
179/// # Examples
180///
181/// ```
182/// use adam_web_ui::{GraphData, GraphView};
183/// use dioxus::prelude::*;
184///
185/// fn graph(id: ReadSignal<String>, data: ReadSignal<GraphData>,
186///          source: ReadSignal<String>) -> Element {
187///     rsx! { GraphView { graph_id: id, data, source_id: source } }
188/// }
189/// ```
190#[component]
191pub fn GraphView(
192    graph_id: ReadSignal<String>,
193    data: ReadSignal<GraphData>,
194    source_id: ReadSignal<String>,
195    #[props(default = false)] external_container: bool,
196) -> Element {
197    let container_id = graph_id.read().clone();
198    let mut mounted = use_signal(|| false);
199    let mut failure = use_signal(|| None::<String>);
200    let session = use_hook(|| Rc::new(RefCell::new(None::<ChannelSession>)));
201    let cleanup = session.clone();
202    use_drop(move || {
203        if let Some(session) = cleanup.borrow_mut().take() {
204            session.stop();
205        }
206    });
207
208    let update_session = session.clone();
209    use_effect(move || {
210        let id = graph_id.read().clone();
211        let _source = source_id.read();
212        if !mounted() {
213            return;
214        }
215        if let Some(previous) = session.borrow_mut().take() {
216            previous.stop();
217        }
218        failure.set(None);
219        let eval = document::eval(&graph_channel_script(&id, &data.peek(), external_container));
220        let task = spawn(receive_views(eval, failure));
221        *session.borrow_mut() = Some(ChannelSession {
222            identity: Rc::new(()),
223            eval,
224            task,
225        });
226    });
227
228    use_effect(move || {
229        let id = graph_id.read().clone();
230        let script = graph_drive_script(&id, &data.read(), GraphDrive::Update);
231        let update_session = update_session.clone();
232        let identity = update_session
233            .borrow()
234            .as_ref()
235            .map(|session| session.identity.clone());
236        spawn(async move {
237            if let Err(error) = document::eval(&script).await {
238                let current = update_session.borrow();
239                if !same_owner(
240                    current.as_ref().map(|session| &session.identity),
241                    identity.as_ref(),
242                ) {
243                    return;
244                }
245                let message = format!("Graph update failed: {error}");
246                if let Some(session) = current.as_ref() {
247                    channel_failed(session.eval, failure, message);
248                } else {
249                    crate::diagnostics::report_error(&message);
250                    failure.set(Some(message));
251                }
252            }
253        });
254    });
255
256    rsx! {
257        div {
258            id: (!external_container).then_some(container_id),
259            class: if external_container { "graph-channel-owner" } else { "graph-view" },
260            onmounted: move |_| mounted.set(true),
261            if let Some(message) = failure() {
262                p { class: "graph-failure", role: "alert", "{message}" }
263            }
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    /// External book containers retain their DOM identity while sharing the channel owner.
273    #[test]
274    fn external_graph_host_does_not_steal_existing_container() {
275        /// Renders a lifecycle owner for a container outside this Dioxus root.
276        fn external_host() -> Element {
277            let id = use_signal(|| "book-graph".to_string());
278            let data = use_signal(empty_graph_data);
279            let source = use_signal(|| "book-source".to_string());
280            rsx! { GraphView { graph_id: id, data, source_id: source, external_container: true } }
281        }
282        let mut dom = VirtualDom::new(external_host);
283        let mutations = dom.rebuild_to_vec();
284        assert!(!mutations.edits.iter().any(|edit| matches!(
285            edit,
286            dioxus::core::Mutation::SetAttribute {
287                name: "id",
288                value: dioxus::core::AttributeValue::Text(_),
289                ..
290            }
291        )));
292    }
293
294    /// A replaced channel's one-shot evaluation failure cannot affect its successor.
295    #[test]
296    fn delayed_update_error_requires_the_same_channel_owner() {
297        let old = Rc::new(());
298        let new = Rc::new(());
299        assert!(same_owner(Some(&old), Some(&old)));
300        assert!(!same_owner(Some(&new), Some(&old)));
301        assert!(!same_owner(None, Some(&old)));
302        assert!(!same_owner(Some(&new), None));
303        assert!(same_owner(None, None));
304    }
305
306    /// Queued diagnostics win a simultaneous channel completion; closed channels cannot hang.
307    #[test]
308    fn receiver_prioritizes_messages_and_detects_completion() {
309        use std::{
310            future::{pending, ready},
311            task::{Context, Poll, Waker},
312        };
313        let mut context = Context::from_waker(Waker::noop());
314        let mut completed = std::pin::pin!(ready("closed"));
315        let mut message = std::pin::pin!(prefer_message(ready("diagnostic"), completed.as_mut()));
316        assert_eq!(
317            message.as_mut().poll(&mut context),
318            Poll::Ready(Ok("diagnostic"))
319        );
320        let mut completed = std::pin::pin!(ready("closed"));
321        let mut message = std::pin::pin!(prefer_message(pending::<()>(), completed.as_mut()));
322        assert_eq!(
323            message.as_mut().poll(&mut context),
324            Poll::Ready(Err("closed"))
325        );
326        let mut completed = std::pin::pin!(pending::<()>());
327        let mut message = std::pin::pin!(prefer_message(pending::<()>(), completed.as_mut()));
328        assert_eq!(message.as_mut().poll(&mut context), Poll::Pending);
329    }
330    /// The mounted channel script starts its receiver before graph initialization.
331    #[test]
332    fn channel_script_embeds_shared_runtime_and_escapes_ids() {
333        let script = graph_channel_script("a\"b", &empty_graph_data(), false);
334        assert!(script.contains("async function runGraphChannel"));
335        assert!(script.contains(r#"await runGraphChannel(window, "a\"b""#));
336    }
337    fn empty_graph_data() -> GraphData {
338        GraphData {
339            nodes: vec![],
340            links: vec![],
341            changed: vec![],
342            forced: vec![],
343            forced_relationships: vec![],
344        }
345    }
346
347    #[test]
348    fn graph_drive_script_init_calls_begin_graph_init() {
349        let script = graph_drive_script("g1", &empty_graph_data(), GraphDrive::Init);
350        // The id is embedded as a JSON string literal (double-quoted).
351        assert!(script.contains("window.beginGraph.init(\"g1\""));
352        assert!(script.contains("window.__beginGraphData[\"g1\"]"));
353    }
354
355    #[test]
356    fn graph_drive_script_update_calls_begin_graph_update() {
357        let script = graph_drive_script("g1", &empty_graph_data(), GraphDrive::Update);
358        assert!(script.contains("window.beginGraph.update(\"g1\""));
359    }
360
361    #[test]
362    fn graph_drive_script_guards_on_begin_graph_being_defined() {
363        let script = graph_drive_script("g1", &empty_graph_data(), GraphDrive::Init);
364        assert!(script.contains("typeof window.beginGraph !== 'undefined'"));
365    }
366
367    /// The one-shot evaluation closes its transport and returns a JSON value on web.
368    #[test]
369    fn graph_drive_script_has_explicit_serializable_completion() {
370        let script = graph_drive_script("g1", &empty_graph_data(), GraphDrive::Update);
371        assert!(script.ends_with("dioxus.close(); return null;"));
372    }
373
374    #[test]
375    fn graph_drive_script_embeds_the_serialized_data() {
376        let script = graph_drive_script("g1", &empty_graph_data(), GraphDrive::Init);
377        // GraphData serializes its fields; `nodes` is always present.
378        assert!(script.contains("\"nodes\""));
379    }
380
381    #[test]
382    fn graph_drive_script_escapes_a_quote_in_the_container_id() {
383        // A container id containing a single quote must not break out of the JS string or
384        // inject: JSON-encoding turns `'` into a plain character inside a double-quoted literal,
385        // and there must be no raw `'g'` string-open left in the output.
386        let script = graph_drive_script("a'b", &empty_graph_data(), GraphDrive::Init);
387        assert!(script.contains("window.beginGraph.init(\"a'b\""));
388        assert!(!script.contains("['a'b']"));
389    }
390
391    #[test]
392    fn graph_drive_script_escapes_a_double_quote_and_backslash_in_the_container_id() {
393        // JSON-encoding escapes `"` and `\`, so the emitted literal stays a single well-formed
394        // JS string rather than terminating early.
395        let script = graph_drive_script("a\"b\\c", &empty_graph_data(), GraphDrive::Init);
396        assert!(script.contains(r#"window.beginGraph.init("a\"b\\c""#));
397    }
398}