1use 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
46pub trait Component {
49 const NAME: &'static str;
52 fn register();
58
59 #[doc(hidden)]
63 fn mount_template(
64 _root: &Element,
65 _scope_id: crate::reactive::ScopeId,
66 _proxy: &wasm_bindgen::JsValue,
67 ) {
68 }
69}
70
71pub trait RouteComponent: Component {
77 fn config() -> RouteConfig<Self>
78 where
79 Self: Sized,
80 {
81 RouteConfig::new()
82 }
83}
84
85pub 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
93pub 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#[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#[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#[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
276pub 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
286pub 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
293pub 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#[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 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 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#[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#[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
520pub 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#[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#[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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
746pub(crate) enum RejectionSource {
747 Guard,
750 Loader,
753}
754
755impl RouteRejection {
756 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 (_, RouteRejection::Custom { reason }) => reason,
780 }
781 }
782}
783
784#[derive(Clone, Debug, PartialEq, Eq)]
786pub enum RouteGuardDecision {
787 Allow,
788 Pending,
793 Reject(RouteRejection),
794 Redirect(RouteTarget),
795}
796
797pub 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
811pub 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#[derive(Clone, Debug, PartialEq, Eq)]
821pub enum RouteRejectionAction {
822 Redirect(RouteTarget),
823 Paint(RouteErrorSurface),
824 AbortNavigation,
825}
826
827#[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
865pub 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#[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 pub fn is_navigation_active(&self) -> bool {
921 crate::router::route_token_is_current(self.navigation_token)
922 }
923
924 pub fn abort_signal(&self) -> Option<web_sys::AbortSignal> {
929 self.abort_signal.clone()
930 }
931}
932
933#[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 other => LoaderError::Server(other),
963 }
964 }
965}
966
967impl LoaderError {
968 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
985pub type RouteLoaderFuture = Pin<Box<dyn Future<Output = Result<Box<dyn Any>, LoaderError>>>>;
991
992pub 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
1022pub 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#[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 pub fn name(mut self, name: RouteName) -> Self {
1114 self.name = Some(name);
1115 self
1116 }
1117
1118 pub fn meta<T: 'static>(mut self, key: RouteMetaKey<T>, value: T) -> Self {
1125 self.meta.insert(key, value);
1126 self
1127 }
1128
1129 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 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 pub fn guard(mut self, guard: impl RouteGuard) -> Self {
1155 self.guards.push(Rc::new(guard));
1156 self
1157 }
1158
1159 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 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: ¶ms,
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 #[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 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 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 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 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 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 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: ¶ms,
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: ¶ms,
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 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 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 assert!(Rc::strong_count(&rc) > strong_count_before);
1584 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 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 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 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
1695pub 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#[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 route_error_component: Option<&'static str>,
1737 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 pub fn register<C: Component>(mut self) -> Self {
1756 C::register();
1757 self.record_component_name(C::NAME);
1758 self
1759 }
1760
1761 pub fn store<S: Store>(mut self) -> Self {
1763 S::__register_store();
1764 self.stores.push(S::STORE_NAME);
1765 self
1766 }
1767
1768 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 pub fn provide_plugin<T: 'static>(mut self, service: T) -> Self {
1790 self.plugins.provide(service, self.installing_plugin);
1791 self
1792 }
1793
1794 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 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 pub fn route<C: RouteComponent>(self, pattern: &'static str) -> Self {
1843 self.route_with::<C>(pattern, C::config())
1844 }
1845
1846 pub fn route_component<C: RouteComponent>(self, pattern: &'static str) -> Self {
1852 self.route::<C>(pattern)
1853 }
1854
1855 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 fn record_component_name(&mut self, name: &'static str) {
1880 if !self.components.contains(&name) {
1881 self.components.push(name);
1882 }
1883 }
1884
1885 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 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 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 #[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 #[doc(hidden)]
1971 pub fn component_static(mut self, name: &'static str) -> Self {
1972 self.record_component_name(name);
1973 self
1974 }
1975
1976 pub fn before_mount(mut self, f: impl FnOnce() + 'static) -> Self {
1978 self.before_mount.push(Box::new(f));
1979 self
1980 }
1981
1982 pub fn after_mount(mut self, f: impl FnOnce() + 'static) -> Self {
1985 self.after_mount.push(Box::new(f));
1986 self
1987 }
1988
1989 pub fn with_devtools(mut self) -> Self {
1998 self.devtools = true;
1999 self
2000 }
2001
2002 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 pub fn run(self) {
2030 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 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 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 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 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 crate::animate::install();
2115 for f in before_mount {
2116 f();
2117 }
2118 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 pub fn registered_components(&self) -> &[&'static str] {
2174 &self.components
2175 }
2176
2177 pub fn registered_stores(&self) -> &[&'static str] {
2179 &self.stores
2180 }
2181
2182 pub fn registered_routes(&self) -> &[&'static str] {
2184 &self.routes
2185 }
2186
2187 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
2213fn 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
2236fn 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
2273fn 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
2296fn 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#[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 pub fn unmount(mut self) {
2398 self.release();
2399 }
2400
2401 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
2414fn 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
2450fn 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;\"><body>\n <div pp-app></div>\n</body></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::<C>(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}