Skip to main content

pocopine_core/
lifecycle.rs

1//! RFC-032 — `LifecycleContext` carrier + built-in extractors.
2//!
3//! `on_mount` and `on_ready` handlers receive typed projections of
4//! a shared `LifecycleContext` via stdlib `From`. The mount builds
5//! one carrier per hook call; `#[handlers]` inspects the user's
6//! method signature and emits one `.into()` per parameter. Each
7//! extractor is a plain `impl From<LifecycleContext<'a>> for
8//! MyType` — no new trait to learn, orphan rule works naturally
9//! for author-defined types, and fallible extractors just return
10//! `Option<T>`.
11
12use std::cell::RefCell;
13use std::collections::HashMap;
14use std::marker::PhantomData;
15
16use wasm_bindgen::JsCast;
17use web_sys::{Document, Element, HtmlElement, Window};
18
19use crate::handle::Handle;
20use crate::reactive::ScopeId;
21use crate::scope::Scope;
22
23/// Read-only carrier handed to `on_mount` / `on_ready` by the
24/// mount. Authors don't construct it; built-in extractors project
25/// from it into typed values (see §4.3 of RFC-032).
26///
27/// `#[non_exhaustive]` keeps future field additions additive —
28/// adding a parent scope id, refs map, or timing info costs
29/// nothing at author callsites because extractors read the
30/// specific fields they need.
31/// Which lifecycle slot a [`LifecycleContext`] was minted for. Lets
32/// element-dependent extractors panic with a precise message when
33/// used in a phase where the rendered element doesn't yet exist
34/// (setup) or may already be detaching (unmount).
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum LifecyclePhase {
37    /// Pre-template-walk. `ctx.el` is the custom-element host, not
38    /// the rendered template root. Refs aren't registered yet.
39    Setup,
40    /// Post-template-walk. `ctx.el` is the rendered root. Refs are
41    /// fully populated. Full extractor surface available.
42    Mount,
43    /// One microtask after `Mount`. Same context; the user method
44    /// receives `&self` so internal proxy reads don't double-borrow.
45    Ready,
46    /// Component teardown. `ctx.el` is the element being detached;
47    /// refs may already be cleared by the time the body runs.
48    Unmount,
49}
50
51#[non_exhaustive]
52#[derive(Clone, Copy)]
53pub struct LifecycleContext<'a> {
54    /// The component's rendered root element — what `SCOPE_ID_KEY`
55    /// is pinned on. Template root on the normal mount path; the
56    /// hoisted user element under `pp-as`. At [`LifecyclePhase::Setup`]
57    /// this is the custom-element host (template hasn't been walked).
58    pub el: &'a Element,
59    /// This component's scope id.
60    pub scope_id: ScopeId,
61    /// Which lifecycle slot the mount fired this hook from.
62    /// Element-dependent extractors guard on this.
63    pub phase: LifecyclePhase,
64}
65
66impl<'a> LifecycleContext<'a> {
67    /// Internal constructor — mount mints these in `fire_*_hook`;
68    /// not exposed to downstream because the type is
69    /// `#[non_exhaustive]`.
70    #[doc(hidden)]
71    pub fn __new(el: &'a Element, scope_id: ScopeId, phase: LifecyclePhase) -> Self {
72        Self {
73            el,
74            scope_id,
75            phase,
76        }
77    }
78}
79
80#[track_caller]
81fn check_phase(ctx_phase: LifecyclePhase, allowed: &[LifecyclePhase], extractor: &str) {
82    if !allowed.contains(&ctx_phase) {
83        panic!(
84            "{extractor} extractor is not valid in `on_{phase}` (allowed: {allowed:?}). \
85             At setup the rendered template hasn't been walked yet; at unmount the element \
86             may already be detaching. Reach for Handle / Inject / Parent / NearestParent \
87             / ScopeId / Doc / Win / Body — those work in every phase.",
88            phase = match ctx_phase {
89                LifecyclePhase::Setup => "setup",
90                LifecyclePhase::Mount => "mount",
91                LifecyclePhase::Ready => "ready",
92                LifecyclePhase::Unmount => "unmount",
93            },
94        );
95    }
96}
97
98const ELEMENT_PHASES: &[LifecyclePhase] = &[LifecyclePhase::Mount, LifecyclePhase::Ready];
99
100// ── Tier 1 — rendered root, scope id, carrier itself ───────────────
101
102/// Rendered root as a thin newtype over `&'a Element`. Newtype
103/// (rather than a bare `&'a Element`) so `#[handlers]` has a
104/// concrete, nameable type to match against in handler
105/// signatures. Derefs to `Element` so authors can call any
106/// `Element` method directly.
107#[derive(Clone, Copy)]
108pub struct El<'a>(pub &'a Element);
109
110impl<'a> std::ops::Deref for El<'a> {
111    type Target = Element;
112    fn deref(&self) -> &Self::Target {
113        self.0
114    }
115}
116
117impl<'a> From<LifecycleContext<'a>> for El<'a> {
118    #[track_caller]
119    fn from(ctx: LifecycleContext<'a>) -> Self {
120        check_phase(ctx.phase, ELEMENT_PHASES, "El");
121        El(ctx.el)
122    }
123}
124
125impl<'a> From<LifecycleContext<'a>> for ScopeId {
126    fn from(ctx: LifecycleContext<'a>) -> Self {
127        ctx.scope_id
128    }
129}
130
131// `LifecycleContext<'a> -> LifecycleContext<'a>` is covered by
132// stdlib's `impl<T> From<T> for T` blanket — no explicit impl here.
133
134// ── Tier 2 — Handle to self, parent id, Window/Document/Body, tag ──
135
136/// Extracting a `Handle<T>` directly is the RFC-032 replacement
137/// for `this::<Self>()` inside hooks — write
138/// `fn on_ready(&self, handle: Handle<Self>)` and call
139/// `handle.update(|s| …)` straight through, no wrapper newtype
140/// to unpack.
141///
142/// Blanket `From` impl: any `T: 'static` that matches this
143/// scope's concrete Rust type. Panics if the scope has been
144/// evicted or if `T` doesn't match — either indicates a
145/// framework bug, not an author bug.
146impl<'a, T: 'static> From<LifecycleContext<'a>> for Handle<T> {
147    fn from(ctx: LifecycleContext<'a>) -> Self {
148        let scope = Scope::find(ctx.scope_id)
149            .expect("LifecycleContext carried a scope id whose entry no longer exists");
150        let rc = scope
151            .typed::<T>()
152            .expect("Handle<T>: `T` doesn't match this scope's Rust type");
153        Handle::new(rc, ctx.scope_id)
154    }
155}
156
157/// This component's parent scope id (RFC-027 inject chain), or
158/// `None` when the component sits at the root of its subtree.
159/// Wraps a single `context::parent_of` lookup.
160#[derive(Clone, Copy)]
161pub struct ParentId(pub Option<ScopeId>);
162
163impl<'a> From<LifecycleContext<'a>> for ParentId {
164    fn from(ctx: LifecycleContext<'a>) -> Self {
165        ParentId(crate::context::parent_of(ctx.scope_id))
166    }
167}
168
169/// Shortcut to `web_sys::Document`. Wraps the
170/// `window().unwrap().document().unwrap()` pair — fatal in
171/// practice if either is missing.
172#[derive(Clone)]
173pub struct Doc(pub Document);
174
175impl<'a> From<LifecycleContext<'a>> for Doc {
176    fn from(_: LifecycleContext<'a>) -> Self {
177        Doc(web_sys::window()
178            .and_then(|w| w.document())
179            .expect("Doc extractor: no document"))
180    }
181}
182
183/// Shortcut to `web_sys::Window`. Named `Win` to keep it short
184/// at handler callsites.
185#[derive(Clone)]
186pub struct Win(pub Window);
187
188impl<'a> From<LifecycleContext<'a>> for Win {
189    fn from(_: LifecycleContext<'a>) -> Self {
190        Win(web_sys::window().expect("Win extractor: no window"))
191    }
192}
193
194/// Document body — useful for teleport-adjacent listener installs.
195/// Wraps a `HtmlElement` (already-cast from `Element`) so authors
196/// can call `HtmlElement` methods directly.
197#[derive(Clone)]
198pub struct Body(pub HtmlElement);
199
200impl<'a> From<LifecycleContext<'a>> for Body {
201    fn from(_: LifecycleContext<'a>) -> Self {
202        Body(
203            web_sys::window()
204                .and_then(|w| w.document())
205                .and_then(|d| d.body())
206                .expect("Body extractor: no document.body"),
207        )
208    }
209}
210
211/// The component's registered kebab-case custom-element tag
212/// (`"pine-dialog-root"`) as it appears in the DOM. Reads the
213/// tag name directly off the rendered root's parent (the custom
214/// element tag) — falls back to the root's own `tagName` if the
215/// component is rendered without a host (rare; `pp-as` etc.).
216#[derive(Clone, Copy)]
217pub struct TagName(pub &'static str);
218
219impl<'a> From<LifecycleContext<'a>> for TagName {
220    #[track_caller]
221    fn from(ctx: LifecycleContext<'a>) -> Self {
222        check_phase(ctx.phase, ELEMENT_PHASES, "TagName");
223        // Resolve at hook-call time: rendered-root's parent is
224        // normally the custom-element tag. Leak the string to get
225        // a `'static str` — one per tag-name string, tiny cost,
226        // matches `type_name()`'s existing lifetime story.
227        let name = ctx
228            .el
229            .parent_element()
230            .map(|p| p.tag_name().to_lowercase())
231            .unwrap_or_else(|| ctx.el.tag_name().to_lowercase());
232        TagName(Box::leak(name.into_boxed_str()))
233    }
234}
235
236// ── Tier 3 — Refs, TypedEl, HostEl, IsTeleported ───────────────────
237
238/// Map-like accessor over the component's `pp-ref` entries. Wraps
239/// `refs::get_on` + optional typed cast.
240///
241/// ```ignore
242/// fn on_ready(&self, refs: Refs) {
243///     if let Some(menu) = refs.get_as::<HtmlUListElement>("menu") {
244///         // …
245///     }
246/// }
247/// ```
248#[derive(Clone, Copy)]
249pub struct Refs<'a> {
250    scope_id: ScopeId,
251    _m: PhantomData<&'a ()>,
252}
253
254impl<'a> Refs<'a> {
255    /// Look up a named ref by its `pp-ref="name"` attribute.
256    /// Returns `None` if no element has that name stamped on
257    /// this component's scope.
258    pub fn get(&self, name: &str) -> Option<Element> {
259        crate::refs::get_on(self.scope_id, name)
260    }
261
262    /// Look up + downcast to a specific `JsCast` type. Returns
263    /// `None` on either a missing ref or a failed cast.
264    pub fn get_as<T: JsCast>(&self, name: &str) -> Option<T> {
265        self.get(name).and_then(|el| el.dyn_into().ok())
266    }
267
268    /// Look up a child-component handle by `pp-ref="name"`
269    /// (RFC 081). Returns `None` when the named ref isn't a
270    /// child-component host or the registered child's Rust
271    /// type doesn't match `T`. Mirrors the free-fn
272    /// [`crate::refs::get_component`].
273    pub fn get_component<T: 'static>(&self, name: &str) -> Option<crate::handle::Handle<T>> {
274        crate::refs::get_component_on::<T>(self.scope_id, name)
275    }
276}
277
278impl<'a> From<LifecycleContext<'a>> for Refs<'a> {
279    #[track_caller]
280    fn from(ctx: LifecycleContext<'a>) -> Self {
281        check_phase(ctx.phase, ELEMENT_PHASES, "Refs");
282        Refs {
283            scope_id: ctx.scope_id,
284            _m: PhantomData,
285        }
286    }
287}
288
289/// Rendered root pre-cast via `dyn_into::<T>()`. Panics if the
290/// rendered root isn't of the expected type — author's contract,
291/// caught during development. Use `Option<TypedEl<T>>` for the
292/// fallible form.
293///
294/// ```ignore
295/// fn on_ready(&self, el: TypedEl<HtmlButtonElement>) {
296///     let _ = el.0.focus();
297/// }
298/// ```
299pub struct TypedEl<T: JsCast>(pub T);
300
301impl<'a, T: JsCast + 'static> From<LifecycleContext<'a>> for TypedEl<T> {
302    #[track_caller]
303    fn from(ctx: LifecycleContext<'a>) -> Self {
304        check_phase(ctx.phase, ELEMENT_PHASES, "TypedEl");
305        TypedEl(
306            ctx.el
307                .clone()
308                .dyn_into::<T>()
309                .expect("TypedEl<T>: rendered root doesn't cast to T"),
310        )
311    }
312}
313
314impl<'a, T: JsCast + 'static> From<LifecycleContext<'a>> for Option<TypedEl<T>> {
315    #[track_caller]
316    fn from(ctx: LifecycleContext<'a>) -> Self {
317        check_phase(ctx.phase, ELEMENT_PHASES, "Option<TypedEl>");
318        ctx.el.clone().dyn_into::<T>().ok().map(TypedEl)
319    }
320}
321
322/// The custom-element tag parent of the rendered root — the DOM
323/// ancestor whose tag name matches the component's registered
324/// name. Useful when events need to dispatch from the tag rather
325/// than the template's inner root (`pp-model` listens on the tag,
326/// not the template root).
327#[derive(Clone)]
328pub struct HostEl(pub Element);
329
330impl<'a> From<LifecycleContext<'a>> for HostEl {
331    #[track_caller]
332    fn from(ctx: LifecycleContext<'a>) -> Self {
333        check_phase(ctx.phase, ELEMENT_PHASES, "HostEl");
334        HostEl(ctx.el.parent_element().unwrap_or_else(|| ctx.el.clone()))
335    }
336}
337
338/// Whether the rendered root lives inside a teleported subtree
339/// (an `pp-teleport` clone rehomed to `<body>` or another target).
340/// Walks ancestors checking the `__pp_teleport_origin` back-link.
341#[derive(Clone, Copy)]
342pub struct IsTeleported(pub bool);
343
344impl<'a> From<LifecycleContext<'a>> for IsTeleported {
345    #[track_caller]
346    fn from(ctx: LifecycleContext<'a>) -> Self {
347        check_phase(ctx.phase, ELEMENT_PHASES, "IsTeleported");
348        IsTeleported(crate::directives::teleport::host_of(ctx.el).is_some())
349    }
350}
351
352// ── Tier 4 — scope path, teleport host, mount epoch, slots, elapsed
353
354/// Full scope chain from the current scope up to the
355/// first-without-parent — useful for devtools or hierarchical
356/// lookups that bypass inject.
357#[derive(Clone)]
358pub struct ScopePath(pub Vec<ScopeId>);
359
360impl<'a> From<LifecycleContext<'a>> for ScopePath {
361    fn from(ctx: LifecycleContext<'a>) -> Self {
362        let mut out = vec![ctx.scope_id];
363        let mut cur = ctx.scope_id;
364        while let Some(p) = crate::context::parent_of(cur) {
365            out.push(p);
366            cur = p;
367        }
368        ScopePath(out)
369    }
370}
371
372/// Original host of a teleport — the parent of the
373/// `<template pp-teleport>` whose body was cloned. Returns `None`
374/// when the rendered root isn't inside a teleported subtree.
375#[derive(Clone)]
376pub struct TeleportHost(pub Option<Element>);
377
378impl<'a> From<LifecycleContext<'a>> for TeleportHost {
379    #[track_caller]
380    fn from(ctx: LifecycleContext<'a>) -> Self {
381        check_phase(ctx.phase, ELEMENT_PHASES, "TeleportHost");
382        TeleportHost(crate::directives::teleport::host_of(ctx.el))
383    }
384}
385
386thread_local! {
387    /// Monotonic counter bumped by the mount for each scope's
388    /// first hook firing. `MountEpoch` exposes it so authors can
389    /// tell a re-walk apart from the original mount.
390    static MOUNT_EPOCH_COUNTER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
391    static MOUNT_EPOCHS: RefCell<HashMap<ScopeId, u64>> = RefCell::new(HashMap::new());
392}
393
394/// Monotonic mount epoch for this scope — increments on each hook
395/// firing per scope id. First fire = 0, second fire (e.g. after a
396/// keyed `pp-for` resurrection) = 1, etc. Stays stable within a
397/// single hook invocation.
398#[derive(Clone, Copy, PartialEq, Eq)]
399pub struct MountEpoch(pub u64);
400
401impl<'a> From<LifecycleContext<'a>> for MountEpoch {
402    fn from(ctx: LifecycleContext<'a>) -> Self {
403        MountEpoch(MOUNT_EPOCHS.with(|m| {
404            let mut map = m.borrow_mut();
405            *map.entry(ctx.scope_id).or_insert_with(|| {
406                MOUNT_EPOCH_COUNTER.with(|c| {
407                    let v = c.get();
408                    c.set(v + 1);
409                    v
410                })
411            })
412        }))
413    }
414}
415
416/// Clean up a scope's `MountEpoch` entry. Called by
417/// `Scope::remove` via the standard per-scope cleanup pass.
418#[doc(hidden)]
419pub fn __clear_mount_epoch(scope: ScopeId) {
420    MOUNT_EPOCHS.with(|m| {
421        m.borrow_mut().remove(&scope);
422    });
423}
424
425/// Bulk cleanup for compiled-row teardown. Avoids one
426/// `thread_local::with` borrow per row during large keyed-list
427/// clears.
428#[doc(hidden)]
429pub fn __clear_mount_epochs(scopes: &[ScopeId]) {
430    if scopes.is_empty() {
431        return;
432    }
433    MOUNT_EPOCHS.with(|m| {
434        let mut map = m.borrow_mut();
435        if map.is_empty() {
436            return;
437        }
438        for scope in scopes {
439            map.remove(scope);
440        }
441    });
442}
443
444/// Millisecond timestamp (via `Date::now()`) at the moment the
445/// extractor ran. Useful for scope-level timing — pair with
446/// `on_unmount` (which takes no ctx) using an author-stored
447/// field if you need end-to-end duration. Uses `Date::now` rather
448/// than `performance.now` to avoid the web-sys `Performance`
449/// feature gate; monotonicity is not guaranteed across system
450/// clock changes, good enough for framework-level timing.
451#[derive(Clone, Copy)]
452pub struct Elapsed(pub f64);
453
454impl<'a> From<LifecycleContext<'a>> for Elapsed {
455    fn from(_: LifecycleContext<'a>) -> Self {
456        Elapsed(js_sys::Date::now())
457    }
458}
459
460impl<'a, T: 'static> From<LifecycleContext<'a>> for crate::plugin::Plugin<T> {
461    fn from(_: LifecycleContext<'a>) -> Self {
462        crate::plugin::required_plugin::<T>()
463    }
464}
465
466impl<'a, T: 'static> From<LifecycleContext<'a>> for Option<crate::plugin::Plugin<T>> {
467    fn from(_: LifecycleContext<'a>) -> Self {
468        crate::plugin::active_plugin::<T>()
469    }
470}
471
472// Fallibility: authors impl `From<LifecycleContext>` for
473// `Option<TheirType>` directly when they want the non-panicking
474// variant. No catch_unwind blanket — wasm-hostile and magics too
475// much.