Skip to main content

logicaffeine_language/parser/lgx_lower/
gate.rs

1use super::json::JsonShapes;
2use super::*;
3use crate::ast::component::{ComponentDef, ComponentKind, RouteBody, Trigger};
4use crate::ast::stmt::{Expr, Literal, Stmt, TypeExpr};
5use crate::ast::view::{Attr, ViewNode};
6use crate::lgx::html::{Behaviour, EventModifier};
7use crate::token::Span;
8use logicaffeine_base::{Interner, Symbol};
9
10/// Whether a store's state can be given a record type here. A derived field or a field whose
11/// type cannot be named from its annotation or its initializer means it cannot.
12/// The storage AREA a store keeps its state in between page loads, or `None` for one that keeps
13/// nothing there. The area is the lifetime the author asked for, spelled the way the browser
14/// spells it — the one decision every tier reads, so every tier puts a persisted store in the same
15/// place.
16pub fn store_area(def: &ComponentDef<'_>) -> Option<&'static str> {
17    match &def.kind {
18        ComponentKind::Store {
19            mode: crate::ast::component::StoreMode::Persisted { .. },
20        } => Some("local"),
21        ComponentKind::Store {
22            mode: crate::ast::component::StoreMode::Preserved,
23        } => Some("session"),
24        ComponentKind::Store {
25            mode: crate::ast::component::StoreMode::Shared { .. },
26        } => Some(SHARED_AREA),
27        _ => None,
28    }
29}
30
31/// Whether a field of this type has a faithful TEXT form, which is what storage carries.
32///
33/// Refusing is the point: a record quietly stored as its display form and read back as the TEXT
34/// of that display is a page that loses its state and says nothing about it.
35pub(crate) fn storable_type(interner: &Interner, ty: Symbol) -> bool {
36    matches!(
37        interner.resolve(ty),
38        "Int" | "Text" | "Bool" | "Float" | "Real"
39    )
40}
41
42/// The element `Seq of <element>` names, from EITHER an explicit annotation (`Let x: Seq of Text
43/// be …`) or the constructor an UNANNOTATED field was given (`Let x be a new Seq of Text.`, which
44/// is how every one of this campaign's own fixtures writes one) — the one shape a store's own
45/// record, and `Save`/`Load`, thread a list through. `None` for anything else, including a `Seq`
46/// of more than one type parameter or of something other than a plain name.
47pub(crate) fn seq_field_element<'a>(
48    ty: Option<&TypeExpr<'a>>,
49    init: &Expr<'a>,
50    interner: &Interner,
51) -> Option<Symbol> {
52    let is_seq = |base: Symbol| interner.resolve(base).eq_ignore_ascii_case("Seq");
53    let elem_of = |params: &[TypeExpr<'a>]| -> Option<Symbol> {
54        if params.len() != 1 {
55            return None;
56        }
57        match &params[0] {
58            TypeExpr::Primitive(e) | TypeExpr::Named(e) => Some(*e),
59            _ => None,
60        }
61    };
62    match ty {
63        Some(TypeExpr::Generic { base, params }) if is_seq(*base) => elem_of(params),
64        Some(_) => None,
65        None => match init {
66            Expr::New {
67                type_name,
68                type_args,
69                ..
70            } if is_seq(*type_name) => elem_of(type_args),
71            _ => None,
72        },
73    }
74}
75
76/// Why a store cannot be lowered, or `None` when it can.
77///
78/// TWO DIFFERENT PROBLEMS with two different fixes, and they must not borrow each other's words:
79/// a field whose TYPE this tier cannot name is a record-typing problem, while a field with no
80/// faithful TEXT form in a store that keeps its state between page loads is a persistence problem
81/// — and the second is fixed either by changing the field or by dropping `persisted as`. A
82/// refusal that names the wrong cause costs the reader the time it takes to rule out the thing
83/// you told them, which is worse than naming no cause at all.
84///
85/// Asked with the same codec questions ([`JsonShapes`]) the store is emitted behind
86/// ([`store_lowerable`]), so a store is never lowered by the one and refused by the other.
87pub(crate) fn store_not_lowerable(
88    def: &ComponentDef<'_>,
89    shapes: &JsonShapes<'_>,
90) -> Option<&'static str> {
91    if !store_lowerable(def, shapes) {
92        // A store this tier cannot type at all — the record-shape problem.
93        if !store_shape_lowerable(def, shapes.interner()) {
94            return Some("it uses a store whose state this tier cannot give a record type");
95        }
96        // It types fine; what it cannot do is CARRY those fields through text.
97        return Some(
98            "it uses a store that keeps its state between page loads, and one of its fields holds \
99             something text cannot carry. A stored field can be a Text, an Int, a Real, a Bool, a \
100             Seq of one of those, or a record this file can fully encode and decode as JSON",
101        );
102    }
103    None
104}
105
106/// The record-shape half: a store whose fields this tier can give a named type, regardless of
107/// whether it also has to survive a page load.
108///
109/// A `Seq of <anything named>` is accepted UNCONDITIONALLY here, scalar or record element alike —
110/// sound because a store that does not persist needs no serialization at all: its `Seq` field
111/// becomes an ordinary `FieldType::Generic` in the type registry, resolved by the exact same
112/// per-field boundary inference every plain `## A Foo has: … Seq of X.` record's field already
113/// goes through. Only a store that KEEPS its state between page loads needs the codec, and that
114/// is [`store_lowerable`]'s own, separate, stricter question.
115pub(crate) fn store_shape_lowerable(def: &ComponentDef<'_>, interner: &Interner) -> bool {
116    matches!(
117        def.kind,
118        ComponentKind::Store {
119            mode: crate::ast::component::StoreMode::Local
120                | crate::ast::component::StoreMode::Persisted { .. }
121                | crate::ast::component::StoreMode::Preserved
122                | crate::ast::component::StoreMode::Shared { .. }
123        }
124    ) && !def.state.iter().any(|f| f.derived)
125        && def.state.iter().all(|f| {
126            shared_convergent_field(def, f, interner)
127                || matches!(f.ty, Some(TypeExpr::Primitive(_) | TypeExpr::Named(_)))
128                || seq_field_element(f.ty, f.init, interner).is_some()
129                || matches!(
130                    f.init,
131                    Expr::Literal(
132                        Literal::Number(_)
133                            | Literal::Text(_)
134                            | Literal::Boolean(_)
135                            | Literal::Float(_)
136                    )
137                )
138        })
139}
140
141/// Whether `field` of `def` is a convergent field of a `shared on` store — a `Tally`, a
142/// `SharedSet of T`, … — whose record type is the plain value it converges to and whose topic form
143/// is its CRDT's frame (`lgx_lower::shared`).
144fn shared_convergent_field(
145    def: &ComponentDef<'_>,
146    field: &crate::ast::component::StateField<'_>,
147    interner: &Interner,
148) -> bool {
149    matches!(
150        def.kind,
151        ComponentKind::Store {
152            mode: crate::ast::component::StoreMode::Shared { .. }
153        }
154    ) && field
155        .ty
156        .and_then(|ty| super::shared::shared_field_kind(ty, interner))
157        .is_some_and(|kind| kind.is_ok())
158}
159
160/// Whether a store fully lowers — its shape ([`store_shape_lowerable`]) AND, when it keeps its
161/// state between page loads, every field's own text form: a scalar's fixed tag, or a record this
162/// file can fully encode AND decode as JSON ([`JsonShapes::record_round_trips`]).
163pub(crate) fn store_lowerable(def: &ComponentDef<'_>, shapes: &JsonShapes<'_>) -> bool {
164    let interner = shapes.interner();
165    let record_round_trips = |ty: Symbol| shapes.record_round_trips(ty);
166    // A SHARED store is a module record like any other, its fields carried to and from the relay
167    // topic in their TEXT form (`lgx_lower::shared`) exactly as a persisted store's are carried to
168    // and from storage — so it takes the same field rule below.
169    let persisted_field_ok = |f: &crate::ast::component::StateField<'_>| -> bool {
170        if shared_convergent_field(def, f, interner) {
171            return true;
172        }
173        if let Some(elem) = seq_field_element(f.ty, f.init, interner) {
174            return storable_type(interner, elem) || record_round_trips(elem);
175        }
176        match f.ty {
177            Some(TypeExpr::Primitive(t) | TypeExpr::Named(t)) => {
178                storable_type(interner, *t) || record_round_trips(*t)
179            }
180            Some(_) => false,
181            None => matches!(
182                f.init,
183                Expr::Literal(
184                    Literal::Number(_) | Literal::Text(_) | Literal::Boolean(_) | Literal::Float(_)
185                )
186            ),
187        }
188    };
189    matches!(
190        def.kind,
191        ComponentKind::Store {
192            mode: crate::ast::component::StoreMode::Local
193                | crate::ast::component::StoreMode::Persisted { .. }
194                | crate::ast::component::StoreMode::Preserved
195                | crate::ast::component::StoreMode::Shared { .. }
196        }
197    )
198        && !def.state.iter().any(|f| f.derived)
199        // A store that keeps its state between page loads carries every field through TEXT (a
200        // scalar's own fixed tag, or the JSON codec's `j:` tag), so a field with neither a
201        // faithful text form nor a verified JSON round-trip is refused HERE rather than lowered
202        // into a page that silently forgets it.
203        && (store_area(def).is_none() || def.state.iter().all(persisted_field_ok))
204        && store_shape_lowerable(def, interner)
205}
206
207/// Why a component cannot be lowered — the message the VM refuses a `Mount` with, and WHERE.
208pub struct NotLowerable {
209    pub message: String,
210    /// The node, handler or definition that could not be lowered.
211    pub span: Span,
212}
213
214impl NotLowerable {
215    /// A refusal, from a literal or from a message built around the author's own words, AT the
216    /// place that could not be lowered.
217    ///
218    /// ★ IT CARRIES A `String` SO IT CAN NAME WHAT IT REFUSED. Every refusal here used to be a
219    /// `&'static str`, which quietly forced each one to describe a CLASS ("one of its `Actions:`
220    /// sentences waits for something this tier cannot deliver yet") rather than the sentence the
221    /// author wrote. That is the anonymous-refusal shape this campaign has now fixed five times:
222    /// the reader is told a page failed and left to find which line did it.
223    ///
224    /// ★ AND IT CARRIES A `Span`, WHICH IS THE OTHER HALF OF THAT SAME FIX. Naming the construct
225    /// tells the reader WHAT; only a span tells them WHERE, and every one of these rendered at
226    /// `Span::default()` — the top of the file — because the walk destructured each node with
227    /// `..` and threw the location away. A message that says "its template uses a directive" and
228    /// points at line 1 leaves the author scanning a forty-node view by hand.
229    ///
230    /// The span is a REQUIRED argument rather than an `Option` or a defaulted field, so a new
231    /// refusal cannot be added without saying where it happened. That is the only version of this
232    /// that does not rot: the previous shape made "no span" the path of least resistance, and all
233    /// twenty-one sites took it.
234    pub(crate) fn at(span: Span, text: impl Into<String>) -> Self {
235        NotLowerable {
236            message: text.into(),
237            span,
238        }
239    }
240}
241
242/// Whether a component's state has to survive between renders — because an action writes it,
243/// a sentence answers an event, or an input is bound to it. Such a component keeps its state in
244/// module-level bindings; one without any of these reads its initializers each render.
245
246/// Every component a view PLACES, at any depth — the edges of the component tree, which is what
247/// the server renderer's demand rule closes over.
248/// Every component this view PLACES, at any depth.
249///
250/// ★ PUBLIC BECAUSE TWO RENDERERS NEED THE SAME ANSWER. The server hoists stylesheets from the
251/// root and the browser hoists them at the mount point — both walk this exact graph, and two
252/// copies of it would be two chances to disagree about which sheets a page carries.
253/// The components a view places INSIDE a `{for each}` — the ones that share a mount point.
254///
255/// ★ SHARING A MOUNT POINT IS WHAT MAKES A REPAINT UNSAFE, and a loop is the second way to get
256/// there. `repaint_def` re-renders into `__M_<C>[key]` and CLEARS it first; for a row drawn by
257/// `{for each t in tiles:}<Tile …/>{end}` that recorded mount point is the enclosing `<ul>`,
258/// which every row shares. So one row's unpatchable turn cleared the list and redrew itself
259/// alone — two rows in, click the first, and the second is gone from the document.
260///
261/// A placed WRAPPER already had this exact problem for the same reason and is already redirected;
262/// this is the same fact about a different shape, which is why it joins that predicate rather than
263/// growing a second one.
264/// The components an instance of which can ever LEAVE the page.
265///
266/// ★ MACHINERY FOR SOMETHING THAT CANNOT HAPPEN IS STILL SHIPPED TO THE READER. The unmount sweep
267/// answers "which instances were not drawn in the last paint", and an instance can only fail to be
268/// drawn if something chose not to draw it — a `{if}` arm, a `{for each}` whose list shrank, an
269/// `{inspect}` branch. A page whose components are all placed unconditionally can never sweep
270/// anything, and it was carrying the sweep anyway: measured at 1,052 bytes of wasm on a page with
271/// ONE state field, which is a third of what such a page costs at all.
272///
273/// A component under a conditional node is unmountable, and so is everything it places, however
274/// unconditionally it places them — when the `{if}` closes, the whole subtree goes with it.
275pub(crate) fn unmountable_components(all: &[ComponentDef<'_>]) -> Vec<Symbol> {
276    fn walk(nodes: &[ViewNode<'_>], conditional: bool, out: &mut Vec<Symbol>) {
277        for node in nodes {
278            match node {
279                ViewNode::Component { name, children, .. } => {
280                    if conditional && !out.contains(name) {
281                        out.push(*name);
282                    }
283                    walk(children, conditional, out);
284                }
285                ViewNode::Element { children, .. } => walk(children, conditional, out),
286                // The three ways a view chooses. Every arm is conditional, including the `otherwise`
287                // — it is not drawn when another arm is.
288                ViewNode::If {
289                    arms, otherwise, ..
290                } => {
291                    for (_, body) in arms {
292                        walk(body, true, out);
293                    }
294                    if let Some(o) = otherwise {
295                        walk(o, true, out);
296                    }
297                }
298                ViewNode::Each { body, empty, .. } => {
299                    walk(body, true, out);
300                    walk(empty, true, out);
301                }
302                ViewNode::Inspect { arms, .. } => {
303                    for arm in arms {
304                        walk(&arm.body, true, out);
305                    }
306                }
307                ViewNode::Block {
308                    default: Some(body),
309                    ..
310                } => walk(body, conditional, out),
311                _ => {}
312            }
313        }
314    }
315    let mut out: Vec<Symbol> = Vec::new();
316    for def in all {
317        if let Some(view) = def.view.as_ref() {
318            walk(&view.nodes, false, &mut out);
319        }
320    }
321    // Everything an unmountable component places goes with it, to any depth.
322    let mut grew = true;
323    while grew {
324        grew = false;
325        for name in out.clone() {
326            let Some(def) = all.iter().find(|d| d.name == name) else {
327                continue;
328            };
329            let Some(view) = def.view.as_ref() else {
330                continue;
331            };
332            for placed in placed_components(&view.nodes) {
333                if !out.contains(&placed) {
334                    out.push(placed);
335                    grew = true;
336                }
337            }
338        }
339    }
340    out
341}
342
343pub fn loop_placed_components(nodes: &[ViewNode<'_>]) -> Vec<Symbol> {
344    let mut out = Vec::new();
345    fn walk(nodes: &[ViewNode<'_>], inside: bool, out: &mut Vec<Symbol>) {
346        for node in nodes {
347            match node {
348                ViewNode::Component { name, children, .. } => {
349                    if inside {
350                        out.push(*name);
351                    }
352                    walk(children, inside, out);
353                }
354                ViewNode::Element { children, .. } => walk(children, inside, out),
355                ViewNode::If {
356                    arms, otherwise, ..
357                } => {
358                    for (_, body) in arms {
359                        walk(body, inside, out);
360                    }
361                    if let Some(o) = otherwise {
362                        walk(o, inside, out);
363                    }
364                }
365                // The loop's BODY is inside; its `{empty}` markup draws once and is not.
366                ViewNode::Each { body, empty, .. } => {
367                    walk(body, true, out);
368                    walk(empty, inside, out);
369                }
370                ViewNode::Inspect { arms, .. } => {
371                    for arm in arms {
372                        walk(&arm.body, inside, out);
373                    }
374                }
375                ViewNode::Block {
376                    default: Some(body),
377                    ..
378                } => walk(body, inside, out),
379                _ => {}
380            }
381        }
382    }
383    walk(nodes, false, &mut out);
384    out
385}
386
387/// The lowerable components with a `When this component appears:` handler, in declaration order —
388/// the numbering the appears queue (`__LGX_APPEAR_WHO`) and its drain (`__lgx_appear`) share.
389pub(crate) fn appearing_components(
390    all: &[ComponentDef<'_>],
391    types: Option<&crate::analysis::registry::TypeRegistry>,
392    interner: &Interner,
393) -> Vec<Symbol> {
394    all.iter()
395        .filter(|d| {
396            matches!(
397                d.kind,
398                ComponentKind::Component | ComponentKind::Page { .. }
399            )
400        })
401        .filter(|d| {
402            d.events
403                .iter()
404                .any(|h| matches!(h.trigger, Trigger::Appears))
405        })
406        .filter(|d| lowerable(d, all, types, interner).is_ok())
407        .map(|d| d.name)
408        .collect()
409}
410
411/// Whether `def` can be drawn inside itself: its view places `def` again, directly or through the
412/// components it places. Such a component has a finite rendering only if something in its view
413/// stops the nesting, so its render counts how deep it is (`__LGX_RENDER_DEPTH`).
414pub(crate) fn on_placement_cycle(def: &ComponentDef<'_>, all: &[ComponentDef<'_>]) -> bool {
415    let mut seen: Vec<Symbol> = Vec::new();
416    let mut pending: Vec<Symbol> = def
417        .view
418        .as_ref()
419        .map(|v| placed_components(&v.nodes))
420        .unwrap_or_default();
421    while let Some(name) = pending.pop() {
422        if name == def.name {
423            return true;
424        }
425        if seen.contains(&name) {
426            continue;
427        }
428        seen.push(name);
429        if let Some(view) = all
430            .iter()
431            .find(|d| d.name == name)
432            .and_then(|d| d.view.as_ref())
433        {
434            pending.extend(placed_components(&view.nodes));
435        }
436    }
437    false
438}
439
440pub fn placed_components(nodes: &[ViewNode<'_>]) -> Vec<Symbol> {
441    let mut out = Vec::new();
442    fn walk(nodes: &[ViewNode<'_>], out: &mut Vec<Symbol>) {
443        for node in nodes {
444            match node {
445                ViewNode::Component { name, children, .. } => {
446                    out.push(*name);
447                    walk(children, out);
448                }
449                ViewNode::Element { children, .. } => walk(children, out),
450                ViewNode::If {
451                    arms, otherwise, ..
452                } => {
453                    for (_, body) in arms {
454                        walk(body, out);
455                    }
456                    if let Some(o) = otherwise {
457                        walk(o, out);
458                    }
459                }
460                ViewNode::Each { body, empty, .. } => {
461                    walk(body, out);
462                    walk(empty, out);
463                }
464                ViewNode::Inspect { arms, .. } => {
465                    for arm in arms {
466                        walk(&arm.body, out);
467                    }
468                }
469                // ★ A LAYOUT IS A COMPONENT THIS VIEW DEPENDS ON, and it fell into the catch-all
470                // below. Every reader of this walk asks the same question — which components does
471                // this page reach — and `{extends Site}` reaches `Site` as surely as `<Site/>`
472                // would. Dropping it meant the LAYOUT'S STYLESHEET WAS NEVER EMITTED: its elements
473                // were stamped with its scope class, the scoped rule would have matched, and the
474                // rule was not in the document. A page that renders with its styling silently
475                // missing, which is the worst shape a styling bug can take — nothing to see, and
476                // nothing said.
477                ViewNode::Extends { layout, .. } => out.push(*layout),
478                // And a block's DEFAULT body places components too; it is the markup used whenever
479                // the page does not fill the block, so a component there is as real as any other.
480                ViewNode::Block {
481                    default: Some(body),
482                    ..
483                } => walk(body, out),
484                _ => {}
485            }
486        }
487    }
488    walk(nodes, &mut out);
489    out
490}
491
492/// Whether these statements call a `<Store>`'s action — asked of an action body BEFORE the store
493/// rewrite runs over it, because that rewrite replaces the sentence with its lowered form.
494pub(crate) fn touches_store(stmts: &[Stmt<'_>]) -> bool {
495    stmts.iter().any(|s| match s {
496        Stmt::Ui(crate::ast::stmt::UiStmt::CallStore { .. }) => true,
497        Stmt::If {
498            then_block,
499            else_block,
500            ..
501        } => touches_store(then_block) || else_block.is_some_and(touches_store),
502        Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
503            touches_store(body)
504        }
505        Stmt::Inspect { arms, .. } => arms.iter().any(|a| touches_store(a.body)),
506        _ => false,
507    })
508}
509
510/// Whether `def` has a `When <field> changes:` handler on one of its own plain fields — the handlers
511/// a turn of `def` runs after its action commits.
512pub(crate) fn watches_own_fields(def: &ComponentDef<'_>) -> bool {
513    def.events.iter().any(|h| match h.trigger {
514        Trigger::FieldChanges { store: None, field } => {
515            def.state.iter().any(|f| f.name == field && !f.derived)
516        }
517        _ => false,
518    })
519}
520
521/// The components with such handlers, in declaration order; a component's watch id (what
522/// `__LGX_WATCHING` holds while its handlers run) is its position here plus one.
523pub(crate) fn watching_components(all: &[ComponentDef<'_>]) -> Vec<Symbol> {
524    all.iter()
525        .filter(|d| watches_own_fields(d))
526        .map(|d| d.name)
527        .collect()
528}
529
530/// Whether these statements, at any depth, move the location with `Navigate to` — which ends the
531/// turn by delivering the move to the page's route listeners.
532pub(crate) fn navigates(stmts: &[Stmt<'_>]) -> bool {
533    stmts.iter().any(|s| match s {
534        Stmt::Ui(crate::ast::stmt::UiStmt::Navigate { .. }) => true,
535        Stmt::If {
536            then_block,
537            else_block,
538            ..
539        } => navigates(then_block) || else_block.is_some_and(navigates),
540        Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
541            navigates(body)
542        }
543        Stmt::Inspect { arms, .. } => arms.iter().any(|a| navigates(a.body)),
544        _ => false,
545    })
546}
547
548/// Whether any action or handler anywhere in `all` moves the location — the question the route
549/// fan-out's emission asks, so a turn's epilogue never calls a fan-out that was not emitted.
550pub(crate) fn any_navigates(all: &[ComponentDef<'_>]) -> bool {
551    all.iter().any(|d| {
552        d.actions.iter().any(|a| navigates(a.body)) || d.events.iter().any(|h| navigates(h.body))
553    })
554}
555
556/// Whether `stmt` itself asks for an element event delivered after its turn commits — the
557/// statements that lower through `Parser::queue_element_event`: a `Publish` (its page-local
558/// delivery) and a `Hover` (the `mouseover` a browser raises for it). `Focus`/`Blur` are not
559/// here: the host raises their events itself, and only on a real move of focus.
560pub(crate) fn queues_element_event(stmt: &Stmt<'_>) -> bool {
561    use crate::ast::stmt::UiStmt;
562    matches!(
563        stmt,
564        Stmt::Ui(UiStmt::Publish { .. } | UiStmt::Hover { .. })
565    )
566}
567
568/// Whether any statement in `stmts`, at any depth, queues an element event.
569pub(crate) fn body_queues_element_event(stmts: &[Stmt<'_>]) -> bool {
570    stmts.iter().any(|s| {
571        queues_element_event(s)
572            || match s {
573                Stmt::If {
574                    then_block,
575                    else_block,
576                    ..
577                } => {
578                    body_queues_element_event(then_block)
579                        || else_block.is_some_and(body_queues_element_event)
580                }
581                Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
582                    body_queues_element_event(body)
583                }
584                Stmt::Inspect { arms, .. } => {
585                    arms.iter().any(|a| body_queues_element_event(a.body))
586                }
587                _ => false,
588            }
589    })
590}
591
592/// Whether running `store`'s action `action` queues an element event — in its own body, or in any
593/// store action it calls in turn.
594pub(crate) fn store_action_queues(all: &[ComponentDef<'_>], store: Symbol, action: Symbol) -> bool {
595    fn go(
596        all: &[ComponentDef<'_>],
597        store: Symbol,
598        action: Symbol,
599        seen: &mut Vec<(Symbol, Symbol)>,
600    ) -> bool {
601        if seen.contains(&(store, action)) {
602            return false;
603        }
604        seen.push((store, action));
605        let Some(body) = all
606            .iter()
607            .find(|d| d.name == store)
608            .and_then(|d| d.actions.iter().find(|a| a.name == action))
609            .map(|a| a.body)
610        else {
611            return false;
612        };
613        if body_queues_element_event(body) {
614            return true;
615        }
616        let mut calls = Vec::new();
617        store_calls_in(body, &mut calls);
618        calls.into_iter().any(|(s, a)| go(all, s, a, seen))
619    }
620    go(all, store, action, &mut Vec::new())
621}
622
623/// Every `Call <Store>'s <action>` in `stmts`, at any depth.
624fn store_calls_in(stmts: &[Stmt<'_>], out: &mut Vec<(Symbol, Symbol)>) {
625    for s in stmts {
626        match s {
627            Stmt::Ui(crate::ast::stmt::UiStmt::CallStore { store, action, .. }) => {
628                out.push((*store, *action))
629            }
630            Stmt::If {
631                then_block,
632                else_block,
633                ..
634            } => {
635                store_calls_in(then_block, out);
636                if let Some(e) = else_block {
637                    store_calls_in(e, out);
638                }
639            }
640            Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
641                store_calls_in(body, out)
642            }
643            Stmt::Inspect { arms, .. } => {
644                for a in arms {
645                    store_calls_in(a.body, out);
646                }
647            }
648            _ => {}
649        }
650    }
651}
652
653/// Whether any action or handler anywhere in `all` queues an element event — the one question the
654/// outbox's declaration and the dispatcher's flush arm both ask, so neither exists without the other.
655pub(crate) fn uses_outbox(all: &[ComponentDef<'_>]) -> bool {
656    all.iter().any(|d| {
657        d.actions.iter().any(|a| body_queues_element_event(a.body))
658            || d.events.iter().any(|h| body_queues_element_event(h.body))
659    })
660}
661
662/// [`touches_store`], seeing through `Call <sibling>.` into every action of `def` those statements
663/// reach — a sibling runs nested in the caller's turn, so a store it moves is a store the caller's
664/// turn moved.
665pub(crate) fn touches_store_through(def: &ComponentDef<'_>, stmts: &[Stmt<'_>]) -> bool {
666    if touches_store(stmts) {
667        return true;
668    }
669    let mut reached = Vec::new();
670    own_action_calls(stmts, def, &mut reached);
671    let mut next = 0;
672    while next < reached.len() {
673        let name = reached[next];
674        next += 1;
675        let Some(action) = def.actions.iter().find(|a| a.name == name) else {
676            continue;
677        };
678        if touches_store(action.body) {
679            return true;
680        }
681        own_action_calls(action.body, def, &mut reached);
682    }
683    false
684}
685
686/// Whether an `on-<event>.once` handler sits anywhere in `nodes`.
687pub(crate) fn nodes_have_once_handler(nodes: &[ViewNode<'_>]) -> bool {
688    nodes.iter().any(|node| {
689        matches!(node, ViewNode::Handler { modifiers, .. }
690            if modifiers.contains(&EventModifier::Behaviour(Behaviour::Once)))
691            || view_subtrees(node).into_iter().any(nodes_have_once_handler)
692    })
693}
694
695/// Whether `def`'s own markup carries an `on-<event>.once` handler — the component whose instances
696/// own that listener's spent record.
697pub(crate) fn has_once_handler(def: &ComponentDef<'_>) -> bool {
698    def.view
699        .as_ref()
700        .is_some_and(|v| nodes_have_once_handler(&v.nodes))
701}
702
703/// Whether any component in the program declares a `.once` handler — the one question the spent map,
704/// the listener-to-identity map and every reader of them ask, so none exists without the others.
705pub(crate) fn uses_once(all: &[ComponentDef<'_>]) -> bool {
706    all.iter().any(has_once_handler)
707}
708
709/// Whether this component keeps a live-instance registry (`__KEYS_<C>` / `__SEEN_<C>`).
710///
711/// ★ ONE PREDICATE, EVERY READER. Four places have to agree about this — the registry
712/// declarations, the per-paint STAMP that records an instance as drawn, the unmount SWEEP that
713/// walks for instances that were not, and the pass counter both of those read. They were three
714/// separate conditions, and the moment one of them said yes while the stamp said no, the sweep
715/// saw every LIVE instance as gone and cleared its state on the next paint. A predicate they all
716/// ask cannot drift that way.
717///
718/// State is on the list because an instance that leaves must take its state with it: without a
719/// registry nothing notices it vanished, `__S_<C>_<field>` kept the entry, and a remount under the
720/// same key resurrected the old value.
721pub fn keeps_instance_registry(def: &ComponentDef<'_>) -> bool {
722    // ★ ONLY SOMETHING THAT RENDERS CAN BE SWEPT. A registry answers "was this instance drawn in
723    // the last paint", and the sweep treats "not drawn" as GONE. A `## Store` has state and no
724    // view, so it is never drawn and never stamped — including it here made the sweep wipe every
725    // store's state on the first paint. Twenty-one tests said so at once, which is the useful
726    // shape of that mistake: a predicate that is nearly right is worse than one that is obviously
727    // wrong, because it fails only where the two ideas it conflates come apart.
728    if !matches!(
729        def.kind,
730        ComponentKind::Component | ComponentKind::Page { .. }
731    ) {
732        return false;
733    }
734    // A `.once` listener's spent record belongs to its instance: the sweep forgets it when the
735    // instance leaves, so a remount starts with the listener armed again.
736    def.state.iter().any(|f| !f.derived)
737        || has_once_handler(def)
738        || def.events.iter().any(|h| {
739            // ★ `Appears` IS HERE FOR THE SAME REASON `Disappears` IS. Both ask a question only
740            // a registry can answer — "is this the first paint this instance was drawn in" — and
741            // the answer is read from `__SEEN_<C>` at opposite ends of an instance's life. Listing
742            // only one of them gave a component with `When this component appears:` and no state
743            // no presence map at all, so the first-render guard had nothing to consult and the
744            // handler never ran.
745            // A route change and a published message are delivered to every LIVE instance, which
746            // is the registry's question too. `When the page loads:` asks the first-render
747            // question too: a placed instance loads the first time it is drawn.
748            matches!(
749                h.trigger,
750                Trigger::Appears
751                    | Trigger::PageLoads
752                    | Trigger::Disappears
753                    | Trigger::FieldChanges { store: Some(_), .. }
754                    | Trigger::Every { .. }
755                    | Trigger::After { .. }
756                    | Trigger::RouteChanges
757                    | Trigger::Message { .. }
758            )
759        })
760}
761
762/// A `{for each …:}` with NO `keyed by` whose body reaches a component that owns `State:`.
763///
764/// ★ SILENT STATE CORRUPTION, IN THE MOST ORDINARY SHAPE A PAGE HAS. Without a key an instance is
765/// identified by its POSITION in the list, so the moment the list is reordered or an item is
766/// removed every survivor inherits its neighbour's state. Measured on a list of three counters
767/// with the second bumped twice and the third once: dropping the FIRST item left the page reading
768/// `b:0 c:2` — b took a's count and c took b's — while the keyed spelling of the same page
769/// correctly read `b:2 c:1`. Nothing was reported on either tier.
770///
771/// The framework cannot tell an append-only list (where positional identity is harmless) from one
772/// that reorders, so it refuses and names the clause that settles it. That is the same judgement
773/// this file makes everywhere else: refused LOUDLY beats rendered wrongly, and a page whose rows
774/// quietly swap their contents is as wrong as a page gets.
775///
776/// TRANSITIVE, because the damage is not limited to the component named in the loop: a stateless
777/// `<Panel>` placed unkeyed still carries a stateful `<Tile>` inside it, and the `<Tile>` is the
778/// one that loses its identity. It answers the same question `placed_components` answers, one
779/// reachability step further out.
780#[allow(dead_code)]
781pub fn unkeyed_stateful_loop<'a>(
782    def: &ComponentDef<'a>,
783    all: &[ComponentDef<'a>],
784) -> Option<(Symbol, Span)> {
785    fn stateful(name: Symbol, all: &[ComponentDef<'_>], seen: &mut Vec<Symbol>) -> Option<Symbol> {
786        if seen.contains(&name) {
787            return None;
788        }
789        seen.push(name);
790        let def = all.iter().find(|d| d.name == name)?;
791        if def.state.iter().any(|f| !f.derived) {
792            return Some(name);
793        }
794        let view = def.view.as_ref()?;
795        placed_components(&view.nodes)
796            .into_iter()
797            .find_map(|c| stateful(c, all, seen))
798    }
799    fn walk<'a>(nodes: &[ViewNode<'a>], all: &[ComponentDef<'a>]) -> Option<(Symbol, Span)> {
800        for node in nodes {
801            let found = match node {
802                ViewNode::Each {
803                    key: None,
804                    body,
805                    empty,
806                    span,
807                    ..
808                } => placed_components(body)
809                    .into_iter()
810                    .find_map(|c| stateful(c, all, &mut Vec::new()))
811                    .map(|c| (c, *span))
812                    .or_else(|| walk(body, all))
813                    .or_else(|| walk(empty, all)),
814                ViewNode::Each { body, empty, .. } => walk(body, all).or_else(|| walk(empty, all)),
815                ViewNode::Element { children, .. } => walk(children, all),
816                ViewNode::Component { children, .. } => walk(children, all),
817                ViewNode::If {
818                    arms, otherwise, ..
819                } => arms
820                    .iter()
821                    .find_map(|(_, b)| walk(b, all))
822                    .or_else(|| otherwise.as_ref().and_then(|o| walk(o, all))),
823                ViewNode::Inspect { arms, .. } => arms.iter().find_map(|a| walk(&a.body, all)),
824                ViewNode::Block {
825                    default: Some(b), ..
826                } => walk(b, all),
827                _ => None,
828            };
829            if found.is_some() {
830                return found;
831            }
832        }
833        None
834    }
835    walk(&def.view.as_ref()?.nodes, all)
836}
837
838/// Which view nodes the server-side HTML walk covers.
839///
840/// A page rendered short is worse than one refused: the reader sees a plausible document and no
841/// error, and the missing piece is whatever the walk did not understand.
842pub fn view_html_lowerable(
843    nodes: &[ViewNode<'_>],
844    interner: &Interner,
845) -> Result<(), NotLowerable> {
846    view_html_lowerable_with(nodes, &[], interner)
847}
848
849/// [`view_html_lowerable`] with the component list, so a CHILD COMPONENT can be checked: a
850/// `<TodoRow todo={t}/>` is server-renderable exactly when the child's own view is.
851///
852/// Without the list a child is refused, which is the honest answer for a caller that cannot see
853/// the other components (a `-> View` route body checked on its own).
854pub fn view_html_lowerable_with(
855    nodes: &[ViewNode<'_>],
856    all: &[ComponentDef<'_>],
857    interner: &Interner,
858) -> Result<(), NotLowerable> {
859    view_html_lowerable_seen(nodes, all, interner, &mut Vec::new())
860}
861
862pub(crate) fn view_html_lowerable_seen(
863    nodes: &[ViewNode<'_>],
864    all: &[ComponentDef<'_>],
865    interner: &Interner,
866    seen: &mut Vec<Symbol>,
867) -> Result<(), NotLowerable> {
868    for node in nodes {
869        let at = node.span();
870        match node {
871            ViewNode::Element {
872                tag,
873                attrs,
874                children,
875                ..
876            } => {
877                for a in attrs {
878                    match a {
879                        Attr::Plain { .. } => {}
880                        // A `bind` renders the field's current value as the attribute the element
881                        // reads it from. Which attribute depends on the element's type, the same
882                        // question the browser tier asks; a computed type leaves it undecidable
883                        // and is refused.
884                        Attr::Bind { .. } => {
885                            if let Some(why) =
886                                crate::ast::view::bound_input_kind(*tag, attrs, interner).refusal()
887                            {
888                                return Err(NotLowerable::at(at, why));
889                            }
890                        }
891                        // `class:active={on}` is a class the element carries when `on` holds —
892                        // which is a rendering, not a behaviour.
893                        Attr::Class { .. } => {}
894                        Attr::Directive { .. } => {
895                            return Err(NotLowerable::at(
896                                at,
897                                "its template uses a directive, which is browser behaviour and \
898                                 has no server-rendered form",
899                            ));
900                        }
901                    }
902                }
903                view_html_lowerable_seen(children, all, interner, seen)?;
904            }
905            ViewNode::Text { .. } => {}
906            // Control flow renders on a server exactly as it does in a browser: the same tree,
907            // a different destination.
908            ViewNode::If {
909                arms, otherwise, ..
910            } => {
911                for (_, body) in arms {
912                    view_html_lowerable_seen(body, all, interner, seen)?;
913                }
914                if let Some(o) = otherwise {
915                    view_html_lowerable_seen(o, all, interner, seen)?;
916                }
917            }
918            ViewNode::Each { body, empty, .. } => {
919                view_html_lowerable_seen(body, all, interner, seen)?;
920                view_html_lowerable_seen(empty, all, interner, seen)?;
921            }
922            ViewNode::Let { .. } => {}
923            // Served markup is inert until hydration attaches the listeners, so a handler
924            // contributes nothing to the string.
925            ViewNode::Handler { .. } => {}
926            // `{inspect}` is `{if}` with a shape to match on.
927            ViewNode::Inspect { arms, .. } => {
928                for arm in arms {
929                    view_html_lowerable_seen(&arm.body, all, interner, seen)?;
930                }
931            }
932            // A placed child lowers to a call to its own `__html_<Name>`, as the browser tier
933            // calls its `__render_<Name>`. A wrapper's children belong to the caller: they are
934            // walked in the caller's context here and rendered in the caller's scope.
935            ViewNode::Children { .. } => {}
936            ViewNode::Component { name, children, .. } => {
937                view_html_lowerable_seen(children, all, interner, seen)?;
938                let Some(child) = all.iter().find(|d| d.name == *name) else {
939                    return Err(NotLowerable::at(
940                        at,
941                        format!(
942                        "its template places `<{}/>`, which this renderer cannot see, so whether \
943                         it can be server-rendered is unknown",
944                        interner.resolve(*name)
945                    ),
946                    ));
947                };
948                // A component may place itself (a tree, a thread, a nested menu). A component
949                // already on the path answers "can it be server-rendered" the same way the second
950                // time, so it is not walked again and the walk terminates on any cycle.
951                let repeat = seen.contains(name);
952                if !repeat {
953                    seen.push(*name);
954                }
955                match child.view.as_ref() {
956                    Some(_) if repeat => {}
957                    Some(v) => view_html_lowerable_seen(&v.nodes, all, interner, seen)?,
958                    None => {
959                        return Err(NotLowerable::at(
960                            at,
961                            format!(
962                                "its template places `<{}/>`, which has no `View:` to render",
963                                interner.resolve(*name)
964                            ),
965                        ))
966                    }
967                }
968            }
969            other => {
970                return Err(NotLowerable::at(
971                    at,
972                    format!(
973                        "its template uses {}, which the server-side renderer does not cover yet \
974                     (elements, text, holes, `{{if}}`, `{{inspect}}`, `{{for each}}`, `{{let}}`, a \
975                     placed component, `bind`, a `class:` toggle and an event handler all render \
976                     today)",
977                        other.construct()
978                    ),
979                ))
980            }
981        }
982    }
983    Ok(())
984}
985
986/// Does this view have a `{children}` hole anywhere in it — is this component a WRAPPER?
987pub(crate) fn view_takes_children(nodes: &[ViewNode<'_>]) -> bool {
988    // A slot can sit inside another placement — `View: <Box>{children}</Box>` passes this
989    // component's own children straight through to the one it wraps — which `view_subtrees`
990    // walks like any other nesting.
991    nodes.iter().any(|n| {
992        matches!(n, ViewNode::Children { .. })
993            || view_subtrees(n).into_iter().any(view_takes_children)
994    })
995}
996
997/// Why a `## Route` cannot be lowered to a plain function yet.
998///
999/// A route body IS a function body, so the ones this understands need nothing new from any tier.
1000/// The rest are refused BY NAME rather than lowered into something that half-works: a route that
1001/// answers the wrong thing is worse than one that will not start.
1002/// The types whose LOGOS display IS valid JSON with no quoting — a number, a boolean.
1003///
1004/// ONE definition, read by the record derive and by the router. A route answering an `Int` and a
1005/// record FIELD holding one have to agree about what reaches the wire, and two lists drift the
1006/// first time a numeric type is added.
1007pub fn json_bare_scalar_name(n: &str) -> bool {
1008    n.eq_ignore_ascii_case("Int")
1009        || n.eq_ignore_ascii_case("Nat")
1010        || n.eq_ignore_ascii_case("Bool")
1011        || n.eq_ignore_ascii_case("Real")
1012        || n.eq_ignore_ascii_case("Float")
1013}
1014
1015/// A route's own heading, as its author wrote it — the one place a route names itself in a
1016/// refusal, called by both the tree-walker and the VM compiler so the wording cannot diverge.
1017pub fn route_heading(method: Symbol, path: Symbol, interner: &Interner) -> String {
1018    format!(
1019        "## Route {} {}",
1020        interner.resolve(method),
1021        interner.resolve(path)
1022    )
1023}
1024
1025/// How an LGX construct is named in a refusal: by the header its author wrote.
1026///
1027/// A page's component is interned under a lowered name (`__page__about`) that appears nowhere in
1028/// the file, so a page is named by its `## Page` header, an error page by its status, and a
1029/// component by its declaration — called by both the tree-walker and the VM compiler so the
1030/// wording cannot diverge between them.
1031pub fn construct_heading(def: &ComponentDef<'_>, interner: &Interner) -> String {
1032    match &def.kind {
1033        ComponentKind::Page { path, .. } => format!("## Page {}", interner.resolve(*path)),
1034        ComponentKind::ErrorPage { status } => format!("## Error page {status}"),
1035        ComponentKind::Store { .. } => format!("## Store {}", interner.resolve(def.name)),
1036        ComponentKind::Component => format!("## Component {}", interner.resolve(def.name)),
1037    }
1038}
1039
1040/// A `-> View` function whose body is a template — `## To badge (label: Text) -> View:` with markup
1041/// under it — as its name, its parameters and its view. The one reading of that shape: the served
1042/// twin (`lower_view_function_twins`), the server roots a template places, and the browser tier's own
1043/// lowering of a view function all ask it here.
1044pub(crate) fn view_template<'s, 'a>(
1045    stmt: &'s Stmt<'a>,
1046) -> Option<(
1047    Symbol,
1048    &'s [(Symbol, &'a TypeExpr<'a>)],
1049    &'a crate::ast::view::ViewDef<'a>,
1050)> {
1051    let Stmt::FunctionDef {
1052        name, params, body, ..
1053    } = stmt
1054    else {
1055        return None;
1056    };
1057    let body: &'a [Stmt<'a>] = body;
1058    match body {
1059        [Stmt::Return {
1060            value: Some(Expr::View(view)),
1061        }] => Some((*name, params.as_slice(), &**view)),
1062        _ => None,
1063    }
1064}
1065
1066/// Every `## Route` this program cannot lower, and why — computed ONCE where the whole program is
1067/// visible, for the tiers whose per-statement walk cannot see it.
1068///
1069/// A route's lowerability depends on the COMPONENTS beside it (a `-> View` template may place
1070/// one), so it is not a property of the `Stmt::RouteDef` alone. The VM's statement walk and the
1071/// AOT emitter each reach a route with no program in hand; asking there with an empty component
1072/// list refuses every route that places a component, which is a loud wrong answer where the parser
1073/// already knew the right one. Both read this instead.
1074pub fn route_refusals(
1075    stmts: &[Stmt<'_>],
1076    interner: &Interner,
1077    is_record: &dyn Fn(Symbol) -> bool,
1078) -> Vec<(Symbol, Symbol, NotLowerable)> {
1079    let defs: Vec<ComponentDef<'_>> = stmts
1080        .iter()
1081        .filter_map(|s| match s {
1082            Stmt::ComponentDef(d) => Some((**d).clone()),
1083            _ => None,
1084        })
1085        .collect();
1086    let mut out = Vec::new();
1087    for s in stmts {
1088        let Stmt::RouteDef {
1089            method,
1090            path,
1091            params,
1092            return_type,
1093            body,
1094            span,
1095        } = s
1096        else {
1097            continue;
1098        };
1099        if let Err(why) = route_lowerable(
1100            params,
1101            *return_type,
1102            body,
1103            interner,
1104            is_record,
1105            &defs,
1106            *span,
1107        ) {
1108            out.push((*method, *path, why));
1109        }
1110    }
1111    out
1112}
1113
1114pub fn route_lowerable(
1115    params: &[(Symbol, &TypeExpr<'_>)],
1116    return_type: Option<&TypeExpr<'_>>,
1117    body: &RouteBody<'_>,
1118    interner: &Interner,
1119    is_record: &dyn Fn(Symbol) -> bool,
1120    components: &[ComponentDef<'_>],
1121    // Where the route was declared. A route is not a node, so there is nothing to walk for a
1122    // location — the caller holds the only one, and passing it is what keeps these two refusals
1123    // from being the pair that still reports at the top of the file.
1124    at: Span,
1125) -> Result<(), NotLowerable> {
1126    if let RouteBody::View(view) = body {
1127        // A template body renders to HTML TEXT rather than to DOM nodes — the same tree, a
1128        // different destination. Only the node kinds this walk covers may lower; the rest are
1129        // refused by name rather than rendered as a page with a piece missing.
1130        return view_html_lowerable_with(&view.nodes, components, interner);
1131    }
1132    for (_, ty) in params {
1133        // ★ `Request` IS NOT PARSED FROM ANYWHERE — it is the request itself, handed over whole.
1134        // The other two are read out of the path, the query or the body by name.
1135        let ok = matches!(ty, TypeExpr::Primitive(s) | TypeExpr::Named(s)
1136            if interner.resolve(*s).eq_ignore_ascii_case("Text")
1137                || interner.resolve(*s).eq_ignore_ascii_case("Int")
1138                || interner.resolve(*s).eq_ignore_ascii_case("Request"));
1139        if !ok {
1140            return Err(NotLowerable::at(
1141                at,
1142                "one of its parameters is a type the path matcher cannot bind yet (Text, Int and \
1143                 Request bind today)",
1144            ));
1145        }
1146    }
1147    // Text answers verbatim; a RECORD answers as JSON through its derived encoder; a `Seq of
1148    // <record>` answers as a JSON ARRAY through the list encoder emitted beside it — which is what
1149    // a collection endpoint always is, and the plan's own headline example. Anything else — an
1150    // Option, an enum — has no encoder yet, and answering something plausible would be worse than
1151    // saying so.
1152    match return_type {
1153        Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1154            if interner.resolve(*sym).eq_ignore_ascii_case("Text") =>
1155        {
1156            Ok(())
1157        }
1158        // A view answers as the markup it renders (`lower_view_function_twins`).
1159        Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1160            if interner.resolve(*sym) == "View" =>
1161        {
1162            Ok(())
1163        }
1164        Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym)) if is_record(*sym) => Ok(()),
1165        // A number or a boolean IS valid JSON printed bare — `42`, `true` — so a count endpoint,
1166        // a health check or an id lookup answers without a record to wrap it in.
1167        Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1168            if json_bare_scalar_name(interner.resolve(*sym)) =>
1169        {
1170            Ok(())
1171        }
1172        Some(TypeExpr::Generic { base, params })
1173            if interner.resolve(*base).eq_ignore_ascii_case("Seq")
1174                && params.len() == 1
1175                && matches!(&params[0], TypeExpr::Primitive(s) | TypeExpr::Named(s) if is_record(*s)) =>
1176        {
1177            Ok(())
1178        }
1179        _ => Err(NotLowerable::at(
1180            at,
1181            "it answers with a type the derived JSON codecs do not cover yet (Text, a View, a \
1182             record, and a Seq of records answer today)",
1183        )),
1184    }
1185}
1186
1187/// Whether some `## Store` has an action — something that can commit a store while a turn runs,
1188/// which is what `__LGX_STORES_DIRTY` tracks.
1189pub(crate) fn commits_stores(all: &[ComponentDef<'_>]) -> bool {
1190    all.iter()
1191        .any(|d| matches!(d.kind, ComponentKind::Store { .. }) && !d.actions.is_empty())
1192}
1193
1194pub fn stateful(def: &ComponentDef<'_>) -> bool {
1195    !def.actions.is_empty()
1196        || !def.events.is_empty()
1197        || def.view.as_ref().is_some_and(|v| has_bind(&v.nodes))
1198}
1199
1200pub(crate) fn has_bind(nodes: &[ViewNode<'_>]) -> bool {
1201    nodes.iter().any(|node| {
1202        matches!(node, ViewNode::Element { attrs, .. } if attrs.iter().any(|a| matches!(a, Attr::Bind { .. })))
1203            || view_subtrees(node).into_iter().any(has_bind)
1204    })
1205}
1206
1207/// Whether this view registers ANY listener (`register_listener_with`'s own gate, read
1208/// backwards): a `bind={field}` (`ListenerSite::Bind`/`DefaultToggle`) or a real `ViewNode::
1209/// Handler` (`on-click={…}`/`on click:` — `ListenerSite::Action`, already its own view-tree node
1210/// by the time this runs, per the parser's own desugaring). A component with neither pays nothing
1211/// for `__LISTENER_SITES_<C>`'s ledger or the retire/write-back blocks `lower_component_render`
1212/// would otherwise emit into every render unconditionally — the bundle-size cost
1213/// `bundle_size_lock` measures on a page that places components which never listen for anything.
1214pub(crate) fn has_listener_site(nodes: &[ViewNode<'_>]) -> bool {
1215    nodes.iter().any(|node| {
1216        let here = match node {
1217            ViewNode::Element { attrs, .. } => attrs.iter().any(|a| matches!(a, Attr::Bind { .. })),
1218            ViewNode::Handler { .. } => true,
1219            _ => false,
1220        };
1221        here || view_subtrees(node).into_iter().any(has_listener_site)
1222    })
1223}
1224
1225/// Every node list nested directly under `node` that is drawn in the SAME render function as
1226/// `node` itself — an element's children, each arm of an `{if}`/`{inspect}`, a loop's body and its
1227/// empty branch, a block's default, and a placement's own children (which `slot_argument` renders
1228/// inline, in the placing component's frame). Exhaustive over `ViewNode`, so a walker built on it
1229/// cannot miss a nesting construct the way an enumerated `_ => false` does.
1230pub(crate) fn view_subtrees<'n, 'a>(node: &'n ViewNode<'a>) -> Vec<&'n [ViewNode<'a>]> {
1231    match node {
1232        ViewNode::Element { children, .. } | ViewNode::Component { children, .. } => vec![children],
1233        ViewNode::Each { body, empty, .. } => vec![body, empty],
1234        ViewNode::If {
1235            arms, otherwise, ..
1236        } => {
1237            let mut out: Vec<&'n [ViewNode<'a>]> = arms.iter().map(|(_, b)| &b[..]).collect();
1238            if let Some(o) = otherwise {
1239                out.push(o);
1240            }
1241            out
1242        }
1243        ViewNode::Inspect { arms, .. } => arms.iter().map(|a| &a.body[..]).collect(),
1244        ViewNode::Block { default, .. } => default.iter().map(|d| &d[..]).collect(),
1245        ViewNode::Text { .. }
1246        | ViewNode::Handler { .. }
1247        | ViewNode::Raw { .. }
1248        | ViewNode::Let { .. }
1249        | ViewNode::Extends { .. }
1250        | ViewNode::Children { .. } => Vec::new(),
1251    }
1252}
1253
1254/// The nodes `def`'s own render function draws: its view. A layout it `{extends}` is an instance
1255/// drawn by its own render function, as a placed child is.
1256pub(crate) fn drawn_view<'n, 'a>(def: &'n ComponentDef<'a>) -> &'n [ViewNode<'a>] {
1257    def.view.as_ref().map_or(&[][..], |v| &v.nodes[..])
1258}
1259
1260/// The `{block <name>}` slots `def`'s view declares, in document order, once each — the block
1261/// parameters its render function takes. A view that `{extends}` a layout declares none: its
1262/// `{block name:}` bodies FILL the layout's slots.
1263pub(crate) fn slot_blocks(def: &ComponentDef<'_>) -> Vec<Symbol> {
1264    fn walk(nodes: &[ViewNode<'_>], out: &mut Vec<Symbol>) {
1265        for node in nodes {
1266            if let ViewNode::Block { name, .. } = node {
1267                if !out.contains(name) {
1268                    out.push(*name);
1269                }
1270            }
1271            for sub in view_subtrees(node) {
1272                walk(sub, out);
1273            }
1274        }
1275    }
1276    let Some(view) = &def.view else {
1277        return Vec::new();
1278    };
1279    if super::render::extended_layout(&view.nodes).is_some() {
1280        return Vec::new();
1281    }
1282    let mut out = Vec::new();
1283    walk(&view.nodes, &mut out);
1284    out
1285}
1286
1287/// Whether this component registers ANY listener at all — `has_listener_site` over its view, OR a
1288/// component-level `When … :` handler (`def.events`: a keyed selector, a window event, a message
1289/// subscription, a key press). The latter registers through the exact same `ListenerSite::Action`
1290/// path but OUTSIDE the view tree entirely (there is no `ViewNode` for it), so a component whose
1291/// only listener is one of these was missed by `has_listener_site` alone — the ledger it feeds was
1292/// gated off while the registration it was built to retire still ran, an undeclared-local wasm trap
1293/// waiting to happen the day such a component also got no view-level listener.
1294pub(crate) fn has_any_listener_site(def: &ComponentDef<'_>) -> bool {
1295    has_listener_site(drawn_view(def)) || !def.events.is_empty()
1296}
1297
1298/// Every OWN prop this component's view calls directly as an event handler
1299/// (`on-click={onGo}`, `docs/frontend.md`'s "Passing actions down" pattern) — the shape whose
1300/// TARGET varies by placement and needs its own per-instance identity record, since a closure
1301/// cannot survive in a map the way its identity's two scalar parts can
1302/// (`events.rs::ListenerSite::CallbackProp`'s own doc). Deduplicated: two elements calling the
1303/// same prop share one set of bindings (`events.rs::callback_prop_declarations` declares one).
1304pub(crate) fn callback_prop_targets(def: &ComponentDef<'_>) -> Vec<Symbol> {
1305    let mut out = Vec::new();
1306    if let Some(view) = &def.view {
1307        walk_callback_prop_targets(&view.nodes, def, &mut out);
1308    }
1309    out
1310}
1311
1312fn walk_callback_prop_targets(
1313    nodes: &[ViewNode<'_>],
1314    def: &ComponentDef<'_>,
1315    out: &mut Vec<Symbol>,
1316) {
1317    // A callback can also arrive through a placement's own children, which `slot_argument` lowers
1318    // inline into THIS render — `view_subtrees` walks them with every other nesting.
1319    for node in nodes {
1320        if let ViewNode::Handler { body, .. } = node {
1321            if let Some(Stmt::Call { function, .. }) = body.first() {
1322                if def.props.iter().any(|(p, _)| p == function) && !out.contains(function) {
1323                    out.push(*function);
1324                }
1325            }
1326        }
1327        for sub in view_subtrees(node) {
1328            walk_callback_prop_targets(sub, def, out);
1329        }
1330    }
1331}
1332
1333/// Whether a prop's declared type makes it a CALLBACK: `fn(…) -> …`, or the `Action` spelling the
1334/// resolver accepts for the same shape (`lgx_resolve`'s `named_shape`).
1335pub(crate) fn is_callback_type(ty: &TypeExpr<'_>, interner: &Interner) -> bool {
1336    match ty {
1337        TypeExpr::Function { .. } => true,
1338        TypeExpr::Named(s) | TypeExpr::Primitive(s) => interner.resolve(*s) == "Action",
1339        _ => false,
1340    }
1341}
1342
1343/// Every callback-typed prop `def` declares, in declaration order.
1344pub(crate) fn callback_props<'a>(
1345    def: &ComponentDef<'a>,
1346    interner: &Interner,
1347) -> Vec<(Symbol, &'a TypeExpr<'a>)> {
1348    def.props
1349        .iter()
1350        .copied()
1351        .filter(|(_, t)| is_callback_type(t, interner))
1352        .collect()
1353}
1354
1355/// Every `<Child p={v}/>` in `nodes`, however deeply nested — a placement's own children, a loop's
1356/// body and its `{else:}`, every `{if}` arm, every `{inspect}` arm, a block's default body — with
1357/// the name it places, the props it passes, and where each prop was written.
1358pub(crate) fn for_each_placement<'v, 'a>(
1359    nodes: &'v [ViewNode<'a>],
1360    f: &mut dyn FnMut(Symbol, &'v [(Symbol, &'a Expr<'a>)], &'v [Span], Span),
1361) {
1362    for node in nodes {
1363        match node {
1364            ViewNode::Component {
1365                name,
1366                props,
1367                prop_spans,
1368                children,
1369                span,
1370            } => {
1371                f(*name, props, prop_spans, *span);
1372                for_each_placement(children, f);
1373            }
1374            ViewNode::Element { children, .. } => for_each_placement(children, f),
1375            ViewNode::Each { body, empty, .. } => {
1376                for_each_placement(body, f);
1377                for_each_placement(empty, f);
1378            }
1379            ViewNode::If {
1380                arms, otherwise, ..
1381            } => {
1382                for (_, b) in arms {
1383                    for_each_placement(b, f);
1384                }
1385                if let Some(o) = otherwise {
1386                    for_each_placement(o, f);
1387                }
1388            }
1389            ViewNode::Inspect { arms, .. } => {
1390                for a in arms {
1391                    for_each_placement(&a.body, f);
1392                }
1393            }
1394            ViewNode::Block {
1395                default: Some(d), ..
1396            } => for_each_placement(d, f),
1397            _ => {}
1398        }
1399    }
1400}
1401
1402/// What a callback prop value `value`, written in `placer`'s view, hands down: one of `placer`'s own
1403/// actions, or one of `placer`'s own callback props forwarded unchanged. Anything else carries no
1404/// identity the lowered tier can resolve.
1405pub(crate) enum CallbackSource<'a> {
1406    Action(&'a crate::ast::component::ActionDef<'a>),
1407    Forwarded(Symbol),
1408}
1409
1410pub(crate) fn callback_source<'d, 'a>(
1411    value: &Expr<'a>,
1412    placer: &'d ComponentDef<'a>,
1413    interner: &Interner,
1414) -> Option<CallbackSource<'d>>
1415where
1416    'a: 'd,
1417{
1418    let Expr::Identifier(sym) = value else {
1419        return None;
1420    };
1421    if let Some(action) = placer.actions.iter().find(|a| a.name == *sym) {
1422        return Some(CallbackSource::Action(action));
1423    }
1424    if placer
1425        .props
1426        .iter()
1427        .any(|(p, t)| p == sym && is_callback_type(t, interner))
1428    {
1429        return Some(CallbackSource::Forwarded(*sym));
1430    }
1431    None
1432}
1433
1434/// The parameter types `component`'s callback prop `prop` is called with.
1435///
1436/// A `fn(I…) -> O` prop answers its own `I…`. An `Action` prop names no parameters, so its
1437/// signature is read from what the program hands it: the parameters of the action bound at a
1438/// placement, or — for a value forwarded from the placer's own callback prop — that prop's
1439/// signature. `None` only for an `Action` prop no placement binds to anything.
1440pub(crate) fn callback_signature<'a>(
1441    all: &[ComponentDef<'a>],
1442    component: Symbol,
1443    prop: Symbol,
1444    interner: &Interner,
1445) -> Option<Vec<&'a TypeExpr<'a>>> {
1446    fn go<'a>(
1447        all: &[ComponentDef<'a>],
1448        component: Symbol,
1449        prop: Symbol,
1450        interner: &Interner,
1451        visiting: &mut Vec<(Symbol, Symbol)>,
1452    ) -> Option<Vec<&'a TypeExpr<'a>>> {
1453        let def = all.iter().find(|d| d.name == component)?;
1454        let (_, ty) = def.props.iter().find(|(p, _)| *p == prop)?;
1455        if let TypeExpr::Function { inputs, .. } = ty {
1456            return Some(inputs.iter().collect());
1457        }
1458        if visiting.contains(&(component, prop)) {
1459            return None;
1460        }
1461        visiting.push((component, prop));
1462        for placer in all {
1463            let Some(view) = &placer.view else { continue };
1464            let mut bound: Vec<CallbackSourceOwned> = Vec::new();
1465            for_each_placement(&view.nodes, &mut |name, props, _, _| {
1466                if name != component {
1467                    return;
1468                }
1469                let Some((_, value)) = props.iter().find(|(p, _)| *p == prop) else {
1470                    return;
1471                };
1472                match callback_source(value, placer, interner) {
1473                    Some(CallbackSource::Action(a)) => {
1474                        bound.push(CallbackSourceOwned::Action(a.name))
1475                    }
1476                    Some(CallbackSource::Forwarded(p)) => {
1477                        bound.push(CallbackSourceOwned::Forwarded(p))
1478                    }
1479                    None => {}
1480                }
1481            });
1482            for source in bound {
1483                let found = match source {
1484                    CallbackSourceOwned::Action(a) => placer
1485                        .actions
1486                        .iter()
1487                        .find(|x| x.name == a)
1488                        .map(|x| x.params.iter().map(|(_, t)| *t).collect()),
1489                    CallbackSourceOwned::Forwarded(p) => {
1490                        go(all, placer.name, p, interner, visiting)
1491                    }
1492                };
1493                if found.is_some() {
1494                    return found;
1495                }
1496            }
1497        }
1498        None
1499    }
1500    enum CallbackSourceOwned {
1501        Action(Symbol),
1502        Forwarded(Symbol),
1503    }
1504    go(all, component, prop, interner, &mut Vec::new())
1505}
1506
1507/// Whether two parameter types are the same shape — the question the candidate table asks of every
1508/// action against a callback's signature.
1509pub(crate) fn param_shapes_match(a: &TypeExpr<'_>, b: &TypeExpr<'_>) -> bool {
1510    match (a, b) {
1511        (
1512            TypeExpr::Primitive(x) | TypeExpr::Named(x),
1513            TypeExpr::Primitive(y) | TypeExpr::Named(y),
1514        ) => x == y,
1515        (
1516            TypeExpr::Generic {
1517                base: b1,
1518                params: p1,
1519            },
1520            TypeExpr::Generic {
1521                base: b2,
1522                params: p2,
1523            },
1524        ) => {
1525            b1 == b2
1526                && p1.len() == p2.len()
1527                && p1
1528                    .iter()
1529                    .zip(p2.iter())
1530                    .all(|(x, y)| param_shapes_match(x, y))
1531        }
1532        (TypeExpr::Tuple(a1), TypeExpr::Tuple(a2)) => {
1533            a1.len() == a2.len()
1534                && a1
1535                    .iter()
1536                    .zip(a2.iter())
1537                    .all(|(x, y)| param_shapes_match(x, y))
1538        }
1539        (
1540            TypeExpr::Function {
1541                inputs: i1,
1542                output: o1,
1543            },
1544            TypeExpr::Function {
1545                inputs: i2,
1546                output: o2,
1547            },
1548        ) => {
1549            i1.len() == i2.len()
1550                && i1
1551                    .iter()
1552                    .zip(i2.iter())
1553                    .all(|(x, y)| param_shapes_match(x, y))
1554                && param_shapes_match(o1, o2)
1555        }
1556        (TypeExpr::Mutable { inner: x }, y) | (y, TypeExpr::Mutable { inner: x }) => {
1557            param_shapes_match(x, y)
1558        }
1559        _ => false,
1560    }
1561}
1562
1563/// Why `def`'s view hands a callback prop something the lowered tier cannot resolve, or `None`.
1564///
1565/// A browser calls the closure a render attached; a driving verb, a repaint and a turn cannot, so
1566/// the lowering records which action a callback means — an identity — at the placement. That
1567/// identity exists for exactly two values: one of the placer's own actions, whose parameters match
1568/// the callback's signature, or one of its own callback props forwarded unchanged. Any other value
1569/// is refused here rather than lowered into a page whose handler answers nothing.
1570pub(crate) fn callback_binding_refusal(
1571    def: &ComponentDef<'_>,
1572    all: &[ComponentDef<'_>],
1573    interner: &Interner,
1574) -> Option<NotLowerable> {
1575    let view = def.view.as_ref()?;
1576    let mut refusal: Option<NotLowerable> = None;
1577    for_each_placement(&view.nodes, &mut |name, props, spans, span| {
1578        if refusal.is_some() {
1579            return;
1580        }
1581        let Some(child) = all.iter().find(|d| d.name == name) else {
1582            return;
1583        };
1584        for (i, (prop, value)) in props.iter().enumerate() {
1585            let Some((_, ty)) = child.props.iter().find(|(p, _)| p == prop) else {
1586                continue;
1587            };
1588            if !is_callback_type(ty, interner) {
1589                continue;
1590            }
1591            let at = spans.get(i).copied().unwrap_or(span);
1592            let tag = interner.resolve(name);
1593            let p = interner.resolve(*prop);
1594            match callback_source(value, def, interner) {
1595                None => {
1596                    refusal = Some(NotLowerable::at(
1597                        at,
1598                        format!(
1599                        "`{p}` of `<{tag}>` is a callback, and this value is neither one of this \
1600                         component's actions nor a callback prop it was handed — pass one by name: \
1601                         `{p}={{actionName}}`"
1602                    ),
1603                    ));
1604                    return;
1605                }
1606                Some(CallbackSource::Action(action)) => {
1607                    let Some(sig) = callback_signature(all, name, *prop, interner) else {
1608                        continue;
1609                    };
1610                    let fits = sig.len() == action.params.len()
1611                        && sig
1612                            .iter()
1613                            .zip(action.params.iter())
1614                            .all(|(s, (_, t))| param_shapes_match(s, t));
1615                    if !fits {
1616                        refusal = Some(NotLowerable::at(at, format!(
1617                            "`{p}` of `<{tag}>` is called with one parameter shape, and `{a}` takes \
1618                             another; every action handed to one callback must take the same \
1619                             parameters",
1620                            a = interner.resolve(action.name)
1621                        )));
1622                        return;
1623                    }
1624                }
1625                Some(CallbackSource::Forwarded(_)) => {}
1626            }
1627        }
1628    });
1629    refusal
1630}
1631
1632/// Every action of `def` that `stmts` call by bare name (`Call bump.`), at any depth.
1633pub(crate) fn own_action_calls(stmts: &[Stmt<'_>], def: &ComponentDef<'_>, out: &mut Vec<Symbol>) {
1634    for stmt in stmts {
1635        match stmt {
1636            Stmt::Call { function, .. } => {
1637                if def.actions.iter().any(|a| a.name == *function) && !out.contains(function) {
1638                    out.push(*function);
1639                }
1640            }
1641            Stmt::If {
1642                then_block,
1643                else_block,
1644                ..
1645            } => {
1646                own_action_calls(then_block, def, out);
1647                if let Some(e) = else_block {
1648                    own_action_calls(e, def, out);
1649                }
1650            }
1651            Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
1652                own_action_calls(body, def, out)
1653            }
1654            Stmt::Inspect { arms, .. } => {
1655                for arm in arms {
1656                    own_action_calls(arm.body, def, out);
1657                }
1658            }
1659            _ => {}
1660        }
1661    }
1662}
1663
1664/// An action of `def` that reaches ITSELF through bare `Call <sibling>.` statements, or `None`.
1665///
1666/// The lowered tier runs a sibling call nested in the caller's turn by inlining the sibling's body
1667/// (`Parser::inline_own_action_call`), and a circle of siblings has no finite inlining, so the
1668/// component is refused by name.
1669pub(crate) fn own_action_cycle<'d>(
1670    def: &'d ComponentDef<'_>,
1671) -> Option<&'d crate::ast::component::ActionDef<'d>> {
1672    fn reaches(
1673        def: &ComponentDef<'_>,
1674        from: Symbol,
1675        target: Symbol,
1676        seen: &mut Vec<Symbol>,
1677    ) -> bool {
1678        let Some(action) = def.actions.iter().find(|a| a.name == from) else {
1679            return false;
1680        };
1681        let mut callees = Vec::new();
1682        own_action_calls(action.body, def, &mut callees);
1683        for callee in callees {
1684            if callee == target {
1685                return true;
1686            }
1687            if !seen.contains(&callee) {
1688                seen.push(callee);
1689                if reaches(def, callee, target, seen) {
1690                    return true;
1691                }
1692            }
1693        }
1694        false
1695    }
1696    def.actions
1697        .iter()
1698        .find(|a| reaches(def, a.name, a.name, &mut Vec::new()))
1699}
1700
1701thread_local! {
1702    /// The components whose lowerability is being decided right now — see [`lowerable`].
1703    static CHECKING: std::cell::RefCell<Vec<Symbol>> = const { std::cell::RefCell::new(Vec::new()) };
1704}
1705
1706/// Leaves the in-progress set however [`lowerable`] returns, `?` included.
1707pub(crate) struct CheckingGuard;
1708
1709impl Drop for CheckingGuard {
1710    fn drop(&mut self) {
1711        CHECKING.with(|c| {
1712            c.borrow_mut().pop();
1713        });
1714    }
1715}
1716
1717/// Whether this component's view is expressible as plain DOM calls.
1718///
1719/// `types` is the file's type registry: whether a persisted store's record field, or a typed
1720/// request's `as <T>`, goes through the derived JSON codec is the codec's own question
1721/// (`JsonShapes`), asked here of the registry the lowering emits the codec from.
1722pub fn lowerable<'a>(
1723    def: &ComponentDef<'a>,
1724    all: &[ComponentDef<'a>],
1725    types: Option<&crate::analysis::registry::TypeRegistry>,
1726    interner: &Interner,
1727) -> Result<(), NotLowerable> {
1728    // A component may place itself (`nodes_lowerable` meets `<Tree/>` and asks `lowerable`
1729    // again). "Can this component be lowered" has one answer per component, so a component
1730    // already on the path contributes nothing the second time; every other check runs before the
1731    // descent, so every other reason to refuse is still reached.
1732    if CHECKING.with(|c| c.borrow().contains(&def.name)) {
1733        return Ok(());
1734    }
1735    CHECKING.with(|c| c.borrow_mut().push(def.name));
1736    let _guard = CheckingGuard;
1737    if !matches!(
1738        def.kind,
1739        ComponentKind::Component | ComponentKind::Page { .. }
1740    ) {
1741        return Err(NotLowerable::at(
1742            def.span,
1743            "a store or error page is not a mountable view",
1744        ));
1745    }
1746    let shapes = JsonShapes::new(types, interner);
1747    for used in &def.uses {
1748        match all.iter().find(|d| d.name == *used) {
1749            Some(store) => {
1750                if let Some(why) = store_not_lowerable(store, &shapes) {
1751                    return Err(NotLowerable::at(store.span, why));
1752                }
1753            }
1754            None => {
1755                return Err(NotLowerable::at(
1756                    def.span,
1757                    format!(
1758                        "it uses `{0}`, and this file declares no `## Store {0}`",
1759                        interner.resolve(*used)
1760                    ),
1761                ))
1762            }
1763        }
1764    }
1765    // Invalid CSS is a refusal, pointed at the character that broke it: the render lowering
1766    // emits the `<style>` element only when the stylesheet scopes, so lowering past an invalid
1767    // one would render the page unstyled.
1768    if let Some(style) = &def.style {
1769        let class = scope_class(interner.resolve(def.name));
1770        let css = interner.resolve(style.css);
1771        if let Err(e) = crate::lgx::css::scope(css, &class) {
1772            let at = style.file_offset(css, e.at);
1773            return Err(NotLowerable::at(
1774                Span::new(at, at + 1),
1775                format!(
1776                "its `Style:` block is not valid CSS, so the stylesheet would be dropped and the \
1777                 page would render unstyled. {}",
1778                e.message()
1779            ),
1780            ));
1781        }
1782    }
1783    // A stateful component's triggers must each have a browser delivery. The allowlist below is
1784    // every trigger that does, with the host surface it uses; a trigger added to the language
1785    // without a delivery fails closed here, refused by its written form (`Trigger::form`).
1786    if stateful(def) {
1787        for handler in &def.events {
1788            if !matches!(
1789                handler.trigger,
1790                Trigger::Selector { .. }
1791                    | Trigger::PageLoads
1792                    | Trigger::Appears
1793                    // A turn knows what it wrote, so the watched field is compared before and
1794                    // after (`turn_body`).
1795                    | Trigger::FieldChanges { .. }
1796                    // `onRoute(handler)`, a popstate listener in `runtime.js`.
1797                    | Trigger::RouteChanges
1798                    // Registered on the page (`lower_component_render`) through `body`,
1799                    // `addEventListener` and `eventKey`.
1800                    | Trigger::Key { .. }
1801                    // A typed fetch's failure paths call the handler with the url and the reason.
1802                    | Trigger::FetchFails { .. }
1803                    // A paint stamps every instance it draws with the pass number; the sweep reads
1804                    // which ones it did not draw.
1805                    | Trigger::Disappears
1806                    // One import (`dom_on_window_event`) for all three; `Raise "offline".` causes
1807                    // one where there is no window.
1808                    | Trigger::Online
1809                    | Trigger::Offline
1810                    | Trigger::WindowResizes
1811                    // One geometry read (`dom_scroll_gap`); the threshold is compiled LOGOS.
1812                    | Trigger::ScrollsToBottom { .. }
1813                    // One import (`dom_set_interval`) for the page's clock; which timer is due is
1814                    // compiled LOGOS, so `Advance time by …` runs the same arithmetic.
1815                    | Trigger::Every { .. }
1816                    | Trigger::After { .. }
1817                    // A trap unwinds to the host; the module recorded whose turn it was.
1818                    | Trigger::ActionFails { .. }
1819                    // One read (`dom_event_file_name`); the handler receives `filedropped`.
1820                    | Trigger::FileDropped { .. }
1821                    // One import (`dom_on_message`) subscribing through `logicaffeine_system::relay`;
1822                    // the topic decision is compiled LOGOS.
1823                    | Trigger::Message { .. }
1824            ) {
1825                return Err(NotLowerable::at(
1826                    handler.span,
1827                    format!(
1828                        "its `{}` sentence waits for something this tier cannot deliver yet, so a \
1829                     browser would be missing that handler",
1830                        handler.trigger.form()
1831                    ),
1832                ));
1833            }
1834        }
1835    }
1836    // A typed request whose answer the codec cannot read is refused by its type, rather than
1837    // lowered into a page whose load never happens.
1838    let bodies = def
1839        .actions
1840        .iter()
1841        .map(|a| (a.body, a.span))
1842        .chain(def.events.iter().map(|h| (h.body, h.span)));
1843    for (body, span) in bodies {
1844        if let Some(why) = unreadable_fetch(body, &shapes) {
1845            return Err(NotLowerable::at(span, why));
1846        }
1847    }
1848    let Some(view) = &def.view else {
1849        return Err(NotLowerable::at(def.span, "it has no `View:` to render"));
1850    };
1851    if let Some(why) = callback_binding_refusal(def, all, interner) {
1852        return Err(why);
1853    }
1854    if let Some(action) = own_action_cycle(def) {
1855        return Err(NotLowerable::at(action.span, format!(
1856            "its action `{}` reaches itself through `Call <action>.` statements; a sibling call runs \
1857             nested in the caller's turn, and a circle of them has no end this tier can compile",
1858            interner.resolve(action.name)
1859        )));
1860    }
1861    nodes_lowerable(&view.nodes, all, types, interner)
1862}
1863
1864/// The refusal for the first `Get … as <T>` in `stmts`, at any depth, whose answer the codec
1865/// cannot read ([`JsonShapes::fetch_read`]).
1866fn unreadable_fetch(stmts: &[Stmt<'_>], shapes: &JsonShapes<'_>) -> Option<String> {
1867    let mut refusal = None;
1868    super::json::typed_requests(stmts, &mut |ty| {
1869        if refusal.is_none() {
1870            refusal = shapes.fetch_read(ty).err();
1871        }
1872    });
1873    refusal
1874}
1875
1876pub(crate) fn nodes_lowerable<'a>(
1877    nodes: &[ViewNode<'a>],
1878    all: &[ComponentDef<'a>],
1879    types: Option<&crate::analysis::registry::TypeRegistry>,
1880    interner: &Interner,
1881) -> Result<(), NotLowerable> {
1882    for node in nodes {
1883        let at = node.span();
1884        match node {
1885            ViewNode::Element {
1886                tag,
1887                attrs,
1888                children,
1889                ..
1890            } => {
1891                for attr in attrs {
1892                    match attr {
1893                        Attr::Plain { .. } => {}
1894                        // A `bind` writes `value` or `checked` depending on the element's TYPE,
1895                        // and a computed type leaves that undecidable — refused rather than
1896                        // guessed, since guessing `value` renders a checkbox the browser draws
1897                        // unchecked while the page believes it is checked.
1898                        Attr::Bind { .. } => {
1899                            // Asked through `BoundInput::refusal`, which is total over the kinds.
1900                            if let Some(why) =
1901                                crate::ast::view::bound_input_kind(*tag, attrs, interner).refusal()
1902                            {
1903                                return Err(NotLowerable::at(at, why));
1904                            }
1905                        }
1906                        // A directive runs as a function call against its element; a `When removed:`
1907                        // block runs from the sweep that follows the repaint no longer drawing it.
1908                        Attr::Class { .. } | Attr::Directive { .. } => {}
1909                    }
1910                }
1911                nodes_lowerable(children, all, types, interner)?;
1912            }
1913            ViewNode::Text { .. } => {}
1914            // `{raw h}` drops trusted markup into the containing element: one `setHtml`.
1915            ViewNode::Raw { .. } => {}
1916            ViewNode::If {
1917                arms, otherwise, ..
1918            } => {
1919                for (_, body) in arms {
1920                    nodes_lowerable(body, all, types, interner)?;
1921                }
1922                if let Some(o) = otherwise {
1923                    nodes_lowerable(o, all, types, interner)?;
1924                }
1925            }
1926            ViewNode::Each { body, empty, .. } => {
1927                nodes_lowerable(body, all, types, interner)?;
1928                nodes_lowerable(empty, all, types, interner)?;
1929            }
1930            ViewNode::Let { .. } => {}
1931            ViewNode::Inspect { arms, .. } => {
1932                for arm in arms {
1933                    nodes_lowerable(&arm.body, all, types, interner)?;
1934                }
1935            }
1936            // `{extends L}` draws the layout as an instance of its own — its state, actions and
1937            // registry, keyed like a placement — around the blocks this view draws in its own
1938            // scope, so the layout lowers exactly when a placement of it would. `{extends}` gives
1939            // it no props, so a layout that declares one has nothing to read.
1940            ViewNode::Extends { layout, .. } => match all.iter().find(|d| d.name == *layout) {
1941                Some(l) if !l.props.is_empty() => {
1942                    return Err(NotLowerable::at(
1943                        at,
1944                        format!(
1945                        "the layout it extends, `{}`, declares props, and `{{extends}}` has no way \
1946                         to give them",
1947                        interner.resolve(*layout)
1948                    ),
1949                    ))
1950                }
1951                Some(l) => lowerable(l, all, types, interner)?,
1952                None => {
1953                    return Err(NotLowerable::at(
1954                        at,
1955                        format!(
1956                            "it extends `{0}`, and this file declares no `## Component {0}`",
1957                            interner.resolve(*layout)
1958                        ),
1959                    ))
1960                }
1961            },
1962            ViewNode::Block { default, .. } => {
1963                if let Some(d) = default {
1964                    nodes_lowerable(d, all, types, interner)?;
1965                }
1966            }
1967            // Every qualifier arrives classified by the event-modifier catalogue, and every one
1968            // lowers: a key travels in the dispatch's value slot from a driving verb and through
1969            // `eventKey` from a browser; `.once` is compiled LOGOS (a spent record both the
1970            // dispatcher and the browser's listener consult); `.prevent`, `.stop`, `.capture` and
1971            // `.passive` are declared at the registration for the host to dispatch by. System keys
1972            // never reach here: the template parse refuses them.
1973            ViewNode::Handler { .. } => {}
1974            // A child is only renderable here if IT is: a parent cannot lower past a child that
1975            // needs the runtime, or the page would come out missing a piece with nothing said.
1976            ViewNode::Component { name, children, .. } => {
1977                let Some(child) = all.iter().find(|d| d.name == *name) else {
1978                    return Err(NotLowerable::at(
1979                        at,
1980                        format!(
1981                            "it places `<{0}/>`, and this file declares no `## Component {0}`",
1982                            interner.resolve(*name)
1983                        ),
1984                    ));
1985                };
1986                lowerable(child, all, types, interner)?;
1987                // A wrapper's children are checked here, in the caller: they are drawn here and
1988                // read the caller's scope. The wrapper itself is an instance with its own frame.
1989                nodes_lowerable(children, all, types, interner)?;
1990            }
1991            ViewNode::Children { .. } => {}
1992        }
1993    }
1994    Ok(())
1995}