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 *who* the session is, 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 reads the session's identity is refused Mode B, and the refusal names
24//! the reason 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//! ## Which half of the session, and why the distinction is structural
30//!
31//! `Session` carries three fields and they are not one kind of thing. `actor` and `claims` say
32//! **who** is asking and are what an identity provider verified; `path` says **where** they are and
33//! is the client's own statement about itself. The argument above is entirely about the first pair:
34//! a page that renders by route is not hiding anything from the browser it is running in, because
35//! that browser chose the route and already holds the state.
36//!
37//! So the refusal is decided by [`SessionUse`], which reads the view's own code and asks which
38//! fields of a `Session` it can observe. The coarser fact — whether the page is `per_session` at
39//! all — is still what §3.8's fanout analysis and §5.3's shared cut use, and it is still true of a
40//! page that reads only the route: two people on two routes see two pages, so the operators below
41//! the session are theirs. Eligibility and fanout are different questions, and this is where they
42//! stopped being the same answer.
43//!
44//! # Optimism is a property of what crosses, not of a component
45//!
46//! "The browser applies the expected event to its local copy speculatively — legitimate because it
47//! runs the *same pure fold* the server runs" (D5). That is only available to a client that holds
48//! the value the fold is *of*. A client holding a projection — a session's filtered list, say —
49//! could not apply an event to it without a second, different fold that no program writes. So
50//! optimism is not an extra feature layered on Mode B; it is the same fact stated twice, and this
51//! module reports it as one decision with two consequences.
52//!
53//! # And the rule that points the other way
54//!
55//! §3.7 asks for one more thing of a guess: that the page can *say* it is one. "`Signal[T]` carries
56//! a freshness dimension (`confirmed | pending(n)`) that UI code can render (\"saving…\") —
57//! staleness is typed, not pretended away." `freshness()` is that dimension, and it is the only
58//! thing here a **server** cannot answer: what a server renders is what it has recorded, so its
59//! answer is `Confirmed` at every position of every log. So a page reading it is refused Mode A
60//! (`B0518`) exactly as a page reading `presence` is refused Mode B — the two rules are the same
61//! rule about two facts that live on opposite sides of the wire.
62
63use std::collections::{BTreeMap, BTreeSet};
64use std::sync::Arc;
65
66use beck_diag::{Diagnostic, Diagnostics, Span};
67
68use crate::core::{Core, CoreKind};
69use crate::ty::Ty;
70
71/// The field of a [`Session`](crate::edge::session) that says *where* rather than *who*.
72pub const ROUTE_FIELD: &str = "path";
73
74/// What a view can observe about the `Session` it is handed.
75///
76/// Three verdicts rather than a boolean, because "reads the session" was one word for two facts
77/// and Mode B's refusal only ever meant one of them.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum SessionUse {
80 /// The view never touches its `Session`. §5.1's Mode B class, and what `signal_map` produces.
81 None,
82 /// The view reads the route and nothing else. Eligible for Mode B: the browser chose the route
83 /// and already holds the state, so a page that varies by it discloses nothing.
84 Route,
85 /// The view can observe who is asking — `actor`, `claims`, or the whole record. Refused Mode B.
86 Identity {
87 /// What was read, in the order a message should name it. `"the session itself"` when a
88 /// `Session` reached somewhere this analysis cannot follow it into.
89 what: Vec<Arc<str>>,
90 },
91}
92
93impl SessionUse {
94 pub fn reads_identity(&self) -> bool {
95 matches!(self, SessionUse::Identity { .. })
96 }
97
98 /// What the view reads, for a message and for `beck explain render`.
99 pub fn describe(&self) -> String {
100 match self {
101 SessionUse::None => "nothing".to_string(),
102 SessionUse::Route => format!("`session.{ROUTE_FIELD}`"),
103 SessionUse::Identity { what } => what
104 .iter()
105 .map(|w| w.to_string())
106 .collect::<Vec<_>>()
107 .join(", "),
108 }
109 }
110
111 /// Read the view's code, and every definition it reaches, for what it does with a `Session`.
112 ///
113 /// The rule is one sentence: **a `Session` can only be observed by having a field read off it,
114 /// so collect every field read whose base is `Session`-typed anywhere the view can reach.**
115 /// That is sound without tracking where the value flows, because flow does not create an
116 /// observation — wherever the record ends up, reading it is still a `Field` over a
117 /// `Session`-typed base, and every definition it could end up in is in this closure. What flow
118 /// *could* hide is an observation that is not a field read: an equality, a digest, a session
119 /// stored inside a value that crosses. Those are the `escapes` below, and they are the
120 /// conservative answer rather than an ignored case.
121 ///
122 /// Types make it cheap. A field read needs a concrete record type, so a `Session` passed
123 /// through a generic definition cannot have anything read off it there — the parameter is a
124 /// rigid variable and `x.actor` does not check. There is nowhere for a read to hide.
125 pub fn of(view: &Core, defs: &BTreeMap<Arc<str>, crate::check::Def>) -> SessionUse {
126 let mut found = Found::default();
127 let mut seen: BTreeSet<Arc<str>> = BTreeSet::new();
128 walk(view, defs, &mut seen, &mut found);
129
130 if found.escapes {
131 return SessionUse::Identity {
132 what: vec![Arc::from("the session itself")],
133 };
134 }
135 let identity: Vec<Arc<str>> = found
136 .fields
137 .iter()
138 .filter(|f| f.as_ref() != ROUTE_FIELD)
139 .map(|f| Arc::from(format!("`session.{f}`").as_str()))
140 .collect();
141 if !identity.is_empty() {
142 return SessionUse::Identity { what: identity };
143 }
144 if found.fields.is_empty() {
145 SessionUse::None
146 } else {
147 SessionUse::Route
148 }
149 }
150}
151
152#[derive(Default)]
153struct Found {
154 fields: BTreeSet<Arc<str>>,
155 /// A `Session` reached somewhere a field read is not what happens to it — a primitive, or the
156 /// inside of a constructed value. Neither can be followed, so both are identity.
157 escapes: bool,
158}
159
160fn is_session(ty: &Ty) -> bool {
161 ty.con_name() == Some("Session")
162}
163
164fn walk(
165 code: &Core,
166 defs: &BTreeMap<Arc<str>, crate::check::Def>,
167 seen: &mut BTreeSet<Arc<str>>,
168 found: &mut Found,
169) {
170 match &code.kind {
171 CoreKind::Field { base, name } if is_session(&base.ty) => {
172 found.fields.insert(name.clone());
173 walk(base, defs, seen, found);
174 return;
175 }
176 CoreKind::Global(name) => {
177 if seen.insert(name.clone()) {
178 if let Some(def) = defs.get(name) {
179 walk(&def.body, defs, seen, found);
180 }
181 }
182 return;
183 }
184 // A primitive is opaque: `==`, a digest, anything that consumes the record whole.
185 CoreKind::Prim { args, .. } if args.iter().any(|a| is_session(&a.ty)) => {
186 found.escapes = true;
187 }
188 // A session put *inside* a value goes wherever that value goes, including across the wire.
189 CoreKind::Make { fields, .. } | CoreKind::With { fields, .. }
190 if fields.iter().any(|(_, v)| is_session(&v.ty)) =>
191 {
192 found.escapes = true;
193 }
194 CoreKind::ListLit(items) if items.iter().any(|i| is_session(&i.ty)) => {
195 found.escapes = true;
196 }
197 CoreKind::MapLit(pairs)
198 if pairs
199 .iter()
200 .any(|(k, v)| is_session(&k.ty) || is_session(&v.ty)) =>
201 {
202 found.escapes = true;
203 }
204 _ => {}
205 }
206 for child in crate::core::children(code) {
207 walk(child, defs, seen, found);
208 }
209}
210
211/// Where a component's `view` runs.
212#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub enum Mode {
214 /// Mode A: the server renders and the wire carries DOM patches. The default.
215 Server,
216 /// Mode B: the browser renders and the wire carries data patches.
217 Client,
218}
219
220impl Mode {
221 pub fn name(self) -> &'static str {
222 match self {
223 Mode::Server => "server",
224 Mode::Client => "client",
225 }
226 }
227
228 /// The mode's letter, for a report and for the wire.
229 pub fn letter(self) -> &'static str {
230 match self {
231 Mode::Server => "A",
232 Mode::Client => "B",
233 }
234 }
235
236 pub fn parse(s: &str) -> Option<Mode> {
237 match s {
238 "server" => Some(Mode::Server),
239 "client" => Some(Mode::Client),
240 _ => None,
241 }
242 }
243}
244
245/// Why a component renders where it does.
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub enum Why {
248 /// Nothing said otherwise. §5.1: "**v0.1 ships Mode A only**", and Mode A stays the default
249 /// because it is the mode that ships no application code to the browser at all.
250 Default,
251 /// `@render(client)` or `@render(server)`.
252 Declared,
253}
254
255/// Why a Mode B client may not guess.
256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
257pub enum NoOptimism {
258 /// The component renders on the server, so there is no local copy to guess about.
259 ModeA,
260 /// A library, or a program with no chokepoint: nothing to propose, so nothing to guess.
261 NotAnApplication,
262}
263
264/// How one component renders, and what follows from it.
265#[derive(Clone, Debug)]
266pub struct Decision {
267 pub component: Arc<str>,
268 pub mode: Mode,
269 pub why: Why,
270 /// What crosses to the browser: `Html` in Mode A, the fold's accumulator in Mode B.
271 pub carries: Ty,
272 /// Whether the client may apply a command speculatively before the server answers.
273 pub optimistic: bool,
274 pub no_optimism: Option<NoOptimism>,
275 /// True when the view reads the session at all — §3.8's fanout fact, and §5.3's shared cut.
276 pub per_session: bool,
277 /// What the view can observe about the session. **This** is what decides Mode B eligibility:
278 /// a page may vary by where the browser is and may not vary by who is holding it.
279 pub uses: SessionUse,
280 /// True when the view reads `presence()`, which is the second fact that decides it.
281 pub reads_presence: bool,
282 /// True when the view reads `awareness()`, which decides it for the same reason: a roster with
283 /// a payload is still a fact the server holds about its own sockets.
284 pub reads_awareness: bool,
285 /// True when the view reads `freshness()`. The only condition here that refuses **Mode A**:
286 /// a server renders the state it has recorded, so its answer is `Confirmed` and nothing else,
287 /// and a page that branches on it is a page with a dead branch.
288 pub reads_freshness: bool,
289 /// True when the view reads a `gestures(step, init)` — D30's non-durable fold. The second
290 /// condition that refuses **Mode A**, and for `reads_freshness`'s reason on the other fact: a
291 /// server has received no gestures, so its answer is `init` and nothing else.
292 pub reads_gestures: bool,
293 /// Where the component is declared, for a diagnostic.
294 pub span: Span,
295}
296
297impl Decision {
298 /// The decision for one component: what it declared, and what follows.
299 ///
300 /// Takes the roles rather than the [`crate::split::Placed`] they end up in because this is what decides a
301 /// field of that struct — and takes the declaration as an argument so that
302 /// `beck explain render`, `beck build` and the checker cannot disagree about where it came
303 /// from.
304 pub fn of(
305 roles: &crate::split::Roles,
306 defs: &BTreeMap<Arc<str>, crate::check::Def>,
307 is_application: bool,
308 declared: Option<(Mode, Span)>,
309 span: Span,
310 ) -> Decision {
311 let mode = declared.map_or(Mode::Server, |(m, _)| m);
312 let application = is_application;
313 let (optimistic, no_optimism) = match (mode, application) {
314 (Mode::Server, _) => (false, Some(NoOptimism::ModeA)),
315 (Mode::Client, false) => (false, Some(NoOptimism::NotAnApplication)),
316 (Mode::Client, true) => (true, None),
317 };
318 Decision {
319 component: roles.page_name.clone(),
320 mode,
321 why: if declared.is_some() {
322 Why::Declared
323 } else {
324 Why::Default
325 },
326 carries: match mode {
327 Mode::Server => Ty::html(),
328 Mode::Client => roles.state_ty.clone(),
329 },
330 optimistic,
331 no_optimism,
332 per_session: roles.view_is_per_session,
333 uses: SessionUse::of(&roles.view, defs),
334 reads_presence: roles.view_reads_presence,
335 reads_awareness: roles.awareness.is_some(),
336 reads_freshness: roles.view_reads_freshness,
337 reads_gestures: roles.gestures.is_some(),
338 // A declared mode is refused where it was written; a defaulted one, at the component.
339 span: declared.map_or(span, |(_, s)| s),
340 }
341 }
342
343 /// What `beck explain render` prints: the decision, what it puts on the wire, and what would
344 /// change it.
345 ///
346 /// The counterfactual is the useful half. A reader who wants Mode B and has Mode A needs to
347 /// know whether one annotation would do it or whether the program's shape refuses — and that
348 /// is a question only the compiler can answer, because the answer is `view_is_per_session`.
349 pub fn explain(&self, bundle: &crate::bundle::Bundle) -> String {
350 let mut out = String::new();
351 let line = |out: &mut String, k: &str, v: String| {
352 out.push_str(&format!("{k:<18}{v}\n"));
353 };
354 line(&mut out, "component", self.component.to_string());
355 line(
356 &mut out,
357 "mode",
358 format!(
359 "{} — the {} renders ({})",
360 self.mode.letter(),
361 match self.mode {
362 Mode::Server => "server",
363 Mode::Client => "browser",
364 },
365 match self.why {
366 Why::Declared => format!("declared: `@render({})`", self.mode.name()),
367 Why::Default => "the default".to_string(),
368 }
369 ),
370 );
371 line(
372 &mut out,
373 "the wire carries",
374 match self.mode {
375 Mode::Server => "Html, as DOM patches".to_string(),
376 Mode::Client => format!("{}, as data patches", self.carries),
377 },
378 );
379 line(
380 &mut out,
381 "optimistic",
382 match self.no_optimism {
383 None => "yes — the client holds the accumulator the fold is of, so it runs the \
384 same fold locally and reconciles by `seq`"
385 .to_string(),
386 Some(NoOptimism::ModeA) => {
387 "no — every interaction is a round trip, because the page is rendered where \
388 the state is"
389 .to_string()
390 }
391 Some(NoOptimism::NotAnApplication) => {
392 "no — this module has no chokepoint, so there is nothing to propose".to_string()
393 }
394 },
395 );
396 if self.mode == Mode::Client {
397 line(
398 &mut out,
399 "bundle",
400 format!(
401 "{} Core nodes, {} definitions, {} bytes",
402 bundle.nodes(),
403 bundle.defs.len(),
404 bundle.to_bytes().len()
405 ),
406 );
407 }
408 line(&mut out, "reads of session", self.uses.describe());
409 // §3.7's freshness dimension, when the program asked for it. Printed beside optimism
410 // because it is the same fact seen from the page: optimism is what makes a guess, and this
411 // is the page being able to say so.
412 if self.reads_gestures {
413 line(
414 &mut out,
415 "interface state",
416 "kept — this page folds its own gestures into a client-local accumulator that \
417 never reaches the log (`docs/10` D30)"
418 .to_string(),
419 );
420 }
421 if self.reads_freshness {
422 line(
423 &mut out,
424 "freshness",
425 "read — this page renders `Pending(n)` while its own commands are in flight, and \
426 `Confirmed` otherwise"
427 .to_string(),
428 );
429 }
430 out.push('\n');
431 // The counterfactual is the useful half, so the *reason* a page cannot move has to be the
432 // one that applies. A page reading the roster is refused whatever it does with the session.
433 if self.mode == Mode::Server && self.reads_presence {
434 out.push_str(
435 "This page reads `presence`, so it cannot move to the browser: `@render(client)` \
436 would be refused (B0516). Who is connected is in neither the accumulator nor the \
437 log — it is a fact the server holds about its own sockets.\n",
438 );
439 return out;
440 }
441 if self.mode == Mode::Server && self.reads_awareness {
442 out.push_str(
443 "This page reads `awareness`, so it cannot move to the browser: `@render(client)` \
444 would be refused (B0521). What every other connection is contributing is in \
445 neither the accumulator nor the log.\n",
446 );
447 return out;
448 }
449 match (self.mode, &self.uses) {
450 (Mode::Server, SessionUse::None) => out.push_str(
451 "This page is a function of the state alone, so `@render(client)` would move it \
452 to the browser.\n",
453 ),
454 (Mode::Server, SessionUse::Route) => out.push_str(
455 &format!(
456 "This page is a function of the state and of `session.{ROUTE_FIELD}`, which the \
457 browser chose. `@render(client)` would move it to the browser, where the route \
458 changes without a round trip.\n"
459 ),
460 ),
461 (Mode::Server, SessionUse::Identity { .. }) => out.push_str(
462 "This page reads who is asking, so it cannot move to the browser: \
463 `@render(client)` would be refused (B0514). Mode B sends the state rather than \
464 the page, and a page that filters by identity is a page whose state is not the \
465 client's to hold.\n",
466 ),
467 (Mode::Client, _) => out.push_str(
468 "The browser holds the accumulator and renders from it, so an interaction costs \
469 no round trip. What it costs instead is the bundle above, once.\n",
470 ),
471 }
472 out
473 }
474
475 /// Refuse a component that may not render where it says it does.
476 ///
477 /// Three conditions, and they do not all point the same way. Two are things a page can read
478 /// that a browser handed the accumulator would not have: **who** is asking ([`SessionUse`])
479 /// and **who is connected** (`presence`). Where the browser *is* is not one of them — it chose
480 /// the route. The third is the mirror: **whether a guess is outstanding** (`freshness`) is
481 /// something only a browser can have, so a page reading it may not render on the *server*.
482 ///
483 /// What is deliberately not a third condition is worth writing down. Mode B puts the
484 /// **accumulator** on the wire, so the obvious check is §3.5's `Sendable` — and it is
485 /// already discharged: a durable fold's state must be *storable* (`B0411`), storable is
486 /// strictly stronger than sendable ([`crate::secure`]), and the accumulator is what crosses.
487 /// A `secret[T]` therefore cannot reach a Mode B client because it cannot reach the log, and
488 /// a check here would be a second gate on a door that is shut. `mode_b.rs` asserts that
489 /// composition rather than trusting it.
490 pub fn refuse(&self, diags: &mut Diagnostics) {
491 if self.mode == Mode::Server {
492 // The one refusal that points the other way. Every rule above asks whether the browser
493 // may be given something; this asks whether the *server* can answer something, and the
494 // answer is no: a server renders what it has recorded, so `freshness()` there is
495 // `Confirmed` at every seq of every log. A page that renders "saving…" from it would
496 // have written a branch nothing can take.
497 if self.reads_freshness {
498 diags.push(
499 Diagnostic::error(
500 "B0518",
501 format!(
502 "`{}` reads `freshness`, so it cannot render on the server",
503 self.component
504 ),
505 self.span,
506 )
507 .with_primary_label("a server has nothing in flight")
508 .with_note(
509 "freshness is a client's account of the commands it has proposed and not \
510 yet had confirmed. The server holds the log: what it renders is confirmed \
511 by definition, so this page would render `Confirmed` at every position of \
512 every log and its other branch would be unreachable.",
513 )
514 .with_fix(
515 "render this component in the browser — `@render(client)`, which is what \
516 makes a guess possible in the first place — or take `freshness` out of \
517 its page",
518 ),
519 );
520 }
521 // The same refusal about the other thing only a client holds. D30's five homes put
522 // this one fourth on purpose: a page that reaches for interface state and cannot
523 // render in the browser is usually a page whose state belongs in the platform or the
524 // URL, and those are free.
525 if self.reads_gestures {
526 diags.push(
527 Diagnostic::error(
528 "B0522",
529 format!(
530 "`{}` reads a `gestures` fold, so it cannot render on the server",
531 self.component
532 ),
533 self.span,
534 )
535 .with_primary_label("a server has received no gestures")
536 .with_note(
537 "a gesture is one client's movement of its own interface: it is not \
538 proposed, not validated and not recorded, so it never reaches a server. A \
539 page rendered there would render the interface state's initial value at \
540 every position of every log, and every branch that depended on a gesture \
541 would be unreachable.",
542 )
543 .with_fix(
544 "render this component in the browser — `@render(client)` — or give the \
545 state one of the homes that survives a server render: markup the platform \
546 already knows (`<dialog>`, `popover`, `<details name>`), or the route on \
547 the `Session` (`docs/10` D30)",
548 ),
549 );
550 }
551 return;
552 }
553 if self.uses.reads_identity() {
554 diags.push(
555 Diagnostic::error(
556 "B0514",
557 format!(
558 "`{}` renders differently for each *actor*, so it cannot render on the client",
559 self.component
560 ),
561 self.span,
562 )
563 .with_primary_label("`@render(client)` sends the browser the state, not the page")
564 .with_note(format!(
565 "This page reads {}: it filters, scopes or hides by identity. A client that \
566 rendered it locally would first have to be given the state it filters — \
567 including everything the filter removes. Reading `session.{ROUTE_FIELD}` is \
568 allowed and is not this: the browser chose the route and already holds the \
569 state.",
570 self.uses.describe()
571 ))
572 .with_fix(
573 "render this component on the server (the default), or make the page a \
574 function of the state and the route alone",
575 ),
576 );
577 }
578 if self.reads_presence {
579 diags.push(
580 Diagnostic::error(
581 "B0516",
582 format!(
583 "`{}` reads `presence`, so it cannot render on the client",
584 self.component
585 ),
586 self.span,
587 )
588 .with_primary_label("`@render(client)` sends the browser the accumulator")
589 .with_note(
590 "Who is connected is not in the accumulator and is not in the log: it is a \
591 fact the server holds about its own sockets. A browser handed the state \
592 would have nothing to render this part of the page from, and shipping the \
593 roster alongside would be a second wire nothing reconciles by `seq`.",
594 )
595 .with_fix(
596 "render this component on the server (the default), or take `presence` out of \
597 its page",
598 ),
599 );
600 }
601 if self.reads_awareness {
602 diags.push(
603 Diagnostic::error(
604 "B0521",
605 format!(
606 "`{}` reads `awareness`, so it cannot render on the client",
607 self.component
608 ),
609 self.span,
610 )
611 .with_primary_label("`@render(client)` sends the browser the accumulator")
612 .with_note(
613 "What every other connection is contributing is not in the accumulator and \
614 is not in the log: like `presence`, it is a fact the server holds about its \
615 own sockets, and unlike `presence` it carries a value each of those sockets \
616 chose. A browser handed the state would have nothing to render this part of \
617 the page from.",
618 )
619 .with_fix(
620 "render this component on the server (the default), or take `awareness` out \
621 of its page",
622 ),
623 );
624 }
625 }
626}