pocopine-core 0.1.0

Client-side reactive runtime for pocopine — a Rust/WASM port of Alpine.js.
Documentation
//! Parent-owned slot fragment ABI (RFC-058 §5.5).
//!
//! Today the runtime mount captures slot content from a child
//! component's `light DOM` children, stashes the resulting
//! `DocumentFragment`s in a thread-local keyed on the child's
//! `ScopeId`, and replays them when the mount reaches the
//! child's `<slot>` placeholder. That model puts the mount in
//! the middle of every parent/child slot exchange — which is
//! the exact ownership boundary RFC-058 §5.5 wants to remove.
//!
//! The replacement: parents emit slot **fragment functions**
//! at compile time and pass them to the child's mount call.
//! When the child reaches `<slot>` it invokes the parent's
//! fragment function directly, with no mount discovery in
//! between. The fragment function runs in the parent's scope
//! (so `@click="parent_handler"` inside slotted content works
//! without scope acrobatics) and stamps directly into the
//! child's slot host.
//!
//! This module ships the **type surface** the macro-generated
//! mount code (RFC-058 Phase 3+) will populate. Phase 1 only
//! defines the shapes — the existing slots / capture path
//! continues to drive mount-mounted parents unchanged. Phase 3
//! replaces the runtime capture path for compiled parents with
//! direct fragment-function passing.

use std::cell::RefCell;
use std::collections::HashMap;

use wasm_bindgen::{JsCast, JsValue};
use web_sys::{DocumentFragment, Element};

use crate::reactive::ScopeId;

/// Function pointer the macro emits per parent-authored slot.
///
/// The function is stateless and `'static` — it captures the
/// expression ASTs and constants from the parent template at
/// macro time, then runs against the live `SlotMountCtx` at
/// invocation time. Stateless `fn` (rather than `Box<dyn FnMut>`)
/// keeps the SlotSet payload compact across the eventual
/// Component Model boundary (RFC-058 §5.10).
pub type SlotFragment = fn(ctx: SlotMountCtx<'_>);

/// Per-invocation context for a slot fragment. The fragment
/// appends DOM into `host` (a buffer the runtime then splices
/// before the live `<slot>` element it's materialising), and
/// receives both scope ids — the parent's (so directive
/// expressions evaluate in the right scope) and the child's
/// (so refs registered inside the slotted content participate
/// in the correct child component's `refs::register` table).
///
/// `parent_proxy` is the parent component's `js_sys::Proxy`,
/// captured at slot-set install time (RFC-058 Phase 3.5c).
/// Dynamic slot fragments — content with `pp-text` / `@click`
/// / `pp-bind` etc. that reads or writes parent state — pass
/// it to the generated install closure when stamping bindings
/// against the parent scope. Static fragments ignore it.
///
/// Using a `DocumentFragment` rather than the live slot host
/// keeps the fragment side oblivious to where in the DOM the
/// content lands — same buffer pattern the legacy mount's
/// capture/replay path uses (see [`crate::mount::materialize_slot`]).
pub struct SlotMountCtx<'a> {
    pub host: &'a DocumentFragment,
    pub parent_scope_id: ScopeId,
    pub parent_proxy: &'a JsValue,
    pub child_scope_id: ScopeId,
}

/// One entry in a [`SlotSet`] — the macro-emitted fragment fn
/// plus optional scoped-slot metadata (the parent's `pp-let`
/// identifier when the slot was authored as
/// `<template pp-slot="N" pp-let="ident">`, RFC-058 Phase 3.5g).
#[derive(Clone, Copy)]
pub struct SlotEntry {
    pub fragment: SlotFragment,
    pub scoped_let: Option<&'static str>,
}

/// Set of slot fragments a parent passes to a child mount call.
/// Built fluently by the macro:
///
/// ```ignore
/// SlotSet::new()
///     .default(parent_default_slot_fn)
///     .named("footer", parent_footer_slot_fn);
/// ```
///
/// Backed by a small `HashMap<&'static str, SlotEntry>` —
/// the slot-name keys are macro-emitted string literals so the
/// hash cost is negligible at typical slot counts (1-3 slots
/// per component is the canonical case).
#[derive(Default)]
pub struct SlotSet {
    fragments: HashMap<&'static str, SlotEntry>,
}

/// Reserved slot name for the default (unnamed) slot. Matches
/// the wire-name the runtime mount uses for `default` slot
/// keying so the migration from mount-captured slots to
/// fragment-function slots can interoperate during Phase 3.
pub const DEFAULT_SLOT_NAME: &str = "default";

impl SlotSet {
    /// Empty set — what the runtime mount passes today for
    /// mount-driven mounts (no parent-emitted fragments yet).
    pub fn new() -> Self {
        Self::default()
    }

    /// Register the parent's default slot fragment. Convenience
    /// for `named(DEFAULT_SLOT_NAME, frag)`.
    pub fn default_slot(mut self, frag: SlotFragment) -> Self {
        self.fragments.insert(
            DEFAULT_SLOT_NAME,
            SlotEntry {
                fragment: frag,
                scoped_let: None,
            },
        );
        self
    }

    /// Register a named slot fragment.
    pub fn named(mut self, name: &'static str, frag: SlotFragment) -> Self {
        self.fragments.insert(
            name,
            SlotEntry {
                fragment: frag,
                scoped_let: None,
            },
        );
        self
    }

    /// Register a named scoped slot fragment authored with
    /// `pp-let="ident"` (RFC-058 Phase 3.5g). The runtime
    /// constructs a [`crate::slot_scope::SlotScope`] from the
    /// child's `<slot>` element bindings before invoking the
    /// fragment, so directives inside resolve `ident.field`
    /// against the parent's bound state.
    pub fn scoped(
        mut self,
        name: &'static str,
        frag: SlotFragment,
        let_ident: &'static str,
    ) -> Self {
        self.fragments.insert(
            name,
            SlotEntry {
                fragment: frag,
                scoped_let: Some(let_ident),
            },
        );
        self
    }

    /// Look up the entry for `name`, or `None` if the parent
    /// didn't supply one for that slot. The child should fall
    /// back to its compiled default slot content in that case.
    pub fn get(&self, name: &str) -> Option<SlotEntry> {
        self.fragments.get(name).copied()
    }

    /// `true` when the parent supplied no fragments at all —
    /// i.e. an opaque component invocation with no children.
    /// The child's compiled default slot fragments handle
    /// every slot site.
    pub fn is_empty(&self) -> bool {
        self.fragments.is_empty()
    }
}

// ─── runtime registry ────────────────────────────────────────────

/// Stored per child instance — the parent-supplied [`SlotSet`]
/// alongside the parent's scope id + proxy captured at install
/// time. RFC-058 Phase 3.5c threads the parent context through
/// to [`SlotMountCtx`] so dynamic slot content can install
/// bindings against the parent scope.
struct InstalledSlots {
    set: SlotSet,
    parent_scope_id: ScopeId,
    parent_proxy: JsValue,
}

thread_local! {
    /// Fragments the parent passed for each child component
    /// instance, keyed by the child's `ScopeId`. Populated by
    /// [`crate::mount::mount_child_component_with_slots`] right
    /// after [`crate::mount::mount_component`] instantiates the
    /// child, consumed by [`crate::mount::materialize_slot`]
    /// when the child template's `<slot>` element is reached.
    /// Cleared from [`crate::scope::Scope::remove`] so the map
    /// doesn't outlive the child component.
    static FRAGMENTS: RefCell<HashMap<ScopeId, InstalledSlots>> = RefCell::new(HashMap::new());
}

/// Register the parent-supplied [`SlotSet`] for a child component
/// instance, capturing the parent's scope id + proxy alongside.
/// Idempotent — a repeat call replaces the prior set (the
/// runtime mount remounts the same scope id only after
/// teardown, so this only fires for fresh mounts).
///
/// No-op when the set is empty so an opaque mount call (every
/// `<my-tag></my-tag>` site) doesn't leak a registry entry.
pub fn install(
    child_scope_id: ScopeId,
    set: SlotSet,
    parent_scope_id: ScopeId,
    parent_proxy: JsValue,
) {
    if set.is_empty() {
        return;
    }
    FRAGMENTS.with(|m| {
        m.borrow_mut().insert(
            child_scope_id,
            InstalledSlots {
                set,
                parent_scope_id,
                parent_proxy,
            },
        );
    });
}

/// Look up the parent entry for `(child_scope_id, name)`,
/// returning the fragment fn + scoped-slot metadata alongside
/// the parent context the installer captured. `None` when the
/// parent didn't register a [`SlotSet`] for this child, or
/// registered one without an entry for `name`. Caller (the
/// mount's slot materialiser) falls back to the legacy capture
/// path when this returns `None`.
pub fn lookup(child_scope_id: ScopeId, name: &str) -> Option<(SlotEntry, ScopeId, JsValue)> {
    FRAGMENTS.with(|m| {
        let map = m.borrow();
        let installed = map.get(&child_scope_id)?;
        let entry = installed.set.get(name)?;
        Some((
            entry,
            installed.parent_scope_id,
            installed.parent_proxy.clone(),
        ))
    })
}

/// Drop the fragment registration for `child_scope_id`. Hooked
/// from [`crate::scope::Scope::remove`].
pub fn clear(child_scope_id: ScopeId) {
    FRAGMENTS.with(|m| {
        m.borrow_mut().remove(&child_scope_id);
    });
}

/// Stamp a static HTML string into the slot fragment buffer.
///
/// RFC-058 Phase 3.5b — the macro emits one [`SlotFragment`]
/// per child-mount site whose slot content is statically
/// eligible (no `pp-*` / `@` / `:` directives, no
/// non-HTML5-native descendants); the body of that fragment
/// is just `stamp_static_html(ctx.host, "<inline html>")`. By
/// going through a `<template>` element rather than
/// `set_inner_html` directly on the buffer, the parse picks
/// up the correct content-model rules (e.g. `<tr>` inside
/// `<table>`, `<li>` outside `<ul>`) the same way the
/// browser would for any author-written markup.
/// Stamp slot HTML with directives + apply a static plan against
/// the parent scope. RFC-058 Phase 3.5c / RFC 064 §5.1 —
/// emitted by the macro for slot subtrees that carry plan-
/// eligible directives (`pp-text`, `pp-bind`, `@click`, etc.).
///
/// Goes through a temporary `<div>` host so the install pass
/// can resolve `node_path`s against a single root element.
/// `install_plan` is the macro-emitted per-fragment closure
/// that has the plan body unrolled inline; it installs every
/// directive against the parent scope using the Phase 1
/// helpers (effects subscribe to the parent proxy, listeners
/// delegate via the parent's scope). After install, the
/// just-installed children move into the slot fragment buffer
/// the runtime then splices before the live `<slot>` element.
///
/// The detached-DOM install is safe — text/html/bind/show
/// effects run synchronously at install and write to detached
/// DOM; listeners attach to detached elements and fire normally
/// once the children land in the document.
///
/// The closure boundary eliminates generic runtime plan
/// iteration for dynamic slot fragments (RFC 064 §5.1 Phase
/// 1.B). Top-level children are scope-stamped before the
/// install runs so any nested controller resolves its
/// `CTX_PARENT_KEY` against the slot owner.
pub fn stamp_dynamic_slot_with(
    host: &DocumentFragment,
    html: &str,
    parent_scope_id: ScopeId,
    parent_proxy: &JsValue,
    child_scope_id: ScopeId,
    install_plan: impl FnOnce(&Element, ScopeId, &JsValue),
) {
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
        return;
    };
    let Ok(temp) = doc.create_element("div") else {
        return;
    };
    temp.set_inner_html(html);
    crate::mount::bind_borrowed_scope_to(&temp, parent_scope_id, parent_proxy);
    let key = wasm_bindgen::JsValue::from_str(crate::mount::CTX_PARENT_KEY);
    let val = wasm_bindgen::JsValue::from_f64(child_scope_id.0 as f64);
    let _ = js_sys::Reflect::set(temp.as_ref(), &key, &val);
    let elements = temp.children();
    for i in 0..elements.length() {
        if let Some(el) = elements.item(i) {
            crate::mount::bind_borrowed_scope_to(&el, parent_scope_id, parent_proxy);
            let _ = js_sys::Reflect::set(el.as_ref(), &key, &val);
        }
    }
    install_plan(&temp, parent_scope_id, parent_proxy);

    let kids = temp.child_nodes();
    let mut snapshot: Vec<web_sys::Node> = Vec::with_capacity(kids.length() as usize);
    for i in 0..kids.length() {
        if let Some(n) = kids.item(i) {
            snapshot.push(n);
        }
    }
    for n in snapshot {
        let _ = host.append_child(&n);
    }
}

pub fn stamp_static_html(host: &DocumentFragment, html: &str) {
    let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
        return;
    };
    let Ok(template) = doc.create_element("template") else {
        return;
    };
    template.set_inner_html(html);
    let Ok(template) = template.dyn_into::<web_sys::HtmlTemplateElement>() else {
        return;
    };
    let content = template.content();
    let kids = content.child_nodes();
    let mut snapshot: Vec<web_sys::Node> = Vec::with_capacity(kids.length() as usize);
    for i in 0..kids.length() {
        if let Some(n) = kids.item(i) {
            snapshot.push(n);
        }
    }
    for n in snapshot {
        let _ = host.append_child(&n);
    }
}