Skip to main content

pocopine_core/
app.rs

1//! `App` — the builder that wires registered components / stores and
2//! starts the runtime.
3//!
4//! Every component emitted by `#[component]` implements the [`Component`]
5//! trait; every store emitted by `#[store]` implements the [`Store`]
6//! trait. `App` accepts those bounds so a project's startup reads as
7//! a single declarative chain:
8//!
9//! ```ignore
10//! use pocopine::prelude::*;
11//!
12//! #[wasm_bindgen(start)]
13//! pub fn main() {
14//!     App::new()
15//!         .register::<Counter>()
16//!         .register::<TodoList>()
17//!         .store::<Preferences>()
18//!         .plugin(my_observability_plugin())
19//!         .before_mount(|| web_sys::console::log_1(&"booting".into()))
20//!         .run();
21//! }
22//! ```
23//!
24//! `Counter::register()` and `pocopine::run()` still work for
25//! ad-hoc use — the trait is what `App` calls under the hood.
26
27use std::any::{Any, TypeId};
28use std::collections::HashMap;
29use std::fmt;
30use std::future::Future;
31use std::marker::PhantomData;
32use std::pin::Pin;
33use std::rc::Rc;
34
35use crate::lifecycle::LifecycleContext;
36use crate::server::ServerError;
37
38use wasm_bindgen::{JsCast, JsValue};
39use wasm_bindgen_futures::spawn_local;
40use web_sys::Element;
41
42use crate::mount;
43use crate::router;
44use crate::store::Store;
45
46/// Every component participates in the app surface via this trait. The
47/// `#[component]` macro emits the impl automatically.
48pub trait Component {
49    /// The runtime name (kebab-case of the struct ident unless overridden).
50    /// Identical to the registered tag name.
51    const NAME: &'static str;
52    /// Register this component (scope constructor, template, stylesheet)
53    /// with the runtime. Idempotent for the *same* owner — re-registering
54    /// the same `(canonical, owner)` pair is a no-op (RFC 056 §6.1). A
55    /// distinct owner colliding on the same canonical tag records a
56    /// [`crate::RegistryError`] instead of silently overwriting.
57    fn register();
58
59    /// RFC 062 — per-component compiled mount ABI. Macro-emitted
60    /// components override this with a generated mount body; manual
61    /// components have no template plan to apply here.
62    #[doc(hidden)]
63    fn mount_template(
64        _root: &Element,
65        _scope_id: crate::reactive::ScopeId,
66        _proxy: &wasm_bindgen::JsValue,
67    ) {
68    }
69}
70
71/// Component that can be mounted by the client router.
72///
73/// Route-local configuration lives here so guards/loaders stay next
74/// to the component that consumes them. RFC 078 lands this trait
75/// before the guard/loader fields are populated.
76pub trait RouteComponent: Component {
77    fn config() -> RouteConfig<Self>
78    where
79        Self: Sized,
80    {
81        RouteConfig::new()
82    }
83}
84
85/// Route context visible to sync guards.
86pub struct RouteContext<'a> {
87    pub path: &'a str,
88    pub params: &'a HashMap<String, String>,
89    pub query: &'a HashMap<String, String>,
90    pub matched_pattern: Option<&'static str>,
91}
92
93/// Route context passed to page metadata factories.
94pub struct PageMetaContext<'a> {
95    pub path: &'a str,
96    pub full_path: &'a str,
97    pub params: &'a HashMap<String, String>,
98    pub query: &'a HashMap<String, String>,
99    pub hash: Option<&'a str>,
100    pub route_pattern: Option<&'static str>,
101    pub component: Option<&'static str>,
102}
103
104/// Metadata that Pocopine manages in the document `<head>` for the
105/// currently mounted page.
106///
107/// Route components attach this through [`RouteConfig::page_meta`].
108/// Unlike [`RouteMeta`], this is document/head metadata: title,
109/// description, canonical URL, Open Graph tags, robots directives,
110/// and similar browser-visible page metadata.
111#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct PageMeta {
113    title: Option<String>,
114    meta_tags: Vec<PageMetaTag>,
115    links: Vec<PageLink>,
116}
117
118impl PageMeta {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn title(mut self, title: impl Into<String>) -> Self {
124        self.title = Some(title.into());
125        self
126    }
127
128    pub fn description(self, content: impl Into<String>) -> Self {
129        self.meta_name("description", content)
130    }
131
132    pub fn robots(self, content: impl Into<String>) -> Self {
133        self.meta_name("robots", content)
134    }
135
136    pub fn canonical(self, href: impl Into<String>) -> Self {
137        self.link("canonical", href)
138    }
139
140    pub fn og_title(self, content: impl Into<String>) -> Self {
141        self.meta_property("og:title", content)
142    }
143
144    pub fn og_description(self, content: impl Into<String>) -> Self {
145        self.meta_property("og:description", content)
146    }
147
148    pub fn meta_name(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
149        self.meta_tags.push(PageMetaTag::Name {
150            name: name.into(),
151            content: content.into(),
152        });
153        self
154    }
155
156    pub fn meta_property(
157        mut self,
158        property: impl Into<String>,
159        content: impl Into<String>,
160    ) -> Self {
161        self.meta_tags.push(PageMetaTag::Property {
162            property: property.into(),
163            content: content.into(),
164        });
165        self
166    }
167
168    pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
169        self.links.push(PageLink {
170            rel: rel.into(),
171            href: href.into(),
172        });
173        self
174    }
175
176    pub fn title_text(&self) -> Option<&str> {
177        self.title.as_deref()
178    }
179
180    pub fn meta_tags(&self) -> &[PageMetaTag] {
181        &self.meta_tags
182    }
183
184    pub fn links(&self) -> &[PageLink] {
185        &self.links
186    }
187
188    pub fn is_empty(&self) -> bool {
189        self.title.is_none() && self.meta_tags.is_empty() && self.links.is_empty()
190    }
191}
192
193#[derive(Clone, Debug, PartialEq, Eq)]
194pub enum PageMetaTag {
195    Name { name: String, content: String },
196    Property { property: String, content: String },
197}
198
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct PageLink {
201    pub rel: String,
202    pub href: String,
203}
204
205pub(crate) type PageMetaFactory = Rc<dyn for<'a> Fn(&PageMetaContext<'a>) -> PageMeta>;
206
207/// Stable application-owned name for a route.
208///
209/// Named routes are optional. They exist for Rust-side navigation
210/// where the caller wants parameter validation rather than stringly
211/// assembling `/users/{id}` paths.
212#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
213pub struct RouteName(&'static str);
214
215impl RouteName {
216    pub const fn new(name: &'static str) -> Self {
217        Self(name)
218    }
219
220    pub const fn as_str(self) -> &'static str {
221        self.0
222    }
223}
224
225/// Query pairs attached to a [`RouteTarget`].
226#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub struct RouteQuery {
228    pairs: Vec<(String, String)>,
229}
230
231impl RouteQuery {
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    pub fn pair(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
237        self.pairs.push((key.into(), value.into()));
238        self
239    }
240
241    pub fn is_empty(&self) -> bool {
242        self.pairs.is_empty()
243    }
244
245    pub(crate) fn append_to(&self, path: &mut String) {
246        if self.pairs.is_empty() {
247            return;
248        }
249        let hash = path.find('#').map(|idx| path.split_off(idx));
250        let joiner = if path.contains('?') { '&' } else { '?' };
251        path.push(joiner);
252        for (idx, (key, value)) in self.pairs.iter().enumerate() {
253            if idx > 0 {
254                path.push('&');
255            }
256            push_encoded_route_query_part(key, path);
257            path.push('=');
258            push_encoded_route_query_part(value, path);
259        }
260        if let Some(hash) = hash {
261            path.push_str(&hash);
262        }
263    }
264}
265
266impl<const N: usize> From<[(&str, &str); N]> for RouteQuery {
267    fn from(value: [(&str, &str); N]) -> Self {
268        let mut query = RouteQuery::new();
269        for (key, val) in value {
270            query = query.pair(key, val);
271        }
272        query
273    }
274}
275
276/// Percent-encode one path segment for a Pocopine route URL.
277///
278/// This encodes `/`, `?`, `#`, spaces, and other reserved bytes so a
279/// dynamic id can safely become exactly one route segment.
280pub fn encode_route_path_segment(input: &str) -> String {
281    let mut out = String::new();
282    push_encoded_route_path_segment(input, &mut out);
283    out
284}
285
286/// Percent-encode one query key or value for a Pocopine route URL.
287pub fn encode_route_query_part(input: &str) -> String {
288    let mut out = String::new();
289    push_encoded_route_query_part(input, &mut out);
290    out
291}
292
293/// Percent-encode one URL fragment value for a Pocopine route URL.
294pub fn encode_route_fragment(input: &str) -> String {
295    let mut out = String::new();
296    push_encoded_route_query_part(input, &mut out);
297    out
298}
299
300pub(crate) fn push_encoded_route_path_segment(input: &str, out: &mut String) {
301    pocopine_codec::percent_encode_into(out, input);
302}
303
304pub(crate) fn push_encoded_route_query_part(input: &str, out: &mut String) {
305    pocopine_codec::percent_encode_into(out, input);
306}
307
308/// Builder for app-local route URLs.
309///
310/// Use this when any part of the path, query, or fragment comes from
311/// data. `segment(...)`, `query(...)`, and `hash(...)` encode their
312/// inputs before producing the final [`RouteTarget`].
313#[derive(Clone, Debug, Default, PartialEq, Eq)]
314pub struct RouteUrl {
315    path: String,
316    query: RouteQuery,
317    hash: Option<String>,
318}
319
320impl RouteUrl {
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    pub fn root() -> Self {
326        Self {
327            path: "/".into(),
328            query: RouteQuery::new(),
329            hash: None,
330        }
331    }
332
333    /// Start from an already-formed app-local path.
334    pub fn path(path: impl Into<String>) -> Self {
335        Self {
336            path: path.into(),
337            query: RouteQuery::new(),
338            hash: None,
339        }
340    }
341
342    /// Append one encoded path segment.
343    pub fn segment(mut self, value: impl AsRef<str>) -> Self {
344        if self.path.is_empty() || !self.path.ends_with('/') {
345            self.path.push('/');
346        }
347        push_encoded_route_path_segment(value.as_ref(), &mut self.path);
348        self
349    }
350
351    pub fn query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
352        self.query = self.query.pair(key, value);
353        self
354    }
355
356    pub fn query_pairs(mut self, query: impl Into<RouteQuery>) -> Self {
357        self.query = query.into();
358        self
359    }
360
361    pub fn hash(mut self, hash: impl AsRef<str>) -> Self {
362        self.hash = Some(encode_route_fragment(hash.as_ref()));
363        self
364    }
365
366    pub fn into_string(self) -> String {
367        let mut out = if self.path.is_empty() {
368            "/".to_string()
369        } else {
370            self.path
371        };
372        let existing_hash = out.find('#').map(|idx| out.split_off(idx));
373        self.query.append_to(&mut out);
374        if let Some(hash) = self.hash {
375            out.push('#');
376            out.push_str(&hash);
377        } else if let Some(hash) = existing_hash {
378            out.push_str(&hash);
379        }
380        out
381    }
382
383    pub fn target(self) -> Result<RouteTarget, RouteTargetError> {
384        RouteTarget::new(self.into_string())
385    }
386}
387
388/// Concrete client-side route target.
389#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct RouteTarget(String);
391
392impl RouteTarget {
393    pub fn new(path: impl Into<String>) -> Result<Self, RouteTargetError> {
394        let path = path.into();
395        if path.is_empty() {
396            return Err(RouteTargetError::Empty);
397        }
398        if !is_app_local_route_target(&path) {
399            return Err(RouteTargetError::NotAppLocalPath);
400        }
401        if route_target_path(&path).starts_with("/_pocopine") {
402            return Err(RouteTargetError::ReservedNamespace);
403        }
404        Ok(Self(path))
405    }
406
407    pub fn path(path: impl Into<String>) -> Self {
408        match Self::new(path) {
409            Ok(target) => target,
410            Err(err) => panic!("invalid route target: {err}"),
411        }
412    }
413
414    pub fn path_with_query(
415        path: impl Into<String>,
416        query: impl Into<RouteQuery>,
417    ) -> Result<Self, RouteTargetError> {
418        RouteUrl::path(path).query_pairs(query).target()
419    }
420
421    pub fn url(url: RouteUrl) -> Result<Self, RouteTargetError> {
422        url.target()
423    }
424
425    pub fn named(name: RouteName) -> RouteTargetBuilder {
426        RouteTargetBuilder::new(name)
427    }
428
429    pub fn as_str(&self) -> &str {
430        &self.0
431    }
432
433    pub fn into_path(self) -> String {
434        self.0
435    }
436}
437
438#[derive(Clone, Debug, PartialEq, Eq)]
439pub enum RouteTargetError {
440    Empty,
441    NotAppLocalPath,
442    ReservedNamespace,
443    UnknownRouteName(&'static str),
444    DuplicateRouteName(&'static str),
445    MissingParam(String),
446    EmptyParam(String),
447    UnbuildablePattern(&'static str),
448}
449
450impl fmt::Display for RouteTargetError {
451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452        match self {
453            Self::Empty => f.write_str("route target is empty"),
454            Self::NotAppLocalPath => f.write_str("route target must be an app-local path"),
455            Self::ReservedNamespace => {
456                f.write_str("route target uses the reserved /_pocopine namespace")
457            }
458            Self::UnknownRouteName(name) => write!(f, "unknown route name `{name}`"),
459            Self::DuplicateRouteName(name) => write!(f, "duplicate route name `{name}`"),
460            Self::MissingParam(param) => write!(f, "missing route param `{param}`"),
461            Self::EmptyParam(param) => write!(f, "route param `{param}` is empty"),
462            Self::UnbuildablePattern(pattern) => {
463                write!(f, "route pattern `{pattern}` cannot be built")
464            }
465        }
466    }
467}
468
469impl std::error::Error for RouteTargetError {}
470
471fn is_app_local_route_target(path: &str) -> bool {
472    path.starts_with('/') && !path.starts_with("//") && !path.contains('\\')
473}
474
475fn route_target_path(target: &str) -> &str {
476    target
477        .split(['?', '#'])
478        .next()
479        .filter(|path| !path.is_empty())
480        .unwrap_or("/")
481}
482
483/// Builder returned by [`RouteTarget::named`].
484#[derive(Clone, Debug)]
485pub struct RouteTargetBuilder {
486    name: RouteName,
487    params: HashMap<String, String>,
488    query: RouteQuery,
489}
490
491impl RouteTargetBuilder {
492    fn new(name: RouteName) -> Self {
493        Self {
494            name,
495            params: HashMap::new(),
496            query: RouteQuery::new(),
497        }
498    }
499
500    pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
501        self.params.insert(key.into(), value.into());
502        self
503    }
504
505    pub fn query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
506        self.query = self.query.pair(key, value);
507        self
508    }
509
510    pub fn query_pairs(mut self, query: impl Into<RouteQuery>) -> Self {
511        self.query = query.into();
512        self
513    }
514
515    pub fn build(self) -> Result<RouteTarget, RouteTargetError> {
516        crate::router::target_for_name(self.name, &self.params, self.query)
517    }
518}
519
520/// Convert common caller inputs into a validated [`RouteTarget`].
521pub trait IntoRouteTarget {
522    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError>;
523}
524
525impl IntoRouteTarget for RouteTarget {
526    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
527        Ok(self)
528    }
529}
530
531impl IntoRouteTarget for &RouteTarget {
532    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
533        Ok(self.clone())
534    }
535}
536
537impl IntoRouteTarget for &str {
538    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
539        RouteTarget::new(self)
540    }
541}
542
543impl IntoRouteTarget for String {
544    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
545        RouteTarget::new(self)
546    }
547}
548
549impl IntoRouteTarget for RouteUrl {
550    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
551        self.target()
552    }
553}
554
555impl IntoRouteTarget for &RouteUrl {
556    fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
557        self.clone().target()
558    }
559}
560
561#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
562struct RouteMetaId {
563    name: &'static str,
564    type_id: TypeId,
565}
566
567/// Typed key for route metadata.
568///
569/// Metadata is declared inside `RouteComponent::config()` through
570/// [`RouteConfig::meta`]. This keeps route policy local to the route
571/// component while preserving Pocopine's central `App::route(...)`
572/// URL table. This is Vue Router-style route metadata for app UI and
573/// policy; document head metadata belongs in a separate `PageMeta`
574/// surface.
575#[derive(Debug)]
576pub struct RouteMetaKey<T: 'static> {
577    name: &'static str,
578    _marker: PhantomData<fn() -> T>,
579}
580
581impl<T: 'static> Clone for RouteMetaKey<T> {
582    fn clone(&self) -> Self {
583        *self
584    }
585}
586
587impl<T: 'static> Copy for RouteMetaKey<T> {}
588
589impl<T: 'static> RouteMetaKey<T> {
590    pub const fn new(name: &'static str) -> Self {
591        Self {
592            name,
593            _marker: PhantomData,
594        }
595    }
596
597    pub const fn name(self) -> &'static str {
598        self.name
599    }
600
601    fn id(self) -> RouteMetaId {
602        RouteMetaId {
603            name: self.name,
604            type_id: TypeId::of::<T>(),
605        }
606    }
607}
608
609/// Typed route-record metadata.
610///
611/// Use this for data that belongs to the route graph: side-nav labels,
612/// breadcrumbs, access hints, layout flags, analytics names, and other
613/// route-adjacent UI policy. It is intentionally separate from page
614/// metadata such as title, description, canonical URL, or Open Graph
615/// tags.
616#[derive(Clone, Default)]
617pub struct RouteMeta {
618    entries: Vec<RouteMetaEntry>,
619}
620
621impl fmt::Debug for RouteMeta {
622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623        f.debug_struct("RouteMeta")
624            .field("len", &self.entries.len())
625            .finish()
626    }
627}
628
629#[derive(Clone)]
630struct RouteMetaEntry {
631    id: RouteMetaId,
632    value: Rc<dyn Any>,
633}
634
635impl RouteMeta {
636    pub fn new() -> Self {
637        Self::default()
638    }
639
640    pub fn insert<T: 'static>(&mut self, key: RouteMetaKey<T>, value: T) {
641        let id = key.id();
642        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
643            entry.value = Rc::new(value);
644            return;
645        }
646        self.entries.push(RouteMetaEntry {
647            id,
648            value: Rc::new(value),
649        });
650    }
651
652    pub fn get<T: 'static>(&self, key: RouteMetaKey<T>) -> Option<&T> {
653        let id = key.id();
654        self.entries
655            .iter()
656            .find(|entry| entry.id == id)
657            .and_then(|entry| entry.value.as_ref().downcast_ref::<T>())
658    }
659
660    pub fn contains<T: 'static>(&self, key: RouteMetaKey<T>) -> bool {
661        self.get(key).is_some()
662    }
663
664    pub fn is_empty(&self) -> bool {
665        self.entries.is_empty()
666    }
667}
668
669/// Route prefetch policy.
670///
671/// The trigger controls how directives may schedule a prefetch. The
672/// `loader` flag is opt-in because loader functions may be expensive
673/// or touch app data; plain route/code prefetch is the conservative
674/// default.
675#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676pub struct Prefetch {
677    trigger: PrefetchTrigger,
678    loader: bool,
679}
680
681impl Prefetch {
682    pub const fn none() -> Self {
683        Self {
684            trigger: PrefetchTrigger::Never,
685            loader: false,
686        }
687    }
688
689    pub const fn on_intent() -> Self {
690        Self {
691            trigger: PrefetchTrigger::Intent,
692            loader: false,
693        }
694    }
695
696    pub const fn on_visible() -> Self {
697        Self {
698            trigger: PrefetchTrigger::Visible,
699            loader: false,
700        }
701    }
702
703    pub const fn loader(mut self) -> Self {
704        self.loader = true;
705        self
706    }
707
708    pub const fn trigger(self) -> PrefetchTrigger {
709        self.trigger
710    }
711
712    pub const fn includes_loader(self) -> bool {
713        self.loader
714    }
715}
716
717impl Default for Prefetch {
718    fn default() -> Self {
719        Self::none()
720    }
721}
722
723#[derive(Clone, Copy, Debug, PartialEq, Eq)]
724pub enum PrefetchTrigger {
725    Never,
726    Intent,
727    Visible,
728}
729
730/// Reason a route cannot continue through the normal mount path.
731#[derive(Clone, Debug, PartialEq, Eq)]
732pub enum RouteRejection {
733    Unauthorized,
734    Forbidden(&'static str),
735    Blocked(&'static str),
736    NotFound,
737    Server(&'static str),
738    Custom { reason: &'static str },
739}
740
741/// Where in the navigation pipeline a [`RouteRejection`]
742/// originated. Drives the stable identifiers fired in
743/// `RouteNavigationFailed` so observers can tell guard-side and
744/// loader-side rejections apart (RFC-078 §5.10.7).
745#[derive(Clone, Copy, Debug, PartialEq, Eq)]
746pub(crate) enum RejectionSource {
747    /// Synchronous guard (`RouteConfig::guard`) returned `Reject`
748    /// or `Redirect`.
749    Guard,
750    /// Async loader (`RouteConfig::loader`) returned an `Err`
751    /// that mapped to a `RouteRejection`.
752    Loader,
753}
754
755impl RouteRejection {
756    /// Stable closed-set `reason` identifier for this rejection
757    /// **as observed from `source`**. The same `RouteRejection`
758    /// produces `"guard_unauthorized"` when a guard fired it and
759    /// `"loader_unauthorized"` when a loader fired it; observability
760    /// pipelines that aggregate by `reason` can split traffic by
761    /// origin without parsing the rejection enum.
762    pub(crate) fn reason(&self, source: RejectionSource) -> &'static str {
763        match (source, self) {
764            (RejectionSource::Guard, RouteRejection::Unauthorized) => "guard_unauthorized",
765            (RejectionSource::Loader, RouteRejection::Unauthorized) => "loader_unauthorized",
766            (RejectionSource::Guard, RouteRejection::Forbidden(_)) => "guard_forbidden",
767            (RejectionSource::Loader, RouteRejection::Forbidden(_)) => "loader_forbidden",
768            (RejectionSource::Guard, RouteRejection::Blocked(_)) => "guard_blocked",
769            (RejectionSource::Loader, RouteRejection::Blocked(_)) => "loader_blocked",
770            (RejectionSource::Guard, RouteRejection::NotFound) => "guard_not_found",
771            (RejectionSource::Loader, RouteRejection::NotFound) => "loader_not_found",
772            (RejectionSource::Guard, RouteRejection::Server(_)) => "guard_server_error",
773            (RejectionSource::Loader, RouteRejection::Server(_)) => "loader_server_error",
774            // Custom rejections own their reason string; both
775            // sources surface the user-supplied identifier
776            // verbatim. Apps that want a source-distinguished
777            // custom reason should encode the source into their
778            // chosen identifier.
779            (_, RouteRejection::Custom { reason }) => reason,
780        }
781    }
782}
783
784/// Decision returned by a sync route guard.
785#[derive(Clone, Debug, PartialEq, Eq)]
786pub enum RouteGuardDecision {
787    Allow,
788    /// Guard cannot decide yet because an async client-side
789    /// prerequisite is still hydrating. The router leaves the
790    /// current outlet untouched and waits for the owner of that
791    /// prerequisite to call [`crate::reevaluate_current`].
792    Pending,
793    Reject(RouteRejection),
794    Redirect(RouteTarget),
795}
796
797/// Sync guard evaluated before a route component mounts.
798pub trait RouteGuard: 'static {
799    fn decide(&self, ctx: &RouteContext<'_>) -> RouteGuardDecision;
800}
801
802impl<F> RouteGuard for F
803where
804    F: for<'a> Fn(&RouteContext<'a>) -> RouteGuardDecision + 'static,
805{
806    fn decide(&self, ctx: &RouteContext<'_>) -> RouteGuardDecision {
807        self(ctx)
808    }
809}
810
811/// Context passed to route rejection handlers.
812pub struct RouteRejectionContext<'a> {
813    pub path: &'a str,
814    pub params: &'a HashMap<String, String>,
815    pub query: &'a HashMap<String, String>,
816    pub matched_pattern: Option<&'static str>,
817}
818
819/// Action a route rejection handler can take for a rejected navigation.
820#[derive(Clone, Debug, PartialEq, Eq)]
821pub enum RouteRejectionAction {
822    Redirect(RouteTarget),
823    Paint(RouteErrorSurface),
824    AbortNavigation,
825}
826
827/// Generic route error surface painted when route control stops.
828#[derive(Clone, Debug, PartialEq, Eq)]
829pub struct RouteErrorSurface {
830    pub title: &'static str,
831    pub message: &'static str,
832}
833
834impl RouteErrorSurface {
835    pub const fn new(title: &'static str, message: &'static str) -> Self {
836        Self { title, message }
837    }
838
839    pub(crate) fn for_rejection(rejection: &RouteRejection) -> Self {
840        match rejection {
841            RouteRejection::Unauthorized => Self::new(
842                "Authentication required",
843                "Sign in to continue to this route.",
844            ),
845            RouteRejection::Forbidden(_) => {
846                Self::new("Access denied", "You do not have access to this route.")
847            }
848            RouteRejection::Blocked(_) => Self::new(
849                "Route unavailable",
850                "This route is not available right now.",
851            ),
852            RouteRejection::NotFound => Self::new("Route not found", "No route matched this URL."),
853            RouteRejection::Server(_) => Self::new(
854                "Route unavailable",
855                "This route could not be loaded right now.",
856            ),
857            RouteRejection::Custom { .. } => Self::new(
858                "Route unavailable",
859                "This route is not available right now.",
860            ),
861        }
862    }
863}
864
865/// App/plugin extension point for rejected route navigations.
866pub trait RouteRejectionHandler: 'static {
867    fn handle(
868        &self,
869        ctx: &RouteRejectionContext<'_>,
870        rejection: &RouteRejection,
871    ) -> Option<RouteRejectionAction>;
872}
873
874impl<F> RouteRejectionHandler for F
875where
876    F: for<'a> Fn(&RouteRejectionContext<'a>, &RouteRejection) -> Option<RouteRejectionAction>
877        + 'static,
878{
879    fn handle(
880        &self,
881        ctx: &RouteRejectionContext<'_>,
882        rejection: &RouteRejection,
883    ) -> Option<RouteRejectionAction> {
884        self(ctx, rejection)
885    }
886}
887
888/// Owned per-navigation context handed to a route loader.
889///
890/// All fields are owned (`String` / `HashMap`) so the loader's
891/// future can outlive the synchronous call that constructed the
892/// context. The router clones the matched route's `params` /
893/// `query` map at navigation time and passes the result through.
894///
895/// `navigation_token` records the [`RouteToken`] that was current
896/// when the loader started; long-running loaders can poll
897/// [`Self::is_navigation_active`] to short-circuit work once the
898/// router has moved on (RFC-078 §5.10.5). The router itself drops
899/// any result returned under a stale token, so honoring this is
900/// only an optimisation — correctness of the painted view does
901/// not depend on the loader checking. `abort_signal` is also
902/// inherited automatically by generated `#[server]` calls made while
903/// the loader future is being polled, so route supersession cancels
904/// the underlying browser fetch.
905#[derive(Clone, Debug)]
906pub struct LoaderContext {
907    pub path: String,
908    pub params: HashMap<String, String>,
909    pub query: HashMap<String, String>,
910    pub matched_pattern: Option<&'static str>,
911    pub navigation_token: crate::router::RouteToken,
912    pub(crate) abort_signal: Option<web_sys::AbortSignal>,
913}
914
915impl LoaderContext {
916    /// `true` when the navigation that started this loader is
917    /// still the router's current one. Returning `false` means the
918    /// user navigated away while the loader was in flight; the
919    /// loader can early-exit (its result will be dropped anyway).
920    pub fn is_navigation_active(&self) -> bool {
921        crate::router::route_token_is_current(self.navigation_token)
922    }
923
924    /// Browser abort signal for this route navigation, when the
925    /// platform supplied one. Generated `#[server]` stubs inherit this
926    /// automatically inside loader futures; direct `fetch::call`
927    /// users can also pass it through `FetchOptions`.
928    pub fn abort_signal(&self) -> Option<web_sys::AbortSignal> {
929        self.abort_signal.clone()
930    }
931}
932
933/// Failure modes a route loader can return.
934///
935/// `Unauthorized` and `Forbidden` flow through the existing
936/// [`RouteRejection`] chain — the loader doesn't have to know
937/// that the auth plugin is the eventual handler. `NotFound` and
938/// `Server` likewise dispatch through the rejection chain so apps
939/// can wire a single error surface that handles both
940/// guard-rejection and loader-failure.
941///
942/// `From<ServerError>` lets a loader body call `?` on a
943/// `#[server]` invocation and surface the right router signal
944/// automatically.
945#[derive(Debug)]
946pub enum LoaderError {
947    Unauthorized,
948    Forbidden(String),
949    NotFound(String),
950    Server(ServerError),
951}
952
953impl From<ServerError> for LoaderError {
954    fn from(err: ServerError) -> Self {
955        match err {
956            ServerError::Unauthorized(_) => LoaderError::Unauthorized,
957            ServerError::Forbidden(reason) => LoaderError::Forbidden(reason),
958            // App / BadRequest / Network all collapse into Server
959            // for routing purposes — the loader closure is free to
960            // pre-translate them to NotFound/Forbidden if it has
961            // app-specific knowledge.
962            other => LoaderError::Server(other),
963        }
964    }
965}
966
967impl LoaderError {
968    /// Convert a loader failure into the matching route rejection.
969    /// The dynamic message string is **dropped** at this boundary;
970    /// per RFC-078 §5.10.7 the rejection chain operates on stable
971    /// closed-set identifiers, never on user-visible error
972    /// strings. Apps that need to surface the original message
973    /// should consume `LoaderError` directly through a custom
974    /// route-error component (see [`RouteErrorSurface`]).
975    pub fn to_rejection(&self) -> RouteRejection {
976        match self {
977            LoaderError::Unauthorized => RouteRejection::Unauthorized,
978            LoaderError::Forbidden(_) => RouteRejection::Forbidden("loader_forbidden"),
979            LoaderError::NotFound(_) => RouteRejection::NotFound,
980            LoaderError::Server(_) => RouteRejection::Server("loader_server_error"),
981        }
982    }
983}
984
985/// Pinned future returned by [`RouteLoader::run`]. Erases the
986/// concrete loader output type into `Box<dyn Any>` so the router
987/// can drive every route's loader through a uniform call site;
988/// the [`Loader<T>`] extractor downcasts the contents inside the
989/// component's lifecycle hook.
990pub type RouteLoaderFuture = Pin<Box<dyn Future<Output = Result<Box<dyn Any>, LoaderError>>>>;
991
992/// Trait-erased route loader stored in the route registry.
993///
994/// `RouteConfig::loader(...)` accepts any closure returning a
995/// future of `Result<T, LoaderError>` and wraps it into this
996/// trait object.
997pub trait RouteLoader: 'static {
998    fn run(&self, ctx: LoaderContext) -> RouteLoaderFuture;
999}
1000
1001struct LoaderClosure<F, T> {
1002    f: F,
1003    _t: PhantomData<fn() -> T>,
1004}
1005
1006impl<F, Fut, T> RouteLoader for LoaderClosure<F, T>
1007where
1008    F: Fn(LoaderContext) -> Fut + 'static,
1009    Fut: Future<Output = Result<T, LoaderError>> + 'static,
1010    T: 'static,
1011{
1012    fn run(&self, ctx: LoaderContext) -> RouteLoaderFuture {
1013        let abort_signal = ctx.abort_signal();
1014        let fut = (self.f)(ctx);
1015        Box::pin(crate::fetch::with_abort_signal_future(
1016            abort_signal,
1017            async move { fut.await.map(|t| Box::new(t) as Box<dyn Any>) },
1018        ))
1019    }
1020}
1021
1022/// Loader-produced data extracted in a component lifecycle hook.
1023///
1024/// Mirrors the [`crate::plugin::Plugin<T>`] shape: held as an
1025/// `Rc<T>`, dereferences to `&T`, populated by the router into a
1026/// thread-local pending slot just before the component mounts and
1027/// taken by the [`From<LifecycleContext>`] impl during setup.
1028///
1029/// `Option<Loader<T>>` is supported for components that may also
1030/// be mounted via `App::mount_subtree` (no router, no loader);
1031/// required `Loader<T>` panics if the slot is empty or the stored
1032/// value's type doesn't match `T`.
1033pub struct Loader<T: 'static> {
1034    data: Rc<T>,
1035}
1036
1037impl<T: 'static> Clone for Loader<T> {
1038    fn clone(&self) -> Self {
1039        Self {
1040            data: self.data.clone(),
1041        }
1042    }
1043}
1044
1045impl<T: 'static> Loader<T> {
1046    pub fn get(&self) -> &T {
1047        &self.data
1048    }
1049}
1050
1051impl<T: 'static> std::ops::Deref for Loader<T> {
1052    type Target = T;
1053
1054    fn deref(&self) -> &T {
1055        &self.data
1056    }
1057}
1058
1059impl<T: 'static> From<LifecycleContext<'_>> for Loader<T> {
1060    fn from(ctx: LifecycleContext<'_>) -> Self {
1061        match crate::router::take_pending_loader_data::<T>(ctx.scope_id) {
1062            Some(loader) => loader,
1063            None => panic!(
1064                "Loader<{}>: no loader data is available for the mounting \
1065                 component. Either the route has no `RouteConfig::loader(...)` \
1066                 or the component is being mounted via `App::mount_subtree` \
1067                 (in which case use `Option<Loader<{}>>`).",
1068                std::any::type_name::<T>(),
1069                std::any::type_name::<T>(),
1070            ),
1071        }
1072    }
1073}
1074
1075impl<T: 'static> From<LifecycleContext<'_>> for Option<Loader<T>> {
1076    fn from(ctx: LifecycleContext<'_>) -> Self {
1077        crate::router::take_pending_loader_data::<T>(ctx.scope_id)
1078    }
1079}
1080
1081impl<T: 'static> Loader<T> {
1082    pub(crate) fn from_rc(data: Rc<T>) -> Self {
1083        Self { data }
1084    }
1085}
1086
1087/// Route-local configuration for component `C`.
1088#[derive(Clone)]
1089pub struct RouteConfig<C: Component> {
1090    pub(crate) name: Option<RouteName>,
1091    pub(crate) meta: RouteMeta,
1092    pub(crate) page_meta: Option<PageMetaFactory>,
1093    pub(crate) guards: Vec<Rc<dyn RouteGuard>>,
1094    pub(crate) loader: Option<Rc<dyn RouteLoader>>,
1095    pub(crate) prefetch: Prefetch,
1096    _component: PhantomData<fn() -> C>,
1097}
1098
1099impl<C: Component> RouteConfig<C> {
1100    pub fn new() -> Self {
1101        Self {
1102            name: None,
1103            meta: RouteMeta::new(),
1104            page_meta: None,
1105            guards: Vec::new(),
1106            loader: None,
1107            prefetch: Prefetch::none(),
1108            _component: PhantomData,
1109        }
1110    }
1111
1112    /// Give this route a stable Rust-side name for typed navigation.
1113    pub fn name(mut self, name: RouteName) -> Self {
1114        self.name = Some(name);
1115        self
1116    }
1117
1118    /// Attach typed metadata to this route.
1119    ///
1120    /// Route metadata is route policy/configuration, not component
1121    /// state or document head metadata. Define static keys near the
1122    /// plugin or feature that consumes them, then attach values from
1123    /// `RouteComponent::config()`.
1124    pub fn meta<T: 'static>(mut self, key: RouteMetaKey<T>, value: T) -> Self {
1125        self.meta.insert(key, value);
1126        self
1127    }
1128
1129    /// Attach document/head metadata for this route.
1130    ///
1131    /// The factory runs after a successful navigation and receives the
1132    /// resolved route params/query. Use this for page title,
1133    /// description, canonical URL, Open Graph tags, and similar
1134    /// browser-visible metadata.
1135    pub fn page_meta(
1136        mut self,
1137        meta: impl for<'a> Fn(&PageMetaContext<'a>) -> PageMeta + 'static,
1138    ) -> Self {
1139        self.page_meta = Some(Rc::new(meta));
1140        self
1141    }
1142
1143    /// Attach static document/head metadata for this route.
1144    pub fn static_page_meta(mut self, meta: PageMeta) -> Self {
1145        self.page_meta = Some(Rc::new(move |_| meta.clone()));
1146        self
1147    }
1148
1149    /// Attach a synchronous client-side guard.
1150    ///
1151    /// Guards are a paint/UX primitive, not an authorization
1152    /// boundary. Any sensitive data touched by the route must still be
1153    /// protected by server-side `#[server(guard = ...)]` checks.
1154    pub fn guard(mut self, guard: impl RouteGuard) -> Self {
1155        self.guards.push(Rc::new(guard));
1156        self
1157    }
1158
1159    /// Attach an async loader to this route. The router runs the
1160    /// loader after guards Allow and before the component mounts;
1161    /// the produced `T` is read by a [`Loader<T>`] extractor in
1162    /// the component's lifecycle hook.
1163    ///
1164    /// **Only one loader per route.** Calling `loader` a second
1165    /// time panics at config-build time — multiple parallel
1166    /// fetches should compose inside a single loader body
1167    /// (`try_join!` returning a struct).
1168    pub fn loader<F, Fut, T>(mut self, loader: F) -> Self
1169    where
1170        F: Fn(LoaderContext) -> Fut + 'static,
1171        Fut: Future<Output = Result<T, LoaderError>> + 'static,
1172        T: 'static,
1173    {
1174        if self.loader.is_some() {
1175            panic!(
1176                "RouteConfig::loader called twice — only one loader per \
1177                 route is supported. Compose multiple async fetches inside \
1178                 a single loader body (`tokio::try_join!` returning a struct \
1179                 of all the data the route needs)."
1180            );
1181        }
1182        self.loader = Some(Rc::new(LoaderClosure {
1183            f: loader,
1184            _t: PhantomData,
1185        }));
1186        self
1187    }
1188
1189    /// Configure route prefetch behavior.
1190    ///
1191    /// Route/code prefetch is conservative; loader prefetch happens
1192    /// only when [`Prefetch::loader`] is set. Guards still run before
1193    /// a loader prefetch, so rejected or pending routes do not fetch
1194    /// loader data speculatively.
1195    pub fn prefetch(mut self, prefetch: Prefetch) -> Self {
1196        self.prefetch = prefetch;
1197        self
1198    }
1199
1200    pub(crate) fn into_runtime(self) -> router::RouteRuntimeConfig {
1201        router::RouteRuntimeConfig {
1202            name: self.name,
1203            meta: self.meta,
1204            page_meta: self.page_meta,
1205            guards: self.guards,
1206            loader: self.loader,
1207            prefetch: self.prefetch,
1208        }
1209    }
1210}
1211
1212impl<C: Component> Default for RouteConfig<C> {
1213    fn default() -> Self {
1214        Self::new()
1215    }
1216}
1217
1218#[cfg(test)]
1219mod route_config_tests {
1220    use super::*;
1221
1222    struct TestRoute;
1223
1224    impl Component for TestRoute {
1225        const NAME: &'static str = "test-route";
1226
1227        fn register() {}
1228    }
1229
1230    impl RouteComponent for TestRoute {}
1231
1232    #[test]
1233    fn route_component_default_config_has_no_guards() {
1234        let config = TestRoute::config();
1235        assert!(config.guards.is_empty());
1236    }
1237
1238    #[test]
1239    fn route_config_records_name_and_prefetch_policy() {
1240        const TEST: RouteName = RouteName::new("test");
1241
1242        let config = RouteConfig::<TestRoute>::new()
1243            .name(TEST)
1244            .prefetch(Prefetch::on_intent().loader());
1245
1246        assert_eq!(config.name, Some(TEST));
1247        assert_eq!(config.prefetch.trigger(), PrefetchTrigger::Intent);
1248        assert!(config.prefetch.includes_loader());
1249    }
1250
1251    #[test]
1252    fn route_config_records_typed_metadata() {
1253        const REQUIRES_AUTH: RouteMetaKey<bool> = RouteMetaKey::new("requires_auth");
1254        const SECTION: RouteMetaKey<&'static str> = RouteMetaKey::new("section");
1255
1256        let config = RouteConfig::<TestRoute>::new()
1257            .meta(REQUIRES_AUTH, true)
1258            .meta(SECTION, "admin");
1259
1260        assert_eq!(config.meta.get(REQUIRES_AUTH), Some(&true));
1261        assert_eq!(config.meta.get(SECTION), Some(&"admin"));
1262        assert!(config.meta.contains(REQUIRES_AUTH));
1263    }
1264
1265    #[test]
1266    fn page_meta_builder_records_head_tags() {
1267        let meta = PageMeta::new()
1268            .title("Dashboard")
1269            .description("Team dashboard")
1270            .canonical("/dashboard")
1271            .og_title("Dashboard");
1272
1273        assert_eq!(meta.title_text(), Some("Dashboard"));
1274        assert_eq!(
1275            meta.meta_tags(),
1276            &[
1277                PageMetaTag::Name {
1278                    name: "description".into(),
1279                    content: "Team dashboard".into()
1280                },
1281                PageMetaTag::Property {
1282                    property: "og:title".into(),
1283                    content: "Dashboard".into()
1284                }
1285            ]
1286        );
1287        assert_eq!(
1288            meta.links(),
1289            &[PageLink {
1290                rel: "canonical".into(),
1291                href: "/dashboard".into()
1292            }]
1293        );
1294    }
1295
1296    #[test]
1297    fn route_config_records_page_meta_factory() {
1298        let mut params = HashMap::new();
1299        params.insert("id".into(), "42".into());
1300        let query = HashMap::new();
1301        let config = RouteConfig::<TestRoute>::new()
1302            .page_meta(|ctx| PageMeta::new().title(format!("Story {}", ctx.params["id"])));
1303        let ctx = PageMetaContext {
1304            path: "/story/42",
1305            full_path: "/story/42",
1306            params: &params,
1307            query: &query,
1308            hash: None,
1309            route_pattern: Some("/story/:id"),
1310            component: Some("test-route"),
1311        };
1312
1313        let page_meta = config.page_meta.as_ref().expect("page meta")(&ctx);
1314
1315        assert_eq!(page_meta.title_text(), Some("Story 42"));
1316    }
1317
1318    // ── Store-reference scanner (RFC: missing-store boot-error) ──
1319
1320    #[test]
1321    fn collect_store_names_extracts_single_ref() {
1322        let mut into = Vec::new();
1323        super::collect_store_names("$store.preferences.theme", &mut into);
1324        assert_eq!(into, vec!["preferences".to_string()]);
1325    }
1326
1327    #[test]
1328    fn collect_store_names_handles_multiple_and_dedupes() {
1329        let mut into = Vec::new();
1330        // Walks every path in the AST — both ternary branches and
1331        // the condition. The repeated `$store.a` is deduped.
1332        super::collect_store_names("$store.a == $store.b ? $store.a : $store.b", &mut into);
1333        assert_eq!(into, vec!["a".to_string(), "b".to_string()]);
1334    }
1335
1336    #[test]
1337    fn collect_store_names_ignores_bare_store_token() {
1338        // `$store` without a `.` is the container object, not a
1339        // specific store. Don't claim it as a missing reference.
1340        let mut into = Vec::new();
1341        super::collect_store_names("$store", &mut into);
1342        assert!(into.is_empty());
1343    }
1344
1345    #[test]
1346    fn collect_store_names_ignores_string_literal_lookalike() {
1347        // The whole point of the AST-based scanner: text that looks
1348        // like `$store.X` inside a string literal must NOT be picked
1349        // up as a missing-store reference. A substring scanner would
1350        // false-positive here.
1351        let mut into = Vec::new();
1352        super::collect_store_names("'$store.example was renamed'", &mut into);
1353        assert!(into.is_empty());
1354    }
1355
1356    #[test]
1357    fn collect_store_names_finds_refs_in_call_arguments() {
1358        // Refs nested inside call arguments / binops must still be
1359        // discovered.
1360        let mut into = Vec::new();
1361        super::collect_store_names("debug($store.flags.verbose)", &mut into);
1362        assert_eq!(into, vec!["flags".to_string()]);
1363    }
1364
1365    #[test]
1366    fn collect_store_names_silent_on_parse_failure() {
1367        // Macro-time parse failures are surfaced by the template
1368        // pipeline as `compile_error!`. This scanner runs at boot
1369        // on `&'static str` plan sources that already survived the
1370        // macro, but stays defensive: a parse failure here just
1371        // means "no refs to add", never a panic.
1372        let mut into = Vec::new();
1373        super::collect_store_names("status === 'queued'", &mut into);
1374        assert!(into.is_empty());
1375    }
1376
1377    #[test]
1378    fn check_store_registrations_passes_when_complete() {
1379        // No vtables → no scanned refs → no missing.
1380        let result = super::check_store_registrations(&[], &["preferences"]);
1381        assert!(result.is_ok());
1382    }
1383
1384    #[test]
1385    fn route_config_stores_sync_guards() {
1386        let config = RouteConfig::<TestRoute>::new()
1387            .guard(|_: &RouteContext<'_>| RouteGuardDecision::Reject(RouteRejection::Blocked("x")));
1388        assert_eq!(config.guards.len(), 1);
1389
1390        let params = HashMap::new();
1391        let query = HashMap::new();
1392        let ctx = RouteContext {
1393            path: "/test",
1394            params: &params,
1395            query: &query,
1396            matched_pattern: Some("/test"),
1397        };
1398        assert_eq!(
1399            config.guards[0].decide(&ctx),
1400            RouteGuardDecision::Reject(RouteRejection::Blocked("x"))
1401        );
1402    }
1403
1404    #[test]
1405    fn route_target_accepts_only_app_local_paths() {
1406        assert_eq!(RouteTarget::path("/login").into_path(), "/login");
1407        assert_eq!(
1408            RouteTarget::new("https://example.com/login"),
1409            Err(RouteTargetError::NotAppLocalPath)
1410        );
1411        assert_eq!(
1412            RouteTarget::new("//example.com/login"),
1413            Err(RouteTargetError::NotAppLocalPath)
1414        );
1415        assert_eq!(
1416            RouteTarget::new("/_pocopine/server-fn"),
1417            Err(RouteTargetError::ReservedNamespace)
1418        );
1419        assert_eq!(
1420            RouteTarget::new("/_pocopine"),
1421            Err(RouteTargetError::ReservedNamespace)
1422        );
1423        assert_eq!(
1424            RouteTarget::new("/_pocopine-secret/admin"),
1425            Err(RouteTargetError::ReservedNamespace)
1426        );
1427        assert_eq!(RouteTarget::new(""), Err(RouteTargetError::Empty));
1428    }
1429
1430    #[test]
1431    fn route_target_builds_query_strings() {
1432        let target = RouteTarget::path_with_query(
1433            "/search",
1434            RouteQuery::from([("q", "router api"), ("tab", "a&b")]),
1435        )
1436        .unwrap();
1437
1438        assert_eq!(target.into_path(), "/search?q=router%20api&tab=a%26b");
1439
1440        let target = RouteTarget::path_with_query("/search#results", [("q", "router")]).unwrap();
1441
1442        assert_eq!(target.into_path(), "/search?q=router#results");
1443    }
1444
1445    #[test]
1446    fn route_url_encodes_segments_query_and_hash() {
1447        let target = RouteUrl::new()
1448            .segment("users")
1449            .segment("user/42")
1450            .query("tab", "a b")
1451            .hash("top#section")
1452            .target()
1453            .unwrap();
1454
1455        assert_eq!(
1456            target.into_path(),
1457            "/users/user%2F42?tab=a%20b#top%23section"
1458        );
1459    }
1460
1461    #[test]
1462    fn route_url_replaces_existing_hash_when_hash_is_set() {
1463        let target = RouteUrl::path("/reports#old")
1464            .query("tab", "active")
1465            .hash("new")
1466            .target()
1467            .unwrap();
1468
1469        assert_eq!(target.into_path(), "/reports?tab=active#new");
1470    }
1471
1472    #[test]
1473    fn route_encoding_helpers_are_public_and_stable() {
1474        assert_eq!(encode_route_path_segment("a/b c"), "a%2Fb%20c");
1475        assert_eq!(encode_route_query_part("a&b c"), "a%26b%20c");
1476        assert_eq!(encode_route_fragment("top#2"), "top%232");
1477    }
1478
1479    #[test]
1480    fn app_records_route_rejection_handlers() {
1481        let app = App::new().route_rejection_handler(
1482            |_: &RouteRejectionContext<'_>, _: &RouteRejection| {
1483                Some(RouteRejectionAction::AbortNavigation)
1484            },
1485        );
1486
1487        assert_eq!(app.route_rejection_handlers.len(), 1);
1488    }
1489
1490    #[test]
1491    fn route_rejection_handler_closure_can_redirect() {
1492        let handler = |ctx: &RouteRejectionContext<'_>, rejection: &RouteRejection| {
1493            assert_eq!(ctx.path, "/admin");
1494            assert_eq!(ctx.params.get("section"), Some(&"users".to_string()));
1495            assert_eq!(ctx.query.get("tab"), Some(&"active".to_string()));
1496            assert_eq!(ctx.matched_pattern, Some("/admin/:section"));
1497            assert_eq!(rejection, &RouteRejection::Unauthorized);
1498            Some(RouteRejectionAction::Redirect(RouteTarget::path("/login")))
1499        };
1500        let mut params = HashMap::new();
1501        params.insert("section".to_string(), "users".to_string());
1502        let mut query = HashMap::new();
1503        query.insert("tab".to_string(), "active".to_string());
1504        let ctx = RouteRejectionContext {
1505            path: "/admin",
1506            params: &params,
1507            query: &query,
1508            matched_pattern: Some("/admin/:section"),
1509        };
1510
1511        assert_eq!(
1512            handler.handle(&ctx, &RouteRejection::Unauthorized),
1513            Some(RouteRejectionAction::Redirect(RouteTarget::path("/login")))
1514        );
1515    }
1516
1517    #[test]
1518    fn loader_error_from_server_error_maps_unauthorized() {
1519        let err: LoaderError = ServerError::Unauthorized("token expired".into()).into();
1520        assert!(matches!(err, LoaderError::Unauthorized));
1521
1522        let err: LoaderError = ServerError::Forbidden("missing role".into()).into();
1523        assert!(matches!(err, LoaderError::Forbidden(reason) if reason == "missing role"));
1524
1525        let err: LoaderError = ServerError::App("kaboom".into()).into();
1526        assert!(matches!(err, LoaderError::Server(_)));
1527
1528        let err: LoaderError = ServerError::BadRequest("nope".into()).into();
1529        assert!(matches!(err, LoaderError::Server(_)));
1530    }
1531
1532    #[test]
1533    fn loader_error_to_rejection_drops_dynamic_messages() {
1534        // §5.10.7: rejection-chain reasons are stable closed-set
1535        // identifiers, never user-visible error strings. The
1536        // mapping enforces that — dynamic messages stay inside the
1537        // `LoaderError` value the app gets via a custom error
1538        // surface but never reach the rejection chain.
1539        assert_eq!(
1540            LoaderError::Unauthorized.to_rejection(),
1541            RouteRejection::Unauthorized
1542        );
1543        assert_eq!(
1544            LoaderError::Forbidden("policy:account_locked".into()).to_rejection(),
1545            RouteRejection::Forbidden("loader_forbidden")
1546        );
1547        assert_eq!(
1548            LoaderError::NotFound("user 42".into()).to_rejection(),
1549            RouteRejection::NotFound
1550        );
1551        assert_eq!(
1552            LoaderError::Server(ServerError::App("db down".into())).to_rejection(),
1553            RouteRejection::Server("loader_server_error")
1554        );
1555    }
1556
1557    #[test]
1558    fn route_config_loader_records_one_loader() {
1559        let config = RouteConfig::<TestRoute>::new()
1560            .loader(|_ctx: LoaderContext| async move { Ok::<_, LoaderError>(42_u32) });
1561        assert!(config.loader.is_some());
1562    }
1563
1564    #[test]
1565    #[should_panic(expected = "RouteConfig::loader called twice")]
1566    fn route_config_loader_panics_on_duplicate_registration() {
1567        let _ = RouteConfig::<TestRoute>::new()
1568            .loader(|_: LoaderContext| async move { Ok::<_, LoaderError>(1_u32) })
1569            .loader(|_: LoaderContext| async move { Ok::<_, LoaderError>(2_u32) });
1570    }
1571
1572    #[test]
1573    fn loader_from_rc_constructs_loader_from_shared_rc() {
1574        // The router migrates the one-shot pending value into a
1575        // per-scope `Rc` slot on first read; subsequent extractors
1576        // construct `Loader<T>` directly from that shared `Rc`
1577        // through `Loader::from_rc`.
1578        let rc: Rc<String> = Rc::new("hello".to_string());
1579        let strong_count_before = Rc::strong_count(&rc);
1580        let loader = Loader::<String>::from_rc(rc.clone());
1581        assert_eq!(*loader.get(), "hello");
1582        // Loader holds its own clone of the Rc.
1583        assert!(Rc::strong_count(&rc) > strong_count_before);
1584        // Deref to `&T`.
1585        assert_eq!(loader.len(), 5);
1586    }
1587
1588    #[test]
1589    fn app_records_route_error_component() {
1590        let app = App::new().route_error_component::<TestRoute>();
1591        assert_eq!(app.route_error_component, Some(TestRoute::NAME));
1592    }
1593
1594    #[test]
1595    fn app_records_not_found_component() {
1596        let app = App::new().not_found_component::<TestRoute>();
1597        assert_eq!(app.not_found_component, Some(TestRoute::NAME));
1598    }
1599
1600    #[test]
1601    fn app_overrides_default_to_none_for_route_error_and_not_found() {
1602        // Without configuration both slots are `None` so the router
1603        // falls back to its built-in surfaces (HTML banner for the
1604        // error, no-op for the 404).
1605        let app = App::new();
1606        assert!(app.route_error_component.is_none());
1607        assert!(app.not_found_component.is_none());
1608    }
1609
1610    #[test]
1611    fn rejection_reason_distinguishes_guard_and_loader_source() {
1612        // Same rejection variant emits a different stable
1613        // identifier depending on whether a guard or a loader
1614        // produced it. RFC §5.10.7: closed-set reasons let
1615        // observability split traffic by origin without parsing
1616        // the rejection enum.
1617        assert_eq!(
1618            RouteRejection::Unauthorized.reason(RejectionSource::Guard),
1619            "guard_unauthorized"
1620        );
1621        assert_eq!(
1622            RouteRejection::Unauthorized.reason(RejectionSource::Loader),
1623            "loader_unauthorized"
1624        );
1625        assert_eq!(
1626            RouteRejection::Forbidden("policy_x").reason(RejectionSource::Guard),
1627            "guard_forbidden"
1628        );
1629        assert_eq!(
1630            RouteRejection::Forbidden("policy_x").reason(RejectionSource::Loader),
1631            "loader_forbidden"
1632        );
1633        assert_eq!(
1634            RouteRejection::NotFound.reason(RejectionSource::Guard),
1635            "guard_not_found"
1636        );
1637        assert_eq!(
1638            RouteRejection::NotFound.reason(RejectionSource::Loader),
1639            "loader_not_found"
1640        );
1641        assert_eq!(
1642            RouteRejection::Server("db_down").reason(RejectionSource::Guard),
1643            "guard_server_error"
1644        );
1645        assert_eq!(
1646            RouteRejection::Server("db_down").reason(RejectionSource::Loader),
1647            "loader_server_error"
1648        );
1649        assert_eq!(
1650            RouteRejection::Blocked("ab_test").reason(RejectionSource::Guard),
1651            "guard_blocked"
1652        );
1653        assert_eq!(
1654            RouteRejection::Blocked("ab_test").reason(RejectionSource::Loader),
1655            "loader_blocked"
1656        );
1657    }
1658
1659    #[test]
1660    fn rejection_reason_custom_passes_user_string_through() {
1661        // Custom rejections own their reason string verbatim;
1662        // both sources surface the user-supplied identifier so
1663        // apps can encode whatever taxonomy they need.
1664        let custom = RouteRejection::Custom {
1665            reason: "tenant_quota_exceeded",
1666        };
1667        assert_eq!(
1668            custom.reason(RejectionSource::Guard),
1669            "tenant_quota_exceeded"
1670        );
1671        assert_eq!(
1672            custom.reason(RejectionSource::Loader),
1673            "tenant_quota_exceeded"
1674        );
1675    }
1676
1677    #[test]
1678    fn route_error_surface_uses_generic_messages() {
1679        assert_eq!(
1680            RouteErrorSurface::for_rejection(&RouteRejection::Forbidden("secret policy name")),
1681            RouteErrorSurface::new("Access denied", "You do not have access to this route.")
1682        );
1683        assert_eq!(
1684            RouteErrorSurface::for_rejection(&RouteRejection::Server("database exploded")),
1685            RouteErrorSurface::new(
1686                "Route unavailable",
1687                "This route could not be loaded right now."
1688            )
1689        );
1690    }
1691}
1692
1693type Hook = Box<dyn FnOnce()>;
1694
1695/// App-level extension point.
1696///
1697/// Plugins receive the in-progress [`App`] builder and return it after
1698/// installing lifecycle hooks, stores, devtools, logging, analytics, or other
1699/// app-level wiring. This keeps optional integrations out of `pocopine-core`:
1700/// a separate crate can expose a plugin value, and applications opt into it
1701/// from their entrypoint.
1702pub trait AppPlugin {
1703    fn name(&self) -> &'static str {
1704        std::any::type_name::<Self>()
1705    }
1706
1707    fn install(self, app: App) -> App;
1708}
1709
1710impl<F> AppPlugin for F
1711where
1712    F: FnOnce(App) -> App,
1713{
1714    fn install(self, app: App) -> App {
1715        self(app)
1716    }
1717}
1718
1719/// Application-level wiring and lifecycle.
1720///
1721/// Construct with [`App::new`], chain `.register::<T>()` / `.store::<S>()`
1722/// / `.before_mount(...)` / `.after_mount(...)`, and end with `.run()`.
1723#[derive(Default)]
1724pub struct App {
1725    components: Vec<&'static str>,
1726    stores: Vec<&'static str>,
1727    routes: Vec<&'static str>,
1728    before_mount: Vec<Hook>,
1729    after_mount: Vec<Hook>,
1730    route_rejection_handlers: Vec<Rc<dyn RouteRejectionHandler>>,
1731    /// Component painted when a [`RouteRejection`] reaches the
1732    /// fallback (no rejection handler returned `Some(action)`).
1733    /// `None` falls back to the built-in
1734    /// [`paint_route_error_surface`](router::paint_route_error_surface)
1735    /// HTML banner.
1736    route_error_component: Option<&'static str>,
1737    /// Component painted when no route matches the URL and no
1738    /// wildcard route is registered. `None` keeps the prior
1739    /// behaviour (route-state updates, no component paint).
1740    not_found_component: Option<&'static str>,
1741    plugins: crate::plugin::PluginRegistry,
1742    installing_plugin: Option<&'static str>,
1743    devtools: bool,
1744}
1745
1746impl App {
1747    pub fn new() -> Self {
1748        Self::default()
1749    }
1750
1751    /// Register a component. Delegates to the trait method and records the
1752    /// runtime name for introspection. Idempotent — calling
1753    /// `register::<C>` after `route::<C>(...)` (or vice versa) leaves
1754    /// `C::NAME` in the manifest exactly once.
1755    pub fn register<C: Component>(mut self) -> Self {
1756        C::register();
1757        self.record_component_name(C::NAME);
1758        self
1759    }
1760
1761    /// Register a store singleton. Delegates to [`Store::__register_store`].
1762    pub fn store<S: Store>(mut self) -> Self {
1763        S::__register_store();
1764        self.stores.push(S::STORE_NAME);
1765        self
1766    }
1767
1768    /// Install an app-level plugin.
1769    ///
1770    /// The plugin runs while the builder is still being assembled, before
1771    /// registry verification and mount work. External crates should prefer
1772    /// this over asking applications to patch core startup logic.
1773    pub fn plugin<P: AppPlugin>(mut self, plugin: P) -> Self {
1774        let name = plugin.name();
1775        let previous = self.installing_plugin.replace(name);
1776        let mut app = plugin.install(self);
1777        app.installing_plugin = previous;
1778        app
1779    }
1780
1781    /// Provide a typed runtime service to component lifecycle hooks and
1782    /// framework plugin hooks.
1783    ///
1784    /// Components extract this service with `Plugin<T>` or
1785    /// `Option<Plugin<T>>` from `on_setup`, `on_mount`, `on_ready`, and
1786    /// `on_unmount`. This is the primary extension path for reusable
1787    /// components: the app installs one capability, and every component that
1788    /// knows how to use it can opt in without being listed by the plugin.
1789    pub fn provide_plugin<T: 'static>(mut self, service: T) -> Self {
1790        self.plugins.provide(service, self.installing_plugin);
1791        self
1792    }
1793
1794    /// Dispatch framework event `E` to the installed plugin service `T`.
1795    ///
1796    /// `T` must have been provided with [`Self::provide_plugin`] and must
1797    /// implement [`crate::Hook<E>`].
1798    pub fn hook_plugin<T, E>(mut self) -> Self
1799    where
1800        T: crate::plugin::Hook<E> + 'static,
1801        E: Clone + 'static,
1802    {
1803        self.plugins.hook_plugin::<T, E>(self.installing_plugin);
1804        self
1805    }
1806
1807    /// Dispatch framework event `E` for component `C` to plugin service `T`.
1808    ///
1809    /// The service implements `Hook<ForComponent<C, E>>`, so the component
1810    /// filter is carried in the type system and the runtime performs the
1811    /// component-name match before invoking the hook. Use this for
1812    /// app-specific overrides or special cases where the plugin intentionally
1813    /// targets a known component type. Reusable component families should
1814    /// normally opt into a provided capability with `Plugin<T>` or
1815    /// `Option<Plugin<T>>` instead.
1816    pub fn hook_component_plugin<T, C, E>(mut self) -> Self
1817    where
1818        T: crate::plugin::Hook<crate::plugin::ForComponent<C, E>> + 'static,
1819        C: Component + 'static,
1820        E: crate::plugin::ComponentEvent,
1821    {
1822        self.plugins
1823            .hook_component_plugin::<T, C, E>(self.installing_plugin);
1824        self
1825    }
1826
1827    /// Register a route. `pattern` is a path with optional `:name`
1828    /// segments (`"/blog/:id"`) or the 404 fallback `"*"`. `C` must
1829    /// be a `#[component]` that implements [`RouteComponent`];
1830    /// `C::config()` supplies any guards / loaders the route should
1831    /// honour. Matching routes paint the component into the
1832    /// `<pp-outlet>` with captured params passed through as
1833    /// attributes.
1834    ///
1835    /// Components that don't need guards or loaders impl
1836    /// `RouteComponent` with a one-line empty body —
1837    /// `impl RouteComponent for MyComponent {}` — and inherit the
1838    /// default empty `RouteConfig`. The bound exists specifically to
1839    /// guarantee that a component declaring guards
1840    /// (e.g. `require_auth`) cannot silently be mounted unguarded
1841    /// by the wrong builder method.
1842    pub fn route<C: RouteComponent>(self, pattern: &'static str) -> Self {
1843        self.route_with::<C>(pattern, C::config())
1844    }
1845
1846    /// Source-compatible alias for [`Self::route`].
1847    ///
1848    /// Both resolve `C::config()` and register through `route_with`.
1849    /// Kept for app code that adopted the explicit name during the
1850    /// RFC-078 staging window.
1851    pub fn route_component<C: RouteComponent>(self, pattern: &'static str) -> Self {
1852        self.route::<C>(pattern)
1853    }
1854
1855    /// Register a route with explicit route-local configuration.
1856    ///
1857    /// This is the additive RFC 078 entrypoint used while the existing
1858    /// `route::<C>` API remains source-compatible. Once guards/loaders
1859    /// are implemented and call sites migrate, `route::<C:
1860    /// RouteComponent>` can become the primary shorthand for
1861    /// `route_with(pattern, C::config())`.
1862    pub fn route_with<C: Component>(
1863        mut self,
1864        pattern: &'static str,
1865        config: RouteConfig<C>,
1866    ) -> Self {
1867        C::register();
1868        router::register_route_with_config(pattern, C::NAME, config.into_runtime());
1869        self.routes.push(pattern);
1870        self.record_component_name(C::NAME);
1871        self
1872    }
1873
1874    /// Push `name` into the component manifest if not already
1875    /// present. Routes silently call this so route-only direct-
1876    /// builder apps (`App::new().route::<C>(...).run()`) report
1877    /// the right `component_count` in `AppBootStarted` and surface
1878    /// the right names through `App::registered_components()`.
1879    fn record_component_name(&mut self, name: &'static str) {
1880        if !self.components.contains(&name) {
1881            self.components.push(name);
1882        }
1883    }
1884
1885    /// Install a generic route rejection handler.
1886    ///
1887    /// Plugins should use this instead of extending the base [`App`]
1888    /// with auth-specific methods such as login-route configuration.
1889    pub fn route_rejection_handler<H: RouteRejectionHandler>(mut self, handler: H) -> Self {
1890        self.route_rejection_handlers.push(Rc::new(handler));
1891        self
1892    }
1893
1894    /// Configure the component the router mounts when a
1895    /// [`RouteRejection`] reaches the fallback (no installed
1896    /// [`RouteRejectionHandler`] returned `Some(action)`).
1897    ///
1898    /// Without this configuration the router paints the built-in
1899    /// minimal [`RouteErrorSurface`] HTML banner — intentionally
1900    /// plain so production apps notice and override it. Setting a
1901    /// component lets the app paint branded error UI; the
1902    /// component is mounted into the outlet just like a normal
1903    /// route component, so it can use the full template / handler
1904    /// surface.
1905    ///
1906    /// Apps that want to read the rejection variant inside the
1907    /// error component can do so by registering a
1908    /// [`RouteRejectionHandler`] that captures the rejection into
1909    /// reactive state before falling through; the router does not
1910    /// pass rejection metadata into the error component
1911    /// implicitly (closed-set reasons only, per RFC-078 §5.10.7).
1912    pub fn route_error_component<C: Component>(mut self) -> Self {
1913        C::register();
1914        self.route_error_component = Some(C::NAME);
1915        self
1916    }
1917
1918    /// Configure the component the router mounts when no
1919    /// registered route matches the URL **and** no `*` wildcard
1920    /// route is registered. With a wildcard route in place the
1921    /// wildcard wins (its guards / loader / config still run);
1922    /// this slot is the lower-friction alternative for apps that
1923    /// don't want to dedicate a routing slot to 404s.
1924    ///
1925    /// Without this configuration unmatched URLs leave the outlet
1926    /// in its previous state — the route-state is updated and any
1927    /// `$route.*` bindings re-evaluate, but no new component
1928    /// mounts.
1929    pub fn not_found_component<C: Component>(mut self) -> Self {
1930        C::register();
1931        self.not_found_component = Some(C::NAME);
1932        self
1933    }
1934
1935    /// **Internal — invoked by the `app!{}` macro; do not call
1936    /// directly.** Records a route without eagerly calling
1937    /// `C::register()`. Safe only when paired with
1938    /// [`Self::run_with_registry`]: the static `&'static phf::Map`
1939    /// is the authoritative registry, and skipping the eager
1940    /// `register()` keeps it that way. Direct user calls would
1941    /// route to a component the registry never registered.
1942    ///
1943    /// Resolves `C::config()` so guards / loaders declared on
1944    /// route components flow through both the macro path and
1945    /// direct-builder path identically. The `RouteComponent` bound
1946    /// matches [`Self::route`] — every component used in
1947    /// `app!{ routes: [...] }` must implement `RouteComponent`
1948    /// (default empty body is fine).
1949    #[doc(hidden)]
1950    pub fn route_static<C: RouteComponent>(mut self, pattern: &'static str) -> Self {
1951        router::register_route_with_config(pattern, C::NAME, C::config().into_runtime());
1952        self.routes.push(pattern);
1953        self.record_component_name(C::NAME);
1954        self
1955    }
1956
1957    /// **Internal — invoked by the `app!{}` macro; do not call directly.**
1958    /// Records a component name from the macro's static registry without
1959    /// calling its register function. This gives plugins a complete component
1960    /// manifest before [`Self::run_with_registry`] performs the authoritative
1961    /// registry walk.
1962    ///
1963    /// The recorded name is **manifest visibility, not registration** —
1964    /// plugins reading [`Self::registered_components`] will see entries
1965    /// from this method even though the underlying component is not yet
1966    /// in the runtime registry. Only [`Self::run_with_registry`]
1967    /// (driven by the macro's static `phf::Map`) and the eager
1968    /// [`Self::register`] / [`Self::route`] paths actually register a
1969    /// component for mount.
1970    #[doc(hidden)]
1971    pub fn component_static(mut self, name: &'static str) -> Self {
1972        self.record_component_name(name);
1973        self
1974    }
1975
1976    /// Run `f` before the initial DOM walk.
1977    pub fn before_mount(mut self, f: impl FnOnce() + 'static) -> Self {
1978        self.before_mount.push(Box::new(f));
1979        self
1980    }
1981
1982    /// Run `f` after the initial DOM walk completes. Scheduled on the next
1983    /// microtask so scopes bound during the walk are visible.
1984    pub fn after_mount(mut self, f: impl FnOnce() + 'static) -> Self {
1985        self.after_mount.push(Box::new(f));
1986        self
1987    }
1988
1989    /// Install the devtools overlay on `run()`. The panel lists every
1990    /// live scope, its current state, and its registered refs. Toggle
1991    /// visibility with `Ctrl+Shift+D`. Keep this off in release builds
1992    /// — the poll loop is cheap but not free.
1993    ///
1994    /// When the crate is built with `--no-default-features`
1995    /// (devtools feature disabled), this method still exists for
1996    /// API stability but the flag is ignored at `run()` time.
1997    pub fn with_devtools(mut self) -> Self {
1998        self.devtools = true;
1999        self
2000    }
2001
2002    /// RFC 060 Tier 4 — variant of [`Self::run`] that drives the
2003    /// registry off an explicit `&'static phf::Map<&'static str,
2004    /// &'static ComponentVTable>` produced by the `app!{}`
2005    /// macro. Iterates the map's values and calls each vtable's
2006    /// `register` fn (idempotent via the Tier 1
2007    /// `mark_registered` guard) before mounting. The thread-local
2008    /// runtime registries still back lookups; this method is
2009    /// the bridge between the static phf surface and the
2010    /// existing runtime data.
2011    pub fn run_with_registry(
2012        self,
2013        registry: &'static phf::Map<&'static str, &'static crate::registry::ComponentVTable>,
2014    ) {
2015        crate::registry::set_active_phf_registry(registry);
2016        for vtable in registry.values() {
2017            (vtable.register)();
2018        }
2019        self.run();
2020    }
2021
2022    /// Fire pre-mount hooks, mount registered components on the body,
2023    /// initialise the router (if any routes were registered), then
2024    /// fire post-mount hooks.
2025    ///
2026    /// RFC 056 §6.2: before any mount work the registry is verified;
2027    /// when collisions exist the boot error surface is rendered and
2028    /// no further mount work runs.
2029    pub fn run(self) {
2030        // Install the panic-to-`console.error` hook before anything
2031        // else. The framework authors directive `.expect(...)`
2032        // messages throughout (`#[store]` "not registered — call
2033        // App::store::<_>() first" being the canonical example),
2034        // and without this hook a wasm panic surfaces as
2035        // "RuntimeError: unreachable executed" with no message.
2036        // `set_once()` is safe to call from every App::run().
2037        console_error_panic_hook::set_once();
2038        let Self {
2039            components,
2040            stores,
2041            routes,
2042            before_mount,
2043            after_mount,
2044            route_rejection_handlers,
2045            route_error_component,
2046            not_found_component,
2047            plugins,
2048            installing_plugin: _,
2049            devtools,
2050        } = self;
2051        let boot_start_ms = js_sys::Date::now();
2052        clear_existing_boot_errors();
2053        if let Err(errors) = plugins.validate() {
2054            // Defensive reset: an earlier successful App::run on the
2055            // same wasm runtime would have activated its own
2056            // registry. Returning here without clearing would leave
2057            // those services and hooks active, so a subsequent
2058            // App::mount_subtree call (or a stray Plugin<T>
2059            // extractor) would resolve against stale plugin context
2060            // even though the framework has refused to mount this
2061            // app. Drop the previous registry first so failure is
2062            // observable as "no plugins" rather than "old plugins".
2063            crate::plugin::activate(crate::plugin::PluginRegistry::default());
2064            router::set_route_rejection_handlers(Vec::new());
2065            router::set_route_error_component(None);
2066            router::set_not_found_component(None);
2067            // Same reasoning for fetch middleware: a plugin may
2068            // have called `fetch::install_middleware` in its
2069            // `install` fn before validation discovered the
2070            // missing service. Refused-to-mount apps must not
2071            // leave privileged middleware live — clear it and
2072            // freeze so subsequent install attempts panic.
2073            crate::fetch::clear_and_freeze();
2074            crate::plugin::render_plugin_boot_error(&errors);
2075            return;
2076        }
2077        crate::plugin::activate(plugins);
2078        router::set_route_rejection_handlers(route_rejection_handlers);
2079        router::set_route_error_component(route_error_component);
2080        router::set_not_found_component(not_found_component);
2081        // Close the fetch-middleware install seam — RFC-078 §5.10.3
2082        // requires plugin install to run before the first
2083        // `App::run` so middleware can't be slipped in after the
2084        // trust boundary closed. This is the canonical close point.
2085        crate::fetch::freeze_middleware_chain();
2086        crate::plugin::emit(crate::plugin::AppBootStarted {
2087            component_count: components.len(),
2088            route_count: routes.len(),
2089        });
2090        if let Err(errors) = crate::registry::verify_registry() {
2091            crate::plugin::emit(crate::plugin::AppBootFailed {
2092                reason: "component_registry",
2093            });
2094            crate::registry::render_boot_error(&errors);
2095            return;
2096        }
2097        // Cross-check $store.X references in compiled component
2098        // templates against `App::store::<T>()` registrations.
2099        // Without this, a missing `.store::<T>()` call deferred-
2100        // panics at first $store access with a stack trace that
2101        // looks like a framework bug. Fail loud at boot with the
2102        // exact missing names instead.
2103        if let Err(missing) = check_store_registrations(&components, &stores) {
2104            crate::plugin::emit(crate::plugin::AppBootFailed {
2105                reason: "missing_store_registration",
2106            });
2107            render_missing_store_boot_error(&missing);
2108            return;
2109        }
2110        // Inject the animate-preset atom stylesheet before any
2111        // component `register()` injects per-component styles, so
2112        // the preset atoms live earlier in the cascade and
2113        // component styles still win on specificity ties.
2114        crate::animate::install();
2115        for f in before_mount {
2116            f();
2117        }
2118        // RFC 061 Phase 2 — discover the [pp-app] root and mount
2119        // there. Whole-body mounting is gone; apps that want
2120        // multiple roots use `mount_subtree::<C>` instead.
2121        let Some(window) = web_sys::window() else {
2122            crate::plugin::emit(crate::plugin::AppBootFailed {
2123                reason: "missing_window",
2124            });
2125            return;
2126        };
2127        let Some(document) = window.document() else {
2128            crate::plugin::emit(crate::plugin::AppBootFailed {
2129                reason: "missing_document",
2130            });
2131            return;
2132        };
2133        let pp_app = document.query_selector("[pp-app]").ok().flatten();
2134        if let Some(host) = pp_app {
2135            mount_pp_app_subtree(&host);
2136        } else {
2137            crate::plugin::emit(crate::plugin::AppBootFailed {
2138                reason: "missing_pp_app_root",
2139            });
2140            render_missing_pp_app_root();
2141            return;
2142        }
2143        if !routes.is_empty() {
2144            router::init();
2145        }
2146        #[cfg(feature = "devtools")]
2147        if devtools {
2148            crate::devtools::install();
2149        }
2150        #[cfg(not(feature = "devtools"))]
2151        let _ = devtools;
2152        let after = after_mount;
2153        if !after.is_empty() {
2154            spawn_local(async move {
2155                let _ = js_sys::Promise::resolve(&JsValue::NULL);
2156                for f in after {
2157                    f();
2158                }
2159            });
2160        }
2161        let elapsed = js_sys::Date::now() - boot_start_ms;
2162        crate::plugin::emit(crate::plugin::AppBootCompleted {
2163            duration_ms: if elapsed.is_finite() && elapsed >= 0.0 {
2164                elapsed
2165            } else {
2166                0.0
2167            },
2168        });
2169    }
2170
2171    /// Snapshot of the registered component names. Debug utility only —
2172    /// the runtime registry is the source of truth.
2173    pub fn registered_components(&self) -> &[&'static str] {
2174        &self.components
2175    }
2176
2177    /// Snapshot of the registered store names. Debug utility only.
2178    pub fn registered_stores(&self) -> &[&'static str] {
2179        &self.stores
2180    }
2181
2182    /// Snapshot of the registered route patterns. Debug utility only.
2183    pub fn registered_routes(&self) -> &[&'static str] {
2184        &self.routes
2185    }
2186
2187    /// RFC 061 Phase 2 — typed escape hatch for mounting a
2188    /// `#[component]` into an arbitrary DOM element. Intended
2189    /// for tooling: devtools panels, test harnesses,
2190    /// Storybook-style component galleries, embedded widgets.
2191    /// Default app shape stays [`App::run`] with a `[pp-app]`
2192    /// root.
2193    ///
2194    /// Registers `C` (idempotent via the Tier 1 guard) and
2195    /// mounts it onto `host`. Returns a [`SubtreeHandle`] whose
2196    /// `unmount()` tears down the scope tree + lifecycle hooks
2197    /// + DOM cleanly.
2198    ///
2199    /// Subtree mounts inherit plugins from the most recent [`App::run`].
2200    /// If no app has run, `Plugin<T>` extractors observe an empty registry
2201    /// and `Option<Plugin<T>>` extractors return `None`.
2202    pub fn mount_subtree<C: Component>(host: &Element) -> SubtreeHandle {
2203        C::register();
2204        mount::mount_child_component(host, C::NAME);
2205        mount::finalize_compiled_subtree(host);
2206        SubtreeHandle {
2207            host: host.clone(),
2208            active: true,
2209        }
2210    }
2211}
2212
2213/// Parse a compile-time expression source with `pocopine_expr` and
2214/// collect every `$store.<name>` path reference into `into`.
2215///
2216/// AST-based (not substring-based) so the same literal text inside
2217/// a string literal (e.g. `pp-text="'$store.example missing'"`)
2218/// can't trip the boot check — the parser sees that occurrence as
2219/// a `Literal::String`, not an `Expr::Path`. A parse failure means
2220/// the macro-time pipeline already surfaced a compile error for
2221/// that expression, so silently skipping it here is fine.
2222fn collect_store_names(src: &str, into: &mut Vec<String>) {
2223    let Ok(ast) = pocopine_expr::parse(src) else {
2224        return;
2225    };
2226    visit_paths(&ast.value, &mut |segments| {
2227        if segments.len() >= 2 && segments[0] == "$store" {
2228            let name = segments[1].clone();
2229            if !into.contains(&name) {
2230                into.push(name);
2231            }
2232        }
2233    });
2234}
2235
2236/// Walk every `Expr::Path` (including the LHS of `Expr::Assign`)
2237/// in the AST, including paths inside nested binops / ternaries /
2238/// call arguments / statement sequences. Used by
2239/// [`collect_store_names`] to find every `$store.X` reference
2240/// regardless of where it sits in the expression tree.
2241fn visit_paths(expr: &pocopine_expr::Expr, sink: &mut impl FnMut(&[String])) {
2242    use pocopine_expr::Expr;
2243    match expr {
2244        Expr::Literal(_) => {}
2245        Expr::Path(segs) => sink(segs),
2246        Expr::Not(inner) => visit_paths(&inner.value, sink),
2247        Expr::BinOp(_, lhs, rhs) => {
2248            visit_paths(&lhs.value, sink);
2249            visit_paths(&rhs.value, sink);
2250        }
2251        Expr::Ternary(cond, then_branch, else_branch) => {
2252            visit_paths(&cond.value, sink);
2253            visit_paths(&then_branch.value, sink);
2254            visit_paths(&else_branch.value, sink);
2255        }
2256        Expr::Call(_, args) => {
2257            for arg in args {
2258                visit_paths(&arg.value, sink);
2259            }
2260        }
2261        Expr::Assign(lhs_segs, rhs) => {
2262            sink(lhs_segs);
2263            visit_paths(&rhs.value, sink);
2264        }
2265        Expr::Seq(stmts) => {
2266            for stmt in stmts {
2267                visit_paths(&stmt.value, sink);
2268            }
2269        }
2270    }
2271}
2272
2273/// Scan a compiled template plan's expression sources for
2274/// `$store.<name>` references, accumulating into `into`. Covers
2275/// the plan slices that carry plain `expr_src` strings —
2276/// bindings, listeners, `pp-if`, native models — which is where
2277/// store references appear in practice.
2278fn collect_plan_store_names(
2279    plan: &'static crate::templates_plan::StaticTemplatePlan,
2280    into: &mut Vec<String>,
2281) {
2282    for b in plan.bindings {
2283        collect_store_names(b.expr_src, into);
2284    }
2285    for l in plan.listeners {
2286        collect_store_names(l.expr_src, into);
2287    }
2288    for p in plan.if_plans {
2289        collect_store_names(p.expr_src, into);
2290    }
2291    for m in plan.native_models {
2292        collect_store_names(m.expr_src, into);
2293    }
2294}
2295
2296/// Cross-check `$store.X` references across registered components'
2297/// plans against the stores actually registered with
2298/// `App::store::<T>()`. Returns the sorted, deduplicated list of
2299/// missing store names; an empty `Ok(())` means every reference
2300/// has a matching registration.
2301///
2302/// Plan lookup goes through
2303/// [`crate::templates_plan::template_plan_for`], which tries the
2304/// active PHF registry first (only set by
2305/// [`App::run_with_registry`]) and falls back to the
2306/// `TEMPLATE_PLANS` thread-local that every component's macro-
2307/// emitted `register()` populates. Without the fallback this
2308/// check is silently no-op for the canonical
2309/// `App::new()…run()` flow — that path never installs a PHF
2310/// registry, so every per-component vtable lookup returns `None`.
2311fn check_store_registrations(
2312    components: &[&'static str],
2313    stores: &[&'static str],
2314) -> Result<(), Vec<String>> {
2315    let mut needed: Vec<String> = Vec::new();
2316    for &name in components {
2317        if let Some(plan) = crate::templates_plan::template_plan_for(name) {
2318            collect_plan_store_names(plan, &mut needed);
2319        }
2320    }
2321    let mut missing: Vec<String> = needed
2322        .into_iter()
2323        .filter(|name| !stores.contains(&name.as_str()))
2324        .collect();
2325    missing.sort_unstable();
2326    missing.dedup();
2327    if missing.is_empty() {
2328        Ok(())
2329    } else {
2330        Err(missing)
2331    }
2332}
2333
2334fn render_missing_store_boot_error(missing: &[String]) {
2335    let list = missing.join(", ");
2336    let msg = format!(
2337        "pocopine: components reference store(s) not registered with \
2338         `App::store::<T>()`: {list}. Add the missing `.store::<T>()` calls \
2339         to your `App::new()…run()` chain before mount."
2340    );
2341    web_sys::console::error_1(&JsValue::from_str(&msg));
2342    let Some(win) = web_sys::window() else { return };
2343    let Some(doc) = win.document() else { return };
2344    let Some(body) = doc.body() else { return };
2345    if let Ok(banner) = doc.create_element("div") {
2346        let _ = banner.set_attribute("data-pocopine-boot-error", "missing-store");
2347        let _ = banner.set_attribute(
2348            "style",
2349            "all: initial; display: block; background: #b00020; color: #fff; \
2350             font-family: system-ui, sans-serif; padding: 12px 16px; \
2351             font-size: 14px; line-height: 1.4;",
2352        );
2353        banner.set_text_content(Some(&msg));
2354        let _ = body.insert_before(banner.as_ref(), body.first_child().as_ref());
2355    }
2356}
2357
2358fn clear_existing_boot_errors() {
2359    let Some(win) = web_sys::window() else { return };
2360    let Some(doc) = win.document() else { return };
2361    let Ok(nodes) = doc.query_selector_all("[data-pocopine-boot-error]") else {
2362        return;
2363    };
2364    for i in 0..nodes.length() {
2365        let Some(node) = nodes.item(i) else {
2366            continue;
2367        };
2368        if let Some(el) = node.dyn_ref::<Element>() {
2369            el.remove();
2370        }
2371    }
2372}
2373
2374/// RFC 061 Phase 2 — handle returned by [`App::mount_subtree`].
2375/// Drop or call [`Self::unmount`] to release the scope tree's
2376/// effects, listeners, and DOM children.
2377#[must_use = "drop or call `.unmount()` to clean up the subtree"]
2378pub struct SubtreeHandle {
2379    host: Element,
2380    active: bool,
2381}
2382
2383impl SubtreeHandle {
2384    fn release(&mut self) {
2385        if !self.active {
2386            return;
2387        }
2388        mount::release_compiled_subtree(&self.host);
2389        self.host.set_inner_html("");
2390        self.active = false;
2391    }
2392
2393    /// Tear down the subtree. Releases the scope tree
2394    /// (effects + listeners + DOM refs) and clears the host's
2395    /// children. After this the host element remains in the
2396    /// DOM but contains nothing pocopine owns.
2397    pub fn unmount(mut self) {
2398        self.release();
2399    }
2400
2401    /// Detach this handle from automatic cleanup. The caller takes
2402    /// responsibility for removing the host subtree later.
2403    pub fn leak(mut self) {
2404        self.active = false;
2405    }
2406}
2407
2408impl Drop for SubtreeHandle {
2409    fn drop(&mut self) {
2410        self.release();
2411    }
2412}
2413
2414/// RFC 061 Phase 3 — compiled root discovery for `[pp-app]`.
2415/// This is the app-root sibling of [`App::mount_subtree`]:
2416/// both paths call `mount_child_component` for known component
2417/// tags, then finalize the compiled subtree. The app root differs
2418/// only by discovering route-authored descendants from the static
2419/// registry instead of receiving a typed `C`.
2420fn mount_pp_app_subtree(host: &Element) {
2421    let names = crate::templates::registered_template_names();
2422    if !names.is_empty() {
2423        let selector = names.join(",");
2424        if let Ok(matches) = host.query_selector_all(&selector) {
2425            for i in 0..matches.length() {
2426                let Some(node) = matches.item(i) else {
2427                    continue;
2428                };
2429                let Ok(el) = node.dyn_into::<Element>() else {
2430                    continue;
2431                };
2432                let tag = el.local_name();
2433                mount::mount_child_component(&el, &tag);
2434                mount::finalize_compiled_subtree(&el);
2435            }
2436        }
2437    }
2438    if let Ok(outlets) = host.query_selector_all("pp-outlet") {
2439        for i in 0..outlets.length() {
2440            let Some(node) = outlets.item(i) else {
2441                continue;
2442            };
2443            if let Ok(el) = node.dyn_into::<Element>() {
2444                router::set_outlet(el);
2445            }
2446        }
2447    }
2448}
2449
2450/// RFC 061 Phase 2 — paint a friendly boot error when
2451/// [`App::run`] can't find a `[pp-app]` root. Renders a fixed
2452/// overlay without clearing `<body>`, so test harnesses and
2453/// host-page diagnostics survive the fatal boot error.
2454fn render_missing_pp_app_root() {
2455    let Some(win) = web_sys::window() else { return };
2456    let Some(doc) = win.document() else { return };
2457    let Some(body) = doc.body() else { return };
2458    if let Ok(Some(existing)) = body.query_selector("[data-pocopine-boot-error=\"missing-pp-app\"]")
2459    {
2460        existing.remove();
2461    }
2462    let Ok(banner) = doc.create_element("div") else {
2463        return;
2464    };
2465    let _ = banner.set_attribute("data-pocopine-boot-error", "missing-pp-app");
2466    let _ = banner.set_attribute(
2467        "style",
2468        "position:fixed;inset:0;background:#1b1b1f;color:#f5f5f7;\
2469         font-family:ui-monospace,monospace;padding:24px;overflow:auto;\
2470         z-index:2147483647;",
2471    );
2472    banner.set_inner_html(
2473        "<h2 style=\"margin:0 0 12px 0;color:#ff6b6b;\">pocopine: \
2474         no <code>[pp-app]</code> root found</h2>\
2475         <p style=\"margin:0 0 16px 0;\">\
2476         pocopine v2 is compiled-mount-only — `App::run()` looks for \
2477         a single element with the <code>pp-app</code> attribute and \
2478         mounts the active route there. Add it to your HTML host:</p>\
2479         <pre style=\"background:#0d0d10;padding:12px;border-radius:4px;\
2480         overflow:auto;\">&lt;body&gt;\n  &lt;div pp-app&gt;&lt;/div&gt;\n&lt;/body&gt;</pre>\
2481         <p style=\"margin:16px 0 0 0;color:#a0a0a0;font-size:0.875rem;\">\
2482         Apps that need multiple roots use \
2483         <code>App::mount_subtree::&lt;C&gt;(host)</code> instead. \
2484         See RFC 061 for the migration guide.</p>",
2485    );
2486    let _ = body.append_child(&banner);
2487    web_sys::console::error_1(
2488        &"pocopine: App::run() found no [pp-app] root — refusing to mount. See RFC 061.".into(),
2489    );
2490}