use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use deno_core::v8;
use deno_core::{
FastString, JsRuntime, ModuleId, ModuleLoadOptions, ModuleLoadReferrer, ModuleLoadResponse,
ModuleLoader, ModuleSource, ModuleSourceCode, ModuleSpecifier, ModuleType,
PollEventLoopOptions, ResolutionKind, RuntimeOptions,
};
use deno_error::JsErrorBox;
use serde_json::Value;
use super::error::EngineError;
const ZEB_SSR_INIT: &str = concat!(
include_str!("../runtime/zeb_react.js"),
"\n",
include_str!("../runtime/zeb_ssr_init.js"),
);
const TOOL_INIT: &str = include_str!("../../language/runtime/tool_init.js");
thread_local! {
static RENDER_RESULT: RefCell<Option<String>> = const { RefCell::new(None) };
}
#[deno_core::op2(fast)]
fn op_rwe_store_result(#[string] json: String) {
RENDER_RESULT.with(|r| *r.borrow_mut() = Some(json));
}
#[deno_core::op2]
#[serde]
fn op_rwe_search_params_snapshot(
#[string] raw_search: Option<String>,
#[serde] entries: Vec<(String, String)>,
) -> serde_json::Value {
let entries = raw_search.map_or(entries, |raw| {
deno_core::url::form_urlencoded::parse(raw.strip_prefix('?').unwrap_or(&raw).as_bytes())
.into_owned()
.collect()
});
let encoded = deno_core::url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(&entries)
.finish();
serde_json::json!({ "entries": entries, "encoded": encoded })
}
deno_core::extension!(
rwe_ops,
ops = [op_rwe_store_result, op_rwe_search_params_snapshot],
);
pub struct SsrResult {
pub html: String,
pub page_config: Option<Value>,
}
enum JsOp {
RenderSsr { source: String, ctx: Value },
}
struct JsRequest {
op: JsOp,
timeout_ms: u64,
reply: std::sync::mpsc::SyncSender<Result<JsResponse, EngineError>>,
}
enum WatchCmd {
Arm(Duration),
Disarm,
}
fn spawn_render_watchdog(
handle: deno_core::v8::IsolateHandle,
worker_id: usize,
) -> (
std::sync::mpsc::SyncSender<WatchCmd>,
std::sync::mpsc::Receiver<bool>,
) {
let (cmd_tx, cmd_rx) = std::sync::mpsc::sync_channel::<WatchCmd>(2);
let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel::<bool>(1);
std::thread::Builder::new()
.name(format!("rwe-js-watchdog-{worker_id}"))
.spawn(move || {
loop {
match cmd_rx.recv() {
Ok(WatchCmd::Arm(budget)) => {
let fired = match cmd_rx.recv_timeout(budget) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
handle.terminate_execution();
if cmd_rx.recv().is_err() {
return;
}
true
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
Ok(_) => false,
};
if ack_tx.send(fired).is_err() {
return;
}
}
Ok(WatchCmd::Disarm) => {}
Err(_) => return,
}
}
})
.expect("failed spawning rwe-js watchdog");
(cmd_tx, ack_rx)
}
enum JsResponse {
Rendered {
html: String,
page_config: Option<Value>,
},
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
const RESTART_AFTER_RENDERS: u64 = 500;
struct WorkerPool {
workers: Vec<Mutex<tokio::sync::mpsc::UnboundedSender<JsRequest>>>,
next: AtomicUsize,
}
static WORKER_POOL: LazyLock<WorkerPool> = LazyLock::new(|| {
let count = std::env::var("RWE_WORKER_COUNT")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(3)
.max(1);
eprintln!("rwe-js-runtime: starting {count} worker(s) (RWE_WORKER_COUNT={count})");
let workers = (0..count).map(|i| Mutex::new(spawn_js_thread(i))).collect();
WorkerPool {
workers,
next: AtomicUsize::new(0),
}
});
fn spawn_js_thread(worker_id: usize) -> tokio::sync::mpsc::UnboundedSender<JsRequest> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<JsRequest>();
std::thread::Builder::new()
.name(format!("rwe-js-runtime-{worker_id}"))
.spawn(move || run_js_thread(worker_id, rx))
.expect("failed spawning rwe-js-runtime thread");
tx
}
fn get_channel() -> tokio::sync::mpsc::UnboundedSender<JsRequest> {
let pool = &*WORKER_POOL;
let i = pool.next.fetch_add(1, Ordering::Relaxed) % pool.workers.len();
let mut guard = pool.workers[i].lock().unwrap_or_else(|e| e.into_inner());
if guard.is_closed() {
eprintln!("rwe-js-runtime[{i}] died — respawning");
*guard = spawn_js_thread(i);
}
guard.clone()
}
fn run_js_thread(worker_id: usize, mut rx: tokio::sync::mpsc::UnboundedReceiver<JsRequest>) {
cleanup_temp_modules();
let tokio_rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("rwe-js-runtime: failed building tokio runtime");
tokio_rt.block_on(async move {
let mut js_rt = JsRuntime::new(RuntimeOptions {
module_loader: Some(Rc::new(RweModuleLoader)),
extensions: vec![rwe_ops::init()],
..Default::default()
});
let isolate_handle = js_rt.v8_isolate().thread_safe_handle();
let (watch_tx, watch_ack) = spawn_render_watchdog(isolate_handle, worker_id);
if let Err(e) =
js_rt.execute_script("<zeb_ssr_init>", FastString::from_static(ZEB_SSR_INIT))
{
eprintln!("rwe-js-runtime[{worker_id}]: zeb_ssr_init failed: {e}");
return;
}
if let Err(e) = js_rt.execute_script("<tool_init>", FastString::from_static(TOOL_INIT)) {
eprintln!("rwe-js-runtime[{worker_id}]: tool_init failed: {e}");
return;
}
let mut render_count: u64 = 0;
while let Some(req) = rx.recv().await {
if render_count >= RESTART_AFTER_RENDERS {
eprintln!(
"rwe-js-runtime[{worker_id}]: scheduled restart after {render_count} renders \
— forwarding to fresh worker"
);
let one_shot_tx = spawn_js_thread(worker_id);
let _ = one_shot_tx.send(req);
return; }
let budget = Duration::from_millis(req.timeout_ms.max(1));
let _ = watch_tx.send(WatchCmd::Arm(budget));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
match &req.op {
JsOp::RenderSsr { source, ctx } => {
do_render_ssr(&mut js_rt, source, ctx).await
}
}
})
})
}));
let _ = watch_tx.send(WatchCmd::Disarm);
let terminated = watch_ack.recv().unwrap_or(false);
if terminated {
eprintln!(
"rwe-js-runtime[{worker_id}]: render exceeded its allowance — \
terminated, worker retiring"
);
let _ = req.reply.send(Err(EngineError::new(
"RWE_TIMEOUT",
"SSR render exceeded its time allowance and was stopped",
)));
return;
}
let result = match result {
Ok(r) => r,
Err(_panic) => {
eprintln!(
"rwe-js-runtime[{worker_id}]: panic during render — thread will respawn"
);
let _ = req.reply.send(Err(EngineError::new(
"RWE_PANIC",
"SSR render panicked — runtime will respawn",
)));
return; }
};
let _ = req.reply.send(result);
render_count += 1;
}
});
}
async fn load_page_module(
js_rt: &mut JsRuntime,
tsx_source: &str,
ctx: &Value,
) -> Result<(), EngineError> {
RENDER_RESULT.with(|r| *r.borrow_mut() = None);
reset_render_globals(js_rt)?;
let js = transpile_tsx(tsx_source)?;
let js = strip_rwe_imports(&js);
let ctx_json =
serde_json::to_string(ctx).map_err(|e| EngineError::new("RWE_CTX_JSON", e.to_string()))?;
let input_init_code = format!(
"globalThis.ctx = {ctx_json}; globalThis.input = globalThis.ctx; globalThis.__islandCounter = 0;",
);
js_rt
.execute_script("<rwe_ctx_init>", input_init_code)
.map_err(|e| EngineError::new("RWE_CTX_INIT", e.to_string()))?;
let temp_path = write_temp_module(&js)?;
let specifier = path_to_file_url(&temp_path)?;
let module_id: ModuleId = js_rt
.load_side_es_module(&specifier)
.await
.map_err(|e| EngineError::new("RWE_MODULE_LOAD", e.to_string()))?;
let eval_fut = js_rt.mod_evaluate(module_id);
js_rt
.run_event_loop(PollEventLoopOptions::default())
.await
.map_err(|e| EngineError::new("RWE_EVENT_LOOP", e.to_string()))?;
let _ = eval_fut.await;
let module_namespace = js_rt
.get_module_namespace(module_id)
.map_err(|e| EngineError::new("RWE_MODULE_NAMESPACE", e.to_string()))?;
set_render_module_namespace(js_rt, &module_namespace)?;
let _ = std::fs::remove_file(&temp_path);
Ok(())
}
async fn do_render_ssr(
js_rt: &mut JsRuntime,
tsx_source: &str,
ctx: &Value,
) -> Result<JsResponse, EngineError> {
load_page_module(js_rt, tsx_source, ctx).await?;
let ctx_json =
serde_json::to_string(ctx).map_err(|e| EngineError::new("RWE_CTX_JSON", e.to_string()))?;
let render_code = format!(
"(function(){{ \
var __ns = globalThis.__rwe_module_ns || null; \
if (!__ns || typeof __ns.default !== 'function') {{ \
throw new Error('RWE render target missing default export'); \
}} \
function __mergePageConfig(base, dynamic) {{ \
if (!base || typeof base !== 'object') return dynamic; \
if (!dynamic || typeof dynamic !== 'object') return base; \
var out = {{}}; \
var key; \
for (key in base) out[key] = base[key]; \
for (key in dynamic) out[key] = dynamic[key]; \
if (base.head || dynamic.head) out.head = Object.assign({{}}, base.head || {{}}, dynamic.head || {{}}); \
if (base.html || dynamic.html) out.html = Object.assign({{}}, base.html || {{}}, dynamic.html || {{}}); \
if (base.body || dynamic.body) out.body = Object.assign({{}}, base.body || {{}}, dynamic.body || {{}}); \
return out; \
}} \
var __html = globalThis.__rweRenderToString(\
globalThis.__rweWrapWithPageState(__ns.default, {ctx_json})\
); \
var __baseCfg = (typeof __ns.page !== 'undefined' && __ns.page !== null) \
? __ns.page : null; \
var __dynCfg = (typeof __ns.getPage === 'function') \
? __ns.getPage(globalThis.input) : null; \
var __cfg = __mergePageConfig(__baseCfg, __dynCfg); \
Deno.core.ops.op_rwe_store_result(JSON.stringify({{html: __html, page_config: __cfg}})); \
}})()"
);
let render_result = js_rt
.execute_script("<rwe_render>", render_code)
.map_err(|e| EngineError::new("RWE_RENDER_EXEC", e.to_string()));
reset_render_globals(js_rt)?;
render_result?;
let result_str = RENDER_RESULT
.with(|r| r.borrow_mut().take())
.ok_or_else(|| {
EngineError::new(
"RWE_RENDER_EXEC",
"render op was not called — page may have no default export",
)
})?;
let result_json: Value = serde_json::from_str(&result_str)
.map_err(|e| EngineError::new("RWE_RESULT_JSON", e.to_string()))?;
let html = result_json["html"].as_str().unwrap_or("").to_string();
let raw_cfg = result_json["page_config"].clone();
let page_config = if raw_cfg.is_null() {
None
} else {
Some(raw_cfg)
};
Ok(JsResponse::Rendered { html, page_config })
}
pub fn render_ssr(
module_source: &str,
ctx: &Value,
timeout_ms: u64,
) -> Result<SsrResult, EngineError> {
let timeout = Duration::from_millis(timeout_ms.saturating_add(2_000));
let mut last: Option<EngineError> = None;
for attempt in 0..2 {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(1);
if get_channel()
.send(JsRequest {
op: JsOp::RenderSsr {
source: module_source.to_string(),
ctx: ctx.clone(),
},
timeout_ms,
reply: reply_tx,
})
.is_err()
{
last = Some(EngineError::new(
"RWE_CHANNEL",
"js runtime channel disconnected",
));
continue;
}
match reply_rx.recv_timeout(timeout) {
Ok(response) => {
return match response? {
JsResponse::Rendered { html, page_config } => {
Ok(SsrResult { html, page_config })
}
};
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
last = Some(EngineError::new(
"RWE_WORKER_LOST",
"SSR worker exited before answering",
));
let _ = attempt;
continue;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
return Err(EngineError::new("RWE_TIMEOUT", "js render timed out"));
}
}
}
Err(last.unwrap_or_else(|| EngineError::new("RWE_WORKER_LOST", "SSR worker unavailable")))
}
pub fn is_pool_ready() -> bool {
let pool = &*WORKER_POOL;
pool.workers.iter().any(|slot| match slot.try_lock() {
Ok(guard) => !guard.is_closed(),
Err(std::sync::TryLockError::Poisoned(err)) => !err.into_inner().is_closed(),
Err(std::sync::TryLockError::WouldBlock) => false,
})
}
pub fn transpile_client(module_source: &str, _timeout_ms: u64) -> Result<String, EngineError> {
transpile_tsx(module_source)
}
struct RweModuleLoader;
impl ModuleLoader for RweModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: ResolutionKind,
) -> Result<ModuleSpecifier, JsErrorBox> {
if specifier.starts_with('/') {
return ModuleSpecifier::parse(&format!("file://{specifier}"))
.map_err(JsErrorBox::from_err);
}
if let Ok(base) = ModuleSpecifier::parse(referrer) {
if let Ok(resolved) = base.join(specifier) {
return Ok(resolved);
}
}
ModuleSpecifier::parse(specifier).map_err(JsErrorBox::from_err)
}
fn load(
&self,
module_specifier: &ModuleSpecifier,
_maybe_referrer: Option<&ModuleLoadReferrer>,
_options: ModuleLoadOptions,
) -> ModuleLoadResponse {
let specifier = module_specifier.clone();
ModuleLoadResponse::Sync(load_sync(&specifier))
}
}
fn load_sync(specifier: &ModuleSpecifier) -> Result<ModuleSource, JsErrorBox> {
if specifier.scheme() != "file" {
return Err(JsErrorBox::generic(format!(
"RWE module loader only handles file:// URLs; got: {specifier}"
)));
}
let path = specifier
.to_file_path()
.map_err(|_| JsErrorBox::generic(format!("invalid file URL: {specifier}")))?;
if !is_own_render_module(&path) {
return Err(JsErrorBox::generic(format!(
"RWE module loader loads only the page module; '{specifier}' is not one — \
templates and libraries are bundled at compile time, never imported at render"
)));
}
let source = std::fs::read_to_string(&path)
.map_err(|e| JsErrorBox::generic(format!("failed reading {}: {e}", path.display())))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let js = if matches!(ext, "tsx" | "ts" | "jsx") {
transpile_tsx(&source).map_err(|e| {
JsErrorBox::generic(format!("transpile {}: {}", path.display(), e.message))
})?
} else {
source
};
Ok(ModuleSource::new(
ModuleType::JavaScript,
ModuleSourceCode::String(FastString::from(js)),
specifier,
None,
))
}
pub fn transpile_tsx(source: &str) -> Result<String, EngineError> {
transpile_source(source, true)
}
pub fn transpile_ts(source: &str) -> Result<String, EngineError> {
transpile_source(source, false)
}
fn transpile_source(source: &str, jsx: bool) -> Result<String, EngineError> {
use oxc_allocator::Allocator;
use oxc_codegen::Codegen;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_transformer::{JsxOptions, JsxRuntime, TransformOptions, Transformer};
let alloc = Allocator::default();
let source_type = SourceType::default()
.with_module(true)
.with_jsx(jsx)
.with_typescript(true);
let parsed = Parser::new(&alloc, source, source_type).parse();
if parsed.panicked {
let _ = std::fs::write("/tmp/rwe-transpile-failed.tsx", source);
return Err(EngineError::new("RWE_TRANSPILE", "oxc parser panicked"));
}
let mut program = parsed.program;
let sem_ret = SemanticBuilder::new()
.with_excess_capacity(2.0)
.build(&program);
let scoping = sem_ret.semantic.into_scoping();
let options = TransformOptions {
jsx: JsxOptions {
runtime: JsxRuntime::Classic,
pragma: Some("h".to_string()),
pragma_frag: Some("Fragment".to_string()),
..JsxOptions::enable()
},
..Default::default()
};
let source_path = Path::new(if jsx { "module.tsx" } else { "module.ts" });
let transform_ret = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Transformer::new(&alloc, source_path, &options).build_with_scoping(scoping, &mut program)
}))
.map_err(|_| EngineError::new("RWE_TRANSFORM", "oxc transformer panicked"))?;
if !transform_ret.errors.is_empty() {
let msg = transform_ret
.errors
.iter()
.map(|e| format!("{e:?}"))
.collect::<Vec<_>>()
.join("; ");
eprintln!("[RWE] transform diagnostics (non-fatal): {msg}");
}
Ok(Codegen::new().build(&program).code)
}
fn strip_rwe_imports(js: &str) -> String {
js.lines()
.filter(|line| {
let t = line.trim();
if !t.starts_with("import ") {
return true;
}
!(t.contains("from \"zeb/react\"")
|| t.contains("from 'zeb'")
|| t.contains("from \"zeb/")
|| t.contains("from 'zeb/"))
})
.collect::<Vec<_>>()
.join("\n")
}
fn reset_render_globals(js_rt: &mut JsRuntime) -> Result<(), EngineError> {
js_rt
.execute_script(
"<rwe_reset_render_globals>",
"delete globalThis.__rwe_module_ns; \
delete globalThis.__rwe_page; \
delete globalThis.__rwe_page_config; \
delete globalThis.ctx; \
delete globalThis.input; \
globalThis.__islandCounter = 0;",
)
.map(|_| ())
.map_err(|e| EngineError::new("RWE_RESET_GLOBALS", e.to_string()))
}
fn set_render_module_namespace(
js_rt: &mut JsRuntime,
module_namespace: &v8::Global<v8::Object>,
) -> Result<(), EngineError> {
deno_core::scope!(scope, js_rt);
let global = scope.get_current_context().global(scope);
let key = v8::String::new(scope, "__rwe_module_ns")
.ok_or_else(|| EngineError::new("RWE_MODULE_NAMESPACE", "failed creating v8 key"))?;
let module_namespace = v8::Local::<v8::Object>::new(scope, module_namespace);
global
.set(scope, key.into(), module_namespace.into())
.ok_or_else(|| {
EngineError::new(
"RWE_MODULE_NAMESPACE",
"failed binding module namespace for render",
)
})?;
Ok(())
}
pub fn cleanup_temp_modules() {
let tmp_dir = std::env::temp_dir();
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(300))
.unwrap_or(std::time::UNIX_EPOCH);
if let Ok(entries) = std::fs::read_dir(&tmp_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("rwe-module-") && name_str.ends_with(".js") {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified < cutoff {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
}
}
fn is_own_render_module(path: &std::path::Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
path.parent() == Some(std::env::temp_dir().as_path())
&& name.starts_with("rwe-module-")
&& name.ends_with(".js")
}
fn write_temp_module(js: &str) -> Result<std::path::PathBuf, EngineError> {
use std::io::Write;
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!("rwe-module-{n}.js"));
let mut f = std::fs::File::create(&path)
.map_err(|e| EngineError::new("RWE_TEMP_CREATE", e.to_string()))?;
f.write_all(js.as_bytes())
.map_err(|e| EngineError::new("RWE_TEMP_WRITE", e.to_string()))?;
Ok(path)
}
fn path_to_file_url(path: &std::path::Path) -> Result<ModuleSpecifier, EngineError> {
let path_str = path.to_string_lossy();
let url_str = if path_str.starts_with('/') {
format!("file://{path_str}")
} else {
format!("file:///{path_str}")
};
ModuleSpecifier::parse(&url_str)
.map_err(|e| EngineError::new("RWE_URL", format!("invalid module URL: {e}")))
}
#[cfg(test)]
mod tests {
use super::{render_ssr, transpile_client};
use serde_json::json;
#[test]
fn render_ssr_does_not_leak_previous_page_identity_between_renders() {
let first = r#"
export const page = { title: "First" };
export default function FirstPage() {
return <section>FIRST_PAGE_ONLY</section>;
}
"#;
let second = r#"
export const page = { title: "Second" };
export default function SecondPage() {
return <main>SECOND_PAGE_ONLY</main>;
}
"#;
let first_render = render_ssr(first, &json!({}), 10_000).expect("first render");
assert!(first_render.html.contains("FIRST_PAGE_ONLY"));
assert_eq!(first_render.page_config, Some(json!({ "title": "First" })));
let second_render = render_ssr(second, &json!({}), 10_000).expect("second render");
assert!(second_render.html.contains("SECOND_PAGE_ONLY"));
assert!(!second_render.html.contains("FIRST_PAGE_ONLY"));
assert_eq!(
second_render.page_config,
Some(json!({ "title": "Second" }))
);
}
#[test]
fn a_template_imports_no_file_at_render() {
let secret = std::env::temp_dir().join(format!("rwe-secret-{}.js", std::process::id()));
std::fs::write(&secret, "export const leaked = 'SERVER_FILE_CONTENT';").expect("write");
let source = format!(
r#"
const mod = await import("{}");
export default function Page() {{
return <main>{{mod.leaked}}</main>;
}}
"#,
secret.display()
);
let rendered = render_ssr(&source, &json!({}), 10_000);
let _ = std::fs::remove_file(&secret);
match rendered {
Ok(out) => assert!(!out.html.contains("SERVER_FILE_CONTENT"), "{}", out.html),
Err(err) => assert!(err.message.contains("loads only the page module"), "{err:?}"),
}
}
#[test]
fn render_ssr_merges_static_page_with_get_page_input() {
let source = r#"
export const page = {
html: { lang: "en" },
body: { className: "base-body" },
navigation: "history",
};
export function getPage(input) {
return {
head: {
title: `${input.artist} — ${input.song}`,
description: `Notes for ${input.song}`,
},
body: { className: "dynamic-body" },
};
}
export default function Page(input) {
return <main>{input.song}</main>;
}
"#;
let render = render_ssr(
source,
&json!({ "artist": "Demo Artist", "song": "Demo Song" }),
10_000,
)
.expect("render");
assert!(render.html.contains("Demo Song"));
assert_eq!(
render.page_config,
Some(json!({
"head": {
"title": "Demo Artist — Demo Song",
"description": "Notes for Demo Song"
},
"html": { "lang": "en" },
"body": { "className": "dynamic-body" },
"navigation": "history"
}))
);
}
#[test]
fn render_ssr_serializes_style_object_props() {
let source = r##"
export default function Page() {
return (
<div
style={{
width: "100%",
backgroundColor: "#fff",
"--track-size": "12px"
}}
>
inline-style-ok
</div>
);
}
"##;
let render = render_ssr(source, &json!({}), 10_000).expect("render");
assert!(render.html.contains("inline-style-ok"));
assert!(
render
.html
.contains(r#"style="width:100%;background-color:#fff;--track-size:12px""#)
|| render
.html
.contains(r#"style="width:100%;background-color:#fff;--track-size:12px;""#),
"expected SSR style object serialization, got {}",
render.html
);
}
#[test]
fn transpile_client_preserves_style_object_for_hydration() {
let source = r##"
export default function Page() {
return (
<div style={{ width: "100%", backgroundColor: "#fff", "--track-size": "12px" }}>
hydrated-style-ok
</div>
);
}
"##;
let js = transpile_client(source, 10_000).expect("transpile client");
assert!(js.contains("style: {"));
assert!(js.contains("width: \"100%\""));
assert!(js.contains("backgroundColor: \"#fff\""));
assert!(js.contains("\"--track-size\": \"12px\""));
}
}