Skip to main content

autumn_plugin_react/
update.rs

1//! [`PropsUpdate`]: new island props from the server, without a remount.
2
3use std::convert::Infallible;
4
5use autumn_web::reexports::axum::response::{IntoResponseParts, ResponseParts};
6use autumn_web::reexports::http::{HeaderMap, HeaderName, HeaderValue};
7use serde::Serialize;
8
9use crate::island::{PropsError, props_json};
10
11/// The DOM event that the loader reads new props from.
12pub const PROPS_EVENT: &str = "autumn:react:props";
13
14const HX_TRIGGER: &str = "hx-trigger";
15const HX_TRIGGER_AFTER_SETTLE: &str = "hx-trigger-after-settle";
16
17/// New props for mounted islands, sent in an `HX-Trigger` header.
18///
19/// htmx sends the [`PROPS_EVENT`] event. The loader sets `data-react-props`
20/// on the first element that matches each selector. React renders the same
21/// root again, so the component keeps its state.
22///
23/// ```rust
24/// use autumn_plugin_react::PropsUpdate;
25/// use autumn_web::prelude::*;
26///
27/// #[post("/cart")]
28/// async fn add_to_cart() -> AutumnResult<(PropsUpdate, Markup)> {
29///     let update = PropsUpdate::new().set("#cart", &serde_json::json!({ "count": 3 }))?;
30///     Ok((update, html! { p { "Added." } }))
31/// }
32/// ```
33///
34/// - htmx handles `HX-Trigger` before the swap. To update an island that
35///   the same response swaps in, use [`after_settle`](Self::after_settle).
36/// - The update merges with a trigger header that the response already
37///   has. A header that later code sets with `insert` replaces it. Then
38///   call [`apply_to`](Self::apply_to) last.
39/// - Props travel in a response header. Keep them small: proxies limit
40///   header size (nginx: 4 KB to 8 KB by default).
41/// - Build selectors from trusted values. A selector from user input can
42///   select a different island.
43///
44/// An empty update sends no header.
45#[derive(Debug, Clone, Default)]
46#[must_use]
47pub struct PropsUpdate {
48    /// `(selector, props JSON)` pairs, in call order.
49    entries: Vec<(String, String)>,
50    after_settle: bool,
51}
52
53impl PropsUpdate {
54    /// Makes an empty update.
55    pub const fn new() -> Self {
56        Self {
57            entries: Vec::new(),
58            after_settle: false,
59        }
60    }
61
62    /// Adds new props for the island at the CSS `selector`.
63    ///
64    /// # Errors
65    ///
66    /// The same errors as [`Island::props`](crate::Island::props).
67    pub fn set<T: Serialize + ?Sized>(
68        mut self,
69        selector: impl Into<String>,
70        props: &T,
71    ) -> Result<Self, PropsError> {
72        self.entries.push((selector.into(), props_json(props)?));
73        Ok(self)
74    }
75
76    /// Sends the update in `HX-Trigger-After-Settle`. htmx then sends the
77    /// event after the swap, so the update can reach a new island.
78    pub const fn after_settle(mut self) -> Self {
79        self.after_settle = true;
80        self
81    }
82
83    /// The header that carries the update: `hx-trigger`, or
84    /// `hx-trigger-after-settle`.
85    #[must_use]
86    pub const fn header_name(&self) -> &'static str {
87        if self.after_settle {
88            HX_TRIGGER_AFTER_SETTLE
89        } else {
90            HX_TRIGGER
91        }
92    }
93
94    /// The number of updates.
95    #[must_use]
96    pub const fn len(&self) -> usize {
97        self.entries.len()
98    }
99
100    /// `true` when the update has no entries.
101    #[must_use]
102    pub const fn is_empty(&self) -> bool {
103        self.entries.is_empty()
104    }
105
106    /// The header value:
107    /// `{"autumn:react:props":[{"target":"#cart","props":{…}}]}`.
108    ///
109    /// The text is visible ASCII. Other characters become `\uXXXX`
110    /// escapes, because a header value cannot hold them.
111    #[must_use]
112    pub fn to_header_value(&self) -> String {
113        self.merged_value(&serde_json::Map::new(), &[])
114    }
115
116    /// Writes the update into `headers`. It keeps the events that the
117    /// header already has: a JSON object, or a comma list of event names.
118    /// It replaces a value that is neither.
119    pub fn apply_to(&self, headers: &mut HeaderMap) {
120        if self.is_empty() {
121            return;
122        }
123        let name = HeaderName::from_static(self.header_name());
124        let (events, earlier) = headers
125            .get(&name)
126            .and_then(|value| value.to_str().ok())
127            .map(existing_events)
128            .unwrap_or_default();
129        // `ascii_json` makes the value valid, so `from_str` cannot fail.
130        if let Ok(value) = HeaderValue::from_str(&self.merged_value(&events, &earlier)) {
131            headers.insert(name, value);
132        }
133    }
134
135    /// The header value with other `events` and `earlier` updates first.
136    fn merged_value(
137        &self,
138        events: &serde_json::Map<String, serde_json::Value>,
139        earlier: &[serde_json::Value],
140    ) -> String {
141        let mut json = String::from("{");
142        for (event, detail) in events {
143            json.push_str(&serde_json::Value::String(event.clone()).to_string());
144            json.push(':');
145            json.push_str(&detail.to_string());
146            json.push(',');
147        }
148        json.push('"');
149        json.push_str(PROPS_EVENT);
150        json.push_str("\":[");
151        let mut first = true;
152        for value in earlier {
153            if !first {
154                json.push(',');
155            }
156            first = false;
157            json.push_str(&value.to_string());
158        }
159        for (selector, props) in &self.entries {
160            if !first {
161                json.push(',');
162            }
163            first = false;
164            json.push_str("{\"target\":");
165            json.push_str(&serde_json::Value::String(selector.clone()).to_string());
166            json.push_str(",\"props\":");
167            json.push_str(props);
168            json.push('}');
169        }
170        json.push_str("]}");
171        ascii_json(&json)
172    }
173}
174
175/// Splits an existing trigger header into other events and earlier props
176/// updates. A value that is neither JSON nor an event list gives nothing.
177fn existing_events(
178    value: &str,
179) -> (
180    serde_json::Map<String, serde_json::Value>,
181    Vec<serde_json::Value>,
182) {
183    let value = value.trim();
184    let mut events = if value.starts_with('{') {
185        match serde_json::from_str(value) {
186            Ok(serde_json::Value::Object(map)) => map,
187            _ => serde_json::Map::new(),
188        }
189    } else {
190        value
191            .split(',')
192            .map(str::trim)
193            .filter(|event| !event.is_empty())
194            .map(|event| (event.to_owned(), serde_json::Value::Null))
195            .collect()
196    };
197    let earlier = match events.remove(PROPS_EVENT) {
198        Some(serde_json::Value::Array(list)) => list,
199        _ => Vec::new(),
200    };
201    (events, earlier)
202}
203
204const HEX: [char; 16] = [
205    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
206];
207
208fn push_escape(out: &mut String, c: char) {
209    for unit in c.encode_utf16(&mut [0; 2]) {
210        out.push_str("\\u");
211        for shift in [12, 8, 4, 0] {
212            out.push(HEX[usize::from((*unit >> shift) & 0xf)]);
213        }
214    }
215}
216
217/// Makes compact, visible-ASCII JSON from valid JSON text.
218///
219/// Outside strings it drops whitespace (raw JSON can keep newlines). Inside
220/// strings it escapes each character outside visible ASCII as `\uXXXX`.
221/// The JSON value does not change.
222fn ascii_json(json: &str) -> String {
223    let mut out = String::with_capacity(json.len());
224    let mut in_string = false;
225    let mut escaped = false;
226    for c in json.chars() {
227        if in_string {
228            if escaped {
229                escaped = false;
230            } else if c == '\\' {
231                escaped = true;
232            } else if c == '"' {
233                in_string = false;
234            }
235            if (' '..='~').contains(&c) {
236                out.push(c);
237            } else {
238                push_escape(&mut out, c);
239            }
240        } else if c == '"' {
241            in_string = true;
242            out.push(c);
243        } else if !c.is_ascii_whitespace() {
244            out.push(c);
245        }
246    }
247    out
248}
249
250impl IntoResponseParts for PropsUpdate {
251    type Error = Infallible;
252
253    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
254        self.apply_to(res.headers_mut());
255        Ok(res)
256    }
257}