use std::{
borrow::Cow,
collections::BTreeMap,
sync::{Arc, Mutex},
thread,
};
use serde::{Deserialize, Serialize};
use crate::{
HOVERFX_PACKAGE_NAME, HOVERFX_PACKAGE_VERSION, HoverFxConfig, HoverFxDiagnosticVerbosity,
HoverFxFallbackStrategy, HoverFxManifestFragment, HoverFxOutputReport, HoverFxPreset,
HoverFxRoutePolicy, HoverFxSerializationFormat, explain_hoverfx, hoverfx_cache_key,
hoverfx_manifest_fragment,
};
pub const DEFAULT_HOVERFX_CONFIG_ID: &str = "__DXH_CONFIG__";
pub const DEFAULT_HOVERFX_PREPAINT_STYLE_ID: &str = "__DXH_PREPAINT__";
pub const DEFAULT_HOVERFX_RUNTIME_ASSET_ID: &str = "hoverfx.runtime";
pub const DEFAULT_HOVERFX_WORKER_ASSET_ID: &str = "hoverfx.worker";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxDiagnosticContext {
Build,
Ssr,
Runtime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxDiagnosticVerbosityByContext {
pub build: HoverFxDiagnosticVerbosity,
pub ssr: HoverFxDiagnosticVerbosity,
pub runtime: HoverFxDiagnosticVerbosity,
}
impl Default for HoverFxDiagnosticVerbosityByContext {
fn default() -> Self {
Self {
build: HoverFxDiagnosticVerbosity::Detailed,
ssr: HoverFxDiagnosticVerbosity::Summary,
runtime: HoverFxDiagnosticVerbosity::Off,
}
}
}
impl HoverFxDiagnosticVerbosityByContext {
pub fn for_context(&self, context: HoverFxDiagnosticContext) -> HoverFxDiagnosticVerbosity {
match context {
HoverFxDiagnosticContext::Build => self.build,
HoverFxDiagnosticContext::Ssr => self.ssr,
HoverFxDiagnosticContext::Runtime => self.runtime,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxRuntimeIds {
pub config_id: String,
pub prepaint_style_id: String,
pub runtime_asset_id: String,
pub worker_asset_id: String,
}
impl Default for HoverFxRuntimeIds {
fn default() -> Self {
Self {
config_id: DEFAULT_HOVERFX_CONFIG_ID.to_string(),
prepaint_style_id: DEFAULT_HOVERFX_PREPAINT_STYLE_ID.to_string(),
runtime_asset_id: DEFAULT_HOVERFX_RUNTIME_ASSET_ID.to_string(),
worker_asset_id: DEFAULT_HOVERFX_WORKER_ASSET_ID.to_string(),
}
}
}
impl HoverFxRuntimeIds {
pub fn with_config_id(mut self, id: impl Into<String>) -> Self {
self.config_id = id.into();
self
}
pub fn with_prepaint_style_id(mut self, id: impl Into<String>) -> Self {
self.prepaint_style_id = id.into();
self
}
pub fn with_runtime_asset_id(mut self, id: impl Into<String>) -> Self {
self.runtime_asset_id = id.into();
self
}
pub fn with_worker_asset_id(mut self, id: impl Into<String>) -> Self {
self.worker_asset_id = id.into();
self
}
pub fn duplicate_ids(&self) -> Vec<String> {
let mut seen = BTreeMap::<&str, usize>::new();
for id in [
self.config_id.as_str(),
self.prepaint_style_id.as_str(),
self.runtime_asset_id.as_str(),
self.worker_asset_id.as_str(),
] {
*seen.entry(id).or_default() += 1;
}
seen.into_iter()
.filter_map(|(id, count)| (count > 1).then(|| id.to_string()))
.collect()
}
pub fn deduped(mut self) -> Self {
let mut counts = BTreeMap::<String, usize>::new();
for id in [
&mut self.config_id,
&mut self.prepaint_style_id,
&mut self.runtime_asset_id,
&mut self.worker_asset_id,
] {
let count = counts.entry(id.clone()).or_default();
if *count > 0 {
id.push('-');
id.push_str(&(*count + 1).to_string());
}
*count += 1;
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxAssetPaths {
pub runtime_base_path: String,
pub worker_base_path: String,
pub runtime_asset_name: String,
pub worker_asset_name: String,
}
impl Default for HoverFxAssetPaths {
fn default() -> Self {
Self {
runtime_base_path: "/assets".to_string(),
worker_base_path: "/assets".to_string(),
runtime_asset_name: "dioxus-hoverfx.js".to_string(),
worker_asset_name: "dioxus-hoverfx-worker.js".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxAssetBudgetCategory {
RuntimeScript,
WorkerScript,
PrepaintStyle,
ConfigJson,
EffectCssVars,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxAssetBudgetEntry {
pub category: HoverFxAssetBudgetCategory,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxAssetBudgetBridge {
pub package: String,
pub entries: Vec<HoverFxAssetBudgetEntry>,
}
impl HoverFxAssetBudgetBridge {
pub fn total_bytes(&self) -> usize {
self.entries.iter().map(|entry| entry.bytes).sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxBatchOptions {
pub format: HoverFxSerializationFormat,
pub deterministic_parallel: bool,
pub sort_by_cache_key: bool,
}
impl Default for HoverFxBatchOptions {
fn default() -> Self {
Self {
format: HoverFxSerializationFormat::StableJson,
deterministic_parallel: false,
sort_by_cache_key: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxBatchPayload {
pub route: Option<String>,
pub cache_key: String,
pub json: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxBatchSerializationReport {
pub package: String,
pub deterministic_parallel: bool,
pub payloads: Vec<HoverFxBatchPayload>,
pub total_bytes: usize,
}
#[derive(Debug)]
pub struct HoverFxBorrowedView<'a> {
pub config: &'a HoverFxConfig,
pub policy: Cow<'a, HoverFxRoutePolicy>,
}
impl<'a> HoverFxBorrowedView<'a> {
pub fn new(config: &'a HoverFxConfig, policy: &'a HoverFxRoutePolicy) -> Self {
Self {
config,
policy: Cow::Borrowed(policy),
}
}
pub fn with_owned_policy(config: &'a HoverFxConfig, policy: HoverFxRoutePolicy) -> Self {
Self {
config,
policy: Cow::Owned(policy),
}
}
pub fn manifest_fragment(&self) -> HoverFxManifestFragment {
hoverfx_manifest_fragment(self.config, self.policy.as_ref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxCssFragment {
pub id: String,
pub css: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxMotionPolicy {
pub reduced_motion: String,
pub view_transition: String,
pub render_lane: String,
}
impl Default for HoverFxMotionPolicy {
fn default() -> Self {
Self {
reduced_motion: "respect".to_string(),
view_transition: "optional".to_string(),
render_lane: "background".to_string(),
}
}
}
pub trait HoverFxMotionPolicyHook {
fn apply(&self, policy: HoverFxMotionPolicy) -> HoverFxMotionPolicy;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxRouteMetadata {
pub route: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache_keys: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxOptimizerArtifact {
pub id: String,
pub content_hash: String,
pub bytes: usize,
pub minified: bool,
}
pub trait HoverFxOptimizerReuseHook {
fn reuse_artifact(&self, artifact: &HoverFxOptimizerArtifact) -> Option<String>;
}
pub trait HoverFxCacheBackend {
fn get(&self, key: &str) -> Option<String>;
fn put(&self, key: String, value: String);
}
#[derive(Clone, Default)]
pub struct HoverFxMemoryCache {
values: Arc<Mutex<BTreeMap<String, String>>>,
}
impl HoverFxCacheBackend for HoverFxMemoryCache {
fn get(&self, key: &str) -> Option<String> {
self.values.lock().ok()?.get(key).cloned()
}
fn put(&self, key: String, value: String) {
if let Ok(mut values) = self.values.lock() {
values.insert(key, value);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxOffloadPlan {
pub package: String,
pub lane: String,
pub serializable: bool,
pub fallback: HoverFxFallbackStrategy,
pub payload_cache_key: String,
pub tasks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxCompactDictionary {
pub version: u8,
pub terms: BTreeMap<String, u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTraceEvent {
pub name: String,
pub cache_key: String,
pub route: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxStrataMigrationPlan {
pub package: String,
pub route: Option<String>,
pub steps: Vec<String>,
}
pub fn hoverfx_serialize_batch<'a>(
entries: impl IntoIterator<Item = (&'a HoverFxConfig, HoverFxRoutePolicy)>,
options: HoverFxBatchOptions,
) -> serde_json::Result<HoverFxBatchSerializationReport> {
let entries = entries.into_iter().collect::<Vec<_>>();
let mut payloads = if options.deterministic_parallel && entries.len() > 1 {
thread::scope(|scope| {
let mut handles = Vec::new();
for (config, policy) in entries {
handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
}
handles
.into_iter()
.map(|handle| handle.join().expect("HoverFX batch worker panicked"))
.collect::<serde_json::Result<Vec<_>>>()
})?
} else {
entries
.into_iter()
.map(|(config, policy)| serialize_one(config, &policy, options.format))
.collect::<serde_json::Result<Vec<_>>>()?
};
if options.sort_by_cache_key {
payloads.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
}
let total_bytes = payloads.iter().map(|payload| payload.bytes).sum();
Ok(HoverFxBatchSerializationReport {
package: HOVERFX_PACKAGE_NAME.to_string(),
deterministic_parallel: options.deterministic_parallel,
payloads,
total_bytes,
})
}
pub fn hoverfx_asset_budget_bridge(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxAssetBudgetBridge {
let output = config.output_report(policy);
HoverFxAssetBudgetBridge {
package: HOVERFX_PACKAGE_NAME.to_string(),
entries: vec![
HoverFxAssetBudgetEntry {
category: HoverFxAssetBudgetCategory::ConfigJson,
bytes: output.config_bytes,
},
HoverFxAssetBudgetEntry {
category: HoverFxAssetBudgetCategory::RuntimeScript,
bytes: output.runtime_bytes,
},
HoverFxAssetBudgetEntry {
category: HoverFxAssetBudgetCategory::EffectCssVars,
bytes: output.style_bytes,
},
],
}
}
pub fn hoverfx_precomputed_css_fragments() -> Vec<HoverFxCssFragment> {
HoverFxPreset::ALL
.into_iter()
.map(|preset| {
let css = format!(
"[data-dxh-effect=\"{}\"]{{--dxh-preset:\"{}\";}}",
preset.as_attr(),
preset.as_attr()
);
HoverFxCssFragment {
id: preset.as_attr().to_string(),
bytes: css.len(),
css,
}
})
.collect()
}
pub fn hoverfx_apply_motion_hook<H: HoverFxMotionPolicyHook>(
policy: HoverFxMotionPolicy,
hook: &H,
) -> HoverFxMotionPolicy {
hook.apply(policy)
}
pub fn hoverfx_coalesce_route_metadata(
route: impl Into<String>,
fragments: impl IntoIterator<Item = HoverFxManifestFragment>,
) -> HoverFxRouteMetadata {
let mut metadata = HoverFxRouteMetadata {
route: route.into(),
..HoverFxRouteMetadata::default()
};
for fragment in fragments {
metadata.cache_keys.push(fragment.cache_key);
if !metadata.packages.contains(&fragment.package) {
metadata.packages.push(fragment.package);
}
metadata.labels.extend(fragment.labels);
}
metadata.cache_keys.sort();
metadata.packages.sort();
metadata
}
pub fn hoverfx_optimizer_artifacts(report: &HoverFxOutputReport) -> Vec<HoverFxOptimizerArtifact> {
[
("hoverfx.config", report.config_bytes, true),
("hoverfx.runtime", report.runtime_bytes, true),
("hoverfx.style", report.style_bytes, true),
]
.into_iter()
.map(|(id, bytes, minified)| HoverFxOptimizerArtifact {
id: id.to_string(),
content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
bytes,
minified,
})
.collect()
}
pub fn hoverfx_cache_put_report<B: HoverFxCacheBackend>(
cache: &B,
report: &HoverFxOutputReport,
) -> String {
let key = format!(
"{}:{}:{}",
HOVERFX_PACKAGE_NAME, HOVERFX_PACKAGE_VERSION, report.cache_key
);
cache.put(
key.clone(),
serde_json::to_string(report).unwrap_or_default(),
);
key
}
pub fn hoverfx_workertown_offload_plan(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxOffloadPlan {
HoverFxOffloadPlan {
package: HOVERFX_PACKAGE_NAME.to_string(),
lane: "hoverfx".to_string(),
serializable: true,
fallback: policy.fallback,
payload_cache_key: hoverfx_cache_key(config, policy.route.as_deref(), Some("workertown")),
tasks: vec![
"pointer-sampling".to_string(),
"dirty-rect-render".to_string(),
"tooltip-textfx-trigger".to_string(),
],
}
}
pub fn hoverfx_compact_dictionary() -> HoverFxCompactDictionary {
let mut terms = BTreeMap::new();
for (index, term) in [
"config",
"runtime",
"worker",
"style",
"route",
"profile",
"fallback",
"workertown",
]
.into_iter()
.enumerate()
{
terms.insert(term.to_string(), index as u16);
}
HoverFxCompactDictionary { version: 1, terms }
}
pub fn hoverfx_trace_report(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
mut emit: impl FnMut(HoverFxTraceEvent),
) {
let explain = explain_hoverfx(config, policy);
emit(HoverFxTraceEvent {
name: "hoverfx.policy".to_string(),
cache_key: explain.cache_key.clone(),
route: policy.route.clone(),
message: explain.runtime_decision,
});
emit(HoverFxTraceEvent {
name: "hoverfx.output".to_string(),
cache_key: explain.cache_key,
route: policy.route.clone(),
message: format!("{} config bytes", explain.output.config_bytes),
});
}
pub fn hoverfx_strata_migration_plan(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
) -> HoverFxStrataMigrationPlan {
HoverFxStrataMigrationPlan {
package: HOVERFX_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
steps: vec![
"emit HoverFxManifestFragment from standalone config".to_string(),
format!(
"register cache key {}",
config.cache_key(policy.route.as_deref())
),
"attach runtime/style assets through Strata route metadata".to_string(),
"preserve fallback and diagnostic context policy".to_string(),
],
}
}
pub fn hoverfx_conformance_fixture() -> serde_json::Value {
let config = HoverFxConfig::default();
let policy = HoverFxRoutePolicy::default().route("/hoverfx");
serde_json::json!({
"manifest": hoverfx_manifest_fragment(&config, &policy),
"dictionary": hoverfx_compact_dictionary(),
"cssFragments": hoverfx_precomputed_css_fragments(),
})
}
fn serialize_one(
config: &HoverFxConfig,
policy: &HoverFxRoutePolicy,
format: HoverFxSerializationFormat,
) -> serde_json::Result<HoverFxBatchPayload> {
let json = config.to_preferred_json(format)?;
Ok(HoverFxBatchPayload {
route: policy.route.clone(),
cache_key: hoverfx_cache_key(config, policy.route.as_deref(), Some(format.as_attr())),
bytes: json.len(),
json,
})
}
fn integration_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hash = 0xcbf29ce484222325u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{HoverFxDefinition, HoverFxPresetProfile, hoverfx_output_budget};
#[test]
fn batch_serialization_parallel_is_deterministic() {
let config = HoverFxConfig::default()
.with_effect(HoverFxDefinition::from_preset(HoverFxPreset::Tooltip));
let a = HoverFxRoutePolicy::default().route("/a");
let b = HoverFxRoutePolicy::default().route("/b");
let serial = hoverfx_serialize_batch(
[(&config, a.clone()), (&config, b.clone())],
HoverFxBatchOptions::default(),
)
.unwrap();
let parallel = hoverfx_serialize_batch(
[(&config, a), (&config, b)],
HoverFxBatchOptions {
deterministic_parallel: true,
..HoverFxBatchOptions::default()
},
)
.unwrap();
assert_eq!(serial.payloads, parallel.payloads);
assert!(parallel.deterministic_parallel);
}
#[test]
fn asset_budget_duplicate_ids_cache_and_offload_are_reported() {
let config = HoverFxConfig::default().with_profile(HoverFxPresetProfile::Aggressive);
let policy = HoverFxRoutePolicy::default()
.route("/hoverfx")
.budget(hoverfx_output_budget().runtime_bytes(8));
let report = config.output_report(&policy);
let bridge = hoverfx_asset_budget_bridge(&config, &policy);
let ids = HoverFxRuntimeIds::default().with_config_id(DEFAULT_HOVERFX_PREPAINT_STYLE_ID);
let cache = HoverFxMemoryCache::default();
let key = hoverfx_cache_put_report(&cache, &report);
let offload = hoverfx_workertown_offload_plan(&config, &policy);
assert!(bridge.total_bytes() >= report.config_bytes);
assert_eq!(
ids.duplicate_ids(),
vec![DEFAULT_HOVERFX_PREPAINT_STYLE_ID.to_string()]
);
assert!(ids.deduped().duplicate_ids().is_empty());
assert!(cache.get(&key).is_some());
assert!(offload.serializable);
assert!(offload.tasks.iter().any(|task| task == "dirty-rect-render"));
}
#[test]
fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
struct ForceDisable;
impl HoverFxMotionPolicyHook for ForceDisable {
fn apply(&self, mut policy: HoverFxMotionPolicy) -> HoverFxMotionPolicy {
policy.reduced_motion = "disable".to_string();
policy
}
}
let config = HoverFxConfig::default();
let policy = HoverFxRoutePolicy::default().route("/hoverfx");
let manifest = config.manifest_fragment(&policy);
let metadata = hoverfx_coalesce_route_metadata("/hoverfx", [manifest]);
let css = hoverfx_precomputed_css_fragments();
let output = config.output_report(&policy);
let artifacts = hoverfx_optimizer_artifacts(&output);
let motion = hoverfx_apply_motion_hook(HoverFxMotionPolicy::default(), &ForceDisable);
let dictionary = hoverfx_compact_dictionary();
let migration = hoverfx_strata_migration_plan(&config, &policy);
let mut traces = Vec::new();
hoverfx_trace_report(&config, &policy, |event| traces.push(event));
let fixture = hoverfx_conformance_fixture();
assert_eq!(metadata.route, "/hoverfx");
assert!(css.iter().any(|fragment| fragment.id == "tooltip"));
assert!(
artifacts
.iter()
.any(|artifact| artifact.id == "hoverfx.runtime")
);
assert_eq!(motion.reduced_motion, "disable");
assert_eq!(dictionary.terms["runtime"], 1);
assert!(migration.steps.iter().any(|step| step.contains("Strata")));
assert_eq!(traces.len(), 2);
assert!(fixture.get("manifest").is_some());
}
}