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.