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}