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}