Skip to main content

pocopine_core/
plugin.rs

1//! App plugin runtime services and lifecycle hook dispatch.
2//!
3//! `AppPlugin` installs configuration on the [`crate::App`] builder.
4//! This module stores the runtime services those installers provide,
5//! exposes them to component lifecycle hooks through [`Plugin<T>`],
6//! and dispatches framework lifecycle events to services that implement
7//! [`Hook<E>`].
8
9use std::any::{type_name, Any, TypeId};
10use std::cell::{Cell, RefCell};
11use std::collections::HashMap;
12use std::fmt;
13use std::marker::PhantomData;
14use std::ops::Deref;
15use std::rc::Rc;
16
17use crate::app::Component;
18use crate::reactive::ScopeId;
19
20type HookDispatch = Rc<dyn Fn(&PluginRegistry, &dyn Any)>;
21
22thread_local! {
23    static ACTIVE_PLUGINS: RefCell<PluginRegistry> = RefCell::new(PluginRegistry::default());
24    static ACTIVE_HOOK_MASK: Cell<HookMask> = const { Cell::new(0) };
25}
26
27const APP_PROVIDER: &str = "app";
28type HookMask = u16;
29
30const HOOK_APP_BOOT_STARTED: HookMask = 1 << 0;
31const HOOK_APP_BOOT_COMPLETED: HookMask = 1 << 1;
32const HOOK_APP_BOOT_FAILED: HookMask = 1 << 2;
33const HOOK_ROUTE_NAVIGATION_STARTED: HookMask = 1 << 3;
34const HOOK_ROUTE_NAVIGATION_COMPLETED: HookMask = 1 << 4;
35const HOOK_ROUTE_NAVIGATION_FAILED: HookMask = 1 << 5;
36const HOOK_COMPONENT_SETUP: HookMask = 1 << 6;
37const HOOK_COMPONENT_MOUNTED: HookMask = 1 << 7;
38const HOOK_COMPONENT_READY: HookMask = 1 << 8;
39const HOOK_COMPONENT_UNMOUNTED: HookMask = 1 << 9;
40const HOOK_SERVER_FUNCTION_CLIENT_STARTED: HookMask = 1 << 10;
41const HOOK_SERVER_FUNCTION_CLIENT_COMPLETED: HookMask = 1 << 11;
42const HOOK_SERVER_FUNCTION_CLIENT_FAILED: HookMask = 1 << 12;
43const HOOK_COMPONENT_NAME_EVENTS: HookMask =
44    HOOK_COMPONENT_MOUNTED | HOOK_COMPONENT_READY | HOOK_COMPONENT_UNMOUNTED;
45const HOOK_ROUTE_NAVIGATION_EVENTS: HookMask =
46    HOOK_ROUTE_NAVIGATION_STARTED | HOOK_ROUTE_NAVIGATION_COMPLETED | HOOK_ROUTE_NAVIGATION_FAILED;
47const HOOK_SERVER_FUNCTION_CLIENT_EVENTS: HookMask = HOOK_SERVER_FUNCTION_CLIENT_STARTED
48    | HOOK_SERVER_FUNCTION_CLIENT_COMPLETED
49    | HOOK_SERVER_FUNCTION_CLIENT_FAILED;
50
51/// Per-mount snapshot of which plugin-side stamps the runtime needs to
52/// produce. Sampled once from `ACTIVE_HOOK_MASK` at the top of a mount so
53/// `mount_component` and `try_mount_component_as` skip both the JS-FFI
54/// `Date.now()` call and the `COMPONENT_NAME_KEY` allocation when no
55/// observer is listening. `needs_component_name` covers any event that
56/// reads the stamp later (mounted, ready, unmounted); `needs_mount_start`
57/// covers only `ComponentMounted`'s `duration_ms`.
58#[derive(Clone, Copy)]
59pub(crate) struct ComponentHookActivity {
60    pub(crate) needs_component_name: bool,
61    pub(crate) needs_mount_start: bool,
62}
63
64/// Runtime handle for a service installed by an app plugin.
65///
66/// Component lifecycle hooks receive this through the standard extractor
67/// pipeline:
68///
69/// ```ignore
70/// fn on_ready(&self, analytics: Plugin<Analytics>) {
71///     analytics.track("ready");
72/// }
73/// ```
74///
75/// Use `Option<Plugin<T>>` for reusable components where the plugin is
76/// optional.
77pub struct Plugin<T: 'static> {
78    service: Rc<T>,
79}
80
81impl<T: 'static> Clone for Plugin<T> {
82    fn clone(&self) -> Self {
83        Self {
84            service: self.service.clone(),
85        }
86    }
87}
88
89impl<T: 'static> Deref for Plugin<T> {
90    type Target = T;
91
92    fn deref(&self) -> &Self::Target {
93        self.service.as_ref()
94    }
95}
96
97impl<T: 'static> Plugin<T> {
98    pub fn get(&self) -> &T {
99        self.service.as_ref()
100    }
101}
102
103/// App plugin service lookup handle for component methods.
104#[derive(Clone, Copy, Debug, Default)]
105pub struct Plugins;
106
107impl Plugins {
108    pub fn get<T: 'static>(&self) -> Option<Plugin<T>> {
109        active_plugin::<T>()
110    }
111}
112
113/// Convenience methods available on every pocopine component.
114///
115/// The blanket implementation keeps these methods out of macro-generated
116/// inherent impls, so a component can still define its own method with the same
117/// name if it needs to. In normal app code, importing the prelude lets handler
118/// methods call `self.plugin::<T>()` or `self.plugins().get::<T>()`.
119pub trait ComponentPluginExt: Component {
120    fn plugins(&self) -> Plugins {
121        Plugins
122    }
123
124    fn plugin<T: 'static>(&self) -> Plugin<T> {
125        required_plugin::<T>()
126    }
127}
128
129impl<C: Component + ?Sized> ComponentPluginExt for C {}
130
131/// Typed framework event hook implemented by runtime plugin services.
132///
133/// `call` takes the event by value: every subscriber receives its own
134/// copy via the dispatcher's `event.clone()`. Framework events are
135/// designed so the clone is cheap — fields are `&'static str` (registry
136/// names, route patterns, failure reasons) or primitives wherever
137/// possible, so `Clone` collapses to a memcpy. Author-defined event
138/// types should follow the same rule when high-frequency dispatch
139/// matters.
140pub trait Hook<E>: 'static {
141    fn call(&self, event: E);
142}
143
144/// Emitted once app boot starts after plugin validation succeeds.
145#[derive(Copy, Clone, Debug)]
146pub struct AppBootStarted {
147    pub component_count: usize,
148    pub route_count: usize,
149}
150
151/// Emitted once initial app boot has mounted the root and scheduled
152/// post-mount work. `duration_ms` covers synchronous boot only; deferred
153/// `after_mount` callbacks are not included.
154#[derive(Copy, Clone, Debug)]
155pub struct AppBootCompleted {
156    pub duration_ms: f64,
157}
158
159/// Emitted when app boot fails after plugin validation succeeds.
160///
161/// `reason` is a stable identifier from a closed set of `&'static str`
162/// values produced by the runtime: `"component_registry"`,
163/// `"missing_window"`, `"missing_document"`, `"missing_pp_app_root"`.
164#[derive(Copy, Clone, Debug)]
165pub struct AppBootFailed {
166    pub reason: &'static str,
167}
168
169/// Emitted before the router tries to paint the current URL.
170///
171/// `path` is read from `window.location.pathname` and is therefore
172/// owned. `route_pattern` and `component` reference the static route
173/// registry.
174#[derive(Clone, Debug)]
175pub struct RouteNavigationStarted {
176    pub path: String,
177    pub route_pattern: Option<&'static str>,
178    pub component: Option<&'static str>,
179}
180
181/// Emitted after the router paints a matched route or resolves an
182/// unmatched path.
183#[derive(Clone, Debug)]
184pub struct RouteNavigationCompleted {
185    pub path: String,
186    pub route_pattern: Option<&'static str>,
187    pub component: Option<&'static str>,
188    pub duration_ms: f64,
189}
190
191/// Emitted when route matching succeeds but the router cannot paint
192/// the route. `reason` is a stable identifier: `"missing_window"`,
193/// `"missing_outlet"`, `"missing_document"`, `"create_element_failed"`,
194/// `"guard_pending"`, `"guard_redirected"`.
195#[derive(Clone, Debug)]
196pub struct RouteNavigationFailed {
197    pub path: String,
198    pub route_pattern: Option<&'static str>,
199    pub component: Option<&'static str>,
200    pub reason: &'static str,
201    pub duration_ms: f64,
202}
203
204/// Emitted before the browser client sends a generated `#[server]`
205/// function request.
206///
207/// `route` is the public request path with any query string or fragment
208/// stripped. Request arguments and response bodies are never included.
209#[derive(Clone, Debug)]
210pub struct ServerFunctionClientStarted {
211    pub route: String,
212}
213
214/// Emitted after a generated `#[server]` client request succeeds.
215#[derive(Clone, Debug)]
216pub struct ServerFunctionClientCompleted {
217    pub route: String,
218    pub duration_ms: f64,
219    pub status_code: u16,
220}
221
222/// Emitted after a generated `#[server]` client request fails before
223/// returning an application value.
224///
225/// `error_kind` is a stable coarse reason such as `"serialize"`,
226/// `"fetch"`, `"http_status"`, or `"unauthorized"`.
227#[derive(Clone, Debug)]
228pub struct ServerFunctionClientFailed {
229    pub route: String,
230    pub duration_ms: f64,
231    pub error_kind: &'static str,
232}
233
234/// Component-scoped framework event.
235///
236/// Plugin services use this as the event bound for typed per-component
237/// hooks. The runtime keeps the matching private so authors register
238/// `Hook<ForComponent<C, E>>` instead of string-comparing component names.
239pub trait ComponentEvent: Clone + 'static {
240    fn component(&self) -> &'static str;
241
242    fn scope_id(&self) -> ScopeId;
243}
244
245/// Typed wrapper for a component event filtered to one component type.
246///
247/// A service implements `Hook<ForComponent<MyComponent, ComponentMounted>>`
248/// and installs it with `App::hook_component_plugin::<Service,
249/// MyComponent, ComponentMounted>()`. This is for app-specific overrides where
250/// the plugin intentionally targets a known component. Reusable component
251/// behavior should normally be owned by the component through `Plugin<T>` or
252/// `Option<Plugin<T>>` extraction.
253pub struct ForComponent<C, E> {
254    event: E,
255    _component: PhantomData<fn() -> C>,
256}
257
258impl<C, E> ForComponent<C, E> {
259    pub(crate) fn new(event: E) -> Self {
260        Self {
261            event,
262            _component: PhantomData,
263        }
264    }
265
266    pub fn event(&self) -> &E {
267        &self.event
268    }
269
270    pub fn into_event(self) -> E {
271        self.event
272    }
273}
274
275impl<C, E: Clone> Clone for ForComponent<C, E> {
276    fn clone(&self) -> Self {
277        Self::new(self.event.clone())
278    }
279}
280
281impl<C, E> Deref for ForComponent<C, E> {
282    type Target = E;
283
284    fn deref(&self) -> &Self::Target {
285        &self.event
286    }
287}
288
289impl<C, E: fmt::Debug> fmt::Debug for ForComponent<C, E> {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        f.debug_tuple("ForComponent").field(&self.event).finish()
292    }
293}
294
295/// Emitted after a component scope has been created and before the
296/// component's `on_setup` hook runs.
297#[derive(Copy, Clone, Debug)]
298pub struct ComponentSetup {
299    pub component: &'static str,
300    pub scope_id: ScopeId,
301}
302
303/// Emitted after a component subtree has been mounted and finalized.
304#[derive(Copy, Clone, Debug)]
305pub struct ComponentMounted {
306    pub component: &'static str,
307    pub scope_id: ScopeId,
308    pub duration_ms: f64,
309}
310
311/// Emitted on the component ready microtask before the component's
312/// `on_ready` hook runs.
313#[derive(Copy, Clone, Debug)]
314pub struct ComponentReady {
315    pub component: &'static str,
316    pub scope_id: ScopeId,
317}
318
319/// Emitted just before a component scope is removed.
320#[derive(Copy, Clone, Debug)]
321pub struct ComponentUnmounted {
322    pub component: &'static str,
323    pub scope_id: ScopeId,
324}
325
326impl ComponentEvent for ComponentSetup {
327    fn component(&self) -> &'static str {
328        self.component
329    }
330
331    fn scope_id(&self) -> ScopeId {
332        self.scope_id
333    }
334}
335
336impl ComponentEvent for ComponentMounted {
337    fn component(&self) -> &'static str {
338        self.component
339    }
340
341    fn scope_id(&self) -> ScopeId {
342        self.scope_id
343    }
344}
345
346impl ComponentEvent for ComponentReady {
347    fn component(&self) -> &'static str {
348        self.component
349    }
350
351    fn scope_id(&self) -> ScopeId {
352        self.scope_id
353    }
354}
355
356impl ComponentEvent for ComponentUnmounted {
357    fn component(&self) -> &'static str {
358        self.component
359    }
360
361    fn scope_id(&self) -> ScopeId {
362        self.scope_id
363    }
364}
365
366struct PluginService {
367    service: Rc<dyn Any>,
368    provider: &'static str,
369}
370
371struct HookRequirement {
372    plugin: &'static str,
373    service: &'static str,
374    service_type: TypeId,
375    event: &'static str,
376    component: Option<&'static str>,
377}
378
379/// Boot-time plugin validation error.
380///
381/// The runtime records which plugin installed each hook. Before mounting the
382/// app it verifies that every hook's required service has also been provided,
383/// so misconfigured plugin ordering fails at boot with a concrete diagnostic
384/// instead of failing later on the first matching event.
385#[derive(Clone, Debug, PartialEq, Eq)]
386pub struct PluginValidationError {
387    pub plugin: &'static str,
388    pub service: &'static str,
389    pub event: &'static str,
390    pub component: Option<&'static str>,
391}
392
393impl fmt::Display for PluginValidationError {
394    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395        match self.component {
396            Some(component) => write!(
397                f,
398                "plugin `{}` registered a hook for component `{}` and event `{}` \
399                 requiring service `{}`, but that service was not provided",
400                self.plugin, component, self.event, self.service
401            ),
402            None => write!(
403                f,
404                "plugin `{}` registered a hook for event `{}` requiring service `{}`, \
405                 but that service was not provided",
406                self.plugin, self.event, self.service
407            ),
408        }
409    }
410}
411
412#[derive(Default)]
413pub(crate) struct PluginRegistry {
414    services: HashMap<TypeId, PluginService>,
415    hooks: HashMap<TypeId, Vec<HookDispatch>>,
416    requirements: Vec<HookRequirement>,
417}
418
419impl PluginRegistry {
420    pub(crate) fn provide<T: 'static>(&mut self, service: T, provider: Option<&'static str>) {
421        let service_type = TypeId::of::<T>();
422        let provider = provider.unwrap_or(APP_PROVIDER);
423        if let Some(previous) = self.services.get(&service_type) {
424            panic!(
425                "plugin service `{}` is already installed (first provider: `{}`, \
426                 second provider: `{}`)",
427                type_name::<T>(),
428                previous.provider,
429                provider,
430            );
431        }
432        self.services.insert(
433            service_type,
434            PluginService {
435                service: Rc::new(service),
436                provider,
437            },
438        );
439    }
440
441    pub(crate) fn hook_plugin<T, E>(&mut self, plugin: Option<&'static str>)
442    where
443        T: Hook<E> + 'static,
444        E: Clone + 'static,
445    {
446        let plugin = plugin.unwrap_or(APP_PROVIDER);
447        self.requirements.push(HookRequirement {
448            plugin,
449            service: type_name::<T>(),
450            service_type: TypeId::of::<T>(),
451            event: type_name::<E>(),
452            component: None,
453        });
454        self.hooks
455            .entry(TypeId::of::<E>())
456            .or_default()
457            .push(Rc::new(|registry, event| {
458                let event = event
459                    .downcast_ref::<E>()
460                    .expect("plugin hook dispatched with the wrong event type")
461                    .clone();
462                let service = registry.plugin::<T>().unwrap_or_else(|| {
463                    panic!(
464                        "plugin hook for event `{}` requires plugin service `{}`, \
465                         but that service is not installed. Install it with \
466                         `App::provide_plugin(...)` before `App::hook_plugin::<{}, {}>()`.",
467                        type_name::<E>(),
468                        type_name::<T>(),
469                        type_name::<T>(),
470                        type_name::<E>(),
471                    )
472                });
473                service.get().call(event);
474            }));
475    }
476
477    pub(crate) fn hook_component_plugin<T, C, E>(&mut self, plugin: Option<&'static str>)
478    where
479        T: Hook<ForComponent<C, E>> + 'static,
480        C: Component + 'static,
481        E: ComponentEvent,
482    {
483        let plugin = plugin.unwrap_or(APP_PROVIDER);
484        self.requirements.push(HookRequirement {
485            plugin,
486            service: type_name::<T>(),
487            service_type: TypeId::of::<T>(),
488            event: type_name::<E>(),
489            component: Some(C::NAME),
490        });
491        self.hooks
492            .entry(TypeId::of::<E>())
493            .or_default()
494            .push(Rc::new(|registry, event| {
495                let event = event
496                    .downcast_ref::<E>()
497                    .expect("plugin hook dispatched with the wrong event type")
498                    .clone();
499                if event.component() != C::NAME {
500                    return;
501                }
502                let service = registry.plugin::<T>().unwrap_or_else(|| {
503                    panic!(
504                        "plugin hook for component `{}` and event `{}` requires \
505                         plugin service `{}`, but that service is not installed. \
506                         Install it with `App::provide_plugin(...)` before \
507                         `App::hook_component_plugin::<{}, {}, {}>()`.",
508                        C::NAME,
509                        type_name::<E>(),
510                        type_name::<T>(),
511                        type_name::<T>(),
512                        type_name::<C>(),
513                        type_name::<E>(),
514                    )
515                });
516                service.get().call(ForComponent::new(event));
517            }));
518    }
519
520    pub(crate) fn validate(&self) -> Result<(), Vec<PluginValidationError>> {
521        let errors: Vec<_> = self
522            .requirements
523            .iter()
524            .filter(|requirement| !self.services.contains_key(&requirement.service_type))
525            .map(|requirement| PluginValidationError {
526                plugin: requirement.plugin,
527                service: requirement.service,
528                event: requirement.event,
529                component: requirement.component,
530            })
531            .collect();
532        if errors.is_empty() {
533            Ok(())
534        } else {
535            Err(errors)
536        }
537    }
538
539    fn plugin<T: 'static>(&self) -> Option<Plugin<T>> {
540        self.services
541            .get(&TypeId::of::<T>())
542            .and_then(|service| service.service.clone().downcast::<T>().ok())
543            .map(|service| Plugin { service })
544    }
545
546    fn emit<E>(&self, event: E)
547    where
548        E: Clone + 'static,
549    {
550        if let Some(hooks) = self.hooks.get(&TypeId::of::<E>()) {
551            for hook in hooks {
552                hook(self, &event);
553            }
554        }
555    }
556
557    fn has_stored_hooks<E: 'static>(&self) -> bool {
558        self.hooks
559            .get(&TypeId::of::<E>())
560            .map(|hooks| !hooks.is_empty())
561            .unwrap_or(false)
562    }
563
564    fn hook_mask(&self) -> HookMask {
565        let mut mask = 0;
566        if self.has_stored_hooks::<AppBootStarted>() {
567            mask |= HOOK_APP_BOOT_STARTED;
568        }
569        if self.has_stored_hooks::<AppBootCompleted>() {
570            mask |= HOOK_APP_BOOT_COMPLETED;
571        }
572        if self.has_stored_hooks::<AppBootFailed>() {
573            mask |= HOOK_APP_BOOT_FAILED;
574        }
575        if self.has_stored_hooks::<RouteNavigationStarted>() {
576            mask |= HOOK_ROUTE_NAVIGATION_STARTED;
577        }
578        if self.has_stored_hooks::<RouteNavigationCompleted>() {
579            mask |= HOOK_ROUTE_NAVIGATION_COMPLETED;
580        }
581        if self.has_stored_hooks::<RouteNavigationFailed>() {
582            mask |= HOOK_ROUTE_NAVIGATION_FAILED;
583        }
584        if self.has_stored_hooks::<ComponentSetup>() {
585            mask |= HOOK_COMPONENT_SETUP;
586        }
587        if self.has_stored_hooks::<ComponentMounted>() {
588            mask |= HOOK_COMPONENT_MOUNTED;
589        }
590        if self.has_stored_hooks::<ComponentReady>() {
591            mask |= HOOK_COMPONENT_READY;
592        }
593        if self.has_stored_hooks::<ComponentUnmounted>() {
594            mask |= HOOK_COMPONENT_UNMOUNTED;
595        }
596        if self.has_stored_hooks::<ServerFunctionClientStarted>() {
597            mask |= HOOK_SERVER_FUNCTION_CLIENT_STARTED;
598        }
599        if self.has_stored_hooks::<ServerFunctionClientCompleted>() {
600            mask |= HOOK_SERVER_FUNCTION_CLIENT_COMPLETED;
601        }
602        if self.has_stored_hooks::<ServerFunctionClientFailed>() {
603            mask |= HOOK_SERVER_FUNCTION_CLIENT_FAILED;
604        }
605        mask
606    }
607}
608
609/// Install `registry` as the active plugin set and refresh the hook
610/// bitmask cache. The mask is sampled once here and is **not**
611/// recomputed afterwards — the runtime has no public API for
612/// installing hooks after `App::run`, and the mount/unmount fast paths
613/// assume the cache stays in sync with `ACTIVE_PLUGINS`.
614pub(crate) fn activate(registry: PluginRegistry) {
615    let hook_mask = registry.hook_mask();
616    ACTIVE_PLUGINS.with(|plugins| {
617        *plugins.borrow_mut() = registry;
618    });
619    ACTIVE_HOOK_MASK.with(|mask| mask.set(hook_mask));
620}
621
622/// Dispatch `event` to every registered hook for `E`.
623///
624/// Hot-path callers gate on a bitmask predicate (`has_*_hooks`) before
625/// calling this so that plugin-free apps pay only a `Cell::get`. Once a
626/// hook is known to exist, this function does one HashMap lookup to
627/// find the dispatch vec — the lookup is redundant with the bitmask
628/// check (the bitmask is computed from the same map at activation
629/// time), but eliminating it would require a parallel `[Vec<…>; N]`
630/// array indexed by event id, duplicating the registration data and
631/// adding a sealed `FrameworkEvent` trait. The single hash lookup is
632/// only paid when at least one plugin is installed, so the duplication
633/// isn't worth it.
634pub(crate) fn emit<E>(event: E)
635where
636    E: Clone + 'static,
637{
638    ACTIVE_PLUGINS.with(|plugins| {
639        plugins.borrow().emit(event);
640    });
641}
642
643#[inline]
644pub(crate) fn component_hook_activity() -> ComponentHookActivity {
645    ACTIVE_HOOK_MASK.with(|active| {
646        let active = active.get();
647        ComponentHookActivity {
648            needs_component_name: active & HOOK_COMPONENT_NAME_EVENTS != 0,
649            needs_mount_start: active & HOOK_COMPONENT_MOUNTED != 0,
650        }
651    })
652}
653
654#[inline]
655pub(crate) fn has_component_setup_hooks() -> bool {
656    active_hook_mask_contains(HOOK_COMPONENT_SETUP)
657}
658
659#[inline]
660pub(crate) fn has_component_mounted_hooks() -> bool {
661    active_hook_mask_contains(HOOK_COMPONENT_MOUNTED)
662}
663
664#[inline]
665pub(crate) fn has_component_ready_hooks() -> bool {
666    active_hook_mask_contains(HOOK_COMPONENT_READY)
667}
668
669#[inline]
670pub(crate) fn has_component_unmounted_hooks() -> bool {
671    active_hook_mask_contains(HOOK_COMPONENT_UNMOUNTED)
672}
673
674#[inline]
675pub(crate) fn has_route_navigation_hooks() -> bool {
676    active_hook_mask_contains(HOOK_ROUTE_NAVIGATION_EVENTS)
677}
678
679#[inline]
680pub(crate) fn has_server_function_client_hooks() -> bool {
681    active_hook_mask_contains(HOOK_SERVER_FUNCTION_CLIENT_EVENTS)
682}
683
684#[inline]
685fn active_hook_mask_contains(mask: HookMask) -> bool {
686    ACTIVE_HOOK_MASK.with(|active| active.get() & mask != 0)
687}
688
689pub(crate) fn active_plugin<T: 'static>() -> Option<Plugin<T>> {
690    ACTIVE_PLUGINS.with(|plugins| plugins.borrow().plugin::<T>())
691}
692
693pub(crate) fn required_plugin<T: 'static>() -> Plugin<T> {
694    active_plugin::<T>().unwrap_or_else(|| {
695        panic!(
696            "plugin service `{}` is not installed. Install it from an app \
697             plugin with `App::provide_plugin(...)`, or use \
698             `Option<Plugin<{}>>` for reusable components where the plugin is optional.",
699            type_name::<T>(),
700            type_name::<T>(),
701        )
702    })
703}
704
705pub(crate) fn render_plugin_boot_error(errors: &[PluginValidationError]) {
706    let Some(win) = web_sys::window() else { return };
707    let Some(doc) = win.document() else { return };
708    let Some(body) = doc.body() else { return };
709    if let Ok(Some(existing)) = body.query_selector("[data-pocopine-boot-error=\"plugin\"]") {
710        existing.remove();
711    }
712    let Ok(banner) = doc.create_element("div") else {
713        return;
714    };
715    let _ = banner.set_attribute("data-pocopine-boot-error", "plugin");
716    let _ = banner.set_attribute(
717        "style",
718        "position:fixed;inset:0;background:#1b1b1f;color:#f5f5f7;\
719         font-family:ui-monospace,monospace;padding:24px;overflow:auto;\
720         z-index:2147483647;",
721    );
722    let mut html = String::from(
723        "<h2 style=\"margin:0 0 12px 0;color:#ff6b6b;\">pocopine: \
724         app plugin configuration is invalid</h2>\
725         <p style=\"margin:0 0 16px 0;\">The runtime refused to mount \
726         because one or more plugin hooks require services that were not \
727         installed.</p><ul style=\"margin:0;padding-left:20px;\">",
728    );
729    for err in errors {
730        html.push_str("<li style=\"margin-bottom:8px;\">");
731        html.push_str(&html_escape(&err.to_string()));
732        html.push_str("</li>");
733    }
734    html.push_str("</ul>");
735    banner.set_inner_html(&html);
736    let _ = body.append_child(&banner);
737    web_sys::console::error_1(
738        &format!(
739            "pocopine: app plugin configuration has {} error(s); refusing to mount",
740            errors.len()
741        )
742        .into(),
743    );
744    for err in errors {
745        web_sys::console::error_1(&err.to_string().into());
746    }
747}
748
749fn html_escape(s: &str) -> String {
750    s.replace('&', "&amp;")
751        .replace('<', "&lt;")
752        .replace('>', "&gt;")
753}