use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::rc::Rc;
use crate::lifecycle::LifecycleContext;
use crate::server::ServerError;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use web_sys::Element;
use crate::mount;
use crate::router;
use crate::store::Store;
pub trait Component {
const NAME: &'static str;
fn register();
#[doc(hidden)]
fn mount_template(
_root: &Element,
_scope_id: crate::reactive::ScopeId,
_proxy: &wasm_bindgen::JsValue,
) {
}
}
pub trait RouteComponent: Component {
fn config() -> RouteConfig<Self>
where
Self: Sized,
{
RouteConfig::new()
}
}
pub struct RouteContext<'a> {
pub path: &'a str,
pub params: &'a HashMap<String, String>,
pub query: &'a HashMap<String, String>,
pub matched_pattern: Option<&'static str>,
}
pub struct PageMetaContext<'a> {
pub path: &'a str,
pub full_path: &'a str,
pub params: &'a HashMap<String, String>,
pub query: &'a HashMap<String, String>,
pub hash: Option<&'a str>,
pub route_pattern: Option<&'static str>,
pub component: Option<&'static str>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PageMeta {
title: Option<String>,
meta_tags: Vec<PageMetaTag>,
links: Vec<PageLink>,
}
impl PageMeta {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(self, content: impl Into<String>) -> Self {
self.meta_name("description", content)
}
pub fn robots(self, content: impl Into<String>) -> Self {
self.meta_name("robots", content)
}
pub fn canonical(self, href: impl Into<String>) -> Self {
self.link("canonical", href)
}
pub fn og_title(self, content: impl Into<String>) -> Self {
self.meta_property("og:title", content)
}
pub fn og_description(self, content: impl Into<String>) -> Self {
self.meta_property("og:description", content)
}
pub fn meta_name(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
self.meta_tags.push(PageMetaTag::Name {
name: name.into(),
content: content.into(),
});
self
}
pub fn meta_property(
mut self,
property: impl Into<String>,
content: impl Into<String>,
) -> Self {
self.meta_tags.push(PageMetaTag::Property {
property: property.into(),
content: content.into(),
});
self
}
pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
self.links.push(PageLink {
rel: rel.into(),
href: href.into(),
});
self
}
pub fn title_text(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn meta_tags(&self) -> &[PageMetaTag] {
&self.meta_tags
}
pub fn links(&self) -> &[PageLink] {
&self.links
}
pub fn is_empty(&self) -> bool {
self.title.is_none() && self.meta_tags.is_empty() && self.links.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PageMetaTag {
Name { name: String, content: String },
Property { property: String, content: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PageLink {
pub rel: String,
pub href: String,
}
pub(crate) type PageMetaFactory = Rc<dyn for<'a> Fn(&PageMetaContext<'a>) -> PageMeta>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RouteName(&'static str);
impl RouteName {
pub const fn new(name: &'static str) -> Self {
Self(name)
}
pub const fn as_str(self) -> &'static str {
self.0
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RouteQuery {
pairs: Vec<(String, String)>,
}
impl RouteQuery {
pub fn new() -> Self {
Self::default()
}
pub fn pair(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.pairs.push((key.into(), value.into()));
self
}
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
pub(crate) fn append_to(&self, path: &mut String) {
if self.pairs.is_empty() {
return;
}
let hash = path.find('#').map(|idx| path.split_off(idx));
let joiner = if path.contains('?') { '&' } else { '?' };
path.push(joiner);
for (idx, (key, value)) in self.pairs.iter().enumerate() {
if idx > 0 {
path.push('&');
}
push_encoded_route_query_part(key, path);
path.push('=');
push_encoded_route_query_part(value, path);
}
if let Some(hash) = hash {
path.push_str(&hash);
}
}
}
impl<const N: usize> From<[(&str, &str); N]> for RouteQuery {
fn from(value: [(&str, &str); N]) -> Self {
let mut query = RouteQuery::new();
for (key, val) in value {
query = query.pair(key, val);
}
query
}
}
pub fn encode_route_path_segment(input: &str) -> String {
let mut out = String::new();
push_encoded_route_path_segment(input, &mut out);
out
}
pub fn encode_route_query_part(input: &str) -> String {
let mut out = String::new();
push_encoded_route_query_part(input, &mut out);
out
}
pub fn encode_route_fragment(input: &str) -> String {
let mut out = String::new();
push_encoded_route_query_part(input, &mut out);
out
}
pub(crate) fn push_encoded_route_path_segment(input: &str, out: &mut String) {
pocopine_codec::percent_encode_into(out, input);
}
pub(crate) fn push_encoded_route_query_part(input: &str, out: &mut String) {
pocopine_codec::percent_encode_into(out, input);
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RouteUrl {
path: String,
query: RouteQuery,
hash: Option<String>,
}
impl RouteUrl {
pub fn new() -> Self {
Self::default()
}
pub fn root() -> Self {
Self {
path: "/".into(),
query: RouteQuery::new(),
hash: None,
}
}
pub fn path(path: impl Into<String>) -> Self {
Self {
path: path.into(),
query: RouteQuery::new(),
hash: None,
}
}
pub fn segment(mut self, value: impl AsRef<str>) -> Self {
if self.path.is_empty() || !self.path.ends_with('/') {
self.path.push('/');
}
push_encoded_route_path_segment(value.as_ref(), &mut self.path);
self
}
pub fn query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query = self.query.pair(key, value);
self
}
pub fn query_pairs(mut self, query: impl Into<RouteQuery>) -> Self {
self.query = query.into();
self
}
pub fn hash(mut self, hash: impl AsRef<str>) -> Self {
self.hash = Some(encode_route_fragment(hash.as_ref()));
self
}
pub fn into_string(self) -> String {
let mut out = if self.path.is_empty() {
"/".to_string()
} else {
self.path
};
let existing_hash = out.find('#').map(|idx| out.split_off(idx));
self.query.append_to(&mut out);
if let Some(hash) = self.hash {
out.push('#');
out.push_str(&hash);
} else if let Some(hash) = existing_hash {
out.push_str(&hash);
}
out
}
pub fn target(self) -> Result<RouteTarget, RouteTargetError> {
RouteTarget::new(self.into_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteTarget(String);
impl RouteTarget {
pub fn new(path: impl Into<String>) -> Result<Self, RouteTargetError> {
let path = path.into();
if path.is_empty() {
return Err(RouteTargetError::Empty);
}
if !is_app_local_route_target(&path) {
return Err(RouteTargetError::NotAppLocalPath);
}
if route_target_path(&path).starts_with("/_pocopine") {
return Err(RouteTargetError::ReservedNamespace);
}
Ok(Self(path))
}
pub fn path(path: impl Into<String>) -> Self {
match Self::new(path) {
Ok(target) => target,
Err(err) => panic!("invalid route target: {err}"),
}
}
pub fn path_with_query(
path: impl Into<String>,
query: impl Into<RouteQuery>,
) -> Result<Self, RouteTargetError> {
RouteUrl::path(path).query_pairs(query).target()
}
pub fn url(url: RouteUrl) -> Result<Self, RouteTargetError> {
url.target()
}
pub fn named(name: RouteName) -> RouteTargetBuilder {
RouteTargetBuilder::new(name)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_path(self) -> String {
self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RouteTargetError {
Empty,
NotAppLocalPath,
ReservedNamespace,
UnknownRouteName(&'static str),
DuplicateRouteName(&'static str),
MissingParam(String),
EmptyParam(String),
UnbuildablePattern(&'static str),
}
impl fmt::Display for RouteTargetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("route target is empty"),
Self::NotAppLocalPath => f.write_str("route target must be an app-local path"),
Self::ReservedNamespace => {
f.write_str("route target uses the reserved /_pocopine namespace")
}
Self::UnknownRouteName(name) => write!(f, "unknown route name `{name}`"),
Self::DuplicateRouteName(name) => write!(f, "duplicate route name `{name}`"),
Self::MissingParam(param) => write!(f, "missing route param `{param}`"),
Self::EmptyParam(param) => write!(f, "route param `{param}` is empty"),
Self::UnbuildablePattern(pattern) => {
write!(f, "route pattern `{pattern}` cannot be built")
}
}
}
}
impl std::error::Error for RouteTargetError {}
fn is_app_local_route_target(path: &str) -> bool {
path.starts_with('/') && !path.starts_with("//") && !path.contains('\\')
}
fn route_target_path(target: &str) -> &str {
target
.split(['?', '#'])
.next()
.filter(|path| !path.is_empty())
.unwrap_or("/")
}
#[derive(Clone, Debug)]
pub struct RouteTargetBuilder {
name: RouteName,
params: HashMap<String, String>,
query: RouteQuery,
}
impl RouteTargetBuilder {
fn new(name: RouteName) -> Self {
Self {
name,
params: HashMap::new(),
query: RouteQuery::new(),
}
}
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.insert(key.into(), value.into());
self
}
pub fn query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query = self.query.pair(key, value);
self
}
pub fn query_pairs(mut self, query: impl Into<RouteQuery>) -> Self {
self.query = query.into();
self
}
pub fn build(self) -> Result<RouteTarget, RouteTargetError> {
crate::router::target_for_name(self.name, &self.params, self.query)
}
}
pub trait IntoRouteTarget {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError>;
}
impl IntoRouteTarget for RouteTarget {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
Ok(self)
}
}
impl IntoRouteTarget for &RouteTarget {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
Ok(self.clone())
}
}
impl IntoRouteTarget for &str {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
RouteTarget::new(self)
}
}
impl IntoRouteTarget for String {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
RouteTarget::new(self)
}
}
impl IntoRouteTarget for RouteUrl {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
self.target()
}
}
impl IntoRouteTarget for &RouteUrl {
fn into_route_target(self) -> Result<RouteTarget, RouteTargetError> {
self.clone().target()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct RouteMetaId {
name: &'static str,
type_id: TypeId,
}
#[derive(Debug)]
pub struct RouteMetaKey<T: 'static> {
name: &'static str,
_marker: PhantomData<fn() -> T>,
}
impl<T: 'static> Clone for RouteMetaKey<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: 'static> Copy for RouteMetaKey<T> {}
impl<T: 'static> RouteMetaKey<T> {
pub const fn new(name: &'static str) -> Self {
Self {
name,
_marker: PhantomData,
}
}
pub const fn name(self) -> &'static str {
self.name
}
fn id(self) -> RouteMetaId {
RouteMetaId {
name: self.name,
type_id: TypeId::of::<T>(),
}
}
}
#[derive(Clone, Default)]
pub struct RouteMeta {
entries: Vec<RouteMetaEntry>,
}
impl fmt::Debug for RouteMeta {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RouteMeta")
.field("len", &self.entries.len())
.finish()
}
}
#[derive(Clone)]
struct RouteMetaEntry {
id: RouteMetaId,
value: Rc<dyn Any>,
}
impl RouteMeta {
pub fn new() -> Self {
Self::default()
}
pub fn insert<T: 'static>(&mut self, key: RouteMetaKey<T>, value: T) {
let id = key.id();
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
entry.value = Rc::new(value);
return;
}
self.entries.push(RouteMetaEntry {
id,
value: Rc::new(value),
});
}
pub fn get<T: 'static>(&self, key: RouteMetaKey<T>) -> Option<&T> {
let id = key.id();
self.entries
.iter()
.find(|entry| entry.id == id)
.and_then(|entry| entry.value.as_ref().downcast_ref::<T>())
}
pub fn contains<T: 'static>(&self, key: RouteMetaKey<T>) -> bool {
self.get(key).is_some()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Prefetch {
trigger: PrefetchTrigger,
loader: bool,
}
impl Prefetch {
pub const fn none() -> Self {
Self {
trigger: PrefetchTrigger::Never,
loader: false,
}
}
pub const fn on_intent() -> Self {
Self {
trigger: PrefetchTrigger::Intent,
loader: false,
}
}
pub const fn on_visible() -> Self {
Self {
trigger: PrefetchTrigger::Visible,
loader: false,
}
}
pub const fn loader(mut self) -> Self {
self.loader = true;
self
}
pub const fn trigger(self) -> PrefetchTrigger {
self.trigger
}
pub const fn includes_loader(self) -> bool {
self.loader
}
}
impl Default for Prefetch {
fn default() -> Self {
Self::none()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrefetchTrigger {
Never,
Intent,
Visible,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RouteRejection {
Unauthorized,
Forbidden(&'static str),
Blocked(&'static str),
NotFound,
Server(&'static str),
Custom { reason: &'static str },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RejectionSource {
Guard,
Loader,
}
impl RouteRejection {
pub(crate) fn reason(&self, source: RejectionSource) -> &'static str {
match (source, self) {
(RejectionSource::Guard, RouteRejection::Unauthorized) => "guard_unauthorized",
(RejectionSource::Loader, RouteRejection::Unauthorized) => "loader_unauthorized",
(RejectionSource::Guard, RouteRejection::Forbidden(_)) => "guard_forbidden",
(RejectionSource::Loader, RouteRejection::Forbidden(_)) => "loader_forbidden",
(RejectionSource::Guard, RouteRejection::Blocked(_)) => "guard_blocked",
(RejectionSource::Loader, RouteRejection::Blocked(_)) => "loader_blocked",
(RejectionSource::Guard, RouteRejection::NotFound) => "guard_not_found",
(RejectionSource::Loader, RouteRejection::NotFound) => "loader_not_found",
(RejectionSource::Guard, RouteRejection::Server(_)) => "guard_server_error",
(RejectionSource::Loader, RouteRejection::Server(_)) => "loader_server_error",
(_, RouteRejection::Custom { reason }) => reason,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RouteGuardDecision {
Allow,
Pending,
Reject(RouteRejection),
Redirect(RouteTarget),
}
pub trait RouteGuard: 'static {
fn decide(&self, ctx: &RouteContext<'_>) -> RouteGuardDecision;
}
impl<F> RouteGuard for F
where
F: for<'a> Fn(&RouteContext<'a>) -> RouteGuardDecision + 'static,
{
fn decide(&self, ctx: &RouteContext<'_>) -> RouteGuardDecision {
self(ctx)
}
}
pub struct RouteRejectionContext<'a> {
pub path: &'a str,
pub params: &'a HashMap<String, String>,
pub query: &'a HashMap<String, String>,
pub matched_pattern: Option<&'static str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RouteRejectionAction {
Redirect(RouteTarget),
Paint(RouteErrorSurface),
AbortNavigation,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteErrorSurface {
pub title: &'static str,
pub message: &'static str,
}
impl RouteErrorSurface {
pub const fn new(title: &'static str, message: &'static str) -> Self {
Self { title, message }
}
pub(crate) fn for_rejection(rejection: &RouteRejection) -> Self {
match rejection {
RouteRejection::Unauthorized => Self::new(
"Authentication required",
"Sign in to continue to this route.",
),
RouteRejection::Forbidden(_) => {
Self::new("Access denied", "You do not have access to this route.")
}
RouteRejection::Blocked(_) => Self::new(
"Route unavailable",
"This route is not available right now.",
),
RouteRejection::NotFound => Self::new("Route not found", "No route matched this URL."),
RouteRejection::Server(_) => Self::new(
"Route unavailable",
"This route could not be loaded right now.",
),
RouteRejection::Custom { .. } => Self::new(
"Route unavailable",
"This route is not available right now.",
),
}
}
}
pub trait RouteRejectionHandler: 'static {
fn handle(
&self,
ctx: &RouteRejectionContext<'_>,
rejection: &RouteRejection,
) -> Option<RouteRejectionAction>;
}
impl<F> RouteRejectionHandler for F
where
F: for<'a> Fn(&RouteRejectionContext<'a>, &RouteRejection) -> Option<RouteRejectionAction>
+ 'static,
{
fn handle(
&self,
ctx: &RouteRejectionContext<'_>,
rejection: &RouteRejection,
) -> Option<RouteRejectionAction> {
self(ctx, rejection)
}
}
#[derive(Clone, Debug)]
pub struct LoaderContext {
pub path: String,
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
pub matched_pattern: Option<&'static str>,
pub navigation_token: crate::router::RouteToken,
pub(crate) abort_signal: Option<web_sys::AbortSignal>,
}
impl LoaderContext {
pub fn is_navigation_active(&self) -> bool {
crate::router::route_token_is_current(self.navigation_token)
}
pub fn abort_signal(&self) -> Option<web_sys::AbortSignal> {
self.abort_signal.clone()
}
}
#[derive(Debug)]
pub enum LoaderError {
Unauthorized,
Forbidden(String),
NotFound(String),
Server(ServerError),
}
impl From<ServerError> for LoaderError {
fn from(err: ServerError) -> Self {
match err {
ServerError::Unauthorized(_) => LoaderError::Unauthorized,
ServerError::Forbidden(reason) => LoaderError::Forbidden(reason),
other => LoaderError::Server(other),
}
}
}
impl LoaderError {
pub fn to_rejection(&self) -> RouteRejection {
match self {
LoaderError::Unauthorized => RouteRejection::Unauthorized,
LoaderError::Forbidden(_) => RouteRejection::Forbidden("loader_forbidden"),
LoaderError::NotFound(_) => RouteRejection::NotFound,
LoaderError::Server(_) => RouteRejection::Server("loader_server_error"),
}
}
}
pub type RouteLoaderFuture = Pin<Box<dyn Future<Output = Result<Box<dyn Any>, LoaderError>>>>;
pub trait RouteLoader: 'static {
fn run(&self, ctx: LoaderContext) -> RouteLoaderFuture;
}
struct LoaderClosure<F, T> {
f: F,
_t: PhantomData<fn() -> T>,
}
impl<F, Fut, T> RouteLoader for LoaderClosure<F, T>
where
F: Fn(LoaderContext) -> Fut + 'static,
Fut: Future<Output = Result<T, LoaderError>> + 'static,
T: 'static,
{
fn run(&self, ctx: LoaderContext) -> RouteLoaderFuture {
let abort_signal = ctx.abort_signal();
let fut = (self.f)(ctx);
Box::pin(crate::fetch::with_abort_signal_future(
abort_signal,
async move { fut.await.map(|t| Box::new(t) as Box<dyn Any>) },
))
}
}
pub struct Loader<T: 'static> {
data: Rc<T>,
}
impl<T: 'static> Clone for Loader<T> {
fn clone(&self) -> Self {
Self {
data: self.data.clone(),
}
}
}
impl<T: 'static> Loader<T> {
pub fn get(&self) -> &T {
&self.data
}
}
impl<T: 'static> std::ops::Deref for Loader<T> {
type Target = T;
fn deref(&self) -> &T {
&self.data
}
}
impl<T: 'static> From<LifecycleContext<'_>> for Loader<T> {
fn from(ctx: LifecycleContext<'_>) -> Self {
match crate::router::take_pending_loader_data::<T>(ctx.scope_id) {
Some(loader) => loader,
None => panic!(
"Loader<{}>: no loader data is available for the mounting \
component. Either the route has no `RouteConfig::loader(...)` \
or the component is being mounted via `App::mount_subtree` \
(in which case use `Option<Loader<{}>>`).",
std::any::type_name::<T>(),
std::any::type_name::<T>(),
),
}
}
}
impl<T: 'static> From<LifecycleContext<'_>> for Option<Loader<T>> {
fn from(ctx: LifecycleContext<'_>) -> Self {
crate::router::take_pending_loader_data::<T>(ctx.scope_id)
}
}
impl<T: 'static> Loader<T> {
pub(crate) fn from_rc(data: Rc<T>) -> Self {
Self { data }
}
}
#[derive(Clone)]
pub struct RouteConfig<C: Component> {
pub(crate) name: Option<RouteName>,
pub(crate) meta: RouteMeta,
pub(crate) page_meta: Option<PageMetaFactory>,
pub(crate) guards: Vec<Rc<dyn RouteGuard>>,
pub(crate) loader: Option<Rc<dyn RouteLoader>>,
pub(crate) prefetch: Prefetch,
_component: PhantomData<fn() -> C>,
}
impl<C: Component> RouteConfig<C> {
pub fn new() -> Self {
Self {
name: None,
meta: RouteMeta::new(),
page_meta: None,
guards: Vec::new(),
loader: None,
prefetch: Prefetch::none(),
_component: PhantomData,
}
}
pub fn name(mut self, name: RouteName) -> Self {
self.name = Some(name);
self
}
pub fn meta<T: 'static>(mut self, key: RouteMetaKey<T>, value: T) -> Self {
self.meta.insert(key, value);
self
}
pub fn page_meta(
mut self,
meta: impl for<'a> Fn(&PageMetaContext<'a>) -> PageMeta + 'static,
) -> Self {
self.page_meta = Some(Rc::new(meta));
self
}
pub fn static_page_meta(mut self, meta: PageMeta) -> Self {
self.page_meta = Some(Rc::new(move |_| meta.clone()));
self
}
pub fn guard(mut self, guard: impl RouteGuard) -> Self {
self.guards.push(Rc::new(guard));
self
}
pub fn loader<F, Fut, T>(mut self, loader: F) -> Self
where
F: Fn(LoaderContext) -> Fut + 'static,
Fut: Future<Output = Result<T, LoaderError>> + 'static,
T: 'static,
{
if self.loader.is_some() {
panic!(
"RouteConfig::loader called twice — only one loader per \
route is supported. Compose multiple async fetches inside \
a single loader body (`tokio::try_join!` returning a struct \
of all the data the route needs)."
);
}
self.loader = Some(Rc::new(LoaderClosure {
f: loader,
_t: PhantomData,
}));
self
}
pub fn prefetch(mut self, prefetch: Prefetch) -> Self {
self.prefetch = prefetch;
self
}
pub(crate) fn into_runtime(self) -> router::RouteRuntimeConfig {
router::RouteRuntimeConfig {
name: self.name,
meta: self.meta,
page_meta: self.page_meta,
guards: self.guards,
loader: self.loader,
prefetch: self.prefetch,
}
}
}
impl<C: Component> Default for RouteConfig<C> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod route_config_tests {
use super::*;
struct TestRoute;
impl Component for TestRoute {
const NAME: &'static str = "test-route";
fn register() {}
}
impl RouteComponent for TestRoute {}
#[test]
fn route_component_default_config_has_no_guards() {
let config = TestRoute::config();
assert!(config.guards.is_empty());
}
#[test]
fn route_config_records_name_and_prefetch_policy() {
const TEST: RouteName = RouteName::new("test");
let config = RouteConfig::<TestRoute>::new()
.name(TEST)
.prefetch(Prefetch::on_intent().loader());
assert_eq!(config.name, Some(TEST));
assert_eq!(config.prefetch.trigger(), PrefetchTrigger::Intent);
assert!(config.prefetch.includes_loader());
}
#[test]
fn route_config_records_typed_metadata() {
const REQUIRES_AUTH: RouteMetaKey<bool> = RouteMetaKey::new("requires_auth");
const SECTION: RouteMetaKey<&'static str> = RouteMetaKey::new("section");
let config = RouteConfig::<TestRoute>::new()
.meta(REQUIRES_AUTH, true)
.meta(SECTION, "admin");
assert_eq!(config.meta.get(REQUIRES_AUTH), Some(&true));
assert_eq!(config.meta.get(SECTION), Some(&"admin"));
assert!(config.meta.contains(REQUIRES_AUTH));
}
#[test]
fn page_meta_builder_records_head_tags() {
let meta = PageMeta::new()
.title("Dashboard")
.description("Team dashboard")
.canonical("/dashboard")
.og_title("Dashboard");
assert_eq!(meta.title_text(), Some("Dashboard"));
assert_eq!(
meta.meta_tags(),
&[
PageMetaTag::Name {
name: "description".into(),
content: "Team dashboard".into()
},
PageMetaTag::Property {
property: "og:title".into(),
content: "Dashboard".into()
}
]
);
assert_eq!(
meta.links(),
&[PageLink {
rel: "canonical".into(),
href: "/dashboard".into()
}]
);
}
#[test]
fn route_config_records_page_meta_factory() {
let mut params = HashMap::new();
params.insert("id".into(), "42".into());
let query = HashMap::new();
let config = RouteConfig::<TestRoute>::new()
.page_meta(|ctx| PageMeta::new().title(format!("Story {}", ctx.params["id"])));
let ctx = PageMetaContext {
path: "/story/42",
full_path: "/story/42",
params: ¶ms,
query: &query,
hash: None,
route_pattern: Some("/story/:id"),
component: Some("test-route"),
};
let page_meta = config.page_meta.as_ref().expect("page meta")(&ctx);
assert_eq!(page_meta.title_text(), Some("Story 42"));
}
#[test]
fn collect_store_names_extracts_single_ref() {
let mut into = Vec::new();
super::collect_store_names("$store.preferences.theme", &mut into);
assert_eq!(into, vec!["preferences".to_string()]);
}
#[test]
fn collect_store_names_handles_multiple_and_dedupes() {
let mut into = Vec::new();
super::collect_store_names("$store.a == $store.b ? $store.a : $store.b", &mut into);
assert_eq!(into, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn collect_store_names_ignores_bare_store_token() {
let mut into = Vec::new();
super::collect_store_names("$store", &mut into);
assert!(into.is_empty());
}
#[test]
fn collect_store_names_ignores_string_literal_lookalike() {
let mut into = Vec::new();
super::collect_store_names("'$store.example was renamed'", &mut into);
assert!(into.is_empty());
}
#[test]
fn collect_store_names_finds_refs_in_call_arguments() {
let mut into = Vec::new();
super::collect_store_names("debug($store.flags.verbose)", &mut into);
assert_eq!(into, vec!["flags".to_string()]);
}
#[test]
fn collect_store_names_silent_on_parse_failure() {
let mut into = Vec::new();
super::collect_store_names("status === 'queued'", &mut into);
assert!(into.is_empty());
}
#[test]
fn check_store_registrations_passes_when_complete() {
let result = super::check_store_registrations(&[], &["preferences"]);
assert!(result.is_ok());
}
#[test]
fn route_config_stores_sync_guards() {
let config = RouteConfig::<TestRoute>::new()
.guard(|_: &RouteContext<'_>| RouteGuardDecision::Reject(RouteRejection::Blocked("x")));
assert_eq!(config.guards.len(), 1);
let params = HashMap::new();
let query = HashMap::new();
let ctx = RouteContext {
path: "/test",
params: ¶ms,
query: &query,
matched_pattern: Some("/test"),
};
assert_eq!(
config.guards[0].decide(&ctx),
RouteGuardDecision::Reject(RouteRejection::Blocked("x"))
);
}
#[test]
fn route_target_accepts_only_app_local_paths() {
assert_eq!(RouteTarget::path("/login").into_path(), "/login");
assert_eq!(
RouteTarget::new("https://example.com/login"),
Err(RouteTargetError::NotAppLocalPath)
);
assert_eq!(
RouteTarget::new("//example.com/login"),
Err(RouteTargetError::NotAppLocalPath)
);
assert_eq!(
RouteTarget::new("/_pocopine/server-fn"),
Err(RouteTargetError::ReservedNamespace)
);
assert_eq!(
RouteTarget::new("/_pocopine"),
Err(RouteTargetError::ReservedNamespace)
);
assert_eq!(
RouteTarget::new("/_pocopine-secret/admin"),
Err(RouteTargetError::ReservedNamespace)
);
assert_eq!(RouteTarget::new(""), Err(RouteTargetError::Empty));
}
#[test]
fn route_target_builds_query_strings() {
let target = RouteTarget::path_with_query(
"/search",
RouteQuery::from([("q", "router api"), ("tab", "a&b")]),
)
.unwrap();
assert_eq!(target.into_path(), "/search?q=router%20api&tab=a%26b");
let target = RouteTarget::path_with_query("/search#results", [("q", "router")]).unwrap();
assert_eq!(target.into_path(), "/search?q=router#results");
}
#[test]
fn route_url_encodes_segments_query_and_hash() {
let target = RouteUrl::new()
.segment("users")
.segment("user/42")
.query("tab", "a b")
.hash("top#section")
.target()
.unwrap();
assert_eq!(
target.into_path(),
"/users/user%2F42?tab=a%20b#top%23section"
);
}
#[test]
fn route_url_replaces_existing_hash_when_hash_is_set() {
let target = RouteUrl::path("/reports#old")
.query("tab", "active")
.hash("new")
.target()
.unwrap();
assert_eq!(target.into_path(), "/reports?tab=active#new");
}
#[test]
fn route_encoding_helpers_are_public_and_stable() {
assert_eq!(encode_route_path_segment("a/b c"), "a%2Fb%20c");
assert_eq!(encode_route_query_part("a&b c"), "a%26b%20c");
assert_eq!(encode_route_fragment("top#2"), "top%232");
}
#[test]
fn app_records_route_rejection_handlers() {
let app = App::new().route_rejection_handler(
|_: &RouteRejectionContext<'_>, _: &RouteRejection| {
Some(RouteRejectionAction::AbortNavigation)
},
);
assert_eq!(app.route_rejection_handlers.len(), 1);
}
#[test]
fn route_rejection_handler_closure_can_redirect() {
let handler = |ctx: &RouteRejectionContext<'_>, rejection: &RouteRejection| {
assert_eq!(ctx.path, "/admin");
assert_eq!(ctx.params.get("section"), Some(&"users".to_string()));
assert_eq!(ctx.query.get("tab"), Some(&"active".to_string()));
assert_eq!(ctx.matched_pattern, Some("/admin/:section"));
assert_eq!(rejection, &RouteRejection::Unauthorized);
Some(RouteRejectionAction::Redirect(RouteTarget::path("/login")))
};
let mut params = HashMap::new();
params.insert("section".to_string(), "users".to_string());
let mut query = HashMap::new();
query.insert("tab".to_string(), "active".to_string());
let ctx = RouteRejectionContext {
path: "/admin",
params: ¶ms,
query: &query,
matched_pattern: Some("/admin/:section"),
};
assert_eq!(
handler.handle(&ctx, &RouteRejection::Unauthorized),
Some(RouteRejectionAction::Redirect(RouteTarget::path("/login")))
);
}
#[test]
fn loader_error_from_server_error_maps_unauthorized() {
let err: LoaderError = ServerError::Unauthorized("token expired".into()).into();
assert!(matches!(err, LoaderError::Unauthorized));
let err: LoaderError = ServerError::Forbidden("missing role".into()).into();
assert!(matches!(err, LoaderError::Forbidden(reason) if reason == "missing role"));
let err: LoaderError = ServerError::App("kaboom".into()).into();
assert!(matches!(err, LoaderError::Server(_)));
let err: LoaderError = ServerError::BadRequest("nope".into()).into();
assert!(matches!(err, LoaderError::Server(_)));
}
#[test]
fn loader_error_to_rejection_drops_dynamic_messages() {
assert_eq!(
LoaderError::Unauthorized.to_rejection(),
RouteRejection::Unauthorized
);
assert_eq!(
LoaderError::Forbidden("policy:account_locked".into()).to_rejection(),
RouteRejection::Forbidden("loader_forbidden")
);
assert_eq!(
LoaderError::NotFound("user 42".into()).to_rejection(),
RouteRejection::NotFound
);
assert_eq!(
LoaderError::Server(ServerError::App("db down".into())).to_rejection(),
RouteRejection::Server("loader_server_error")
);
}
#[test]
fn route_config_loader_records_one_loader() {
let config = RouteConfig::<TestRoute>::new()
.loader(|_ctx: LoaderContext| async move { Ok::<_, LoaderError>(42_u32) });
assert!(config.loader.is_some());
}
#[test]
#[should_panic(expected = "RouteConfig::loader called twice")]
fn route_config_loader_panics_on_duplicate_registration() {
let _ = RouteConfig::<TestRoute>::new()
.loader(|_: LoaderContext| async move { Ok::<_, LoaderError>(1_u32) })
.loader(|_: LoaderContext| async move { Ok::<_, LoaderError>(2_u32) });
}
#[test]
fn loader_from_rc_constructs_loader_from_shared_rc() {
let rc: Rc<String> = Rc::new("hello".to_string());
let strong_count_before = Rc::strong_count(&rc);
let loader = Loader::<String>::from_rc(rc.clone());
assert_eq!(*loader.get(), "hello");
assert!(Rc::strong_count(&rc) > strong_count_before);
assert_eq!(loader.len(), 5);
}
#[test]
fn app_records_route_error_component() {
let app = App::new().route_error_component::<TestRoute>();
assert_eq!(app.route_error_component, Some(TestRoute::NAME));
}
#[test]
fn app_records_not_found_component() {
let app = App::new().not_found_component::<TestRoute>();
assert_eq!(app.not_found_component, Some(TestRoute::NAME));
}
#[test]
fn app_overrides_default_to_none_for_route_error_and_not_found() {
let app = App::new();
assert!(app.route_error_component.is_none());
assert!(app.not_found_component.is_none());
}
#[test]
fn rejection_reason_distinguishes_guard_and_loader_source() {
assert_eq!(
RouteRejection::Unauthorized.reason(RejectionSource::Guard),
"guard_unauthorized"
);
assert_eq!(
RouteRejection::Unauthorized.reason(RejectionSource::Loader),
"loader_unauthorized"
);
assert_eq!(
RouteRejection::Forbidden("policy_x").reason(RejectionSource::Guard),
"guard_forbidden"
);
assert_eq!(
RouteRejection::Forbidden("policy_x").reason(RejectionSource::Loader),
"loader_forbidden"
);
assert_eq!(
RouteRejection::NotFound.reason(RejectionSource::Guard),
"guard_not_found"
);
assert_eq!(
RouteRejection::NotFound.reason(RejectionSource::Loader),
"loader_not_found"
);
assert_eq!(
RouteRejection::Server("db_down").reason(RejectionSource::Guard),
"guard_server_error"
);
assert_eq!(
RouteRejection::Server("db_down").reason(RejectionSource::Loader),
"loader_server_error"
);
assert_eq!(
RouteRejection::Blocked("ab_test").reason(RejectionSource::Guard),
"guard_blocked"
);
assert_eq!(
RouteRejection::Blocked("ab_test").reason(RejectionSource::Loader),
"loader_blocked"
);
}
#[test]
fn rejection_reason_custom_passes_user_string_through() {
let custom = RouteRejection::Custom {
reason: "tenant_quota_exceeded",
};
assert_eq!(
custom.reason(RejectionSource::Guard),
"tenant_quota_exceeded"
);
assert_eq!(
custom.reason(RejectionSource::Loader),
"tenant_quota_exceeded"
);
}
#[test]
fn route_error_surface_uses_generic_messages() {
assert_eq!(
RouteErrorSurface::for_rejection(&RouteRejection::Forbidden("secret policy name")),
RouteErrorSurface::new("Access denied", "You do not have access to this route.")
);
assert_eq!(
RouteErrorSurface::for_rejection(&RouteRejection::Server("database exploded")),
RouteErrorSurface::new(
"Route unavailable",
"This route could not be loaded right now."
)
);
}
}
type Hook = Box<dyn FnOnce()>;
pub trait AppPlugin {
fn name(&self) -> &'static str {
std::any::type_name::<Self>()
}
fn install(self, app: App) -> App;
}
impl<F> AppPlugin for F
where
F: FnOnce(App) -> App,
{
fn install(self, app: App) -> App {
self(app)
}
}
#[derive(Default)]
pub struct App {
components: Vec<&'static str>,
stores: Vec<&'static str>,
routes: Vec<&'static str>,
before_mount: Vec<Hook>,
after_mount: Vec<Hook>,
route_rejection_handlers: Vec<Rc<dyn RouteRejectionHandler>>,
route_error_component: Option<&'static str>,
not_found_component: Option<&'static str>,
plugins: crate::plugin::PluginRegistry,
installing_plugin: Option<&'static str>,
devtools: bool,
}
impl App {
pub fn new() -> Self {
Self::default()
}
pub fn register<C: Component>(mut self) -> Self {
C::register();
self.record_component_name(C::NAME);
self
}
pub fn store<S: Store>(mut self) -> Self {
S::__register_store();
self.stores.push(S::STORE_NAME);
self
}
pub fn plugin<P: AppPlugin>(mut self, plugin: P) -> Self {
let name = plugin.name();
let previous = self.installing_plugin.replace(name);
let mut app = plugin.install(self);
app.installing_plugin = previous;
app
}
pub fn provide_plugin<T: 'static>(mut self, service: T) -> Self {
self.plugins.provide(service, self.installing_plugin);
self
}
pub fn hook_plugin<T, E>(mut self) -> Self
where
T: crate::plugin::Hook<E> + 'static,
E: Clone + 'static,
{
self.plugins.hook_plugin::<T, E>(self.installing_plugin);
self
}
pub fn hook_component_plugin<T, C, E>(mut self) -> Self
where
T: crate::plugin::Hook<crate::plugin::ForComponent<C, E>> + 'static,
C: Component + 'static,
E: crate::plugin::ComponentEvent,
{
self.plugins
.hook_component_plugin::<T, C, E>(self.installing_plugin);
self
}
pub fn route<C: RouteComponent>(self, pattern: &'static str) -> Self {
self.route_with::<C>(pattern, C::config())
}
pub fn route_component<C: RouteComponent>(self, pattern: &'static str) -> Self {
self.route::<C>(pattern)
}
pub fn route_with<C: Component>(
mut self,
pattern: &'static str,
config: RouteConfig<C>,
) -> Self {
C::register();
router::register_route_with_config(pattern, C::NAME, config.into_runtime());
self.routes.push(pattern);
self.record_component_name(C::NAME);
self
}
fn record_component_name(&mut self, name: &'static str) {
if !self.components.contains(&name) {
self.components.push(name);
}
}
pub fn route_rejection_handler<H: RouteRejectionHandler>(mut self, handler: H) -> Self {
self.route_rejection_handlers.push(Rc::new(handler));
self
}
pub fn route_error_component<C: Component>(mut self) -> Self {
C::register();
self.route_error_component = Some(C::NAME);
self
}
pub fn not_found_component<C: Component>(mut self) -> Self {
C::register();
self.not_found_component = Some(C::NAME);
self
}
#[doc(hidden)]
pub fn route_static<C: RouteComponent>(mut self, pattern: &'static str) -> Self {
router::register_route_with_config(pattern, C::NAME, C::config().into_runtime());
self.routes.push(pattern);
self.record_component_name(C::NAME);
self
}
#[doc(hidden)]
pub fn component_static(mut self, name: &'static str) -> Self {
self.record_component_name(name);
self
}
pub fn before_mount(mut self, f: impl FnOnce() + 'static) -> Self {
self.before_mount.push(Box::new(f));
self
}
pub fn after_mount(mut self, f: impl FnOnce() + 'static) -> Self {
self.after_mount.push(Box::new(f));
self
}
pub fn with_devtools(mut self) -> Self {
self.devtools = true;
self
}
pub fn run_with_registry(
self,
registry: &'static phf::Map<&'static str, &'static crate::registry::ComponentVTable>,
) {
crate::registry::set_active_phf_registry(registry);
for vtable in registry.values() {
(vtable.register)();
}
self.run();
}
pub fn run(self) {
console_error_panic_hook::set_once();
let Self {
components,
stores,
routes,
before_mount,
after_mount,
route_rejection_handlers,
route_error_component,
not_found_component,
plugins,
installing_plugin: _,
devtools,
} = self;
let boot_start_ms = js_sys::Date::now();
clear_existing_boot_errors();
if let Err(errors) = plugins.validate() {
crate::plugin::activate(crate::plugin::PluginRegistry::default());
router::set_route_rejection_handlers(Vec::new());
router::set_route_error_component(None);
router::set_not_found_component(None);
crate::fetch::clear_and_freeze();
crate::plugin::render_plugin_boot_error(&errors);
return;
}
crate::plugin::activate(plugins);
router::set_route_rejection_handlers(route_rejection_handlers);
router::set_route_error_component(route_error_component);
router::set_not_found_component(not_found_component);
crate::fetch::freeze_middleware_chain();
crate::plugin::emit(crate::plugin::AppBootStarted {
component_count: components.len(),
route_count: routes.len(),
});
if let Err(errors) = crate::registry::verify_registry() {
crate::plugin::emit(crate::plugin::AppBootFailed {
reason: "component_registry",
});
crate::registry::render_boot_error(&errors);
return;
}
if let Err(missing) = check_store_registrations(&components, &stores) {
crate::plugin::emit(crate::plugin::AppBootFailed {
reason: "missing_store_registration",
});
render_missing_store_boot_error(&missing);
return;
}
crate::animate::install();
for f in before_mount {
f();
}
let Some(window) = web_sys::window() else {
crate::plugin::emit(crate::plugin::AppBootFailed {
reason: "missing_window",
});
return;
};
let Some(document) = window.document() else {
crate::plugin::emit(crate::plugin::AppBootFailed {
reason: "missing_document",
});
return;
};
let pp_app = document.query_selector("[pp-app]").ok().flatten();
if let Some(host) = pp_app {
mount_pp_app_subtree(&host);
} else {
crate::plugin::emit(crate::plugin::AppBootFailed {
reason: "missing_pp_app_root",
});
render_missing_pp_app_root();
return;
}
if !routes.is_empty() {
router::init();
}
#[cfg(feature = "devtools")]
if devtools {
crate::devtools::install();
}
#[cfg(not(feature = "devtools"))]
let _ = devtools;
let after = after_mount;
if !after.is_empty() {
spawn_local(async move {
let _ = js_sys::Promise::resolve(&JsValue::NULL);
for f in after {
f();
}
});
}
let elapsed = js_sys::Date::now() - boot_start_ms;
crate::plugin::emit(crate::plugin::AppBootCompleted {
duration_ms: if elapsed.is_finite() && elapsed >= 0.0 {
elapsed
} else {
0.0
},
});
}
pub fn registered_components(&self) -> &[&'static str] {
&self.components
}
pub fn registered_stores(&self) -> &[&'static str] {
&self.stores
}
pub fn registered_routes(&self) -> &[&'static str] {
&self.routes
}
pub fn mount_subtree<C: Component>(host: &Element) -> SubtreeHandle {
C::register();
mount::mount_child_component(host, C::NAME);
mount::finalize_compiled_subtree(host);
SubtreeHandle {
host: host.clone(),
active: true,
}
}
}
fn collect_store_names(src: &str, into: &mut Vec<String>) {
let Ok(ast) = pocopine_expr::parse(src) else {
return;
};
visit_paths(&ast.value, &mut |segments| {
if segments.len() >= 2 && segments[0] == "$store" {
let name = segments[1].clone();
if !into.contains(&name) {
into.push(name);
}
}
});
}
fn visit_paths(expr: &pocopine_expr::Expr, sink: &mut impl FnMut(&[String])) {
use pocopine_expr::Expr;
match expr {
Expr::Literal(_) => {}
Expr::Path(segs) => sink(segs),
Expr::Not(inner) => visit_paths(&inner.value, sink),
Expr::BinOp(_, lhs, rhs) => {
visit_paths(&lhs.value, sink);
visit_paths(&rhs.value, sink);
}
Expr::Ternary(cond, then_branch, else_branch) => {
visit_paths(&cond.value, sink);
visit_paths(&then_branch.value, sink);
visit_paths(&else_branch.value, sink);
}
Expr::Call(_, args) => {
for arg in args {
visit_paths(&arg.value, sink);
}
}
Expr::Assign(lhs_segs, rhs) => {
sink(lhs_segs);
visit_paths(&rhs.value, sink);
}
Expr::Seq(stmts) => {
for stmt in stmts {
visit_paths(&stmt.value, sink);
}
}
}
}
fn collect_plan_store_names(
plan: &'static crate::templates_plan::StaticTemplatePlan,
into: &mut Vec<String>,
) {
for b in plan.bindings {
collect_store_names(b.expr_src, into);
}
for l in plan.listeners {
collect_store_names(l.expr_src, into);
}
for p in plan.if_plans {
collect_store_names(p.expr_src, into);
}
for m in plan.native_models {
collect_store_names(m.expr_src, into);
}
}
fn check_store_registrations(
components: &[&'static str],
stores: &[&'static str],
) -> Result<(), Vec<String>> {
let mut needed: Vec<String> = Vec::new();
for &name in components {
if let Some(plan) = crate::templates_plan::template_plan_for(name) {
collect_plan_store_names(plan, &mut needed);
}
}
let mut missing: Vec<String> = needed
.into_iter()
.filter(|name| !stores.contains(&name.as_str()))
.collect();
missing.sort_unstable();
missing.dedup();
if missing.is_empty() {
Ok(())
} else {
Err(missing)
}
}
fn render_missing_store_boot_error(missing: &[String]) {
let list = missing.join(", ");
let msg = format!(
"pocopine: components reference store(s) not registered with \
`App::store::<T>()`: {list}. Add the missing `.store::<T>()` calls \
to your `App::new()…run()` chain before mount."
);
web_sys::console::error_1(&JsValue::from_str(&msg));
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };
let Some(body) = doc.body() else { return };
if let Ok(banner) = doc.create_element("div") {
let _ = banner.set_attribute("data-pocopine-boot-error", "missing-store");
let _ = banner.set_attribute(
"style",
"all: initial; display: block; background: #b00020; color: #fff; \
font-family: system-ui, sans-serif; padding: 12px 16px; \
font-size: 14px; line-height: 1.4;",
);
banner.set_text_content(Some(&msg));
let _ = body.insert_before(banner.as_ref(), body.first_child().as_ref());
}
}
fn clear_existing_boot_errors() {
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };
let Ok(nodes) = doc.query_selector_all("[data-pocopine-boot-error]") else {
return;
};
for i in 0..nodes.length() {
let Some(node) = nodes.item(i) else {
continue;
};
if let Some(el) = node.dyn_ref::<Element>() {
el.remove();
}
}
}
#[must_use = "drop or call `.unmount()` to clean up the subtree"]
pub struct SubtreeHandle {
host: Element,
active: bool,
}
impl SubtreeHandle {
fn release(&mut self) {
if !self.active {
return;
}
mount::release_compiled_subtree(&self.host);
self.host.set_inner_html("");
self.active = false;
}
pub fn unmount(mut self) {
self.release();
}
pub fn leak(mut self) {
self.active = false;
}
}
impl Drop for SubtreeHandle {
fn drop(&mut self) {
self.release();
}
}
fn mount_pp_app_subtree(host: &Element) {
let names = crate::templates::registered_template_names();
if !names.is_empty() {
let selector = names.join(",");
if let Ok(matches) = host.query_selector_all(&selector) {
for i in 0..matches.length() {
let Some(node) = matches.item(i) else {
continue;
};
let Ok(el) = node.dyn_into::<Element>() else {
continue;
};
let tag = el.local_name();
mount::mount_child_component(&el, &tag);
mount::finalize_compiled_subtree(&el);
}
}
}
if let Ok(outlets) = host.query_selector_all("pp-outlet") {
for i in 0..outlets.length() {
let Some(node) = outlets.item(i) else {
continue;
};
if let Ok(el) = node.dyn_into::<Element>() {
router::set_outlet(el);
}
}
}
}
fn render_missing_pp_app_root() {
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };
let Some(body) = doc.body() else { return };
if let Ok(Some(existing)) = body.query_selector("[data-pocopine-boot-error=\"missing-pp-app\"]")
{
existing.remove();
}
let Ok(banner) = doc.create_element("div") else {
return;
};
let _ = banner.set_attribute("data-pocopine-boot-error", "missing-pp-app");
let _ = banner.set_attribute(
"style",
"position:fixed;inset:0;background:#1b1b1f;color:#f5f5f7;\
font-family:ui-monospace,monospace;padding:24px;overflow:auto;\
z-index:2147483647;",
);
banner.set_inner_html(
"<h2 style=\"margin:0 0 12px 0;color:#ff6b6b;\">pocopine: \
no <code>[pp-app]</code> root found</h2>\
<p style=\"margin:0 0 16px 0;\">\
pocopine v2 is compiled-mount-only — `App::run()` looks for \
a single element with the <code>pp-app</code> attribute and \
mounts the active route there. Add it to your HTML host:</p>\
<pre style=\"background:#0d0d10;padding:12px;border-radius:4px;\
overflow:auto;\"><body>\n <div pp-app></div>\n</body></pre>\
<p style=\"margin:16px 0 0 0;color:#a0a0a0;font-size:0.875rem;\">\
Apps that need multiple roots use \
<code>App::mount_subtree::<C>(host)</code> instead. \
See RFC 061 for the migration guide.</p>",
);
let _ = body.append_child(&banner);
web_sys::console::error_1(
&"pocopine: App::run() found no [pp-app] root — refusing to mount. See RFC 061.".into(),
);
}