use std::any::TypeId;
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering};
use serde::Serialize;
use serde_json::Value;
use super::effect::EffectId;
use super::signal::SignalId;
pub const INLINE_HANDLER_MAX_BYTES: usize = 256;
thread_local! {
static CURRENT: RefCell<Option<Rc<RenderContext>>> = const { RefCell::new(None) };
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
Ssr,
Island,
Static,
}
#[derive(Debug, Clone, Serialize)]
pub struct SignalSnapshot {
pub id: SignalId,
pub value: Value,
}
pub struct RenderContext {
pub mode: RenderMode,
next_signal: AtomicU32,
next_effect: AtomicU32,
state: RefCell<BTreeMap<SignalId, Value>>,
#[allow(clippy::type_complexity)]
effects: RefCell<BTreeMap<u32, Rc<RefCell<Box<dyn FnMut()>>>>>,
running_effects: RefCell<BTreeSet<u32>>,
current_effect: RefCell<Option<u32>>,
handler_chunks: RefCell<BTreeMap<String, BTreeMap<String, String>>>,
islands: RefCell<Vec<String>>,
actions: RefCell<Vec<String>>,
contexts: RefCell<BTreeMap<String, Value>>,
visible_tasks: RefCell<BTreeMap<u32, String>>,
next_visible_task: AtomicU32,
effect_deps: RefCell<BTreeMap<u32, Vec<SignalId>>>,
signal_subscribers: RefCell<BTreeMap<SignalId, Vec<u32>>>,
client_effects: RefCell<Vec<ClientEffectSpec>>,
handler_chunk_stack: RefCell<Vec<String>>,
}
impl RenderContext {
pub fn new(mode: RenderMode) -> Rc<Self> {
Rc::new(Self {
mode,
next_signal: AtomicU32::new(1),
next_effect: AtomicU32::new(1),
state: RefCell::new(BTreeMap::new()),
effects: RefCell::new(BTreeMap::new()),
running_effects: RefCell::new(BTreeSet::new()),
current_effect: RefCell::new(None),
handler_chunks: RefCell::new(BTreeMap::new()),
islands: RefCell::new(Vec::new()),
actions: RefCell::new(Vec::new()),
contexts: RefCell::new(BTreeMap::new()),
visible_tasks: RefCell::new(BTreeMap::new()),
next_visible_task: AtomicU32::new(1),
effect_deps: RefCell::new(BTreeMap::new()),
signal_subscribers: RefCell::new(BTreeMap::new()),
client_effects: RefCell::new(Vec::new()),
handler_chunk_stack: RefCell::new(Vec::new()),
})
}
pub fn current_handler_chunk(&self) -> String {
self.handler_chunk_stack
.borrow()
.last()
.cloned()
.unwrap_or_else(|| "__page__".to_string())
}
pub fn push_handler_chunk(&self, chunk: impl Into<String>) {
self.handler_chunk_stack.borrow_mut().push(chunk.into());
}
pub fn pop_handler_chunk(&self) {
self.handler_chunk_stack.borrow_mut().pop();
}
pub fn next_signal_id(&self) -> SignalId {
SignalId(self.next_signal.fetch_add(1, Ordering::Relaxed))
}
pub fn next_effect_id(&self) -> u32 {
self.next_effect.fetch_add(1, Ordering::Relaxed)
}
pub fn current_effect_id(&self) -> Option<u32> {
*self.current_effect.borrow()
}
pub fn set_current_effect(&self, id: Option<EffectId>) {
*self.current_effect.borrow_mut() = id.map(|e| e.0);
}
pub fn register_signal(&self, id: SignalId, value: Value) {
self.state.borrow_mut().insert(id, value);
}
pub fn update_signal(&self, id: SignalId, value: Value) {
self.state.borrow_mut().insert(id, value);
}
pub fn register_effect<F: FnMut() + 'static>(&self, id: EffectId, f: F) {
self.effects
.borrow_mut()
.insert(id.0, Rc::new(RefCell::new(Box::new(f))));
}
pub fn run_effect(&self, id: u32) {
if *self.current_effect.borrow() == Some(id) {
tracing::warn!(
effect_id = id,
"effect re-entered while tracking dependencies — skipped (possible dependency cycle)"
);
return;
}
if self.running_effects.borrow().contains(&id) {
tracing::warn!(
effect_id = id,
"effect cycle detected — skipped re-entrant run (derived state may be stale)"
);
return;
}
let cb = self.effects.borrow().get(&id).cloned();
if let Some(cb) = cb {
self.clear_effect_deps(id);
self.running_effects.borrow_mut().insert(id);
self.set_current_effect(Some(EffectId(id)));
(cb.borrow_mut())();
self.set_current_effect(None);
self.running_effects.borrow_mut().remove(&id);
}
}
pub fn register_handler(&self, chunk: &str, symbol: &str, source: &str) {
self.handler_chunks
.borrow_mut()
.entry(chunk.to_string())
.or_default()
.insert(symbol.to_string(), source.to_string());
}
pub fn register_island(&self, chunk_id: &str) {
self.islands.borrow_mut().push(chunk_id.to_string());
}
pub fn register_action(&self, name: &str) {
self.actions.borrow_mut().push(name.to_string());
}
pub fn register_context(&self, id: TypeId, value: Value) {
let key = format!("{:?}", id);
self.contexts.borrow_mut().insert(key, value);
}
pub fn get_context(&self, id: TypeId) -> Option<Value> {
let key = format!("{:?}", id);
self.contexts.borrow().get(&key).cloned()
}
pub fn register_visible_task(&self, source: &str) -> u32 {
let id = self.next_visible_task.fetch_add(1, Ordering::Relaxed);
self.visible_tasks
.borrow_mut()
.insert(id, source.to_string());
id
}
pub fn record_effect_dep(&self, effect_id: u32, signal_id: SignalId) {
{
let mut deps = self.effect_deps.borrow_mut();
let list = deps.entry(effect_id).or_default();
if !list.contains(&signal_id) {
list.push(signal_id);
}
}
let mut subs = self.signal_subscribers.borrow_mut();
let list = subs.entry(signal_id).or_default();
if !list.contains(&effect_id) {
list.push(effect_id);
}
}
pub fn clear_effect_deps(&self, effect_id: u32) {
let old = self
.effect_deps
.borrow_mut()
.remove(&effect_id)
.unwrap_or_default();
let mut subs = self.signal_subscribers.borrow_mut();
for sig_id in old {
if let Some(list) = subs.get_mut(&sig_id) {
list.retain(|&e| e != effect_id);
}
}
}
pub fn signal_subscriber_ids(&self, signal_id: SignalId) -> Vec<u32> {
self.signal_subscribers
.borrow()
.get(&signal_id)
.cloned()
.unwrap_or_default()
}
pub fn take_effect_deps(&self, effect_id: u32) -> Vec<SignalId> {
self.effect_deps
.borrow_mut()
.remove(&effect_id)
.unwrap_or_default()
}
pub fn register_client_effect(&self, spec: ClientEffectSpec) {
self.client_effects.borrow_mut().push(spec);
}
pub fn snapshot(&self) -> ResumePayload {
self.snapshot_internal().for_client()
}
pub fn snapshot_full(&self) -> ResumePayload {
self.snapshot_internal()
}
fn snapshot_internal(&self) -> ResumePayload {
ResumePayload {
signals: self
.state
.borrow()
.iter()
.map(|(id, v)| SignalSnapshot {
id: *id,
value: v.clone(),
})
.collect(),
handlers: self.handler_chunks.borrow().clone(),
islands: self.islands.borrow().clone(),
actions: self.actions.borrow().clone(),
contexts: self.contexts.borrow().clone(),
visible_tasks: self.visible_tasks.borrow().clone(),
effects: self.client_effects.borrow().clone(),
lazy_chunks: self
.handler_chunks
.borrow()
.keys()
.filter(|k| *k != "__page__")
.cloned()
.collect(),
csrf_token: None,
}
}
}
impl ResumePayload {
pub fn for_client(&self) -> Self {
let mut client = self.clone();
let mut inline_page = BTreeMap::new();
let mut lazy = self.lazy_chunks.clone();
if let Some(page) = self.handlers.get("__page__") {
for (sym, src) in page {
if src.len() <= INLINE_HANDLER_MAX_BYTES {
inline_page.insert(sym.clone(), src.clone());
} else {
lazy.push("__page__".to_string());
}
}
}
client.handlers = BTreeMap::new();
if !inline_page.is_empty() {
client.handlers.insert("__page__".into(), inline_page);
}
lazy.sort();
lazy.dedup();
client.lazy_chunks = lazy;
client
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ClientEffectSpec {
pub id: u32,
pub deps: Vec<SignalId>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub captures: BTreeMap<String, SignalId>,
pub kind: String,
pub body: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<SignalId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debounce_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResumePayload {
pub signals: Vec<SignalSnapshot>,
pub handlers: BTreeMap<String, BTreeMap<String, String>>,
pub islands: Vec<String>,
pub actions: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub contexts: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub visible_tasks: BTreeMap<u32, String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub effects: Vec<ClientEffectSpec>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub lazy_chunks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub csrf_token: Option<String>,
}
impl ResumePayload {
pub fn needs_client(&self) -> bool {
!self.signals.is_empty()
|| !self.handlers.is_empty()
|| !self.islands.is_empty()
|| !self.actions.is_empty()
|| !self.visible_tasks.is_empty()
|| !self.effects.is_empty()
|| !self.lazy_chunks.is_empty()
}
}
pub fn page_needs_client(payload: &ResumePayload, body_html: &str) -> bool {
if payload.needs_client() {
return true;
}
const MARKERS: &[&str] = &[
"data-r-on:",
"data-r-submit",
"resuma-island",
"resuma-boundary",
"resuma-dyn",
"resuma-show",
"resuma-for",
"resuma-match",
"data-r-bind:",
"data-r-nav",
"data-r-portal",
"data-r-stream",
"data-r-vt",
];
MARKERS.iter().any(|marker| body_html.contains(marker))
}
pub fn with_context<R>(ctx: Rc<RenderContext>, f: impl FnOnce() -> R) -> R {
CURRENT.with(|cell| {
let prev = cell.borrow_mut().replace(ctx);
let result = f();
*cell.borrow_mut() = prev;
result
})
}
pub fn with_handler_chunk<R>(chunk: impl Into<String>, f: impl FnOnce() -> R) -> R {
if let Some(ctx) = current_context() {
ctx.push_handler_chunk(chunk);
let out = f();
ctx.pop_handler_chunk();
out
} else {
f()
}
}
pub fn current_context() -> Option<Rc<RenderContext>> {
CURRENT.with(|cell| cell.borrow().clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_needs_client_detects_resuma_for_marker() {
let payload = ResumePayload {
signals: vec![],
handlers: Default::default(),
islands: vec![],
actions: vec![],
contexts: Default::default(),
visible_tasks: Default::default(),
effects: vec![],
lazy_chunks: vec![],
csrf_token: None,
};
let body = r#"<resuma-for data-r-for="s1"><div data-r-for-list></div></resuma-for>"#;
assert!(page_needs_client(&payload, body));
}
#[test]
fn page_needs_client_detects_resuma_match_marker() {
let payload = ResumePayload {
signals: vec![],
handlers: Default::default(),
islands: vec![],
actions: vec![],
contexts: Default::default(),
visible_tasks: Default::default(),
effects: vec![],
lazy_chunks: vec![],
csrf_token: None,
};
let body = r#"<resuma-match data-r-match="s1"></resuma-match>"#;
assert!(page_needs_client(&payload, body));
}
}