use crate::actions::ClerkOperation;
use crate::core::{AuthObservation, ClerkError, ReverificationLevel, SessionStatus};
use crate::handle::ClerkHandle;
use crate::handle::ClerkListener;
use crate::reverification::ReverificationOutcome;
use dioxus::prelude::Callback;
use serde::Serialize;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::prelude::*;
thread_local! {
static ROUTER_PUSH: Cell<Option<Callback<String>>> = const { Cell::new(None) };
static ROUTER_REPLACE: Cell<Option<Callback<String>>> = const { Cell::new(None) };
static ROUTER_SLOT_OWNER: Cell<u64> = const { Cell::new(0) };
static LOAD_IN_FLIGHT: Cell<bool> = const { Cell::new(false) };
static LOAD_FAILED: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub(crate) fn load_in_flight() -> bool {
LOAD_IN_FLIGHT.with(Cell::get)
}
pub(crate) fn load_failure() -> Option<String> {
LOAD_FAILED.with(|slot| slot.borrow().clone())
}
pub(crate) fn mark_load_timed_out(message: String) {
LOAD_IN_FLIGHT.with(|flag| flag.set(false));
LOAD_FAILED.with(|slot| *slot.borrow_mut() = Some(message));
}
pub(crate) fn reset_load_state() {
LOAD_IN_FLIGHT.with(|flag| flag.set(false));
LOAD_FAILED.with(|slot| *slot.borrow_mut() = None);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct RouterSlotToken(u64);
pub(crate) fn set_router_callbacks(
push: Option<Callback<String>>,
replace: Option<Callback<String>>,
) -> RouterSlotToken {
let token = ROUTER_SLOT_OWNER.with(|owner| {
let next = owner.get().wrapping_add(1);
owner.set(next);
next
});
ROUTER_PUSH.with(|slot| slot.set(push));
ROUTER_REPLACE.with(|slot| slot.set(replace));
RouterSlotToken(token)
}
pub(crate) fn clear_router_callbacks(token: RouterSlotToken) {
ROUTER_SLOT_OWNER.with(|owner| {
if owner.get() == token.0 {
ROUTER_PUSH.with(|slot| slot.set(None));
ROUTER_REPLACE.with(|slot| slot.set(None));
}
});
}
fn read_router_push() -> Option<Callback<String>> {
ROUTER_PUSH.with(Cell::get)
}
fn read_router_replace() -> Option<Callback<String>> {
ROUTER_REPLACE.with(Cell::get)
}
#[derive(Clone)]
pub(crate) struct ClerkBridge {
handle: ClerkHandle,
}
impl ClerkBridge {
pub(crate) fn current() -> Self {
Self {
handle: ClerkHandle::current(),
}
}
pub(crate) fn is_present(&self) -> bool {
self.handle.is_present()
}
pub(crate) fn is_loaded(&self) -> bool {
self.handle.is_loaded_js()
}
pub(crate) fn is_signed_in(&self) -> bool {
self.handle.is_signed_in()
}
pub(crate) async fn load(&self, options: &serde_json::Value) -> Result<(), ClerkError> {
let promise = self.handle.load_promise(load_options_js(options))?;
LOAD_IN_FLIGHT.with(|flag| flag.set(true));
let on_resolve = Closure::<dyn FnMut(JsValue)>::new(move |_value: JsValue| {
LOAD_IN_FLIGHT.with(|flag| flag.set(false));
LOAD_FAILED.with(|slot| *slot.borrow_mut() = None);
});
let on_reject = Closure::<dyn FnMut(JsValue)>::new(move |value: JsValue| {
LOAD_IN_FLIGHT.with(|flag| flag.set(false));
let message = crate::handle::js_error_message(&value);
LOAD_FAILED.with(|slot| *slot.borrow_mut() = Some(message));
});
let _ = promise.then2(&on_resolve, &on_reject);
on_resolve.forget();
on_reject.forget();
wasm_bindgen_futures::JsFuture::from(promise)
.await
.map(|_| ())
.map_err(crate::handle::js_error)
}
pub(crate) async fn get_token(
&self,
options: &serde_json::Value,
) -> Result<Option<String>, ClerkError> {
self.handle.get_token(js_options(options)).await
}
pub(crate) async fn open_reverification(
&self,
level: Option<ReverificationLevel>,
) -> Result<ReverificationOutcome, ClerkError> {
self.handle.open_reverification(level).await
}
pub(crate) async fn run(&self, operation: ClerkOperation) -> Result<(), ClerkError> {
match operation {
ClerkOperation::OpenSignIn(options) => self.handle.open_sign_in(&js_options(&options)),
ClerkOperation::CloseSignIn => self.handle.close_sign_in(),
ClerkOperation::OpenSignUp(options) => self.handle.open_sign_up(&js_options(&options)),
ClerkOperation::CloseSignUp => self.handle.close_sign_up(),
ClerkOperation::OpenUserProfile(options) => {
self.handle.open_user_profile(&js_options(&options))
}
ClerkOperation::CloseUserProfile => self.handle.close_user_profile(),
ClerkOperation::SignOut(options) => self.handle.sign_out(js_options(&options)).await,
ClerkOperation::RedirectToSignIn(options) => self.redirect_to_sign_in(&options).await,
ClerkOperation::RedirectToSignUp(options) => self.redirect_to_sign_up(&options).await,
}
}
pub(crate) fn observation(&self) -> AuthObservation {
if self.handle.is_signed_in() {
return match (self.handle.user(), self.handle.session()) {
(Some(user), Some(session)) => AuthObservation::SignedIn { user, session },
_ => AuthObservation::Loading,
};
}
match (self.handle.user(), self.handle.session()) {
(Some(user), Some(session)) if session.status == SessionStatus::Pending => {
AuthObservation::Pending { user, session }
}
_ => AuthObservation::SignedOut,
}
}
pub(crate) fn subscribe(
&self,
on_observation: impl FnMut(AuthObservation) + 'static,
) -> Option<ListenerSubscription> {
let on_observation = Rc::new(RefCell::new(on_observation));
let redispatch = Rc::new(Cell::new(false));
self.handle
.add_listener(move |_| match on_observation.try_borrow_mut() {
Ok(mut callback) => {
callback(ClerkBridge::current().observation());
drop(callback);
while redispatch.take() {
let Ok(mut callback) = on_observation.try_borrow_mut() else {
redispatch.set(true);
break;
};
callback(ClerkBridge::current().observation());
}
}
Err(_) => redispatch.set(true),
})
.map(|listener| ListenerSubscription {
_listener: listener,
})
}
pub(crate) async fn redirect_to_sign_in(
&self,
options: &serde_json::Value,
) -> Result<(), ClerkError> {
self.handle.redirect_to_sign_in(js_options(options)).await
}
pub(crate) async fn redirect_to_sign_up(
&self,
options: &serde_json::Value,
) -> Result<(), ClerkError> {
self.handle.redirect_to_sign_up(js_options(options)).await
}
pub(crate) fn mount_widget(
&self,
widget: crate::components::widget::Widget,
element: &web_sys::Element,
options: &serde_json::Value,
) -> Result<(), ClerkError> {
let opts = js_options(options);
self.handle
.try_call_method2(widget.mount_method(), element.as_ref(), &opts)
}
pub(crate) fn unmount_widget(
&self,
widget: crate::components::widget::Widget,
element: &web_sys::Element,
) {
self.handle
.call_method1(widget.unmount_method(), element.as_ref());
}
}
pub(crate) struct ListenerSubscription {
_listener: ClerkListener,
}
pub(crate) fn js_options(options: &serde_json::Value) -> JsValue {
if options.is_null() {
return JsValue::UNDEFINED;
}
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
options.serialize(&serializer).unwrap_or_else(|error| {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"dioxus-clerk: failed to convert Clerk options to JS, passing undefined: {error}"
)));
JsValue::UNDEFINED
})
}
fn load_options_js(options: &serde_json::Value) -> JsValue {
let js_options = js_options(options);
let ui_ctor = crate::bindings::clerk_ui_ctor();
if ui_ctor.is_none() && read_router_push().is_none() && read_router_replace().is_none() {
return js_options;
}
let object = if js_options.is_null() || js_options.is_undefined() || !js_options.is_object() {
js_sys::Object::new().into()
} else {
js_options
};
if let Some(ctor) = ui_ctor {
let ui = js_sys::Reflect::get(&object, &JsValue::from_str("ui"))
.ok()
.filter(|value| value.is_object())
.unwrap_or_else(|| js_sys::Object::new().into());
let _ = js_sys::Reflect::set(&ui, &JsValue::from_str("ClerkUI"), ctor.as_ref());
let _ = js_sys::Reflect::set(&object, &JsValue::from_str("ui"), &ui);
}
if read_router_push().is_some() {
set_router_callback(&object, "routerPush", read_router_push);
}
if read_router_replace().is_some() {
set_router_callback(&object, "routerReplace", read_router_replace);
}
object
}
fn set_router_callback(target: &JsValue, key: &str, slot: fn() -> Option<Callback<String>>) {
let closure = Closure::<dyn Fn(JsValue)>::wrap(Box::new(move |to: JsValue| {
let Some(callback) = slot() else {
return;
};
if let Some(to) = to.as_string() {
callback.call(to);
}
}));
let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), closure.as_ref());
closure.forget();
}
#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use js_sys::{Function, Object, Reflect};
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
fn window() -> web_sys::Window {
web_sys::window().expect("wasm tests run in a browser window")
}
fn set_prop(target: &JsValue, key: &str, value: &JsValue) {
Reflect::set(target, &JsValue::from_str(key), value).unwrap();
}
fn get_prop(target: &JsValue, key: &str) -> JsValue {
Reflect::get(target, &JsValue::from_str(key)).unwrap()
}
fn number_prop(target: &JsValue, key: &str) -> f64 {
get_prop(target, key).as_f64().unwrap_or(0.0)
}
fn clear_clerk() {
let key = JsValue::from_str("Clerk");
let _ = Reflect::delete_property(window().as_ref(), &key);
Reflect::set(window().as_ref(), &key, &JsValue::UNDEFINED).unwrap();
}
fn install_listener_clerk() -> JsValue {
let clerk = Object::new();
let user = Object::new();
let session = Object::new();
set_prop(user.as_ref(), "id", &JsValue::from_str("user_2abc"));
set_prop(session.as_ref(), "id", &JsValue::from_str("sess_2def"));
set_prop(session.as_ref(), "status", &JsValue::from_str("active"));
set_prop(clerk.as_ref(), "isSignedIn", &JsValue::FALSE);
set_prop(clerk.as_ref(), "user", user.as_ref());
set_prop(clerk.as_ref(), "session", session.as_ref());
set_prop(
clerk.as_ref(),
"load",
Function::new_no_args("return Promise.resolve();").as_ref(),
);
set_prop(
clerk.as_ref(),
"addListener",
Function::new_no_args(
r#"
var clerk = this;
this.lastListener = arguments[0];
return function unsubscribe() {
clerk.unsubscribeCallCount = (clerk.unsubscribeCallCount || 0) + 1;
clerk.lastListener = undefined;
};
"#,
)
.as_ref(),
);
let clerk: JsValue = clerk.into();
set_prop(window().as_ref(), "Clerk", &clerk);
clerk
}
#[wasm_bindgen_test]
fn bridge_subscription_drop_unsubscribes_listener() {
clear_clerk();
let clerk = install_listener_clerk();
let bridge = ClerkBridge::current();
let subscription = bridge
.subscribe(|_| {})
.expect("mock Clerk returns unsubscribe function");
assert_eq!(number_prop(&clerk, "unsubscribeCallCount"), 0.0);
drop(subscription);
assert_eq!(number_prop(&clerk, "unsubscribeCallCount"), 1.0);
}
#[wasm_bindgen_test]
fn stale_clear_does_not_wipe_newer_router_slot_owner() {
use dioxus::prelude::*;
fn app() -> Element {
let token_a = set_router_callbacks(Some(Callback::new(|_: String| {})), None);
let token_b = set_router_callbacks(Some(Callback::new(|_: String| {})), None);
clear_router_callbacks(token_a);
assert!(
read_router_push().is_some(),
"stale unmount cleared the new provider's router callbacks"
);
clear_router_callbacks(token_b);
assert!(read_router_push().is_none());
rsx! {}
}
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
}
#[wasm_bindgen_test(async)]
async fn load_in_flight_flag_survives_rust_task_cancellation() {
use futures_util::FutureExt;
clear_clerk();
let clerk = Object::new();
set_prop(
clerk.as_ref(),
"load",
Function::new_no_args(
r#"
var clerk = this;
return new Promise(function (resolve) { clerk.resolveLoad = resolve; });
"#,
)
.as_ref(),
);
let clerk: JsValue = clerk.into();
set_prop(window().as_ref(), "Clerk", &clerk);
assert!(!load_in_flight());
assert!(
ClerkBridge::current()
.load(&serde_json::Value::Null)
.now_or_never()
.is_none()
);
assert!(
load_in_flight(),
"cancelling the Rust task must not clear the in-flight marker"
);
let resolve: Function = get_prop(&clerk, "resolveLoad").unchecked_into();
resolve.call0(&JsValue::UNDEFINED).unwrap();
gloo_timers::future::TimeoutFuture::new(10).await;
assert!(!load_in_flight());
clear_clerk();
}
#[wasm_bindgen_test]
fn observation_reports_pending_for_pending_session() {
use crate::core::{AuthObservation, SessionStatus, SessionTaskKey};
clear_clerk();
let clerk = Object::new();
let user = Object::new();
let session = Object::new();
set_prop(user.as_ref(), "id", &JsValue::from_str("user_2abc"));
set_prop(session.as_ref(), "id", &JsValue::from_str("sess_2def"));
set_prop(session.as_ref(), "status", &JsValue::from_str("pending"));
let task = Object::new();
set_prop(task.as_ref(), "key", &JsValue::from_str("setup-mfa"));
set_prop(session.as_ref(), "currentTask", task.as_ref());
set_prop(clerk.as_ref(), "isSignedIn", &JsValue::FALSE);
set_prop(clerk.as_ref(), "user", user.as_ref());
set_prop(clerk.as_ref(), "session", session.as_ref());
set_prop(
clerk.as_ref(),
"load",
Function::new_no_args("return Promise.resolve();").as_ref(),
);
let clerk: JsValue = clerk.into();
set_prop(window().as_ref(), "Clerk", &clerk);
match ClerkBridge::current().observation() {
AuthObservation::Pending { user, session } => {
assert_eq!(user.id, "user_2abc");
assert_eq!(session.status, SessionStatus::Pending);
assert_eq!(
session.current_task.map(|task| task.key),
Some(SessionTaskKey::SetupMfa)
);
}
other => panic!("expected a Pending observation, got {other:?}"),
}
clear_clerk();
}
#[wasm_bindgen_test]
fn observation_reports_signed_out_without_a_session() {
use crate::core::AuthObservation;
clear_clerk();
let clerk = Object::new();
set_prop(clerk.as_ref(), "isSignedIn", &JsValue::FALSE);
set_prop(
clerk.as_ref(),
"load",
Function::new_no_args("return Promise.resolve();").as_ref(),
);
let clerk: JsValue = clerk.into();
set_prop(window().as_ref(), "Clerk", &clerk);
assert!(matches!(
ClerkBridge::current().observation(),
AuthObservation::SignedOut
));
clear_clerk();
}
fn install_reverification_clerk(outcome_callback: &str) -> JsValue {
let clerk = Object::new();
set_prop(
clerk.as_ref(),
"load",
Function::new_no_args("return Promise.resolve();").as_ref(),
);
let body = format!(
r#"
this.lastReverificationProps = arguments[0];
var props = arguments[0];
Promise.resolve().then(function () {{ props.{outcome_callback}(); }});
"#
);
set_prop(
clerk.as_ref(),
"__internal_openReverification",
Function::new_no_args(&body).as_ref(),
);
let clerk: JsValue = clerk.into();
set_prop(window().as_ref(), "Clerk", &clerk);
clerk
}
#[wasm_bindgen_test(async)]
async fn open_reverification_resolves_completed_and_forwards_level() {
use crate::core::ReverificationLevel;
clear_clerk();
let clerk = install_reverification_clerk("afterVerification");
let outcome = ClerkBridge::current()
.open_reverification(Some(ReverificationLevel::SecondFactor))
.await
.expect("reverification prompt resolves");
assert_eq!(outcome, ReverificationOutcome::Completed);
let props = get_prop(&clerk, "lastReverificationProps");
assert_eq!(
get_prop(&props, "level").as_string().as_deref(),
Some("second_factor")
);
clear_clerk();
}
#[wasm_bindgen_test(async)]
async fn open_reverification_resolves_cancelled_when_user_dismisses() {
clear_clerk();
install_reverification_clerk("afterVerificationCancelled");
let outcome = ClerkBridge::current()
.open_reverification(None)
.await
.expect("reverification prompt resolves");
assert_eq!(outcome, ReverificationOutcome::Cancelled);
clear_clerk();
}
#[wasm_bindgen_test(async)]
async fn open_reverification_without_clerk_is_not_loaded() {
clear_clerk();
let err = ClerkBridge::current()
.open_reverification(None)
.await
.expect_err("no clerk-js singleton");
assert!(matches!(err, ClerkError::NotLoaded));
}
#[wasm_bindgen_test]
fn bridge_mount_sign_in_returns_not_loaded_when_clerk_is_missing() {
clear_clerk();
let element = window().document().unwrap().create_element("div").unwrap();
let err = ClerkBridge::current()
.mount_widget(
crate::components::widget::Widget::SignIn,
&element,
&serde_json::Value::Null,
)
.expect_err("missing Clerk should be visible to mounted UI");
assert!(matches!(err, ClerkError::NotLoaded));
}
fn set_clerk_ui_ctor() {
let key = JsValue::from_str("__internal_ClerkUICtor");
Reflect::set(
window().as_ref(),
&key,
Function::new_no_args("return null;").as_ref(),
)
.unwrap();
}
fn clear_clerk_ui_ctor() {
let key = JsValue::from_str("__internal_ClerkUICtor");
let _ = Reflect::delete_property(window().as_ref(), &key);
Reflect::set(window().as_ref(), &key, &JsValue::UNDEFINED).unwrap();
}
#[wasm_bindgen_test]
fn load_options_js_passes_clerk_ui_ctor_when_present() {
set_clerk_ui_ctor();
let options = load_options_js(&serde_json::Value::Null);
let ui = get_prop(&options, "ui");
assert!(ui.is_object(), "load options must carry a `ui` object");
assert!(
get_prop(&ui, "ClerkUI").is_function(),
"`ui.ClerkUI` must be the @clerk/ui constructor"
);
clear_clerk_ui_ctor();
}
#[wasm_bindgen_test]
fn load_options_js_merges_clerk_ui_into_caller_supplied_ui() {
set_clerk_ui_ctor();
let options = load_options_js(&serde_json::json!({ "ui": { "custom": "kept" } }));
let ui = get_prop(&options, "ui");
assert_eq!(
get_prop(&ui, "custom").as_string().as_deref(),
Some("kept"),
"caller-supplied `ui` fields must be preserved"
);
assert!(get_prop(&ui, "ClerkUI").is_function());
clear_clerk_ui_ctor();
}
#[wasm_bindgen_test]
fn load_options_js_omits_ui_without_ctor() {
clear_clerk_ui_ctor();
let options = load_options_js(&serde_json::json!({ "signInUrl": "/si" }));
assert!(
get_prop(&options, "ui").is_undefined(),
"no @clerk/ui ctor -> no `ui` key in load options"
);
assert_eq!(
get_prop(&options, "signInUrl").as_string().as_deref(),
Some("/si")
);
}
}