beck_core/
render.rs

1//! Where a component renders — the Mode A / Mode B decision, and what it costs to be wrong.
2//!
3//! [`docs/05-tier-lowering.md`](../../../../../docs/05-tier-lowering.md) §5.1 gives two rendering
4//! modes over one source:
5//!
6//! | | **Mode A — thin** | **Mode B — local** |
7//! |---|---|---|
8//! | `view` runs on | server | client |
9//! | Wire carries | DOM patches | data patches (state diffs) |
10//! | Optimistic UI | no | yes — the same fold runs locally, reconciled by `seq` |
11//!
12//! The row that decides everything is the second one. Mode A sends the browser a *rendering* of
13//! the state; Mode B sends it the **state**. Everything below follows from taking that literally.
14//!
15//! # The rule: a Mode B page may not be a function of who is asking
16//!
17//! A view has the shape `(state, session) -> Html`. If it reads the session, it renders a
18//! different page for different actors *from the same state* — which is to say it is filtering,
19//! scoping or hiding by identity. Running that view on the client requires giving the client the
20//! state it filters, so every actor receives what the filter was removing. The page would still
21//! look right. That is the worst kind of wrong.
22//!
23//! So a component whose view is per-session is refused Mode B, and the refusal names the reason
24//! rather than a rule. What is left is exactly the class §5.1 and
25//! [`docs/10-decisions.md`](../../../../../docs/10-decisions.md) D5 describe as Mode B's: pages
26//! that are the same function of the same state for everybody — editors, typeaheads, drag-and-drop,
27//! anything single-user or public.
28//!
29//! This is a *placement* rule and not a lint: it is decided from the slicer's own account of the
30//! view ([`crate::split::Roles::view_is_per_session`]), which is the same fact §3.8's fanout
31//! analysis reads, so a program cannot be per-session for one of them and not the other.
32//!
33//! # Optimism is a property of what crosses, not of a component
34//!
35//! "The browser applies the expected event to its local copy speculatively — legitimate because it
36//! runs the *same pure fold* the server runs" (D5). That is only available to a client that holds
37//! the value the fold is *of*. A client holding a projection — a session's filtered list, say —
38//! could not apply an event to it without a second, different fold that no program writes. So
39//! optimism is not an extra feature layered on Mode B; it is the same fact stated twice, and this
40//! module reports it as one decision with two consequences.
41
42use std::sync::Arc;
43
44use beck_diag::{Diagnostic, Diagnostics, Span};
45
46use crate::ty::Ty;
47
48/// Where a component's `view` runs.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum Mode {
51    /// Mode A: the server renders and the wire carries DOM patches. The default.
52    Server,
53    /// Mode B: the browser renders and the wire carries data patches.
54    Client,
55}
56
57impl Mode {
58    pub fn name(self) -> &'static str {
59        match self {
60            Mode::Server => "server",
61            Mode::Client => "client",
62        }
63    }
64
65    /// The mode's letter, for a report and for the wire.
66    pub fn letter(self) -> &'static str {
67        match self {
68            Mode::Server => "A",
69            Mode::Client => "B",
70        }
71    }
72
73    pub fn parse(s: &str) -> Option<Mode> {
74        match s {
75            "server" => Some(Mode::Server),
76            "client" => Some(Mode::Client),
77            _ => None,
78        }
79    }
80}
81
82/// Why a component renders where it does.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum Why {
85    /// Nothing said otherwise. §5.1: "**v0.1 ships Mode A only**", and Mode A stays the default
86    /// because it is the mode that ships no application code to the browser at all.
87    Default,
88    /// `@render(client)` or `@render(server)`.
89    Declared,
90}
91
92/// Why a Mode B client may not guess.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum NoOptimism {
95    /// The component renders on the server, so there is no local copy to guess about.
96    ModeA,
97    /// A library, or a program with no chokepoint: nothing to propose, so nothing to guess.
98    NotAnApplication,
99}
100
101/// How one component renders, and what follows from it.
102#[derive(Clone, Debug)]
103pub struct Decision {
104    pub component: Arc<str>,
105    pub mode: Mode,
106    pub why: Why,
107    /// What crosses to the browser: `Html` in Mode A, the fold's accumulator in Mode B.
108    pub carries: Ty,
109    /// Whether the client may apply a command speculatively before the server answers.
110    pub optimistic: bool,
111    pub no_optimism: Option<NoOptimism>,
112    /// True when the view reads the session — the fact that decides eligibility.
113    pub per_session: bool,
114    /// True when the view reads `presence()`, which is the second fact that decides it.
115    pub reads_presence: bool,
116    /// Where the component is declared, for a diagnostic.
117    pub span: Span,
118}
119
120impl Decision {
121    /// The decision for one component: what it declared, and what follows.
122    ///
123    /// Takes the roles rather than the [`crate::split::Placed`] they end up in because this is what decides a
124    /// field of that struct — and takes the declaration as an argument so that
125    /// `beck explain render`, `beck build` and the checker cannot disagree about where it came
126    /// from.
127    pub fn of(
128        roles: &crate::split::Roles,
129        is_application: bool,
130        declared: Option<(Mode, Span)>,
131        span: Span,
132    ) -> Decision {
133        let mode = declared.map_or(Mode::Server, |(m, _)| m);
134        let application = is_application;
135        let (optimistic, no_optimism) = match (mode, application) {
136            (Mode::Server, _) => (false, Some(NoOptimism::ModeA)),
137            (Mode::Client, false) => (false, Some(NoOptimism::NotAnApplication)),
138            (Mode::Client, true) => (true, None),
139        };
140        Decision {
141            component: roles.page_name.clone(),
142            mode,
143            why: if declared.is_some() {
144                Why::Declared
145            } else {
146                Why::Default
147            },
148            carries: match mode {
149                Mode::Server => Ty::html(),
150                Mode::Client => roles.state_ty.clone(),
151            },
152            optimistic,
153            no_optimism,
154            per_session: roles.view_is_per_session,
155            reads_presence: roles.view_reads_presence,
156            // A declared mode is refused where it was written; a defaulted one, at the component.
157            span: declared.map_or(span, |(_, s)| s),
158        }
159    }
160
161    /// What `beck explain render` prints: the decision, what it puts on the wire, and what would
162    /// change it.
163    ///
164    /// The counterfactual is the useful half. A reader who wants Mode B and has Mode A needs to
165    /// know whether one annotation would do it or whether the program's shape refuses — and that
166    /// is a question only the compiler can answer, because the answer is `view_is_per_session`.
167    pub fn explain(&self, bundle: &crate::bundle::Bundle) -> String {
168        let mut out = String::new();
169        let line = |out: &mut String, k: &str, v: String| {
170            out.push_str(&format!("{k:<18}{v}\n"));
171        };
172        line(&mut out, "component", self.component.to_string());
173        line(
174            &mut out,
175            "mode",
176            format!(
177                "{} — the {} renders ({})",
178                self.mode.letter(),
179                match self.mode {
180                    Mode::Server => "server",
181                    Mode::Client => "browser",
182                },
183                match self.why {
184                    Why::Declared => format!("declared: `@render({})`", self.mode.name()),
185                    Why::Default => "the default".to_string(),
186                }
187            ),
188        );
189        line(
190            &mut out,
191            "the wire carries",
192            match self.mode {
193                Mode::Server => "Html, as DOM patches".to_string(),
194                Mode::Client => format!("{}, as data patches", self.carries),
195            },
196        );
197        line(
198            &mut out,
199            "optimistic",
200            match self.no_optimism {
201                None => "yes — the client holds the accumulator the fold is of, so it runs the \
202                         same fold locally and reconciles by `seq`"
203                    .to_string(),
204                Some(NoOptimism::ModeA) => {
205                    "no — every interaction is a round trip, because the page is rendered where \
206                     the state is"
207                        .to_string()
208                }
209                Some(NoOptimism::NotAnApplication) => {
210                    "no — this module has no chokepoint, so there is nothing to propose".to_string()
211                }
212            },
213        );
214        if self.mode == Mode::Client {
215            line(
216                &mut out,
217                "bundle",
218                format!(
219                    "{} Core nodes, {} definitions, {} bytes",
220                    bundle.nodes(),
221                    bundle.defs.len(),
222                    bundle.to_bytes().len()
223                ),
224            );
225        }
226        out.push('\n');
227        // The counterfactual is the useful half, so the *reason* a page cannot move has to be the
228        // one that applies. A page reading the roster is refused whatever it does with the session.
229        if self.mode == Mode::Server && self.reads_presence {
230            out.push_str(
231                "This page reads `presence`, so it cannot move to the browser: `@render(client)` \
232                 would be refused (B0516). Who is connected is in neither the accumulator nor the \
233                 log — it is a fact the server holds about its own sockets.\n",
234            );
235            return out;
236        }
237        match (self.mode, self.per_session) {
238            (Mode::Server, false) => out.push_str(
239                "This page is a function of the state alone, so `@render(client)` would move it \
240                 to the browser.\n",
241            ),
242            (Mode::Server, true) => out.push_str(
243                "This page reads the session, so it cannot move to the browser: `@render(client)` \
244                 would be refused (B0514). Mode B sends the state rather than the page, and a page \
245                 that filters by identity is a page whose state is not the client's to hold.\n",
246            ),
247            (Mode::Client, _) => out.push_str(
248                "The browser holds the accumulator and renders from it, so an interaction costs \
249                 no round trip. What it costs instead is the bundle above, once.\n",
250            ),
251        }
252        out
253    }
254
255    /// Refuse a component that may not render where it says it does.
256    ///
257    /// One condition, and the reason there is only one is worth writing down. Mode B puts the
258    /// **accumulator** on the wire, so the obvious second check is §3.5's `Sendable` — and it is
259    /// already discharged: a durable fold's state must be *storable* (`B0411`), storable is
260    /// strictly stronger than sendable ([`crate::secure`]), and the accumulator is what crosses.
261    /// A `secret[T]` therefore cannot reach a Mode B client because it cannot reach the log, and
262    /// a check here would be a second gate on a door that is shut. `mode_b.rs` asserts that
263    /// composition rather than trusting it.
264    pub fn refuse(&self, diags: &mut Diagnostics) {
265        if self.mode != Mode::Client {
266            return;
267        }
268        if self.per_session {
269            diags.push(
270                Diagnostic::error(
271                    "B0514",
272                    format!(
273                        "`{}` renders differently for each session, so it cannot render on the client",
274                        self.component
275                    ),
276                    self.span,
277                )
278                .with_primary_label("`@render(client)` sends the browser the state, not the page")
279                .with_note(
280                    "This page is a function of the session as well as of the state: it filters, \
281                     scopes or hides by identity. A client that rendered it locally would first \
282                     have to be given the state it filters — including everything the filter \
283                     removes.",
284                )
285                .with_fix(
286                    "render this component on the server (the default), or make the page a \
287                     function of the state alone",
288                ),
289            );
290        }
291        if self.reads_presence {
292            diags.push(
293                Diagnostic::error(
294                    "B0516",
295                    format!(
296                        "`{}` reads `presence`, so it cannot render on the client",
297                        self.component
298                    ),
299                    self.span,
300                )
301                .with_primary_label("`@render(client)` sends the browser the accumulator")
302                .with_note(
303                    "Who is connected is not in the accumulator and is not in the log: it is a \
304                     fact the server holds about its own sockets. A browser handed the state \
305                     would have nothing to render this part of the page from, and shipping the \
306                     roster alongside would be a second wire nothing reconciles by `seq`.",
307                )
308                .with_fix(
309                    "render this component on the server (the default), or take `presence` out of \
310                     its page",
311                ),
312            );
313        }
314    }
315}