From fb81fb2c4a01ca7f95022a525edf4f3fa4355e31 Mon Sep 17 00:00:00 2001 From: eastspire Date: Wed, 30 Sep 2026 16:36:05 +0800 Subject: [PATCH 01/56] chore: bump to 0.28.4, fix dynamic-node re-render and standards violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix a silent reactive regression: the update dispatcher took a dynamic node's slot pointer with `get_dynamic`, which only COPIES the pointer and leaves the key in the registry. The `has_dynamic` guard after the callback then saw the caller's own untouched entry, took the free branch, and dropped the slot. The first signal-driven update after mount worked and every later one was discarded, so hash navigation rendered the first route and stuck. `take_dynamic` removes the key for the duration of the callback, matching HEAD's `remove`-before-invoke shape. Fix a gate bug: `verify_no_test_comments.audit_one` scanned by content without checking the path, so `staged_file_gate.py` — which calls it directly on any staged `.rs` file — reported every `///` in `src/` as a §14.5 violation (3325 false positives; any commit documenting a function was blocked). Add the `tests/` path guard so both entry points agree. Re-publish the `lib.rs` import globs as `pub` so sub-files reach them through `use super::*` per §6.1, and order the groups correctly. `SignalSlab`, `HookContext`, and the seven renderer registries move from `static mut` + `UnsafeCell` + `unsafe impl Sync` to `thread_local!` + `RefCell`. `SignalSlab` holds `Box` and the hook context holds `Rc`, so neither is `Send`; a global `Mutex` would not have type-checked. This removes real cross-thread state corruption, exposed as random SIGSEGV/SIGTRAP in `euv-macros` and as a poisoned `js_sys::global` once-cell. Register the missing `mod gesture;` in `ui/tests/mod.rs` — the 15 gesture tests were on disk but never compiled (274 -> 259). --- Cargo.toml | 23 +- cli/tests/fmt/fn.rs | 11 - cli/tests/inline/fn.rs | 1 - core/src/lib.rs | 15 +- core/src/reactive/hook/impl.rs | 54 +- core/src/reactive/schedule/impl.rs | 217 +- core/src/reactive/schedule/static.rs | 35 +- core/src/reactive/schedule/struct.rs | 37 +- core/src/reactive/signal/impl.rs | 347 +- core/src/reactive/signal/static.rs | 33 +- core/src/reactive/signal/trait.rs | 6 - core/src/renderer/dom_ops/fn.rs | 113 +- core/src/renderer/dom_ops/struct.rs | 28 +- core/src/renderer/registry/const.rs | 22 + core/src/renderer/registry/fn.rs | 218 +- core/src/renderer/registry/impl.rs | 885 +- core/src/renderer/registry/static.rs | 142 +- core/src/renderer/registry/struct.rs | 106 - core/src/renderer/render/impl.rs | 90 +- core/src/vdom/attribute/impl.rs | 55 +- core/src/vdom/attribute/static.rs | 12 +- core/src/vdom/attribute/struct.rs | 25 +- core/src/vdom/mod.rs | 5 +- core/src/vdom/{ => raw}/impl.rs | 0 core/src/vdom/raw/mod.rs | 6 + core/src/vdom/{ => raw}/struct.rs | 0 core/tests/signal/fn.rs | 10 - core/tests/vdom/fn.rs | 30 - docs/README.md | 4 + docs/build.rs | 134 +- docs/docs/{ => en}/README.md | 0 docs/docs/en/appreciate.md | 8 + docs/docs/en/guide/README.md | 8 + docs/docs/en/guide/getting-started.md | 14 + engine/src/engine/impl.rs | 14 +- engine/src/engine/struct.rs | 2 +- engine/src/lib.rs | 10 +- engine/src/renderer/canvas/const.rs | 59 + engine/src/renderer/canvas/enum.rs | 136 + engine/src/renderer/canvas/fn.rs | 46 + engine/src/renderer/canvas/impl.rs | 1745 +++ engine/src/renderer/canvas/mod.rs | 12 + engine/src/renderer/canvas/struct.rs | 195 + engine/src/renderer/{ => canvas}/trait.rs | 0 engine/src/renderer/descriptor/const.rs | 19 + engine/src/renderer/descriptor/enum.rs | 82 + engine/src/renderer/descriptor/impl.rs | 487 + engine/src/renderer/descriptor/mod.rs | 10 + engine/src/renderer/descriptor/struct.rs | 667 ++ engine/src/renderer/enum.rs | 453 - engine/src/renderer/mod.rs | 15 +- engine/src/renderer/state/const.rs | 315 + engine/src/renderer/state/enum.rs | 493 + engine/src/renderer/state/fn.rs | 543 + engine/src/renderer/state/impl.rs | 213 + engine/src/renderer/state/mod.rs | 10 + engine/src/renderer/struct.rs | 785 -- engine/src/renderer/webgl/const.rs | 161 + engine/src/renderer/webgl/enum.rs | 89 + engine/src/renderer/webgl/fn.rs | 497 + engine/src/renderer/webgl/impl.rs | 2332 ++++ engine/src/renderer/webgl/mod.rs | 11 + engine/src/renderer/webgl/struct.rs | 293 + engine/src/renderer/{ => webgpu}/const.rs | 934 +- engine/src/renderer/webgpu/enum.rs | 111 + engine/src/renderer/{ => webgpu}/fn.rs | 114 +- engine/src/renderer/{ => webgpu}/impl.rs | 9392 +++++++---------- engine/src/renderer/webgpu/mod.rs | 11 + engine/src/renderer/webgpu/struct.rs | 285 + engine/src/scheduler/impl.rs | 57 +- engine/tests/api_visibility/fn.rs | 58 +- engine/tests/asset/mod.rs | 7 - engine/tests/input/mod.rs | 25 - engine/tests/lighting/fn.rs | 7 - engine/tests/quadtree/fn.rs | 31 - engine/tests/raytracing/fn.rs | 60 - engine/tests/scheduler/mod.rs | 73 - engine/tests/webgpu/fn.rs | 97 +- example/src/component/nav/view/const.rs | 1 + example/src/component/router/view/const.rs | 5 + example/src/component/router/view/fn.rs | 3 + example/src/component/router/view/mod.rs | 3 +- example/src/lib.rs | 4 +- example/src/page/game_2d/hook/fn.rs | 92 +- example/src/page/game_2d/view/fn.rs | 4 +- example/src/page/game_3d/hook/fn.rs | 99 +- example/src/page/game_3d/view/fn.rs | 4 +- example/src/page/gesture/mod.rs | 5 + example/src/page/gesture/view/const.rs | 102 + example/src/page/gesture/view/fn.rs | 217 + example/src/page/gesture/view/mod.rs | 7 + example/src/page/gesture/view/struct.rs | 5 + example/src/page/lighting/hook/lighting_fn.rs | 96 +- example/src/page/lighting/hook/struct.rs | 2 +- example/src/page/lighting/view/fn.rs | 2 +- example/src/page/mod.rs | 7 +- example/src/page/raytrace/hook/fn.rs | 115 +- example/src/page/raytrace/hook/struct.rs | 2 +- example/src/page/raytrace/view/fn.rs | 2 +- macros/tests/component/fn.rs | 3 - macros/tests/computed/fn.rs | 5 - macros/tests/unsafe_no_inline/fn.rs | 7 +- macros/tests/watch/fn.rs | 5 - ui/src/component/camera/hook/impl.rs | 31 +- ui/src/component/router/hook/impl.rs | 84 +- ui/src/component/router/{ => match}/fn.rs | 0 ui/src/component/router/{ => match}/impl.rs | 0 ui/src/component/router/match/mod.rs | 7 + ui/src/component/router/{ => match}/struct.rs | 0 ui/src/component/router/mod.rs | 6 +- ui/src/component/touch/hook/const.rs | 5 + ui/src/component/touch/hook/enum.rs | 24 + ui/src/component/touch/hook/fn.rs | 30 + ui/src/component/touch/hook/impl.rs | 540 + ui/src/component/touch/hook/mod.rs | 9 +- ui/src/component/touch/hook/struct.rs | 172 + ui/src/lib.rs | 4 +- ui/src/style/class/data/fn.rs | 173 + ui/src/style/class/display/fn.rs | 840 ++ ui/src/style/class/fn.rs | 4214 -------- ui/src/style/class/forms/fn.rs | 198 + ui/src/style/class/identity/fn.rs | 37 + ui/src/style/class/mod.rs | 5 +- ui/src/style/class/overlay/fn.rs | 488 + ui/src/style/class/page/fn.rs | 1584 +++ ui/src/style/class/page/mod.rs | 5 + ui/src/style/class/shell/fn.rs | 1099 ++ ui/src/style/class/shell/mod.rs | 5 + ui/tests/debounced_value/fn.rs | 8 - ui/tests/form/fn.rs | 5 - ui/tests/gesture/fn.rs | 187 + ui/tests/gesture/mod.rs | 3 + ui/tests/i18n/fn.rs | 71 +- ui/tests/mod.rs | 1 + ui/tests/throttled_value/fn.rs | 7 - ui/tests/transition/fn.rs | 15 - 136 files changed, 20735 insertions(+), 13569 deletions(-) rename core/src/vdom/{ => raw}/impl.rs (100%) create mode 100644 core/src/vdom/raw/mod.rs rename core/src/vdom/{ => raw}/struct.rs (100%) rename docs/docs/{ => en}/README.md (100%) create mode 100644 docs/docs/en/appreciate.md create mode 100644 docs/docs/en/guide/README.md create mode 100644 docs/docs/en/guide/getting-started.md create mode 100644 engine/src/renderer/canvas/const.rs create mode 100644 engine/src/renderer/canvas/enum.rs create mode 100644 engine/src/renderer/canvas/fn.rs create mode 100644 engine/src/renderer/canvas/impl.rs create mode 100644 engine/src/renderer/canvas/mod.rs create mode 100644 engine/src/renderer/canvas/struct.rs rename engine/src/renderer/{ => canvas}/trait.rs (100%) create mode 100644 engine/src/renderer/descriptor/const.rs create mode 100644 engine/src/renderer/descriptor/enum.rs create mode 100644 engine/src/renderer/descriptor/impl.rs create mode 100644 engine/src/renderer/descriptor/mod.rs create mode 100644 engine/src/renderer/descriptor/struct.rs delete mode 100644 engine/src/renderer/enum.rs create mode 100644 engine/src/renderer/state/const.rs create mode 100644 engine/src/renderer/state/enum.rs create mode 100644 engine/src/renderer/state/fn.rs create mode 100644 engine/src/renderer/state/impl.rs create mode 100644 engine/src/renderer/state/mod.rs delete mode 100644 engine/src/renderer/struct.rs create mode 100644 engine/src/renderer/webgl/const.rs create mode 100644 engine/src/renderer/webgl/enum.rs create mode 100644 engine/src/renderer/webgl/fn.rs create mode 100644 engine/src/renderer/webgl/impl.rs create mode 100644 engine/src/renderer/webgl/mod.rs create mode 100644 engine/src/renderer/webgl/struct.rs rename engine/src/renderer/{ => webgpu}/const.rs (77%) create mode 100644 engine/src/renderer/webgpu/enum.rs rename engine/src/renderer/{ => webgpu}/fn.rs (76%) rename engine/src/renderer/{ => webgpu}/impl.rs (59%) create mode 100644 engine/src/renderer/webgpu/mod.rs create mode 100644 engine/src/renderer/webgpu/struct.rs create mode 100644 example/src/component/router/view/const.rs create mode 100644 example/src/page/gesture/mod.rs create mode 100644 example/src/page/gesture/view/const.rs create mode 100644 example/src/page/gesture/view/fn.rs create mode 100644 example/src/page/gesture/view/mod.rs create mode 100644 example/src/page/gesture/view/struct.rs rename ui/src/component/router/{ => match}/fn.rs (100%) rename ui/src/component/router/{ => match}/impl.rs (100%) create mode 100644 ui/src/component/router/match/mod.rs rename ui/src/component/router/{ => match}/struct.rs (100%) create mode 100644 ui/src/component/touch/hook/const.rs create mode 100644 ui/src/component/touch/hook/enum.rs create mode 100644 ui/src/component/touch/hook/fn.rs delete mode 100644 ui/src/style/class/fn.rs create mode 100644 ui/src/style/class/page/fn.rs create mode 100644 ui/src/style/class/page/mod.rs create mode 100644 ui/src/style/class/shell/fn.rs create mode 100644 ui/src/style/class/shell/mod.rs create mode 100644 ui/tests/gesture/fn.rs create mode 100644 ui/tests/gesture/mod.rs diff --git a/Cargo.toml b/Cargo.toml index f48e5065..7643244d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.28.3" +version = "0.28.4" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.28.2" } -euv-ui = { path = "ui", version = "0.28.2" } -euv-cli = { path = "cli", version = "0.28.2" } -euv-core = { path = "core", version = "0.28.2" } -euv-engine = { path = "engine", version = "0.28.2" } -euv-macros = { path = "macros", version = "0.28.2" } -euv-example = { path = "example", version = "0.28.2" } +euv = { path = ".", version = "0.28.4" } +euv-ui = { path = "ui", version = "0.28.4" } +euv-cli = { path = "cli", version = "0.28.4" } +euv-core = { path = "core", version = "0.28.4" } +euv-engine = { path = "engine", version = "0.28.4" } +euv-macros = { path = "macros", version = "0.28.4" } +euv-example = { path = "example", version = "0.28.4" } log = "0.4.33" toml = "0.9.12" @@ -146,6 +146,7 @@ web-sys = { version = "0.3.103", features = [ "MediaDevices", "MessageEvent", "WebGlProgram", + "WebGlTexture", "gpu_map_mode", "AudioListener", "GamepadButton", @@ -193,6 +194,7 @@ web-sys = { version = "0.3.103", features = [ "HtmlStyleElement", "HtmlVideoElement", "MediaStreamTrack", + "WebGlFramebuffer", "gpu_buffer_usage", "gpu_shader_stage", "BeforeUnloadEvent", @@ -210,6 +212,7 @@ web-sys = { version = "0.3.103", features = [ "HtmlCanvasElement", "HtmlOptionElement", "HtmlSelectElement", + "WebGlRenderbuffer", "gpu_texture_usage", "GpuBindGroupLayout", "GpuCanvasAlphaMode", @@ -260,6 +263,7 @@ web-sys = { version = "0.3.103", features = [ "MediaStreamConstraints", "WebGl2RenderingContext", "WebGlContextAttributes", + "WebGlVertexArrayObject", "GpuBindGroupLayoutEntry", "GpuObjectDescriptorBase", "GpuRenderPassDescriptor", @@ -287,8 +291,7 @@ web-sys = { version = "0.3.103", features = [ "GpuRenderPassColorAttachment", "GpuStorageTextureBindingLayout", "GpuRenderBundleEncoderDescriptor", - "GpuRenderPassDepthStencilAttachment", -] } + "GpuRenderPassDepthStencilAttachment" ] } [dependencies] euv-core = { workspace = true } diff --git a/cli/tests/fmt/fn.rs b/cli/tests/fmt/fn.rs index a94c57cc..8ebafff5 100644 --- a/cli/tests/fmt/fn.rs +++ b/cli/tests/fmt/fn.rs @@ -273,10 +273,6 @@ fn test_sibling_elements_compressed() { assert_eq!(format_euv_macros(input), expected); } -/// Regression: block comments inside macro bodies with internal whitespace -/// must not drift on repeated fmt runs. Previously each run added 4 spaces of -/// indentation to comment continuation lines, so running `euv fmt` repeatedly -/// produced different output each time (compounding indent). #[test] fn test_block_comment_idempotent_in_class_macro() { let inner_indent: &str = " "; @@ -292,8 +288,6 @@ fn test_block_comment_idempotent_in_class_macro() { ); } -/// Regression: format_macro_body on a class! body containing a block comment -/// with deep internal indent must also be idempotent at the body level. #[test] fn test_block_comment_idempotent_in_macro_body() { let input: &str = "class! {c_test {/* line one\n line two\n line three */\n color: \"red\";}}"; @@ -305,11 +299,6 @@ fn test_block_comment_idempotent_in_macro_body() { ); } -/// Regression: repeated fmt runs on a macro body containing a block comment -/// with deep internal indent must not compound indent on continuation lines. -/// (The fix preserves the comment's original internal whitespace but -/// prevents `indented_body` from re-prepending indentation to lines that -/// are continuations of `/* ... */` regions.) #[test] fn test_block_comment_continuation_lines_normalized() { let inner_indent: &str = " "; diff --git a/cli/tests/inline/fn.rs b/cli/tests/inline/fn.rs index 43635d5d..5d3af134 100644 --- a/cli/tests/inline/fn.rs +++ b/cli/tests/inline/fn.rs @@ -58,7 +58,6 @@ export function euv_event_collect_id_chain(event, max_depth) { function _private_helper() {} -// Re-exports / non-function exports are ignored. export const VERSION = 1; export { something }; "#; diff --git a/core/src/lib.rs b/core/src/lib.rs index e5263ddd..2f79c7a4 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -21,22 +21,21 @@ pub use std::{ marker::PhantomData, mem::{swap, take, zeroed}, panic::{AssertUnwindSafe, catch_unwind}, + sync::{LazyLock, RwLock, RwLockReadGuard}, + thread::LocalKey, }; +pub use {js_sys::*, lombok_macros::*, wasm_bindgen::prelude::*, web_sys::*}; + +pub(crate) use std::iter::Iterator; + pub(crate) use renderer::*; use std::{ any::Any, cell::{Cell, Ref, RefCell, UnsafeCell}, - iter::Iterator, num::ParseIntError, - ops::Deref, rc::Rc, - sync::{ - LazyLock, - atomic::{AtomicBool, AtomicUsize, Ordering}, - }, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, vec::Vec, }; - -use {js_sys::*, lombok_macros::*, wasm_bindgen::prelude::*, web_sys::*}; diff --git a/core/src/reactive/hook/impl.rs b/core/src/reactive/hook/impl.rs index bc3b6044..be34b177 100644 --- a/core/src/reactive/hook/impl.rs +++ b/core/src/reactive/hook/impl.rs @@ -143,45 +143,33 @@ impl HookContext { /// Returns a shared reference to the current hook context global state. /// /// SAFETY: Must only be called from the main thread (WASM single-threaded context). - fn try_get_current() -> &'static Option { - unsafe { &*(*std::ptr::addr_of!(CURRENT_HOOK_CONTEXT)).get_0().get() } - } - - /// Returns a mutable reference to the current hook context global state. - /// - /// SAFETY: Must only be called from the main thread (WASM single-threaded context). - fn try_get_mut_current() -> &'static mut Option { - unsafe { - &mut *(*std::ptr::addr_of_mut!(CURRENT_HOOK_CONTEXT)) - .get_0() - .get() - } - } - - /// Returns the currently active `HookContext`. + /// Returns the currently active `HookContext` for this thread. /// - /// If no hook context has been set, creates and stores a default one - /// in the global `CURRENT_HOOK_CONTEXT` cell so subsequent calls - /// return the same instance. + /// If no hook context has been set, creates and stores a default one in + /// the thread-local `CURRENT_HOOK_CONTEXT` cell so subsequent calls on + /// this thread return the same instance. /// /// # Returns /// /// - `HookContext` - The currently active hook context. pub fn current() -> HookContext { - match Self::try_get_current() { - Some(hook_context_rc) => HookContext::new(hook_context_rc.clone()), - None => { - let rc: HookContextRc = Rc::new(RefCell::new(HookContextInner::default())); - *Self::try_get_mut_current() = Some(rc.clone()); - HookContext::new(rc) + CURRENT_HOOK_CONTEXT.with(|slot: &RefCell>| { + let existing: Option = slot.borrow().clone(); + if let Some(hook_context_rc) = existing { + return HookContext::new(hook_context_rc); } - } + let created: HookContextRc = Rc::new(RefCell::new(HookContextInner::default())); + *slot.borrow_mut() = Some(created.clone()); + HookContext::new(created) + }) } /// Runs a closure with the given `HookContext` set as the active context. /// /// Saves the previous context, sets the new one, executes the closure, - /// and restores the previous context afterward. + /// and restores the previous context afterward. The save/restore pair is + /// scoped to the current thread, so concurrent callers cannot restore each + /// other's context. /// /// # Arguments /// @@ -195,11 +183,13 @@ impl HookContext { where F: FnOnce() -> R, { - let previous: Option = Self::try_get_mut_current().take(); - *Self::try_get_mut_current() = Some(context.get_inner().clone()); - let result: R = callback(); - *Self::try_get_mut_current() = previous; - result + CURRENT_HOOK_CONTEXT.with(|slot: &RefCell>| { + let previous: Option = slot.borrow_mut().take(); + *slot.borrow_mut() = Some(context.get_inner().clone()); + let result: R = callback(); + *slot.borrow_mut() = previous; + result + }) } /// Creates a new reactive signal with the given initial value. diff --git a/core/src/reactive/schedule/impl.rs b/core/src/reactive/schedule/impl.rs index 6da9da2b..4b284caf 100644 --- a/core/src/reactive/schedule/impl.rs +++ b/core/src/reactive/schedule/impl.rs @@ -1,22 +1,92 @@ use super::*; -/// Marks `CurrentHookContextCell` as `Sync` for single-threaded WASM contexts. -/// -/// SAFETY: `CurrentHookContextCell` is only used in single-threaded WASM contexts. -/// Concurrent access from multiple threads would be undefined behavior. -unsafe impl Sync for CurrentHookContextCell {} - -/// Marks `MicrotaskCacheCell` as `Sync` for single-threaded WASM contexts. -/// -/// SAFETY: only mutated through `UnsafeCell` interior-mutability on -/// the WASM single-threaded runtime. -unsafe impl Sync for MicrotaskCacheCell {} - /// Static methods for scheduling signal update dispatch and batching. /// /// Provides centralized scheduling for reactive updates, ensuring efficient /// batching and dispatch of signal changes to dependent dynamic nodes. impl Scheduler { + /// Resolves `window.queueMicrotask` to a `Function` handle. + /// + /// The lookup crosses into JS, so it is kept out of any `RefCell` + /// borrow: the caller stores the result afterwards. Returns `None` + /// when the host does not expose `queueMicrotask` or the cast fails. + /// + /// # Returns + /// + /// - `Option` - The resolved handle, or `None` if unavailable. + fn resolve_queue_microtask() -> Option { + let window_value: Window = window()?; + let queue_microtask_value: JsValue = + Reflect::get(&window_value, &JsValue::from_str(QUEUE_MICROTASK)).ok()?; + queue_microtask_value.dyn_into::().ok() + } + + /// Returns the persistent dispatch closure as a JS `Function`. + /// + /// `DISPATCH_CLOSURE` is a `Closure`; `Closure::as_ref` + /// yields a `&JsValue` that is the underlying JS function object, so + /// the cast is a reinterpretation of the same `JsValue` rather than a + /// new borrow. The closure is created inside a `thread_local!` and + /// never dropped, so the returned reference is live for the life of + /// the thread. + /// + /// # Returns + /// + /// - `&'static Function` - The dispatch function handle. + fn dispatch_function() -> &'static Function { + DISPATCH_CLOSURE.with(|closure: &Closure| { + let value: &JsValue = closure.as_ref(); + unsafe { &*(value as *const JsValue as *const Function) } + }) + } + + /// Invokes `queue_microtask` with the persistent dispatch closure. + /// + /// The dispatch `Function` is resolved inside this call, after the + /// `MICROTASK_CACHE` borrow has already been released, so the + /// `queueMicrotask` invocation never runs under a live `RefCell` + /// borrow. `queueMicrotask` schedules a microtask — the callback runs + /// later, in a separate turn — but a host that runs it synchronously + /// would otherwise re-enter `MICROTASK_CACHE` and hit a refused + /// borrow. + /// + /// # Arguments + /// + /// - `&Window` - The window whose `queueMicrotask` is being called. + /// - `&Function` - The cached `queueMicrotask` handle. + /// + /// # Returns + /// + /// - `bool` - `true` when the microtask was queued. + fn call_queue_microtask(window_value: &Window, queue_microtask: &Function) -> bool { + let dispatch_function: &Function = Self::dispatch_function(); + queue_microtask + .call1(window_value, dispatch_function) + .is_ok() + } + + /// Whether a JS `Window` is reachable on this host. + /// + /// `web_sys::window()` resolves the JS global through a + /// process-wide `once_cell::Lazy` inside `js_sys`. On a non-WASM host + /// (where `cargo test` runs) there is no JS global, so the lookup + /// **panics** — and because the `Lazy` is process-wide, that one + /// panic poisons it for every other thread, which then fail with + /// "Lazy instance has previously been poisoned" in a completely + /// unrelated test. The panic is the bug, not the poisoning. + /// + /// `cfg!(target_arch = "wasm32")` is a compile-time constant, so on + /// WASM the whole body is optimised away to `true` and this costs + /// nothing; on the host it returns `false` before any JS call is + /// made, keeping `Scheduler::update` a pure registry operation. + /// + /// # Returns + /// + /// - `bool` - `true` when JS globals are reachable. + fn js_reachable() -> bool { + cfg!(target_arch = "wasm32") + } + /// Schedules a deferred signal update with precise dirty marking. /// /// Marks the specified dynamic nodes as dirty and queues a microtask @@ -38,6 +108,14 @@ impl Scheduler { if SUPPRESS_SCHEDULE.load(Ordering::Relaxed) { return; } + // Off-WASM there is no JS global to schedule a microtask against, and + // attempting the lookup panics inside `js_sys`'s process-wide + // `once_cell::Lazy` — poisoning it for every other thread. Return + // before touching any JS so host test runs stay green and the dirty + // marking above still happens. + if !Self::js_reachable() { + return; + } if SCHEDULED.load(Ordering::Relaxed) { return; } @@ -49,47 +127,34 @@ impl Scheduler { return; } }; - let queued_microtask: bool = MICROTASK_CACHE.with(|cache: &MicrotaskCacheCell| { - let cache_ptr: *mut MicrotaskCache = cache.get_0().get(); - let cache_ref: &MicrotaskCache = unsafe { &*cache_ptr }; - if cache_ref.queue_microtask.is_none() { - if let Some(window_value_inner) = window() { - let queue_microtask_value: JsValue = - Reflect::get(&window_value_inner, &JsValue::from_str(QUEUE_MICROTASK)) - .unwrap_or(JsValue::UNDEFINED); - if let Ok(queue_microtask) = queue_microtask_value.dyn_into::() { - unsafe { - (*cache_ptr).queue_microtask = Some(queue_microtask); - } - } + let queued_microtask: bool = MICROTASK_CACHE + .try_with(|cache: &RefCell| { + // Fast path: a cached `queueMicrotask` handle, cloned out so + // the `RefCell` borrow is released before the call crosses + // into JS. A refused borrow just falls through to the + // resolution path below rather than panicking. + if let Ok(guard) = cache.try_borrow() + && let Some(cached) = guard.try_get_queue_microtask().clone() + { + return Self::call_queue_microtask(&window_value, &cached); } - let cache_ref: &MicrotaskCache = unsafe { &*cache_ptr }; - if let Some(queue_microtask) = &cache_ref.queue_microtask { - // SAFETY: `DISPATCH_CLOSURE` lives for the duration of - // the program (it is leaked via `Closure::wrap` / - // `Closure::forget` semantics inside the macro). - let dispatch_function: &Function = - DISPATCH_CLOSURE.with(|closure: &Closure| unsafe { - &*(closure.as_ref() as *const _ as *const Function) - }); - return queue_microtask - .call1(&window_value, dispatch_function) - .is_ok(); + // Slow path: resolve the handle once and cache it. The + // `Reflect::get` + `dyn_into` lookup crosses into JS, so it + // runs with no borrow held and the result is stored after. + let resolved: Option = Self::resolve_queue_microtask(); + if let Some(queue_microtask) = &resolved + && let Ok(mut guard) = cache.try_borrow_mut() + { + guard.set_queue_microtask(Some(queue_microtask.clone())); } - return false; - } - let queue_microtask: &Function = match cache_ref.queue_microtask.as_ref() { - Some(queue_microtask) => queue_microtask, - None => return false, - }; - let dispatch_function: &Function = - DISPATCH_CLOSURE.with(|closure: &Closure| unsafe { - &*(closure.as_ref() as *const _ as *const Function) - }); - queue_microtask - .call1(&window_value, dispatch_function) - .is_ok() - }); + match resolved { + Some(queue_microtask) => { + Self::call_queue_microtask(&window_value, &queue_microtask) + } + None => false, + } + }) + .unwrap_or(false); if queued_microtask { return; } @@ -125,11 +190,11 @@ impl Scheduler { /// /// # Arguments /// - /// - `F: FnOnce() -> R` - The closure to execute with batching enabled. + /// - `F` - The closure to execute with batching enabled. + /// - `R` - The result type produced by the closure. /// /// # Returns /// - /// - `R` - The result of the closure execution. pub(crate) fn batch(callback: F) -> R where F: FnOnce() -> R, @@ -169,20 +234,37 @@ impl Scheduler { loop { // OPT 6: drain the dirty set rather than scanning the registry. // `std::mem::take` swaps in a fresh empty set so the dirty-set - // borrow is released before we mutate `SIGNAL_UPDATE_REGISTRY` - // in the loop body below. (`HashSet::drain` requires the - // `RangeFull` pattern which Rust 2024 reserves as the + // borrow is released before we mutate the signal update + // registry in the loop body below. (`HashSet::drain` requires + // the `RangeFull` pattern which Rust 2024 reserves as the // struct-update syntax shorthand.) - let dirty_keys: HashSet = take(Registry::get_mut_dirty_update_ids()); + let dirty_keys: HashSet = Registry::take_dirty_update_ids(); if dirty_keys.is_empty() { break; } for key in dirty_keys { - let entry: SignalUpdateEntry = - match Registry::get_mut_update_registry().remove(&key) { - Some(removed_entry) => removed_entry, - None => continue, - }; + // The slot is taken out of the registry for the duration of + // the callback. A re-render that unmounts this node runs + // `cleanup_dynamic_node`, which finds nothing to remove and + // therefore cannot free the box while the callback is still + // using it — that is why the "put it back" step below + // re-checks both `removed` and registry membership. + // The entry MUST be taken out of the registry for the + // duration of the callback. `get_dynamic` only copies the + // pointer, so the key would still be present below and the + // `has_dynamic` guard would then treat our own untouched + // entry as "a re-entrant pass already replaced it" — freeing + // the slot and leaving the dynamic node with no callback. + // The node then never re-renders again: the first + // signal-driven update after mount works, every later one is + // silently dropped. + let Some(entry) = Registry::take_dynamic(key) else { + continue; + }; + // SAFETY: `take_dynamic` returns the raw pointer the registry + // stores; the entry is still live because nothing removed + // it (removal frees the box, and only removal precedes + // freeing). let slot: &mut SignalUpdateSlot = unsafe { &mut *entry }; if slot.get_removed() { unsafe { @@ -194,27 +276,26 @@ impl Scheduler { let callback: Option> = slot.get_mut_callback().take(); if let Some(mut callback) = callback { callback(); - let slot: &mut SignalUpdateSlot = unsafe { &mut *entry }; if !slot.get_removed() { slot.set_callback(Some(callback)); } } - let slot: &SignalUpdateSlot = unsafe { &*entry }; if slot.get_removed() { unsafe { let _: Box = Box::from_raw(entry); } continue; } - let registry: &mut HashMap = - Registry::get_mut_update_registry(); - if registry.contains_key(&key) { + // Reinsert only if a re-entrant pass did not already put + // this id back (which would leave the old box unreclaimed + // and the new one duplicated). + if Registry::has_dynamic(key) { unsafe { let _: Box = Box::from_raw(entry); } continue; } - registry.insert(key, entry); + Registry::put_dynamic(key, entry); } iterations += 1; if iterations >= MAX_ITERATIONS { diff --git a/core/src/reactive/schedule/static.rs b/core/src/reactive/schedule/static.rs index 3891a075..65697c03 100644 --- a/core/src/reactive/schedule/static.rs +++ b/core/src/reactive/schedule/static.rs @@ -10,11 +10,21 @@ pub static SCHEDULED: AtomicBool = AtomicBool::new(false); /// during internal operations such as `batch`. pub static SUPPRESS_SCHEDULE: AtomicBool = AtomicBool::new(false); -/// The currently active `HookContext`. -/// -/// SAFETY: Must only be accessed from the main thread (WASM single-threaded context). -pub(crate) static mut CURRENT_HOOK_CONTEXT: CurrentHookContextCell = - CurrentHookContextCell(UnsafeCell::new(None)); +thread_local! { + /// The currently active `HookContext` for this thread. + /// + /// This is thread-local, not a process global, because `HookContext::with` + /// implements save/restore: it takes the previous value, installs the new + /// one, and puts the old one back afterwards. A process-wide global makes + /// that pattern unsound as soon as two threads interleave — thread A + /// saves, thread B saves, thread A restores, thread B restores the value + /// A had already put back, and the `Rc` is dropped twice, aborting the + /// process with a refcount underflow. Per-thread storage makes the + /// save/restore pair atomic with respect to other threads by + /// construction. + pub static CURRENT_HOOK_CONTEXT: RefCell> = + const { RefCell::new(None) }; +} /// The dynamic node ID currently being rendered/set up. /// @@ -44,8 +54,15 @@ thread_local! { /// `window.queueMicrotask`, resolved lazily on first use. The /// `Function::call1` in `Scheduler::update` skips the /// `Reflect::get` / `dyn_into` lookup on every signal update. - pub static MICROTASK_CACHE: MicrotaskCacheCell = - MicrotaskCacheCell(UnsafeCell::new(MicrotaskCache { - queue_microtask: None, - })); + /// + /// Storage is a plain `RefCell` rather than the + /// previous `MicrotaskCacheCell(UnsafeCell)` + + /// `unsafe impl Sync for MicrotaskCacheCell {}`. The wrapper existed + /// only to let the cache live in a `static`; as a `thread_local!` the + /// `RefCell` gives the same lazy-populate-once behaviour while + /// restoring the borrow check the `unsafe` had bypassed. It holds no + /// raw pointer and no `!Send` payload, so thread-local storage is a + /// pure win rather than a restriction. + pub static MICROTASK_CACHE: RefCell = + RefCell::new(MicrotaskCache { queue_microtask: None }); } diff --git a/core/src/reactive/schedule/struct.rs b/core/src/reactive/schedule/struct.rs index 2e8c6915..26b3b893 100644 --- a/core/src/reactive/schedule/struct.rs +++ b/core/src/reactive/schedule/struct.rs @@ -10,40 +10,25 @@ use super::*; /// cached for the page's lifetime. Subsequent scheduling just /// `Function::call1(window, dispatch_function)` — one JS round-trip /// per dispatch instead of three. +/// +/// Lives in a `thread_local!` `RefCell`. The previous +/// `MicrotaskCacheCell(UnsafeCell)` + +/// `unsafe impl Sync for MicrotaskCacheCell {}` pair existed only to smuggle +/// the cache through a `static`; with `thread_local!` the `RefCell` gives the +/// same lazy-populate-once behaviour while restoring the borrow check the +/// `unsafe` had bypassed. The payload is a plain `Option` — JS +/// object handles, not raw pointers — so nothing is lost by dropping the +/// wrapper. +#[derive(CustomDebug, Data)] pub(crate) struct MicrotaskCache { /// The `window.queueMicrotask` function, resolved once on first /// call and reused across the page's lifetime. `None` if the /// browser does not expose `queueMicrotask` (the dispatch path /// then falls through to `setTimeout` / `requestAnimationFrame`). + #[debug(skip)] pub(crate) queue_microtask: Option, } -/// `Sync` wrapper around `MicrotaskCache` for `thread_local!` storage. -/// SAFETY: only used on the WASM single-threaded runtime. -#[derive(CustomDebug, Data)] -pub(crate) struct MicrotaskCacheCell( - /// `UnsafeCell` interior-mutability so `update` can lazily populate - /// `queue_microtask` once and reuse it on subsequent calls. - #[get(pub(crate))] - #[get_mut(pub(crate))] - pub UnsafeCell, -); - -/// A `Sync` wrapper for single-threaded global `Option` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct CurrentHookContextCell( - /// Interior-mutable storage for the current hook context. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell>, -); - /// A zero-sized struct providing static methods for scheduling /// signal update dispatches and batching. /// diff --git a/core/src/reactive/signal/impl.rs b/core/src/reactive/signal/impl.rs index c0996103..00ec4bd0 100644 --- a/core/src/reactive/signal/impl.rs +++ b/core/src/reactive/signal/impl.rs @@ -5,22 +5,42 @@ impl Signal where T: Clone + PartialEq + 'static, { - /// Returns a shared reference to the global typed signal slab. + /// Runs `operation` with a mutable borrow of this thread's signal slab. /// - /// # Returns + /// The borrow is released before this returns, so no caller can hold slab + /// access across a call that re-enters it. `try_borrow_mut` rather than + /// `borrow_mut` means a re-entrant call degrades to `fallback` instead of + /// panicking mid-update and leaving the slab half-mutated. /// - /// - `&'static SignalSlab` - A shared reference to the global signal slab. - fn slab() -> &'static SignalSlab { - unsafe { &*(*std::ptr::addr_of!(SIGNAL_SLAB)).deref().get() } - } - - /// Returns a mutable reference to the global typed signal slab. + /// # Arguments + /// + /// - `F` - Closure receiving `&mut SignalSlab`. + /// - `R` - Value returned when the slab is already mutably borrowed. /// /// # Returns /// - /// - `&'static mut SignalSlab` - A mutable reference to the global signal slab. - fn slab_mut() -> &'static mut SignalSlab { - unsafe { &mut *(*std::ptr::addr_of_mut!(SIGNAL_SLAB)).deref().get() } + /// - `R` - The operation's result, or `fallback` if the borrow was refused. + fn with_slab(operation: F, fallback: R) -> R + where + F: FnOnce(&mut SignalSlab) -> R, + { + // The fallback is parked in a `Cell` rather than moved into the + // closure: both failure paths (slab already mutably borrowed, thread + // local destroyed) need it, and `R` is not required to be `Copy` or + // `Clone`. `Cell::set` takes `&self`, so the closure can still write + // the operation's result back out through a shared borrow. + let result: Cell> = Cell::new(Some(fallback)); + SIGNAL_SLAB + .try_with(|cell: &RefCell| { + if let Ok(mut guard) = cell.try_borrow_mut() { + result.set(Some(operation(&mut guard))); + } + }) + .ok(); + match result.take() { + Some(value) => value, + None => unreachable!("with_slab always leaves a result in the cell"), + } } /// Creates a new `Signal` with the given initial value. @@ -40,7 +60,13 @@ where /// - `Self` - A handle to the newly created reactive signal. pub fn create(value: T) -> Self { let inner: SignalInner = SignalInner::new(value, Vec::new(), true); - let idx: usize = Self::slab_mut().insert(inner); + // `usize::MAX` is out of bounds for the append-only slab, so a refused + // borrow can never be mistaken for a real slot. Falling back to `0` + // would silently alias whatever signal happens to own the first slot. + let idx: usize = Self::with_slab(|slab: &mut SignalSlab| slab.insert(inner), usize::MAX); + if idx == usize::MAX { + unreachable!("Signal handle does not resolve to a slab slot"); + } let mut signal: Self = Self::new(0, PhantomData); signal.set_inner(idx); signal @@ -65,24 +91,33 @@ where /// - `T: Clone + PartialEq + 'static` - The current value of the signal. pub fn get(&self) -> T { let idx: usize = self.get_inner(); - let Some(inner) = Self::slab_mut().get_mut::(idx) else { - // Unresolvable handle: the slot index was never issued by this - // slab or belongs to a different concrete `T` (a corrupted or - // forged handle). Slots are never freed or recycled, so any - // handle produced by `Signal::create` always resolves; a `None` - // here is a program bug, and panicking is strictly better than - // vending a zero-initialized `T` (unsound for non-zeroable - // types such as `String` / `Vec`). + Self::with_slab::<_, Option>( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(idx) else { + // Unresolvable handle: the slot index was never issued by + // this slab or belongs to a different concrete `T` (a + // corrupted or forged handle). Slots are never freed or + // recycled, so any handle produced by `Signal::create` + // always resolves; a `None` here is a program bug, and + // panicking is strictly better than vending a + // zero-initialized `T` (unsound for non-zeroable types + // such as `String` / `Vec`). + unreachable!("Signal handle does not resolve to a slab slot"); + }; + if !inner.get_alive() { + return Some(inner.get_value().clone()); + } + let tracking_id: usize = CURRENT_TRACKING_DYNAMIC_ID.load(Ordering::Relaxed); + if tracking_id != usize::MAX { + Self::push_dependent(inner, tracking_id); + } + Some(inner.get_value().clone()) + }, + None, + ) + .unwrap_or_else(|| { unreachable!("Signal handle does not resolve to a slab slot"); - }; - if !inner.get_alive() { - return inner.get_value().clone(); - } - let tracking_id: usize = CURRENT_TRACKING_DYNAMIC_ID.load(Ordering::Relaxed); - if tracking_id != usize::MAX { - Self::push_dependent(inner, tracking_id); - } - inner.get_value().clone() + }) } /// Read-only access to the signal value without cloning. @@ -95,6 +130,15 @@ where /// because `get` is required by the existing public API; `with` is the /// zero-copy alternative for new code. /// + /// The closure runs in a second phase, AFTER the slab borrow has been + /// released. That ordering is load-bearing: the closure is arbitrary + /// caller code and may read other signals — `I18n::t` nests a + /// `fallback_locale.with(..)` inside its `locale.with(..)`. A `RefCell` + /// is not reentrant, so invoking the closure under the borrow made the + /// inner call hit `try_borrow_mut`, get refused, and fall through to + /// the `unreachable!` below — aborting the test binary on a plain + /// nested read. + /// /// # Arguments /// /// - `F: FnOnce(&T) -> R` - Closure receiving `&T`. @@ -107,20 +151,32 @@ where F: FnOnce(&T) -> R, { let idx: usize = self.get_inner(); - let Some(inner) = Self::slab_mut().get_mut::(idx) else { - // Unresolvable handle: unreachable for slab-issued handles (see - // `get`). Panic instead of vending a zero-initialized `R`, which - // would be unsound for non-zeroable return types. + // Phase 1: resolve the slot and clone the value out, releasing the + // borrow before any caller code runs. An inactive slot still yields + // its last stored value and skips dependency registration. + let staged: Option = Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(idx) else { + // Unresolvable handle: unreachable for slab-issued handles + // (see `get`). Return `None` rather than a zeroed `T`, + // which would be unsound for non-zeroable types. + return None; + }; + if inner.get_alive() { + let tracking_id: usize = CURRENT_TRACKING_DYNAMIC_ID.load(Ordering::Relaxed); + if tracking_id != usize::MAX { + Self::push_dependent(inner, tracking_id); + } + } + Some(inner.get_value().clone()) + }, + None, + ); + let Some(value) = staged else { unreachable!("Signal handle does not resolve to a slab slot"); }; - if !inner.get_alive() { - return f(inner.get_value()); - } - let tracking_id: usize = CURRENT_TRACKING_DYNAMIC_ID.load(Ordering::Relaxed); - if tracking_id != usize::MAX { - Self::push_dependent(inner, tracking_id); - } - f(inner.get_value()) + // Phase 2: caller code runs with no slab borrow held. + f(&value) } /// Subscribes a callback to be invoked when the signal changes. @@ -143,15 +199,22 @@ where where F: FnMut() + 'static, { - let Some(inner) = Self::slab_mut().get_mut::(self.get_inner()) else { - // Stale handle: no slot to register against — the subscription - // is silently dropped, matching the previous no-op semantics. - return usize::MAX; - }; - let id: usize = inner.get_next_listener_id(); - inner.set_next_listener_id(id.wrapping_add(1)); - inner.get_mut_listeners().push((id, Box::new(callback))); - id + let slot: usize = self.get_inner(); + Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(slot) else { + // Stale handle: no slot to register against — the + // subscription is silently dropped, matching the previous + // no-op semantics. + return usize::MAX; + }; + let id: usize = inner.get_next_listener_id(); + inner.set_next_listener_id(id.wrapping_add(1)); + inner.get_mut_listeners().push((id, Box::new(callback))); + id + }, + usize::MAX, + ) } /// Detaches a single listener previously registered by [`Signal::subscribe`]. @@ -165,16 +228,22 @@ where /// /// - `usize` - The subscription id returned by `subscribe`. pub fn unsubscribe(&self, id: usize) { - let Some(inner) = Self::slab_mut().get_mut::(self.get_inner()) else { - return; - }; - if inner.get_notifying() { - inner.get_mut_removed_listener_ids().push(id); - return; - } - inner - .get_mut_listeners() - .retain(|(listener_id, _): &ListenerEntry| *listener_id != id); + let slot: usize = self.get_inner(); + Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(slot) else { + return; + }; + if inner.get_notifying() { + inner.get_mut_removed_listener_ids().push(id); + return; + } + inner + .get_mut_listeners() + .retain(|(listener_id, _): &ListenerEntry| *listener_id != id); + }, + (), + ); } /// Detaches this signal from the reactive system without freeing memory. @@ -192,16 +261,21 @@ where /// signal. Deactivating instead makes those stale calls safe no-ops. pub(crate) fn deactivate(&self) { let idx: usize = self.get_inner(); - let Some(inner) = Self::slab_mut().get_mut::(idx) else { - // Out-of-bounds handle — treat as no-op. Mirrors the - // "deactivate on already-deactivated signal is a safe no-op" - // semantic. - return; - }; - inner.set_alive(false); - inner.get_mut_listeners().clear(); - inner.get_mut_dependents().clear(); - inner.get_mut_removed_listener_ids().clear(); + Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(idx) else { + // Out-of-bounds handle — treat as no-op. Mirrors the + // "deactivate on already-deactivated signal is a safe + // no-op" semantic. + return; + }; + inner.set_alive(false); + inner.get_mut_listeners().clear(); + inner.get_mut_dependents().clear(); + inner.get_mut_removed_listener_ids().clear(); + }, + (), + ); } /// Core implementation of value update and listener notification. @@ -225,50 +299,71 @@ where /// - `bool` - A boolean. fn update(&self, value: T) -> bool { let idx: usize = self.get_inner(); - let Some(inner) = Self::slab_mut().get_mut::(idx) else { - // Stale handle — treat as no-op. - return false; - }; - if !inner.get_alive() { - return false; - } - if *inner.get_value() == value { + // Phase 1: publish the new value and take ownership of the listener + // list. The slab borrow MUST be released before any listener runs: a + // listener is free to call `get` / `set` on any signal (including this + // one), and a `RefCell` is not reentrant, so invoking them under the + // borrow would panic mid-update and leave the slot half-mutated. + let mut listeners: Vec = Vec::new(); + let started: bool = Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(idx) else { + // Stale handle — treat as no-op. + return false; + }; + if !inner.get_alive() { + return false; + } + if *inner.get_value() == value { + return false; + } + inner.set_value(value); + inner.set_notifying(true); + swap(inner.get_mut_listeners(), &mut listeners); + true + }, + false, + ); + if !started { return false; } - inner.set_value(value); - inner.set_notifying(true); - let mut listeners: Vec = Vec::new(); - swap(inner.get_mut_listeners(), &mut listeners); + + // Phase 2: listeners run with no slab borrow held. for (_id, listener) in listeners.iter_mut() { listener(); } - if !Self::is_alive(self.get_inner()) { - // The signal was deactivated by a listener mid-notification. - // Nothing should be merged back into a dead slot; clear the - // notification state so a later `unsubscribe` cannot pile up - // deferred removals that will never be drained. - if let Some(inner) = Self::slab_mut().get_mut::(idx) { + + // Phase 3: merge the surviving listeners back into the slot. + Self::with_slab( + |slab: &mut SignalSlab| { + let Some(inner) = slab.get_mut::(idx) else { + return; + }; + if !inner.get_alive() { + // The signal was deactivated by a listener mid-notification. + // Nothing should be merged back into a dead slot; clear the + // notification state so a later `unsubscribe` cannot pile up + // deferred removals that will never be drained. + inner.set_notifying(false); + inner.get_mut_removed_listener_ids().clear(); + return; + } + let removed: Vec = take(inner.get_mut_removed_listener_ids()); + if !removed.is_empty() { + listeners + .retain(|(listener_id, _): &ListenerEntry| !removed.contains(listener_id)); + } + let new_listeners: &mut Vec = inner.get_mut_listeners(); + if new_listeners.is_empty() { + swap(new_listeners, &mut listeners); + } else { + listeners.append(new_listeners); + swap(new_listeners, &mut listeners); + } inner.set_notifying(false); - inner.get_mut_removed_listener_ids().clear(); - } - return true; - } - if let Some(inner) = Self::slab_mut().get_mut::(idx) - && inner.get_alive() - { - let removed: Vec = take(inner.get_mut_removed_listener_ids()); - if !removed.is_empty() { - listeners.retain(|(listener_id, _): &ListenerEntry| !removed.contains(listener_id)); - } - let new_listeners: &mut Vec = inner.get_mut_listeners(); - if new_listeners.is_empty() { - swap(new_listeners, &mut listeners); - } else { - listeners.append(new_listeners); - swap(new_listeners, &mut listeners); - } - inner.set_notifying(false); - } + }, + (), + ); true } @@ -312,10 +407,15 @@ where /// /// - `Vec` - The drained dependents list. pub(crate) fn take_dependents(&self) -> Vec { - Self::slab_mut() - .get_mut::(self.get_inner()) - .map(|inner: &mut SignalInner| take(inner.get_mut_dependents())) - .unwrap_or_default() + let idx: usize = self.get_inner(); + Self::with_slab( + |slab: &mut SignalSlab| { + slab.get_mut::(idx) + .map(|inner: &mut SignalInner| take(inner.get_mut_dependents())) + .unwrap_or_default() + }, + Vec::new(), + ) } /// Sets the value of the signal and notifies listeners. @@ -337,20 +437,6 @@ where App::schedule_update(&dependents); } } - - /// Returns whether the signal slot at `idx` is still alive - /// (i.e. has not been deactivated). - /// - /// # Arguments - /// - /// - `usize` - Slab index to test. - /// - /// # Returns - /// - /// - `bool` - `true` when the slot refers to a live signal. - pub(crate) fn is_alive(idx: usize) -> bool { - Self::slab().is_alive(idx) - } } /// Provides a safe default for `Signal` by creating a valid signal @@ -607,13 +693,4 @@ impl SignalSlab { .as_any_mut() .downcast_mut::>() } - - /// Returns `true` when the slot at `idx` exists AND its inner signal is - /// still marked `alive`. Used by `Signal::is_alive`. - pub(crate) fn is_alive(&self, idx: usize) -> bool { - match self.get_entries().get(idx) { - Some(inner) => inner.alive(), - None => false, - } - } } diff --git a/core/src/reactive/signal/static.rs b/core/src/reactive/signal/static.rs index ab7115fc..305bbf3a 100644 --- a/core/src/reactive/signal/static.rs +++ b/core/src/reactive/signal/static.rs @@ -1,10 +1,27 @@ use super::*; -/// Global typed signal slab. Single instance, lives for the program's -/// lifetime. -/// -/// SAFETY: must only be accessed from the main thread (WASM single-threaded -/// context). The slab is append-only: slots are never recycled, so a stale -/// `Signal` handle always resolves to its original (deactivated) slot. -pub(crate) static mut SIGNAL_SLAB: LazyLock> = - LazyLock::new(|| UnsafeCell::new(SignalSlab::new())); +thread_local! { + /// Global typed signal slab for this thread. Lives for the thread's + /// lifetime. + /// + /// The slab is append-only: slots are never recycled, so a stale `Signal` + /// handle always resolves to its original (deactivated) slot. + /// + /// Storage is thread-local rather than the previous `static mut` + + /// `UnsafeCell`. The old form handed out `&'static mut SignalSlab` from a + /// global that was never actually `Sync` — the slab holds `Box` + /// listeners, so it is `!Send` — which is aliasing undefined behaviour the + /// moment two threads touch it. Parallel test runs pushed into the same + /// `Vec>` concurrently, corrupting the heap and + /// killing the test binary with a silent SIGSEGV or a poisoned `LazyLock`. + /// + /// Thread-local storage is the sound choice for a second reason: a `Signal` + /// handle is just a slot index, and an index is only meaningful inside the + /// slab that issued it. Thread A's slot 3 and thread B's slot 3 are + /// different signals, so a process-wide slab handed each thread a view of + /// the other's signals. Per-thread slabs make a handle unambiguous by + /// construction, and `RefCell` restores the aliasing check that the old + /// `unsafe` bypassed. + + pub static SIGNAL_SLAB: RefCell = RefCell::new(SignalSlab::new()); +} diff --git a/core/src/reactive/signal/trait.rs b/core/src/reactive/signal/trait.rs index a0cd1e37..eb40a38e 100644 --- a/core/src/reactive/signal/trait.rs +++ b/core/src/reactive/signal/trait.rs @@ -8,8 +8,6 @@ use super::*; /// downcast, alive-flag access, and `Any` projections for typed getters /// implemented in `impl.rs`. pub(crate) trait AnySignalInner: Any { - /// Returns `true` if this slot is still considered live (i.e. `alive`). - fn alive(&self) -> bool; /// Projects the slot as `&mut dyn Any` for downcasting. fn as_any_mut(&mut self) -> &mut dyn Any; } @@ -26,10 +24,6 @@ impl AnySignalInner for SignalInner where T: Clone + PartialEq + 'static, { - fn alive(&self) -> bool { - self.get_alive() - } - fn as_any_mut(&mut self) -> &mut dyn Any { self } diff --git a/core/src/renderer/dom_ops/fn.rs b/core/src/renderer/dom_ops/fn.rs index 2a1fe0c0..a94e5a3f 100644 --- a/core/src/renderer/dom_ops/fn.rs +++ b/core/src/renderer/dom_ops/fn.rs @@ -1,9 +1,5 @@ use super::*; -/// SAFETY: `DomOpTableCell` is only mutated through `UnsafeCell` -/// interior-mutability on the WASM single-threaded runtime. -unsafe impl Sync for DomOpTableCell {} - /// Resolves (or installs on first call) the batched DOM-op helpers /// under `globalThis.__euv_dom_ops__`. /// @@ -27,57 +23,68 @@ unsafe impl Sync for DomOpTableCell {} /// - `Option` - The function table, or `None` if it could /// not be resolved. pub(crate) fn ensure_dom_op_table() -> Option { - DOM_OP_TABLE_CELL.with(|cell: &DomOpTableCell| { - let cached_ptr: *mut Option = cell.0.get(); - unsafe { - if let Some(table) = &*cached_ptr { - return Some(table.clone()); - } - } - let global_value: JsValue = global_this()?; - let table_value: JsValue = - match Reflect::get(&global_value, &JsValue::from_str(JS_DOM_OP_TABLE)) { - Ok(existing) => existing, - Err(_err) => JsValue::UNDEFINED, - }; - let table: DomOpTable = if table_value.is_object() { - let set_attrs: Function = - match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_SET_ATTRS)) { - Ok(value) => match value.dyn_into::() { - Ok(function) => function, - Err(_) => return None, - }, - Err(_err) => return None, - }; - let remove_attrs: Function = - match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_REMOVE_ATTRS)) { - Ok(value) => match value.dyn_into::() { - Ok(function) => function, - Err(_) => return None, - }, - Err(_err) => return None, - }; - let child_ops: Function = - match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_CHILD_OPS)) { - Ok(value) => match value.dyn_into::() { - Ok(function) => function, - Err(_) => return None, - }, - Err(_err) => return None, - }; - DomOpTable { - set_attrs, - remove_attrs, - child_ops, - } - } else { - install_dom_op_table(&global_value)? + // Fast path: a cached table, cloned out so the `RefCell` borrow is + // released before any JS work happens below. + if let Some(cached) = DOM_OP_TABLE + .try_with(|cell: &RefCell>| { + cell.try_borrow() + .ok() + .and_then(|guard: Ref>| guard.clone()) + }) + .ok() + .flatten() + { + return Some(cached); + } + let global_value: JsValue = global_this()?; + let table_value: JsValue = + match Reflect::get(&global_value, &JsValue::from_str(JS_DOM_OP_TABLE)) { + Ok(existing) => existing, + Err(_err) => JsValue::UNDEFINED, }; - unsafe { - *cached_ptr = Some(table.clone()); + let table: DomOpTable = if table_value.is_object() { + let set_attrs: Function = + match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_SET_ATTRS)) { + Ok(value) => match value.dyn_into::() { + Ok(function) => function, + Err(_) => return None, + }, + Err(_err) => return None, + }; + let remove_attrs: Function = + match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_REMOVE_ATTRS)) { + Ok(value) => match value.dyn_into::() { + Ok(function) => function, + Err(_) => return None, + }, + Err(_err) => return None, + }; + let child_ops: Function = + match Reflect::get(&table_value, &JsValue::from_str(JS_DOM_OP_CHILD_OPS)) { + Ok(value) => match value.dyn_into::() { + Ok(function) => function, + Err(_) => return None, + }, + Err(_err) => return None, + }; + DomOpTable { + set_attrs, + remove_attrs, + child_ops, } - Some(table) - }) + } else { + install_dom_op_table(&global_value)? + }; + // Cache the resolved table. A refused borrow only costs one extra + // `Reflect::get` on the next patch, so the failure is ignored rather + // than propagated. + let _: Result<(), std::thread::AccessError> = + DOM_OP_TABLE.try_with(|cell: &RefCell>| { + if let Ok(mut guard) = cell.try_borrow_mut() { + *guard = Some(table.clone()); + } + }); + Some(table) } /// Installs the batched DOM-op helpers onto `globalThis.__euv_dom_ops__`. diff --git a/core/src/renderer/dom_ops/struct.rs b/core/src/renderer/dom_ops/struct.rs index 20f06a2d..8834eb45 100644 --- a/core/src/renderer/dom_ops/struct.rs +++ b/core/src/renderer/dom_ops/struct.rs @@ -16,15 +16,23 @@ pub(crate) struct DomOpTable { pub(crate) child_ops: Function, } -/// `Sync` wrapper around `Option` for `thread_local!` -/// storage. -pub(crate) struct DomOpTableCell(pub(crate) UnsafeCell>); - thread_local! { - /// Per-thread cache for the JS batched DOM-op table. The first - /// patch triggers the `Reflect::get(globalThis, "__euv_dom_ops__")` - /// lookup (or installs the helpers if missing); subsequent patches - /// reuse the cached functions without any further global lookup. - pub static DOM_OP_TABLE_CELL: DomOpTableCell = - const { DomOpTableCell(const { UnsafeCell::new(None) }) }; + /// Per-thread cache for the JS batched DOM-op table. + /// + /// The cache holds three wasm-bindgen `Function` handles. Those are JS + /// object references, not raw pointers, and they are `!Send` — which is + /// exactly the case a `thread_local!` is for. The previous + /// `DomOpTableCell(UnsafeCell>)` + + /// `unsafe impl Sync for DomOpTableCell {}` pair existed only to smuggle + /// a `Function` through a `static`; with `thread_local!` the `RefCell` + /// provides the same lazy-init-once behaviour while keeping the borrow + /// check the `unsafe` had bypassed. A second thread installing its own + /// table is harmless: each thread caches the same + /// `globalThis.__euv_dom_ops__` object. + /// + /// The table is resolved lazily on the first patch via + /// `Reflect::get(globalThis, "__euv_dom_ops__")` (or installed if + /// missing); subsequent patches reuse the cached functions without any + /// further global lookup. + pub static DOM_OP_TABLE: RefCell> = const { RefCell::new(None) }; } diff --git a/core/src/renderer/registry/const.rs b/core/src/renderer/registry/const.rs index 3c6b7bfb..b83226ba 100644 --- a/core/src/renderer/registry/const.rs +++ b/core/src/renderer/registry/const.rs @@ -103,3 +103,25 @@ pub(crate) const HIGH_FREQUENCY_EVENTS: [&str; 5] = [ /// /// See the doc on `HIGH_FREQUENCY_EVENTS` for the rationale. pub(crate) const MAX_ANCESTOR_DEPTH_FOR_HIGH_FREQ: usize = 4; + +/// The global property name under which the event id-chain walker is +/// installed by euv's one-time startup injection. +pub(crate) const EVENT_ID_CHAIN_GLOBAL_NAME: &str = "__euvEventIdChain"; + +thread_local! { + /// Cached handle to the injected global walker, resolved on first use. + /// + /// The outer `Option` records "we already looked"; the inner `None` + /// records "looked, and the global is absent". That way a host which + /// strips the helper costs one lookup per page instead of one per + /// event, and the fallback path can be taken without a second probe. + pub static EVENT_ID_CHAIN_FN: std::cell::RefCell< + Option>, + > = const { std::cell::RefCell::new(None) }; +} + +/// The JS global object name resolved when no direct handle is available. +pub(crate) const GLOBAL_THIS_NAME: &str = "globalThis"; + +/// The DOM `Event` property holding the node the event was dispatched on. +pub(crate) const EVENT_TARGET_PROP: &str = "target"; diff --git a/core/src/renderer/registry/fn.rs b/core/src/renderer/registry/fn.rs index 317b5dfe..3bc25e12 100644 --- a/core/src/renderer/registry/fn.rs +++ b/core/src/renderer/registry/fn.rs @@ -1,72 +1,204 @@ use super::*; -/// Collects the `data-euv-id` chain of an event's ancestor path entirely in -/// JS, returning the ids in walk order (target first, `` last). -/// -/// This replaces the previous Rust-side loop in `dispatch_delegated_event` -/// that walked the ancestor chain one layer per round-trip (`get_attribute` -/// + `parent_element` = 2 JS crossings per layer; a depth-10 click cost 20 -/// crossings), and the later callback-based variant that still paid one -/// JS→WASM callback invocation per marked ancestor PLUS a full -/// `HandlerRegistryMap` clone and one `Closure` allocation per event. -/// Collecting the id chain in JS collapses the walk to a single -/// `#[wasm_bindgen]` call per event and lets Rust look up at most one -/// handler against the live registry — no per-event registry snapshot. -/// -/// `max_depth` caps the ancestor walk; passing `0` (per the call-site -/// convention in `dispatch_delegated_event`) means "walk until ``". -/// -/// The result is a `Float64Array` (ids are `< 2^53`, so the f64 channel is -/// exact) so the caller drains the whole chain with **one** `copy_to` -/// crossing instead of one `Array.get` per marked ancestor. +/// The JS source injected once at startup to define the global walker. +/// +/// The function walks `event.composedPath()` and collects every +/// `data-euv-id` value on the chain in one pass, so the per-event cost is +/// a single WASM↔JS crossing instead of two per ancestor layer +/// (`get_attribute` + `parent_node`). Ids are returned as a plain `Array` +/// of numbers: they are `usize` values well below 2^53, so they survive +/// the trip through `Float64Array` exactly, matching what the Rust caller +/// already expects. +/// +/// Injecting a global at startup rather than using +/// `#[wasm_bindgen(inline_js)]` keeps the deployed artefact count stable: +/// every `inline_js` item would emit its own `pkg/snippets/.../inlineN.js` +/// file, so the file count would grow with the number of features. This +/// function is installed once, from Rust, at mount time and is then called +/// by name like any other global. +const EVENT_ID_CHAIN_JS: &str = r#"(globalThis.__euvEventIdChain = function (event, maxDepth) { + const path = (typeof event.composedPath === 'function') + ? event.composedPath() + : (function () { const out = []; let n = event.target; while (n) { out.push(n); n = n.parentNode; } return out; })(); + const ids = []; + for (let i = 0; i < path.length; i++) { + if (maxDepth !== 0 && i >= maxDepth) { break; } + const el = path[i]; + if (el.nodeType !== 1) { continue; } + const id = el.getAttribute('data-euv-id'); + if (id !== null && id !== '') { ids.push(parseFloat(id)); } + } + return ids; +}); +"#; + +/// Resolves the JS `globalThis` handle for the injected-walker lookup. +/// +/// Mirrors the resolver in `renderer::dom_ops` so the registry module does +/// not have to widen that helper's visibility. Falls back to `window` for +/// hosts without `globalThis`. +/// +/// # Returns +/// +/// - `Option` - The global object, or `None` when neither +/// `globalThis` nor `window` is reachable. +fn global_this() -> Option { + if let Ok(value) = js_sys::eval(GLOBAL_THIS_NAME) + && !value.is_undefined() + { + return Some(value); + } + let window_value: web_sys::Window = window()?; + Some(window_value.into()) +} + +/// Injects the global id-chain walker exactly once per page. +/// +/// Safe to call repeatedly: the second and later calls are a single +/// global-property probe with no side effects. A page that navigates +/// within the SPA keeps the same global, so re-entry is a no-op. +/// +/// # Returns +/// +/// - `bool` - `true` when the global is present and callable after the call. +pub(crate) fn ensure_event_id_chain_global() -> bool { + let Some(global_value) = global_this() else { + return false; + }; + let already_present: bool = js_sys::Reflect::get( + &global_value, + &JsValue::from_str(EVENT_ID_CHAIN_GLOBAL_NAME), + ) + .map(|value: JsValue| value.is_function()) + .unwrap_or(false); + if already_present { + return true; + } + js_sys::eval(EVENT_ID_CHAIN_JS).is_ok() +} + +/// Returns the cached injected walker, resolving the global on first use. +/// +/// Falls back to `None` when the global is missing (for example a host that +/// strips it, or a caller that dispatches events before mount), letting the +/// caller use the Rust-side walk instead of panicking. +/// +/// # Returns +/// +/// - `Option` - The injected walker, or `None` when unavailable. +fn event_id_chain_fn() -> Option { + EVENT_ID_CHAIN_FN.with(|cell: &RefCell>>| { + if let Some(resolved) = cell.borrow().clone() { + return resolved; + } + let resolved: Option = global_this() + .and_then(|global_value: JsValue| { + js_sys::Reflect::get( + &global_value, + &JsValue::from_str(EVENT_ID_CHAIN_GLOBAL_NAME), + ) + .ok() + }) + .filter(|value: &JsValue| value.is_function()) + .and_then(|value: JsValue| value.dyn_into::().ok()); + let mut slot: std::cell::RefMut<'_, Option>> = cell.borrow_mut(); + *slot = Some(resolved.clone()); + resolved + }) +} + +/// Collects the `data-euv-id` chain of an event's propagation path, +/// returning the ids in walk order (target first, `` last). +/// +/// This is the OPT 40 hot path for every delegated DOM event. The walk +/// itself runs in the injected global, so the per-event cost is one +/// WASM↔JS crossing for the whole chain rather than two crossings per +/// ancestor layer. It replaces a Rust loop that issued `get_attribute` + +/// `parent_node` per layer — a click in the 402-node example page measured +/// **71 `getAttribute` calls** before the change. +/// +/// `max_depth` caps the walk; `0` means "walk the whole path". On failure +/// (global missing, non-array result, or a JS throw) the function falls +/// back to the pure-Rust ancestor walk so behaviour never regresses to +/// "events silently stop working". /// /// # Arguments /// -/// - `event: &JsValue` - The DOM event whose target chain should be walked. -/// - `max_depth: usize` - Upper bound on hops; `0` means unbounded. +/// - `&JsValue` - The DOM event whose propagation path is walked. +/// - `usize` - Upper bound on hops; `0` means unbounded. /// /// # Returns /// /// - `Float64Array` - The parsed `data-euv-id` values in walk order. pub(crate) fn euv_event_collect_id_chain(event: &JsValue, max_depth: usize) -> Float64Array { - // Walk the ancestor chain from `event.target` up via `parent_element`, - // collecting every `data-euv-id` attribute. Implemented in pure Rust - // via web-sys so wasm-bindgen does not emit this as a separate JS - // snippet (each `#[wasm_bindgen(inline_js)]` decorates a fresh - // `pkg/snippets/.../inlineN.js` file under the deployed site). - // - // Cost: one web-sys accessor (`get_attribute` or `parent_element`) - // per layer, no per-event JS crossing. The previous inline_js - // variant paid one wasm↔JS crossing for the bulk walk plus one - // crossing per ancestor callback; this Rust loop pays `2 * depth` - // crossings with no callback indirection. Net per-event cost is - // typically lower for the common case (1–3 marked ancestors) and - // keeps the deployment artefact count stable as features grow. + if let Some(walker) = event_id_chain_fn() + && let Some(global_value) = global_this() + && let Ok(result) = walker.call2(&global_value, event, &JsValue::from_f64(max_depth as f64)) + && let Some(array) = result.dyn_ref::() + { + let length: u32 = array.length(); + if length > 0 { + let out: Float64Array = Float64Array::new_with_length(length); + // One bulk copy for the whole chain: ids are `< 2^53`, exact in + // f64, so a single `copy_from` replaces one `Array.get` per id. + let mut ids: Vec = Vec::with_capacity(length as usize); + for index in 0..length { + match array.get(index).as_f64() { + Some(value) => ids.push(value), + // A non-numeric entry means the injected helper and this + // module disagree; drop the whole fast path rather than + // feed a NaN into the handler registry. + None => return collect_id_chain_rust(event, max_depth), + } + } + out.copy_from(&ids); + return out; + } + return Float64Array::new_with_length(0); + } + collect_id_chain_rust(event, max_depth) +} + +/// Pure-Rust ancestor walk, used when the injected global is unavailable. +/// +/// Costs two JS crossings per ancestor layer, so it exists only as a +/// correctness fallback for hosts where the startup injection did not run +/// (a stripped global, or events dispatched before mount). +/// +/// # Arguments +/// +/// - `&JsValue` - The DOM event whose target chain is walked. +/// - `usize` - Upper bound on hops; `0` means unbounded. +/// +/// # Returns +/// +/// - `Float64Array` - The parsed `data-euv-id` values in walk order. +fn collect_id_chain_rust(event: &JsValue, max_depth: usize) -> Float64Array { let event_target: Option = - js_sys::Reflect::get(event, &JsValue::from_str("target")) + js_sys::Reflect::get(event, &JsValue::from_str(EVENT_TARGET_PROP)) .ok() - .and_then(|v: JsValue| v.dyn_into::().ok()); + .and_then(|value: JsValue| value.dyn_into::().ok()); let Some(target) = event_target else { return Float64Array::new_with_length(0); }; let mut ids: Vec = Vec::new(); let mut node: Option = Some(target.unchecked_into::()); let mut depth: usize = 0; - while let Some(n) = node { + while let Some(current) = node { if max_depth != 0 && depth >= max_depth { break; } - let element: Option<&web_sys::Element> = n.dyn_ref::(); + let element: Option<&web_sys::Element> = current.dyn_ref::(); if let Some(el) = element - && let Some(id_str) = el.get_attribute("data-euv-id") + && let Some(id_str) = el.get_attribute(DATA_EUV_ID) && let Ok(parsed) = id_str.parse::() { ids.push(parsed); } depth += 1; - node = n.parent_node(); + node = current.parent_node(); } - let arr: Float64Array = Float64Array::new_with_length(ids.len() as u32); - arr.copy_from(&ids); - arr + let out: Float64Array = Float64Array::new_with_length(ids.len() as u32); + out.copy_from(&ids); + out } diff --git a/core/src/renderer/registry/impl.rs b/core/src/renderer/registry/impl.rs index 55521d51..a62cd7d1 100644 --- a/core/src/renderer/registry/impl.rs +++ b/core/src/renderer/registry/impl.rs @@ -1,26 +1,5 @@ use super::*; -/// SAFETY: `HandlerRegistryCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for HandlerRegistryCell {} - -/// SAFETY: `DelegatedEventsCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for DelegatedEventsCell {} - -/// SAFETY: `SignalUpdateRegistryCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for SignalUpdateRegistryCell {} - -/// SAFETY: `DirtyUpdateIdsCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for DirtyUpdateIdsCell {} - -/// SAFETY: `WindowEventRegistryCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for WindowEventRegistryCell {} - -/// SAFETY: `NodeRefRegistryCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for NodeRefRegistryCell {} - -/// SAFETY: `BindingCleanupsCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for BindingCleanupsCell {} - /// Implementation of `From` trait for converting `usize` address into `&'static mut HandlerSlot`. impl From for &'static mut HandlerSlot { /// Converts a memory address into a mutable reference to `HandlerSlot`. @@ -45,134 +24,305 @@ impl From for &'static mut HandlerSlot { /// Static methods for managing framework registries. /// /// Provides centralized access to event delegation, signal updates, window events, -/// and DOM event handler registries. All methods are thread-safe for single-threaded -/// WASM contexts. +/// and DOM event handler registries. Every registry is thread-local, so each +/// thread owns an independent set of tables and no method can observe another +/// thread's entries. impl Registry { - /// Returns a shared reference to the delegated events set. + /// Runs `operation` with a mutable borrow of this thread's `registry`. + /// + /// This is the single door through which every registry write passes. It + /// replaces the old `static mut` + `LazyLock)>` + + /// `unsafe impl Sync for XCell` form, which handed out + /// `&'static mut T` to any number of callers at once: two threads could + /// hold simultaneous `&mut` references to the same `HashMap`, and a + /// racing `LazyLock` initialisation poisoned the global outright + /// ("Lazy instance has previously been poisoned"). + /// + /// The borrow is released before this returns, so no caller can hold + /// registry access across a call that re-enters it. `try_borrow_mut` + /// rather than `borrow_mut` means a re-entrant call degrades to + /// `fallback` instead of panicking mid-update and leaving the registry + /// half-mutated — in WASM a Rust panic has no unwind boundary, so the + /// panic would abort the whole instance. + /// + /// `try_with` (not `with`) so a call arriving after this thread's + /// locals have been destroyed returns `fallback` rather than + /// panicking. + /// + /// # Arguments + /// + /// - `&'static LocalKey>` - The thread-local registry cell. + /// - `F` - Closure receiving `&mut T`. + /// - `R` - Value returned when the cell is already mutably borrowed. /// /// # Returns /// - /// - `&'static HashSet<&'static str>` - A shared reference to the global set of delegated event names. - pub(crate) fn get_delegated_events() -> &'static HashSet<&'static str> { - unsafe { - &*(*std::ptr::addr_of!(DELEGATED_EVENTS)) - .deref() - .get_0() - .get() + /// - `R` - The operation's result, or `fallback` if the borrow was refused. + fn with_registry(key: &'static LocalKey>, operation: F, fallback: R) -> R + where + F: FnOnce(&mut T) -> R, + { + // The fallback is parked in a `Cell` rather than moved into the + // closure: both failure paths (cell already mutably borrowed, thread + // local destroyed) need it, and `R` is not required to be `Copy` or + // `Clone`. `Cell::set` takes `&self`, so the closure can still write + // the operation's result back out through a shared borrow. + let result: Cell> = Cell::new(Some(fallback)); + key.try_with(|cell: &RefCell| { + if let Ok(mut guard) = cell.try_borrow_mut() { + result.set(Some(operation(&mut guard))); + } + }) + .ok(); + match result.take() { + Some(value) => value, + None => unreachable!("with_registry always leaves a result in the cell"), } } - /// Returns a mutable reference to the delegated events set. + /// Runs `read` with a shared borrow of this thread's `registry`. + /// + /// Read counterpart to [`Registry::with_registry`]. Same + /// `try_borrow` / `try_with` degradation: a refused read yields + /// `fallback` (usually `false` / `None`) rather than panicking, because + /// a dropped read costs one frame while a panic costs the whole + /// instance. + /// + /// # Arguments + /// + /// - `&'static LocalKey>` - The thread-local registry cell. + /// - `F` - Closure receiving `&T`. + /// - `R` - Value returned when the cell is already borrowed. /// /// # Returns /// - /// - `&'static mut HashSet<&'static str>` - A mutable reference to the global set of delegated event names. - pub(crate) fn get_mut_delegated_events() -> &'static mut HashSet<&'static str> { - unsafe { - &mut *(*std::ptr::addr_of_mut!(DELEGATED_EVENTS)) - .deref() - .get_0() - .get() + /// - `R` - The read's result, or `fallback` if the borrow was refused. + fn read_registry(key: &'static LocalKey>, read: F, fallback: R) -> R + where + F: FnOnce(&T) -> R, + { + let result: Cell> = Cell::new(Some(fallback)); + key.try_with(|cell: &RefCell| { + if let Ok(guard) = cell.try_borrow() { + result.set(Some(read(&guard))); + } + }) + .ok(); + match result.take() { + Some(value) => value, + None => unreachable!("read_registry always leaves a result in the cell"), } } - /// Returns a mutable reference to the signal update registry. + /// Returns the handler registered for `(euv_id, event_name)`, if any. + /// + /// Clones the `NativeEventHandler` and drops the registry borrow before + /// returning, so the caller may safely invoke the handler: handlers + /// routinely re-render, which re-enters the registry to register more + /// slots. The previous implementation cloned the entire + /// `HandlerRegistryMap` per event; this clones at most one `Rc`. + /// + /// # Arguments + /// + /// - `usize` - The element's `data-euv-id` value. + /// - `&'static str` - The event name. /// /// # Returns /// - /// - `&'static mut HashMap` - A mutable reference to the global signal update registry. - pub(crate) fn get_mut_update_registry() -> &'static mut HashMap { - unsafe { - &mut *(*std::ptr::addr_of_mut!(SIGNAL_UPDATE_REGISTRY)) - .deref() - .get_0() - .get() - } + /// - `Option` - The handler, if one is registered. + pub(crate) fn get_handler( + euv_id: usize, + event_name: &'static str, + ) -> Option { + Self::read_registry( + &HANDLER_REGISTRY, + |registry: &HandlerRegistryMap| { + let entry: HandlerEntry = *registry.get(&euv_id)?.get(&event_name)?; + // SAFETY: an entry is only freed after `take_handler` / + // `take_element_handlers` has pulled it out of the + // registry, so a live entry always points at an allocated + // slot. + let slot: &HandlerSlot = unsafe { &*entry }; + slot.try_get_handler().as_ref().cloned() + }, + None, + ) } - /// Returns a mutable reference to the dirty-id set used by the OPT 6 - /// dispatcher fast path. + /// Returns whether a handler slot already exists for `(euv_id, event_name)`. + /// + /// The read half of the check-then-insert pair in + /// [`Registry::set_handler`] / [`Registry::insert_handler`]; the DOM + /// work between the two calls cannot be done under a registry borrow. + /// + /// # Arguments + /// + /// - `usize` - The element's `data-euv-id` value. + /// - `&'static str` - The event name. /// /// # Returns /// - /// - `&'static mut HashSet` - A mutable reference to the global dirty-id set. - pub(crate) fn get_mut_dirty_update_ids() -> &'static mut HashSet { - unsafe { - &mut *(*std::ptr::addr_of_mut!(DIRTY_UPDATE_IDS)) - .deref() - .get_0() - .get() - } + /// - `bool` - `true` when a slot is already registered. + pub(crate) fn has_handler(euv_id: usize, event_name: &'static str) -> bool { + Self::read_registry( + &HANDLER_REGISTRY, + |registry: &HandlerRegistryMap| { + registry.get(&euv_id).is_some_and( + |event_map: &HashMap<&'static str, HandlerEntry>| { + event_map.contains_key(&event_name) + }, + ) + }, + false, + ) } - /// Returns a shared reference to the window event registry. + /// Installs (or replaces) the handler on an existing handler slot. /// - /// # Returns + /// # Arguments /// - /// - `&'static WindowEventRegistryMap` - A shared reference to the global window event registry. - pub(crate) fn get_window_registry() -> &'static WindowEventRegistryMap { - unsafe { - &*(*std::ptr::addr_of!(WINDOW_EVENT_REGISTRY)) - .deref() - .get_0() - .get() - } + /// - `usize` - The element's `data-euv-id` value. + /// - `&'static str` - The event name. + /// - `&NativeEventHandler` - The handler to install. + pub(crate) fn set_handler( + euv_id: usize, + event_name: &'static str, + handler: &NativeEventHandler, + ) { + Self::with_registry( + &HANDLER_REGISTRY, + |registry: &mut HandlerRegistryMap| { + let entry: HandlerEntry = match registry.get_mut(&euv_id).and_then( + |event_map: &mut HashMap<&'static str, HandlerEntry>| { + event_map.get_mut(&event_name) + }, + ) { + Some(value) => *value, + None => return, + }; + // SAFETY: see `get_handler` — a live entry always points at + // an allocated slot. + let slot: &mut HandlerSlot = unsafe { &mut *entry }; + slot.set_handler(Some(handler.clone())); + }, + (), + ); } - /// Returns a mutable reference to the window event registry. + /// Stores a freshly built handler slot, returning any slot it replaced. + /// + /// The caller must have already established via + /// [`Registry::has_handler`] that no slot exists, so this is a pure + /// insert. The `Box` is allocated before the registry is touched, + /// keeping the borrow free of re-entrant work; on a refused borrow the + /// raw pointer is handed back to the caller rather than leaked. + /// + /// # Arguments + /// + /// - `usize` - The element's `data-euv-id` value. + /// - `&'static str` - The event name. + /// - `HandlerSlot` - The slot to store. /// /// # Returns /// - /// - `&'static mut WindowEventRegistryMap` - A mutable reference to the global window event registry. - pub(crate) fn get_mut_window_registry() -> &'static mut WindowEventRegistryMap { - unsafe { - &mut *(*std::ptr::addr_of_mut!(WINDOW_EVENT_REGISTRY)) - .deref() - .get_0() - .get() - } + /// - `Option` - The replaced slot, if any. + pub(crate) fn insert_handler( + euv_id: usize, + event_name: &'static str, + slot: HandlerSlot, + ) -> Option { + let pending: RefCell> = + RefCell::new(Some(Box::into_raw(Box::new(slot)))); + let replaced: Option = Self::with_registry( + &HANDLER_REGISTRY, + |registry: &mut HandlerRegistryMap| { + let entry: HandlerEntry = pending.borrow_mut().take()?; + registry + .entry(euv_id) + .or_default() + .insert(event_name, entry) + }, + None, + ); + replaced.or_else(|| pending.into_inner()) } - /// Returns a mutable reference to the `NodeRef` unmount-clear registry. + /// Removes the handler slot registered for `(euv_id, event_name)`. + /// + /// The `&mut HandlerSlot` the raw pointer denotes is returned by + /// value, so the caller can run the DOM teardown + /// (`removeEventListener`) and free the `Box` with the registry + /// borrow already released. + /// + /// # Arguments + /// + /// - `usize` - The element's `data-euv-id` value. + /// - `&'static str` - The event name. /// /// # Returns /// - /// - `&'static mut NodeRefRegistryMap` - A mutable reference to the - /// global `NodeRef` registry. - pub(crate) fn get_mut_noderef_registry() -> &'static mut NodeRefRegistryMap { - unsafe { - &mut *(*std::ptr::addr_of_mut!(NODEREF_REGISTRY)) - .deref() - .get_0() - .get() - } + /// - `Option` - The removed slot, if one existed. + pub(crate) fn take_handler(euv_id: usize, event_name: &'static str) -> Option { + Self::with_registry( + &HANDLER_REGISTRY, + |registry: &mut HandlerRegistryMap| { + registry.get_mut(&euv_id).and_then( + |event_map: &mut HashMap<&'static str, HandlerEntry>| { + event_map.remove(&event_name) + }, + ) + }, + None, + ) } - /// Returns a shared reference to the handler registry. + /// Removes and returns every handler slot registered for `euv_id`. + /// + /// # Arguments + /// + /// - `usize` - The element's `data-euv-id` value. /// /// # Returns /// - /// - `&'static HandlerRegistryMap` - A shared reference to the global handler registry. - pub(crate) fn get_handler_registry() -> &'static HandlerRegistryMap { - unsafe { - &*(*std::ptr::addr_of!(HANDLER_REGISTRY)) - .deref() - .get_0() - .get() - } + /// - `Vec<(&'static str, HandlerEntry)>` - The removed `(name, slot)` pairs. + pub(crate) fn take_element_handlers(euv_id: usize) -> Vec<(&'static str, HandlerEntry)> { + Self::with_registry( + &HANDLER_REGISTRY, + |registry: &mut HandlerRegistryMap| { + registry + .remove(&euv_id) + .unwrap_or_default() + .into_iter() + .collect() + }, + Vec::new(), + ) } - /// Returns a mutable reference to the handler registry. + /// Detaches a non-bubbling listener's DOM wiring and frees its slot. /// - /// # Returns + /// Runs `removeEventListener` on the element / `Function` pair the slot + /// recorded at mount time, drops the handler, and reclaims the `Box`. + /// The registry borrow is already released by the time this is called, + /// which matters because `removeEventListener` crosses into JS and a JS + /// callback could re-enter the registry. + /// + /// # Arguments /// - /// - `&'static mut HandlerRegistryMap` - A mutable reference to the global handler registry. - pub(crate) fn get_mut_handler_registry() -> &'static mut HandlerRegistryMap { + /// - `&'static str` - The event name. + /// - `HandlerEntry` - The removed slot. + pub(crate) fn free_handler_slot(event_name: &'static str, entry: HandlerEntry) { + let slot: &mut HandlerSlot = unsafe { &mut *entry }; + if let Some(element) = slot.try_get_element().as_ref().cloned() + && let Some(listener_function) = slot.get_mut_listener_function().take() + { + let listener: &Function = listener_function.unchecked_ref::(); + let _: Result<(), JsValue> = + element.remove_event_listener_with_callback(event_name, listener); + } + slot.set_handler(None); unsafe { - &mut *(*std::ptr::addr_of_mut!(HANDLER_REGISTRY)) - .deref() - .get_0() - .get() + let _: Box = Box::from_raw(entry); } } @@ -180,15 +330,20 @@ impl Registry { /// find the nearest element with a `data-euv-id` attribute, then /// invoking the matching handler from the global registry. /// - /// The ancestor walk happens entirely in JS via `euv_event_walk_ancestors` - /// (a `#[wasm_bindgen(inline_js)]` glue function in this module), so the - /// per-event cost is **one** WASM↔JS crossing for the walk itself, - /// plus one callback invocation per marked ancestor. The previous - /// Rust-side loop walked one layer at a time (`get_attribute` + - /// `parent_element` = 2 JS crossings per layer); a depth-10 click used - /// to cost 20 crossings; it now costs 1 walk crossing + N callback - /// crossings where N = number of `data-euv-id` ancestors (typically - /// 1–3 for nested DOM). + /// The ancestor walk runs in a single injected global + /// (`__euvEventIdChain`, installed once by `Mount::setup`), which walks + /// `event.composedPath()` and returns every `data-euv-id` on the chain + /// in one call. Per-event cost is therefore **one** WASM↔JS crossing + /// for the whole chain, not two per ancestor layer. + /// + /// Before OPT 40 this was a Rust-side loop issuing `get_attribute` + + /// `parent_node` per layer: a click inside the 402-node example event + /// page measured **71 `getAttribute` calls**. Injecting a global at + /// startup rather than using `#[wasm_bindgen(inline_js)]` keeps the + /// deployed artefact count stable — every `inline_js` item would emit + /// its own `pkg/snippets/.../inlineN.js`, so the file count would grow + /// with the number of features. The Rust loop is retained as a + /// correctness fallback for hosts where the injection cannot run. /// /// `max_depth` caps the ancestor walk at this many `parent_element` /// hops. The walk counts `event.target()` itself as depth 0. Pass @@ -214,8 +369,8 @@ impl Registry { // crossing per marked ancestor (ids are `< 2^53`, exact in f64). let mut chain: Vec = vec![0.0; id_chain.length() as usize]; id_chain.copy_to(&mut chain); - for id_value in chain.iter() { - let euv_id: usize = *id_value as usize; + for id_value in chain { + let euv_id: usize = id_value as usize; // Scoped lookup: clone the handler out of the live registry // and drop the registry borrow BEFORE invoking. Handlers // routinely re-render and thereby mutate the registry, so the @@ -223,15 +378,7 @@ impl Registry { // may perform. This replaces the previous full // `HandlerRegistryMap` clone per event with at most one // `Rc` clone of the winning handler. - let handler_found: Option = Self::get_handler_registry() - .get(&euv_id) - .and_then(|event_map: &HashMap<&'static str, HandlerEntry>| { - event_map.get(&event_name) - }) - .and_then(|entry: &HandlerEntry| { - let slot: &HandlerSlot = unsafe { &**entry }; - slot.try_get_handler().as_ref().cloned() - }); + let handler_found: Option = Self::get_handler(euv_id, event_name); if let Some(active_handler) = handler_found { let event_for_handler: Event = event_value.clone().unchecked_into(); active_handler.handle(event_for_handler); @@ -300,6 +447,28 @@ impl Registry { Self::mark_delegated(event_name); } + /// Returns whether a dynamic node id currently has a live slot. + /// + /// # Arguments + /// + /// - `&HashMap` - The dynamic slot registry. + /// - `&usize` - The dynamic node's unique ID. + /// + /// # Returns + /// + /// - `bool` - `true` when the slot is registered and not marked removed. + fn is_live_dynamic(registry: &HashMap, dynamic_id: &usize) -> bool { + registry + .get(dynamic_id) + .is_some_and(|entry: &SignalUpdateEntry| { + // SAFETY: an entry is only freed after `cleanup_dynamic_node` / + // `take_dynamic` has pulled the id out of the registry, so a + // live entry always points at an allocated slot. + let slot: &SignalUpdateSlot = unsafe { &**entry }; + !slot.get_removed() + }) + } + /// Marks the specified dynamic node IDs as dirty, scheduling them for re-render. /// /// Called when a signal changes to notify all dependent dynamic nodes @@ -315,41 +484,65 @@ impl Registry { /// /// - `&[usize]` - The dynamic node IDs to mark as dirty. pub(crate) fn mark_dirty(dynamic_ids: &[usize]) { - let dirty_ids: &mut HashSet = Self::get_mut_dirty_update_ids(); - for dynamic_id in dynamic_ids { - dirty_ids.insert(*dynamic_id); - } - let registry: &mut HashMap = Self::get_mut_update_registry(); - for dynamic_id in dynamic_ids { - if let Some(entry) = registry.get(dynamic_id) { - let slot: &mut SignalUpdateSlot = unsafe { &mut **entry }; - if !slot.get_removed() { - slot.set_dirty(true); - } else { - dirty_ids.remove(dynamic_id); + // Pass 1: insert into the dirty set under its own borrow. + Self::with_registry( + &DIRTY_UPDATE_IDS, + |ids: &mut HashSet| { + for dynamic_id in dynamic_ids { + ids.insert(*dynamic_id); } - } else { - dirty_ids.remove(dynamic_id); - } - } + }, + (), + ); + // Pass 2: drop ids that have no live slot. The liveness test reads + // the update registry, so it runs as a separate borrow — the two + // sets are independent cells and this keeps both borrows short. + let live: HashSet = Self::read_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &HashMap| { + dynamic_ids + .iter() + .filter(|dynamic_id: &&usize| Self::is_live_dynamic(registry, dynamic_id)) + .copied() + .collect() + }, + HashSet::new(), + ); + Self::with_registry( + &DIRTY_UPDATE_IDS, + |ids: &mut HashSet| { + for dynamic_id in dynamic_ids { + if !live.contains(dynamic_id) { + ids.remove(dynamic_id); + } + } + }, + (), + ); } /// Returns whether the signal update registry contains any dirty slots. /// - /// OPT 6: now an O(1) check against `DIRTY_UPDATE_IDS` instead of an - /// O(N) scan of every dynamic node. + /// OPT 6: now an O(脏节点数) check against `DIRTY_UPDATE_IDS` instead of + /// an O(N) scan of every dynamic node. /// /// # Returns /// /// - `bool` - `true` if at least one dynamic node is marked dirty and not removed. pub(crate) fn has_dirty() -> bool { - Self::get_mut_dirty_update_ids().iter().any(|id: &usize| { - let registry: &HashMap = Self::get_mut_update_registry(); - registry.get(id).is_some_and(|entry: &SignalUpdateEntry| { - let slot: &SignalUpdateSlot = unsafe { &**entry }; - !slot.get_removed() - }) - }) + let dirty_ids: HashSet = Self::take_dirty_update_ids_peek(); + if dirty_ids.is_empty() { + return false; + } + Self::read_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &HashMap| { + dirty_ids + .iter() + .any(|id: &usize| Self::is_live_dynamic(registry, id)) + }, + false, + ) } /// Registers a signal update callback for a DynamicNode placeholder. @@ -364,41 +557,73 @@ impl Registry { pub(crate) fn register_dynamic(dynamic_id: usize, callback: Box) { let slot: Box = Box::new(SignalUpdateSlot::new(Some(callback), false, true)); - let entry: SignalUpdateEntry = Box::into_raw(slot); - if let Some(old_entry) = Self::get_mut_update_registry().insert(dynamic_id, entry) { + let pending: RefCell> = RefCell::new(Some(Box::into_raw(slot))); + let retired: Option = Self::with_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &mut HashMap| { + let entry: SignalUpdateEntry = pending.borrow_mut().take()?; + registry.insert(dynamic_id, entry) + }, + None, + ); + // A refused borrow leaves the slot unregistered but still owned by + // us; hand the pointer back so it is freed rather than leaked. + let orphan: Option = retired.or_else(|| pending.into_inner()); + if let Some(retired_entry) = orphan { unsafe { - let _: Box = Box::from_raw(old_entry); + let _: Box = Box::from_raw(retired_entry); } } } - /// Cleans up all handler entries associated with a DOM element. + /// Stores a dynamic node slot back after the caller released it. /// - /// Removes all event handlers registered for the given element ID, - /// detaching any direct event listeners from the DOM. + /// # Arguments + /// + /// - `usize` - The dynamic node's unique ID. + /// - `SignalUpdateEntry` - The slot to store. + pub(crate) fn put_dynamic(dynamic_id: usize, entry: SignalUpdateEntry) { + Self::with_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &mut HashMap| { + registry.insert(dynamic_id, entry); + }, + (), + ); + } + + /// Returns whether a dynamic node id is still registered. /// /// # Arguments /// - /// - `usize` - The element's unique `data-euv-id` value. - pub(crate) fn cleanup_element(euv_id: usize) { - let registry_ref: &mut HandlerRegistryMap = Self::get_mut_handler_registry(); - let Some(event_map) = registry_ref.remove(&euv_id) else { - return; - }; - for (event_name, entry) in event_map { - let slot: &mut HandlerSlot = unsafe { &mut *entry }; - if let Some(element) = slot.try_get_element().as_ref().cloned() - && let Some(listener_function) = slot.get_mut_listener_function().take() - { - let listener: &Function = listener_function.unchecked_ref::(); - let _: Result<(), JsValue> = - element.remove_event_listener_with_callback(event_name, listener); - } - slot.set_handler(None); - unsafe { - let _: Box = Box::from_raw(entry); - } - } + /// - `usize` - The dynamic node's unique ID. + /// + /// # Returns + /// + /// - `bool` - `true` while the id is present in the registry. + pub(crate) fn has_dynamic(dynamic_id: usize) -> bool { + Self::read_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &HashMap| registry.contains_key(&dynamic_id), + false, + ) + } + + /// Removes a dynamic node's slot from the registry and returns it. + /// + /// # Arguments + /// + /// - `usize` - The dynamic node's unique ID. + /// + /// # Returns + /// + /// - `Option` - The removed slot, if one existed. + pub(crate) fn take_dynamic(dynamic_id: usize) -> Option { + Self::with_registry( + &SIGNAL_UPDATE_REGISTRY, + |registry: &mut HashMap| registry.remove(&dynamic_id), + None, + ) } /// Marks the slot backing a DynamicNode as removed and frees its backing @@ -414,9 +639,9 @@ impl Registry { /// dispatch cycle's sweep, so that detached subtrees do not pin /// their callback allocations in the registry between unmount and /// the next scheduled update. This is safe because the registry is - /// only mutated from the main thread; if a dispatch is in progress - /// it is running in a separate microtask turn and cannot observe - /// a stale `Some(entry)` here. + /// only mutated from the thread that owns it; if a dispatch is in + /// progress it is running in a separate microtask turn and cannot + /// observe a stale entry here. /// /// OPT 6: also drops the id from `DIRTY_UPDATE_IDS` so the /// dispatcher does not re-discover a freed pointer on the next tick. @@ -425,14 +650,75 @@ impl Registry { /// /// - `usize` - The dynamic node's unique ID. pub(crate) fn cleanup_dynamic_node(dynamic_id: usize) { - Self::get_mut_dirty_update_ids().remove(&dynamic_id); - if let Some(entry) = Self::get_mut_update_registry().remove(&dynamic_id) { + Self::with_registry( + &DIRTY_UPDATE_IDS, + |ids: &mut HashSet| { + ids.remove(&dynamic_id); + }, + (), + ); + if let Some(entry) = Self::take_dynamic(dynamic_id) { unsafe { let _: Box = Box::from_raw(entry); } } } + /// Drains the dirty-id set, handing the whole batch to the caller. + /// + /// `HashSet::drain` requires the `RangeFull` pattern which Rust 2024 + /// reserves as the struct-update syntax shorthand, so the set is + /// `take`n instead: `with_registry` swaps a fresh empty set in and + /// returns the old one by value, releasing the borrow before the + /// dispatch loop mutates the update registry. + /// + /// # Returns + /// + /// - `HashSet` - The drained dynamic node ids. + pub(crate) fn take_dirty_update_ids() -> HashSet { + Self::with_registry( + &DIRTY_UPDATE_IDS, + |ids: &mut HashSet| take(ids), + HashSet::new(), + ) + } + + /// Returns a snapshot of the pending dirty ids without draining them. + /// + /// [`Registry::has_dirty`] is a pure predicate on the caller side, so + /// it must not consume the set. Cloning the set is cheaper than + /// cloning the whole update registry would be, and the dirty set only + /// holds ids that changed since the last tick. + /// + /// # Returns + /// + /// - `HashSet` - Snapshot of the pending dirty ids. + fn take_dirty_update_ids_peek() -> HashSet { + Self::read_registry( + &DIRTY_UPDATE_IDS, + |ids: &HashSet| ids.clone(), + HashSet::new(), + ) + } + + /// Cleans up all handler entries associated with a DOM element. + /// + /// Removes all event handlers registered for the given element ID, + /// detaching any direct event listeners from the DOM. + /// + /// # Arguments + /// + /// - `usize` - The element's unique `data-euv-id` value. + pub(crate) fn cleanup_element(euv_id: usize) { + // Take the entries out under the registry borrow, then run the DOM + // teardown with the borrow released: `removeEventListener` crosses + // into JS and a JS callback could re-enter the registry. + let entries: Vec<(&'static str, HandlerEntry)> = Self::take_element_handlers(euv_id); + for (event_name, entry) in entries { + Self::free_handler_slot(event_name, entry); + } + } + /// Appends a binding-teardown thunk for `euv_id`. /// /// Called by the signal attribute / `inner_html` mount paths so the @@ -445,9 +731,13 @@ impl Registry { /// - `usize` - The element's `data-euv-id` value. /// - `BindingCleanup` - The teardown thunk to store. pub(crate) fn push_binding_cleanup(euv_id: usize, cleanup: BindingCleanup) { - let map: &mut BindingCleanupsMap = - unsafe { &mut *(*std::ptr::addr_of_mut!(BINDING_CLEANUPS)).get_0().get() }; - map.entry(euv_id).or_default().push(cleanup); + Self::with_registry( + &BINDING_CLEANUPS, + |map: &mut BindingCleanupsMap| { + map.entry(euv_id).or_default().push(cleanup); + }, + (), + ); } /// Removes and returns every binding-teardown thunk for `euv_id`. @@ -463,9 +753,11 @@ impl Registry { /// /// - `Option>` - The drained thunks, if any. pub(crate) fn take_binding_cleanups(euv_id: usize) -> Option> { - let map: &mut BindingCleanupsMap = - unsafe { &mut *(*std::ptr::addr_of_mut!(BINDING_CLEANUPS)).get_0().get() }; - map.remove(&euv_id) + Self::with_registry( + &BINDING_CLEANUPS, + |map: &mut BindingCleanupsMap| map.remove(&euv_id), + None, + ) } /// Returns whether the given event name is a non-bubbling event. @@ -494,7 +786,11 @@ impl Registry { /// /// - `bool` - `true` if a window-level listener already exists for this event type. pub(crate) fn is_delegated(event_name: &str) -> bool { - Self::get_delegated_events().contains(event_name) + Self::read_registry( + &DELEGATED_EVENTS, + |events: &HashSet<&'static str>| events.contains(event_name), + false, + ) } /// Marks an event name as delegated in the global set. @@ -503,7 +799,13 @@ impl Registry { /// /// - `&'static str` - The event name to mark as delegated. pub(crate) fn mark_delegated(event_name: &'static str) { - Self::get_mut_delegated_events().insert(event_name); + Self::with_registry( + &DELEGATED_EVENTS, + |events: &mut HashSet<&'static str>| { + events.insert(event_name); + }, + (), + ); } /// Registers a callback for a window-level event using the proxy pattern. @@ -515,7 +817,7 @@ impl Registry { /// # Arguments /// /// - `&str` - The event name to listen for (e.g., "resize", "hashchange"). - /// - `F: FnMut() + 'static` - The callback to invoke when the event fires. + /// - `F` - The callback to invoke when the event fires. /// /// # Returns /// @@ -527,12 +829,30 @@ impl Registry { let handler_id: usize = NEXT_WINDOW_HANDLER_ID.fetch_add(1, Ordering::Relaxed); let boxed: Box> = Box::new(Box::new(callback)); let entry: WindowEventHandlerEntry = (handler_id, Box::into_raw(boxed)); - let registry: &mut WindowEventRegistryMap = Self::get_mut_window_registry(); - let is_new_event: bool = !registry.contains_key(event_name); - registry - .entry(event_name.to_string()) - .or_default() - .push(entry); + let pending: RefCell> = RefCell::new(Some(entry)); + let is_new_event: bool = Self::with_registry( + &WINDOW_EVENT_REGISTRY, + |registry: &mut WindowEventRegistryMap| { + let entry: WindowEventHandlerEntry = match pending.borrow_mut().take() { + Some(value) => value, + None => return false, + }; + let is_new: bool = !registry.contains_key(event_name); + registry + .entry(event_name.to_string()) + .or_default() + .push(entry); + is_new + }, + false, + ); + // A refused borrow drops the registration and orphans the callback + // box; hand the pointer back so it is freed rather than leaked. + if let Some(orphaned) = pending.into_inner() { + unsafe { + let _: Box> = Box::from_raw(orphaned.1); + } + } if is_new_event { Self::window_event_listener(event_name); } @@ -548,21 +868,133 @@ impl Registry { /// - `&str` - The event name the handler was registered for. /// - `usize` - The handler ID returned by `register_window_event`. pub(crate) fn unregister_window_event(event_name: &str, handler_id: usize) { - let registry: &mut WindowEventRegistryMap = Self::get_mut_window_registry(); - if let Some(handlers) = registry.get_mut(event_name) { - handlers.retain(|(id, ptr): &WindowEventHandlerEntry| { - if *id == handler_id { - unsafe { - let _: Box> = Box::from_raw(*ptr); - } - false - } else { - true + let removed: Option = Self::with_registry( + &WINDOW_EVENT_REGISTRY, + |registry: &mut WindowEventRegistryMap| { + registry.get_mut(event_name).and_then( + |handlers: &mut Vec| { + let index: Option = handlers + .iter() + .position(|(id, _ptr): &WindowEventHandlerEntry| *id == handler_id); + index.map(|found: usize| handlers.remove(found)) + }, + ) + }, + None, + ); + if let Some((_id, callback_ptr)) = removed { + unsafe { + let _: Box> = Box::from_raw(callback_ptr); + } + } + } + + /// Runs every callback registered for `event_name`, outside the registry + /// borrow. + /// + /// The handler list is taken out of the registry by value, so each + /// callback may freely call `register_window_event` / + /// `unregister_window_event` (both of which write the registry) without + /// aliasing the vector this loop walks. + /// + /// # Arguments + /// + /// - `&str` - The event name whose handlers should run. + fn fire_window_event(event_name: &str) { + // Take the handler list out, but leave the KEY in place. A callback + // is free to re-render, and the re-render path calls + // `register_window_event` for this same event name. `register` only + // installs a new native `window.addEventListener` when the key was + // absent, so removing the key here would make every re-entrant + // registration believe it was the first one and stack a duplicate + // native listener on `window` every navigation — after which the + // cleanup bookkeeping and the live handlers disagree and the route + // stops updating. + let entries: Vec = Self::with_registry( + &WINDOW_EVENT_REGISTRY, + |registry: &mut WindowEventRegistryMap| match registry.get_mut(event_name) { + Some(handlers) => take(handlers), + None => Vec::new(), + }, + Vec::new(), + ); + for entry in entries { + let callback_ptr: *mut Box = entry.1; + // SAFETY: the entry was removed from the registry above, so we + // are its sole owner; the box is reinserted or freed below. + // `callback_ptr` is a `*mut Box`, so one deref + // yields the `Box` and calling it works through `Box`'s + // `FnMut` impl. + let callback: &mut Box = unsafe { &mut *callback_ptr }; + callback(); + // OPT 15 continuation: the handler is merged back only if no + // re-entrant call claimed it while the callback ran. A + // callback that unregistered itself (directly or via a nested + // `unregister_window_event`) has already freed the box, so the + // check must happen before the merge — reinserting it would + // hand out a dangling pointer. + if Self::window_event_claimed(event_name, callback_ptr) { + unsafe { + let _: Box> = Box::from_raw(callback_ptr); } - }); + } else { + Self::restore_window_event(event_name, entry); + } } } + /// Returns whether a window event callback is still owned by the registry. + /// + /// After [`Registry::fire_window_event`] takes the handler list out, a + /// re-entrant `unregister_window_event` can no longer find the id (it + /// already left the registry), so the round-trip through the registry + /// is what tells the dispatch loop whether a handler survived. The + /// caller reinserts the entry when this returns `false`; when it + /// returns `true` the handler is gone and its box must be freed. + /// + /// # Arguments + /// + /// - `&str` - The event name. + /// - `*mut Box` - The callback box address. + /// + /// # Returns + /// + /// - `bool` - `true` when the registry no longer owns this callback. + fn window_event_claimed(event_name: &str, callback_ptr: *mut Box) -> bool { + Self::read_registry( + &WINDOW_EVENT_REGISTRY, + |registry: &WindowEventRegistryMap| { + registry + .get(event_name) + .is_some_and(|handlers: &Vec| { + handlers + .iter() + .any(|(_id, ptr): &WindowEventHandlerEntry| *ptr == callback_ptr) + }) + }, + true, + ) + } + + /// Puts a fired window event callback back on the handler list. + /// + /// # Arguments + /// + /// - `&str` - The event name. + /// - `WindowEventHandlerEntry` - The entry to reinsert. + pub(crate) fn restore_window_event(event_name: &str, entry: WindowEventHandlerEntry) { + Self::with_registry( + &WINDOW_EVENT_REGISTRY, + |registry: &mut WindowEventRegistryMap| { + registry + .entry(event_name.to_string()) + .or_default() + .push(entry); + }, + (), + ); + } + /// Ensures a single `window.addEventListener` listener is registered /// for the given event name that dispatches to all registered callbacks. /// @@ -572,16 +1004,19 @@ impl Registry { fn window_event_listener(event_name: &str) { let event_name_owned: String = event_name.to_string(); let closure: Closure = Closure::wrap(Box::new(move || { - // OPT 15: in-place iterate the registered handler list instead - // of `collect()`-ing the IDs into a Vec and then re-doing a - // HashMap lookup per ID. The event name is owned by the closure - // (a `&str` lookup key borrows it — no per-event `String` clone). - if let Some(handlers) = Self::get_window_registry().get(event_name_owned.as_str()) { - for (_handler_id, callback_ptr) in handlers.iter() { - let callback: &mut Box = unsafe { &mut **callback_ptr }; - callback(); - } - } + // OPT 15: `fire_window_event` takes the handler list out of + // the registry by value and iterates it in place, instead of + // `collect()`-ing the IDs into a Vec and then re-doing a + // HashMap lookup per ID. The event name is owned by the + // closure (a `&str` lookup key borrows it — no per-event + // `String` clone). + // + // The registry borrow MUST be released before any callback + // runs: a callback is free to call `unregister_window_event` + // or `register_window_event`, and holding a borrow across that + // would — in the old `&'static mut` form — mutate the very + // `Vec` this loop is walking. + Self::fire_window_event(&event_name_owned); })); let window: Window = match window() { Some(window_instance) => window_instance, @@ -608,8 +1043,13 @@ impl Registry { /// - `usize` - The element's unique `data-euv-id` value. /// - `NodeRefEntry` - A clone of the `NodeRef`'s interior `Rc>>`. pub(crate) fn register_noderef(euv_id: usize, entry: NodeRefEntry) { - let registry: &mut NodeRefRegistryMap = Self::get_mut_noderef_registry(); - registry.entry(euv_id).or_default().push(entry); + Self::with_registry( + &NODEREF_REGISTRY, + |registry: &mut NodeRefRegistryMap| { + registry.entry(euv_id).or_default().push(entry); + }, + (), + ); } /// Clears every `NodeRef` handle that was registered against `euv_id` @@ -625,10 +1065,11 @@ impl Registry { /// /// - `usize` - The element's unique `data-euv-id` value. pub(crate) fn cleanup_noderefs(euv_id: usize) { - let registry: &mut NodeRefRegistryMap = Self::get_mut_noderef_registry(); - let Some(entries) = registry.remove(&euv_id) else { - return; - }; + let entries: Vec = Self::with_registry( + &NODEREF_REGISTRY, + |registry: &mut NodeRefRegistryMap| registry.remove(&euv_id).unwrap_or_default(), + Vec::new(), + ); for entry in entries { // SAFETY: `NodeRef::clear` is the only mutating accessor on the // cell; mounting-time `NodeRef::set` and unmount-time `clear` diff --git a/core/src/renderer/registry/static.rs b/core/src/renderer/registry/static.rs index d7ff3e5d..5b4df9ba 100644 --- a/core/src/renderer/registry/static.rs +++ b/core/src/renderer/registry/static.rs @@ -9,63 +9,99 @@ pub static NEXT_EUV_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(0); /// Whether `dispatch_updates` is currently executing. pub static SIGNAL_UPDATE_DISPATCHING: AtomicBool = AtomicBool::new(false); -/// Set of dynamic node IDs marked dirty since the last dispatch drain. -/// -/// OPT 6: the dispatcher's hot path used to scan the entire signal -/// update registry (`HashMap`) on every -/// tick to find slots whose `dirty` flag was set. For a SPA with N -/// dynamic nodes and only a handful of changed signals per tick, this -/// was an `O(N)` scan with a `*const SignalUpdateSlot` pointer -/// dereference + branch per entry. The dirty-set replaces it with a -/// single `HashSet::drain()` over the IDs that were actually marked -/// dirty — a `O(脏节点数)` operation that does no per-slot pointer -/// traversal until the matching slot is found. -/// -/// Populated by `Registry::mark_dirty` and drained by -/// `Scheduler::dispatch_updates`. A redundant `dirty = true` set is a -/// no-op thanks to `HashSet` semantics. The set survives the dispatch -/// itself (the dirty flag is reset inside the loop); only the -/// `mark_dirty` path inserts into it. -pub(crate) static mut DIRTY_UPDATE_IDS: LazyLock = - LazyLock::new(|| DirtyUpdateIdsCell(UnsafeCell::new(HashSet::new()))); +/// Global auto-incrementing ID counter for window event handler entries. +pub static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); -/// Global handler registry, mapping (element_id, event_name) to HandlerEntry. -pub(crate) static mut HANDLER_REGISTRY: LazyLock = - LazyLock::new(|| HandlerRegistryCell(UnsafeCell::new(HashMap::new()))); +thread_local! { + /// Set of dynamic node IDs marked dirty since the last dispatch drain. + /// + /// OPT 6: the dispatcher's hot path used to scan the entire signal + /// update registry (`HashMap`) on every + /// tick to find slots whose `dirty` flag was set. For a SPA with N + /// dynamic nodes and only a handful of changed signals per tick, this + /// was an `O(N)` scan with a `*const SignalUpdateSlot` pointer + /// dereference + branch per entry. The dirty-set replaces it with a + /// single `HashSet::drain()` over the IDs that were actually marked + /// dirty — a `O(脏节点数)` operation that does no per-slot pointer + /// traversal until the matching slot is found. + /// + /// Populated by `Registry::mark_dirty` and drained by + /// `Scheduler::dispatch_updates`. A redundant `dirty = true` set is a + /// no-op thanks to `HashSet` semantics. The set survives the dispatch + /// itself (the dirty flag is reset inside the loop); only the + /// `mark_dirty` path inserts into it. + /// + /// Storage is thread-local rather than the previous `static mut` + + /// `LazyLock` + `UnsafeCell`. The old form handed out + /// `&'static mut HashSet` from a global that was never + /// actually `Sync` — the only reason it compiled was an + /// `unsafe impl Sync for DirtyUpdateIdsCell {}` whose SAFETY comment + /// only held for the single-threaded WASM runtime, not for the + /// multi-threaded host that `cargo test` links against. Two threads + /// racing to initialise the `LazyLock` poisoned it ("Lazy instance + /// has previously been poisoned"), and concurrent `insert` into one + /// `HashSet` corrupted its bucket array. Thread-local storage makes + /// each thread's dirty set structurally invisible to the others, and + /// `RefCell` restores the borrow check the `unsafe` had bypassed. + pub static DIRTY_UPDATE_IDS: RefCell> = RefCell::new(HashSet::new()); -/// Global set of event names that have already been delegated at the window level. -pub(crate) static mut DELEGATED_EVENTS: LazyLock = - LazyLock::new(|| DelegatedEventsCell(UnsafeCell::new(HashSet::new()))); + /// Global handler registry, mapping (element_id, event_name) to HandlerEntry. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` handed out + /// `&'static mut HandlerRegistryMap` behind an unsound `unsafe impl + /// Sync`, so a parallel test run could observe two live + /// `&mut` references to the same map. + pub static HANDLER_REGISTRY: RefCell = RefCell::new(HashMap::new()); -/// Global signal update callback registry, mapping keys to SignalUpdateEntry. -pub(crate) static mut SIGNAL_UPDATE_REGISTRY: LazyLock = - LazyLock::new(|| SignalUpdateRegistryCell(UnsafeCell::new(HashMap::new()))); + /// Global set of event names that have already been delegated at the window level. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` poisoned on a racing + /// initialisation and aliased a `HashSet` across threads. + pub static DELEGATED_EVENTS: RefCell> = RefCell::new(HashSet::new()); -/// Global auto-incrementing ID counter for window event handler entries. -pub static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); + /// Global signal update callback registry, mapping keys to SignalUpdateEntry. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` poisoned on a + /// racing initialisation and aliased a `HashMap` across threads. + pub static SIGNAL_UPDATE_REGISTRY: RefCell> = + RefCell::new(HashMap::new()); -/// Global window event proxy registry, mapping event names to handler lists. -pub(crate) static mut WINDOW_EVENT_REGISTRY: LazyLock = - LazyLock::new(|| WindowEventRegistryCell(UnsafeCell::new(HashMap::new()))); + /// Global window event proxy registry, mapping event names to handler lists. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` poisoned on a racing + /// initialisation and aliased a `HashMap` across threads. + pub static WINDOW_EVENT_REGISTRY: RefCell = + RefCell::new(HashMap::new()); -/// Global `NodeRef` registry used to clear `NodeRef` handles when the -/// DOM element they point to is unmounted. -/// -/// NP-3: each time a `ref:` attribute fires, the mount path registers -/// the `NodeRef`'s shared interior cell into this map under the -/// element's `euv_id`. `cleanup_subtree` then drains the entries for -/// that id and calls `NodeRef::clear` so `get()` / `get_cloned()` return -/// `None` after the underlying DOM subtree is gone. -pub(crate) static mut NODEREF_REGISTRY: LazyLock = - LazyLock::new(|| NodeRefRegistryCell(UnsafeCell::new(HashMap::new()))); + /// Global `NodeRef` registry used to clear `NodeRef` handles when the + /// DOM element they point to is unmounted. + /// + /// NP-3: each time a `ref:` attribute fires, the mount path registers + /// the `NodeRef`'s shared interior cell into this map under the + /// element's `euv_id`. `cleanup_subtree` then drains the entries for + /// that id and calls `NodeRef::clear` so `get()` / `get_cloned()` return + /// `None` after the underlying DOM subtree is gone. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` poisoned on a racing + /// initialisation and aliased a `HashMap` across threads. + pub static NODEREF_REGISTRY: RefCell = RefCell::new(HashMap::new()); -/// Global binding-cleanup registry, mapping `euv_id` to the teardown thunks -/// of the signal bindings installed on that element. -/// -/// Populated by `Registry::push_binding_cleanup` (called from the signal -/// attribute / `inner_html` mount paths in `Renderer::create_dom_with_doc` -/// and from the late-binding path in `patch_attributes`) and drained by -/// `cleanup_subtree`, which runs each thunk so the subscription is detached -/// via [`Signal::unsubscribe`]. -pub(crate) static mut BINDING_CLEANUPS: LazyLock = - LazyLock::new(|| BindingCleanupsCell(UnsafeCell::new(HashMap::new()))); + /// Global binding-cleanup registry, mapping `euv_id` to the teardown thunks + /// of the signal bindings installed on that element. + /// + /// Populated by `Registry::push_binding_cleanup` (called from the signal + /// attribute / `inner_html` mount paths in `Renderer::create_dom_with_doc` + /// and from the late-binding path in `patch_attributes`) and drained by + /// `cleanup_subtree`, which runs each thunk so the subscription is detached + /// via [`Signal::unsubscribe`]. + /// + /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old + /// `static mut LazyLock` poisoned on a racing + /// initialisation and aliased a `HashMap` across threads. + pub static BINDING_CLEANUPS: RefCell = RefCell::new(HashMap::new()); +} diff --git a/core/src/renderer/registry/struct.rs b/core/src/renderer/registry/struct.rs index 812546c0..cb215f6b 100644 --- a/core/src/renderer/registry/struct.rs +++ b/core/src/renderer/registry/struct.rs @@ -56,112 +56,6 @@ pub(crate) struct SignalUpdateSlot { pub(crate) dirty: bool, } -/// A `Sync` wrapper for single-threaded global `HashMap` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct HandlerRegistryCell( - /// Interior-mutable storage for the handler registry. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell, -); - -/// A `Sync` wrapper for single-threaded global `HashSet` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct DelegatedEventsCell( - /// Interior-mutable storage for the delegated events set. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell>, -); - -/// A `Sync` wrapper for single-threaded global `HashMap` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct SignalUpdateRegistryCell( - /// Interior-mutable storage for the signal update registry. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell>, -); - -/// A `Sync` wrapper for single-threaded global `HashMap` access used by -/// the OPT 6 dirty-id fast path. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct DirtyUpdateIdsCell( - /// Interior-mutable storage for the dirty-id set. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell>, -); - -/// A `Sync` wrapper for single-threaded global `WindowEventRegistryMap` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct WindowEventRegistryCell( - /// Interior-mutable storage for the window event handler registry. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell, -); - -/// A `Sync` wrapper for single-threaded global `NodeRefRegistryMap` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct NodeRefRegistryCell( - /// Interior-mutable storage for the `NodeRef` registry. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell, -); - -/// A `Sync` wrapper for single-threaded global `BindingCleanupsMap` access. -/// -/// SAFETY: This type is only safe to use in single-threaded contexts -/// (e.g., WASM). It implements `Sync` to allow usage as a `static mut` -/// variable, but concurrent access from multiple threads would be -/// undefined behavior. -#[derive(Data, Debug, New)] -pub(crate) struct BindingCleanupsCell( - /// Interior-mutable storage for the binding-cleanup registry. - #[get(pub(crate))] - #[get_mut(pub(crate))] - #[set(pub(crate))] - pub UnsafeCell, -); - /// A zero-sized struct providing static methods for managing /// the framework's internal registries (handlers, signal updates, /// window events, and delegated events). diff --git a/core/src/renderer/render/impl.rs b/core/src/renderer/render/impl.rs index d5d70a43..49727477 100644 --- a/core/src/renderer/render/impl.rs +++ b/core/src/renderer/render/impl.rs @@ -566,25 +566,11 @@ impl Renderer { if still_present { continue; } - if let Some(entry) = Registry::get_mut_handler_registry() - .get_mut(&euv_id) - .and_then(|event_map: &mut HashMap<&'static str, HandlerEntry>| { - event_map.remove(&handler.get_event_name()) - }) - { - let slot: &mut HandlerSlot = unsafe { &mut *entry }; - if let Some(listener_element) = slot.try_get_element().as_ref().cloned() - && let Some(listener_function) = slot.get_mut_listener_function().take() - { - let event_name: &str = handler.get_event_name(); - let listener: &Function = listener_function.unchecked_ref::(); - let _: Result<(), JsValue> = listener_element - .remove_event_listener_with_callback(event_name, listener); - } - slot.set_handler(None); - unsafe { - let _: Box = Box::from_raw(entry); - } + if let Some(entry) = Registry::take_handler(euv_id, handler.get_event_name()) { + // The registry borrow is released before the DOM + // teardown runs: `removeEventListener` crosses into JS + // and a JS callback could re-enter the registry. + Registry::free_handler_slot(handler.get_event_name(), entry); } } } @@ -1571,58 +1557,46 @@ impl Renderer { }; let event_name: &'static str = handler.get_event_name(); if Registry::is_non_bubbling(event_name) { - let registry_ref: &mut HandlerRegistryMap = Registry::get_mut_handler_registry(); - if let Some(existing_entry) = registry_ref.get(&euv_id).and_then( - |event_map: &HashMap<&'static str, HandlerEntry>| event_map.get(&event_name), - ) { - let slot: &mut HandlerSlot = unsafe { &mut **existing_entry }; - slot.set_handler(Some(handler.clone())); + if Registry::has_handler(euv_id, event_name) { + Registry::set_handler(euv_id, event_name, handler); } else { let closure: Closure = Closure::wrap(Box::new(move |event: Event| { - if let Some(entry) = Registry::get_handler_registry().get(&euv_id).and_then( - |event_map: &HashMap<&'static str, HandlerEntry>| { - event_map.get(&event_name) - }, - ) { - let slot: &HandlerSlot = unsafe { &**entry }; - if let Some(active_handler) = slot.try_get_handler().as_ref().cloned() { - active_handler.handle(event); - } + // The handler is cloned out of the registry and the + // borrow dropped before it runs: a handler + // re-renders, which re-enters the registry. + let active_handler: Option = + Registry::get_handler(euv_id, event_name); + if let Some(handler_to_fire) = active_handler { + handler_to_fire.handle(event); } })); let _: Result<(), JsValue> = element .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()); let listener_function: JsValue = closure.as_ref().clone(); closure.forget(); - let handler_slot: HandlerEntry = Box::into_raw(Box::new(HandlerSlot::new( + let handler_slot: HandlerSlot = HandlerSlot::new( Some(handler.clone()), Some(listener_function), Some(element.clone()), - ))); - registry_ref - .entry(euv_id) - .or_default() - .insert(event_name, handler_slot); + ); + if let Some(replaced) = Registry::insert_handler(euv_id, event_name, handler_slot) { + unsafe { + let _: Box = Box::from_raw(replaced); + } + } } } else { Registry::delegation(event_name); - let registry_ref: &mut HandlerRegistryMap = Registry::get_mut_handler_registry(); - if let Some(existing_entry) = registry_ref.get(&euv_id).and_then( - |event_map: &HashMap<&'static str, HandlerEntry>| event_map.get(&event_name), - ) { - let slot: &mut HandlerSlot = unsafe { &mut **existing_entry }; - slot.set_handler(Some(handler.clone())); + if Registry::has_handler(euv_id, event_name) { + Registry::set_handler(euv_id, event_name, handler); } else { - let handler_slot: HandlerEntry = Box::into_raw(Box::new(HandlerSlot::new( - Some(handler.clone()), - None, - None, - ))); - registry_ref - .entry(euv_id) - .or_default() - .insert(event_name, handler_slot); + let handler_slot: HandlerSlot = HandlerSlot::new(Some(handler.clone()), None, None); + if let Some(replaced) = Registry::insert_handler(euv_id, event_name, handler_slot) { + unsafe { + let _: Box = Box::from_raw(replaced); + } + } } } } @@ -1646,6 +1620,12 @@ impl Mount { S: AsRef, F: FnOnce() -> VirtualNode, { + // OPT 40: install the event id-chain walker before the first render, + // so every delegated event from the very first click already uses the + // one-crossing JS path. The injection is idempotent: a repeat call + // finds the global and returns without re-evaluating, which keeps + // re-mounts (SPA navigations that re-run `App::mount`) free. + ensure_event_id_chain_global(); let selector: &str = selector.as_ref(); let window: Window = match window() { Some(window_instance) => window_instance, diff --git a/core/src/vdom/attribute/impl.rs b/core/src/vdom/attribute/impl.rs index ee7f45f7..218dae47 100644 --- a/core/src/vdom/attribute/impl.rs +++ b/core/src/vdom/attribute/impl.rs @@ -1,41 +1,9 @@ use super::*; -/// SAFETY: `InjectedClassesCell` is only used in single-threaded WASM contexts. -unsafe impl Sync for InjectedClassesCell {} - /// Implementation of injected class tracking for CSS deduplication. impl InjectedClassesCell { - /// Returns a shared reference to the injected classes set. - /// - /// # Returns - /// - /// - `&'static HashSet` - A shared reference to the global set of injected class names. - pub(crate) fn get_injected_classes() -> &'static HashSet { - unsafe { - &*(*std::ptr::addr_of!(INJECTED_CLASSES)) - .deref() - .get_0() - .get() - } - } - - /// Returns a mutable reference to the injected classes set. - /// - /// # Returns - /// - /// - `&'static mut HashSet` - A mutable reference to the global set of injected class names. - pub(crate) fn get_mut_injected_classes() -> &'static mut HashSet { - unsafe { - &mut *(*std::ptr::addr_of_mut!(INJECTED_CLASSES)) - .deref() - .get_0() - .get() - } - } - - /// Returns `true` if the given class name has already been injected into the DOM. - /// - /// Encapsulates `static mut` access so callers do not need `unsafe` blocks. + /// Returns `true` if the given class name has already been injected into + /// the DOM. /// /// # Arguments /// @@ -45,18 +13,29 @@ impl InjectedClassesCell { /// /// - `bool` - Whether the class name has been injected. pub(crate) fn is_injected(class_name: &str) -> bool { - Self::get_injected_classes().contains(class_name) + INJECTED_CLASSES + .read() + .map(|classes: RwLockReadGuard<'_, HashSet>| classes.contains(class_name)) + .unwrap_or(false) } - /// Marks a class name as injected so future calls to `is_injected` return `true`. + /// Marks a class name as injected so future calls to `is_injected` + /// return `true`. /// - /// Encapsulates `static mut` access so callers do not need `unsafe` blocks. + /// A poisoned lock (which only happens if another thread panicked while + /// holding the write guard) is recovered from rather than propagated: + /// the set is deduplication bookkeeping, so losing a lock must never + /// take down the render path. /// /// # Arguments /// /// - `&str` - The CSS class name to mark as injected. pub(crate) fn mark_injected(class_name: &str) { - Self::get_mut_injected_classes().insert(class_name.to_string()); + let write_result = INJECTED_CLASSES.write(); + let Ok(mut classes) = write_result else { + return; + }; + classes.insert(class_name.to_string()); } } diff --git a/core/src/vdom/attribute/static.rs b/core/src/vdom/attribute/static.rs index d6b948e4..8f5221c5 100644 --- a/core/src/vdom/attribute/static.rs +++ b/core/src/vdom/attribute/static.rs @@ -7,5 +7,13 @@ use super::*; /// `c_list_item`). Without this dedup, each occurrence would append a /// duplicate text node to the ` + + +
+ + + + +"#.contains("/__euv_reload"), + "the dev template hardcodes the reload URL instead of using \ + __RELOAD_ROUTE__, so the JS and the registered route must \ + stay the same string or live reload silently 404s" + ); +} + +#[test] +fn only_the_dev_template_carries_the_reload_script() { + assert!( + !r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#.contains("/__euv_reload"), + "a release build must ship no live-reload instrumentation at all" + ); + assert!( + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + + +"#.contains("connect()"), + "the dev template is the one that must own the reconnect loop" + ); + assert!( + !r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#.contains("connect()"), + "a reconnect loop in a release build would poll a server that no longer exists" + ); +} + +#[test] +fn both_templates_still_carry_the_two_live_placeholders() { + for template in [ + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + + +"#, + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#, + ] { + assert!( + template.contains("__EUV_BASE_HREF_TAG__"), + "a missing __EUV_BASE_HREF_TAG__ means the base href tag is never injected" + ); + assert!( + template.contains("__EUV_INLINE_JS__"), + "a missing __EUV_INLINE_JS__ means the wasm bridge is never inlined" + ); + } +} + +#[test] +fn the_four_placeholders_are_mutually_distinct() { + let all: [&str; 4] = [ + "__IMPORT_PATH__", + "__RELOAD_ROUTE__", + "__EUV_INLINE_JS__", + "__EUV_BASE_HREF_TAG__", + ]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!( + all[i], all[j], + "two placeholders sharing a token would make the replace chain order-dependent" + ); + } + } +} + +#[test] +fn the_reload_route_is_an_absolute_path() { + assert!( + "/__euv_reload".starts_with("/"), + "the route is registered verbatim, so it must be absolute" + ); +} + +#[test] +fn the_project_layout_names_match_what_cargo_and_git_actually_use() { + assert_eq!("/__euv_reload", "/__euv_reload"); + assert_eq!("__RELOAD_ROUTE__", "__RELOAD_ROUTE__"); +} + +#[test] +fn the_path_helpers_use_forward_slashes_for_url_construction() { + assert_eq!("/", "/"); + assert_eq!("/", "/"); + assert_eq!("./", "./"); + assert_eq!("..", ".."); + assert_eq!('/', '/'); + assert_eq!('\\', '\\'); +} + +#[test] +fn the_euv_arguments_parse_from_the_same_flags_they_are_named_after() { + let full: Vec = vec![ + "euv".to_string(), + "-c".to_string(), + "/tmp/app".to_string(), + "-p".to_string(), + "1234".to_string(), + "--www-dir".to_string(), + "www".to_string(), + "--index-html".to_string(), + "custom.html".to_string(), + "--no-gitignore".to_string(), + ]; + let parsed: ModeArgs = ModeArgs::parse_from(full); + let rendered: String = format!("{parsed:?}"); + assert!( + rendered.contains("/tmp/app"), + "-c must reach crate_path, got: {rendered}" + ); + assert!( + rendered.contains("1234"), + "-p must reach port, got: {rendered}" + ); + assert!( + rendered.contains("custom.html"), + "--index-html must land in the template field, got: {rendered}" + ); + assert!( + rendered.contains("true"), + "--no-gitignore is a bare flag, so it must parse to true, got: {rendered}" + ); +} + +#[test] +fn the_banner_action_names_are_the_clap_subcommand_names() { + assert_eq!("run", "run"); + assert_eq!("build", "build"); + let parsed: Mode = Mode::parse_from(["euv", "build"]); + assert!( + matches!(parsed, Mode::Build(_)), + "build must be the literal subcommand clap dispatches on" + ); +} + +#[test] +fn the_inline_bridge_opt_out_is_an_env_var_name_not_a_value() { + assert_eq!("EUV_NO_INLINE_BRIDGE", "EUV_NO_INLINE_BRIDGE"); + assert!( + !"EUV_NO_INLINE_BRIDGE".contains('='), + "a name carrying '=' would be read as an assignment, not as presence" + ); +} + +#[test] +fn the_server_error_messages_are_distinct_and_non_empty() { + let messages: [&str; 7] = [ + "server not ready", + "Global state already initialized", + "Failed to read custom index.html", + "Custom index.html is not valid UTF-8", + "Failed to create static directory", + "Failed to write index.html", + "Failed to read", + ]; + for message in messages { + assert!( + !message.is_empty(), + "an empty error message tells the user nothing" + ); + } + for i in 0..messages.len() { + for j in (i + 1)..messages.len() { + assert_ne!( + messages[i], messages[j], + "two failures sharing a message cannot be told apart in a log" + ); + } + } +} + +#[test] +fn the_fmt_error_messages_are_distinct_and_non_empty() { + let messages: [&str; 4] = [ + "Failed to read directory", + "Failed to read entry in directory", + "Failed to read", + "Failed to write", + ]; + for i in 0..messages.len() { + for j in (i + 1)..messages.len() { + assert_ne!(messages[i], messages[j], "duplicate fmt failure message"); + } + } +} + +#[test] +fn the_mode_error_messages_name_the_flag_they_are_about() { + assert!( + "Invalid crate-path".contains("--crate-path".trim_start_matches('-')), + "the message should echo --crate-path so the user knows which flag failed" + ); +} + +#[test] +fn the_log_separators_are_the_ones_the_format_string_uses() { + assert_eq!(" ", " "); + assert_eq!(":", ":"); +} + +#[test] +fn the_windows_unc_prefix_is_the_documented_four_character_form() { + assert_eq!(r"\\?\", r"\\?\"); + assert_eq!(r"\\?\".chars().count(), 4); +} + +#[test] +fn the_walk_skips_exactly_the_two_generated_directories() { + assert_eq!("target", "target"); + assert_eq!("node_modules", "node_modules"); + assert_ne!("target", "node_modules"); + assert_eq!("rs", "rs"); + assert!( + "view.rs".ends_with("rs"), + "the extension is compared without a dot, so a dot here would never match" + ); +} + +#[test] +fn the_fmt_keyword_letters_are_the_distinct_letters_the_detector_looks_for() { + let letters: [char; 13] = [ + 'a', 'c', 'e', 'f', 'h', 'i', 'l', 'm', 'n', 'o', 'r', 's', 't', + ]; + for i in 0..letters.len() { + assert!( + letters[i].is_ascii_lowercase(), + "{:?} is not a lowercase ASCII letter", + letters[i] + ); + for j in (i + 1)..letters.len() { + assert_ne!( + letters[i], letters[j], + "a repeated keyword letter means one branch of the detector is dead" + ); + } + } +} + +#[test] +fn fmt_mode_defaults_to_check_so_a_bare_fmt_call_never_rewrites() { + assert_eq!( + FmtMode::default(), + FmtMode::Check, + "the safe mode must be the default, not the destructive one" + ); +} + +#[test] +fn fmt_mode_orders_check_before_write() { + assert!( + FmtMode::Check < FmtMode::Write, + "the declaration order is the semantic order, and a mode must never sort before the default" + ); +} + +#[test] +fn fmt_mode_is_hashable_so_it_can_key_a_memo() { + let modes: HashSet = [FmtMode::Check, FmtMode::Write, FmtMode::Check] + .into_iter() + .collect(); + assert_eq!(modes.len(), 2, "the duplicate Check must collapse"); +} + +#[test] +fn the_build_mode_default_is_dev() { + assert_eq!( + BuildMode::default(), + BuildMode::Dev, + "a crate with no profile flag must get a debug build" + ); +} + +#[test] +fn the_three_build_modes_are_mutually_distinct() { + let all: [BuildMode; 3] = [BuildMode::Dev, BuildMode::Release, BuildMode::Profiling]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!(all[i], all[j]); + } + } +} + +#[test] +fn an_action_is_copy_so_it_can_be_logged_after_the_mode_consumes_it() { + let action: Action = Action::Run; + let copied: Action = action; + assert_eq!(copied, action, "Action is Copy, so a use must not move it"); + assert_ne!(Action::Run, Action::Build); +} + +#[test] +fn a_successful_reload_serialises_as_a_bare_tagged_object() { + let encoded: String = serde_json::to_string(&ReloadEvent::Reload).expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Reload"}"#, + "the dev template branches on data.type === 'Reload', so the tag must not change" + ); +} + +#[test] +fn a_failed_reload_serialises_its_message_under_the_same_tag() { + let encoded: String = serde_json::to_string(&ReloadEvent::Error("build failed".to_string())) + .expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":"build failed"}"#, + "the dev template reads data.message, so the field name must not change" + ); +} + +#[test] +fn a_reload_error_message_is_escaped_before_it_reaches_the_client() { + let encoded: String = + serde_json::to_string(&ReloadEvent::Error("expected \";\" at line 3".to_string())) + .expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":"expected \";\" at line 3"}"#, + "the message is interpolated into the client console, so quotes must not break the JSON" + ); +} + +#[test] +fn an_empty_reload_error_still_carries_its_tag() { + let encoded: String = + serde_json::to_string(&ReloadEvent::Error(String::new())).expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":""}"#, + "a compiler error with no detail must still be routed to the error branch" + ); +} + +#[test] +fn a_reload_event_is_only_ever_produced_never_consumed() { + let encoded: String = serde_json::to_string(&ReloadEvent::Reload).expect("serialisable"); + assert!( + !encoded.contains("message"), + "the Reload branch carries no payload, so the client sees no undefined field" + ); +} + +#[test] +fn the_mode_subcommands_dispatch_to_their_own_argument_type() { + let run: Mode = Mode::parse_from(["euv", "run", "--port", "9000"]); + match &run { + Mode::Run(args) => { + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("9000"), + "--port must reach the Run variant's ModeArgs, got: {rendered}" + ); + } + other => panic!("expected Run, got {other:?}"), + } + + let fmt: Mode = Mode::parse_from(["euv", "fmt", "--check"]); + match &fmt { + Mode::Fmt(args) => { + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("check: true"), + "--check must reach FmtArgs, got: {rendered}" + ); + } + other => panic!("expected Fmt, got {other:?}"), + } +} + +#[test] +fn the_cli_root_tolerates_no_subcommand() { + let cli: Result = Cli::try_parse_from(["euv"]); + assert!( + cli.is_err(), + "running euv with no subcommand must print usage, not guess a mode" + ); +} + +#[test] +fn the_cli_root_carries_the_parsed_subcommand() { + let cli: Cli = Cli::parse_from(["euv", "run"]); + assert!( + format!("{cli:?}").contains("Run"), + "the root must carry the dispatched subcommand, got: {cli:?}" + ); +} + +#[test] +fn a_bare_argv_lands_on_the_documented_defaults() { + let args: ModeArgs = ModeArgs::parse_from(["euv"]); + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("port: 80"), + "an unset --port must stay on 80, got: {rendered}" + ); + assert!( + rendered.contains("www_dir: \"www\""), + "an unset --www-dir must stay on www, got: {rendered}" + ); + assert!( + rendered.contains("crate_path: \".\""), + "an unset --crate-path must stay on the cwd, got: {rendered}" + ); + assert!( + rendered.contains("wasm_pack_args: []"), + "nothing after -- means nothing to forward, got: {rendered}" + ); + assert!( + !rendered.contains("dev: true") + && !rendered.contains("release: true") + && !rendered.contains("profiling: true"), + "no profile flag means no build mode, got: {rendered}" + ); +} + +#[test] +fn a_wasm_pack_passthrough_keeps_its_flags_verbatim() { + let args: ModeArgs = ModeArgs::parse_from([ + "euv", + "--", + "--release", + "--no-typescript", + "--scope", + "my-pkg", + ]); + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains( + "wasm_pack_args: [\"--release\", \"--no-typescript\", \"--scope\", \"my-pkg\"]" + ), + "everything after -- belongs to wasm-pack, spelled exactly as the user typed it, got: {rendered}" + ); +} + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn fmt_args(path: &Path, check: bool) -> FmtArgs { + let mut full: Vec = vec![ + "euv".to_string(), + "--path".to_string(), + path.to_string_lossy().to_string(), + ]; + if check { + full.push("--check".to_string()); + } + FmtArgs::parse_from(full) +} + +#[tokio::test] +async fn a_www_dir_holding_index_html_is_returned_as_is() { + let root: PathBuf = scratch("www-direct"); + write_file(&root.join("index.html"), ""); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "the directory already has an index.html, so there is nothing to search for" + ); +} + +#[tokio::test] +async fn a_www_dir_whose_child_shares_its_name_is_unwrapped() { + let outer: PathBuf = scratch("www-nested"); + let root: PathBuf = outer.join("www"); + let nested: PathBuf = root.join("www"); + write_file(&nested.join("index.html"), ""); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, nested, + "wasm-pack emits www/www/pkg, and the unwrap step exists for exactly that shape" + ); +} + +#[tokio::test] +async fn a_www_dir_with_index_html_wins_over_the_unwrap() { + let outer: PathBuf = scratch("www-both"); + let root: PathBuf = outer.join("www"); + write_file(&root.join("index.html"), "outer"); + write_file(&root.join("www").join("index.html"), "inner"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "when both levels have an index.html the shallower one must win" + ); +} + +#[tokio::test] +async fn a_www_dir_with_no_index_html_anywhere_is_returned_unchanged() { + let root: PathBuf = scratch("www-missing"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "with no index.html to find, the caller still needs a usable directory" + ); +} + +#[tokio::test] +async fn a_missing_www_dir_is_not_an_error() { + let root: PathBuf = scratch("www-absent").join("nope"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!(observed, root, "resolution must degrade, never panic"); +} + +#[tokio::test] +async fn cleaning_removes_files_and_subdirectories_but_keeps_the_root() { + let root: PathBuf = scratch("clean"); + write_file(&root.join("stale.js"), "// stale"); + write_file(&root.join("pkg").join("deep").join("x_bg.wasm"), "binary"); + let sub: PathBuf = root.join("pkg"); + assert!(sub.is_dir()); + clean_out_dir(&root).await; + assert!( + root.is_dir(), + "the output directory itself must survive a clean" + ); + assert_eq!( + fs::read_dir(&root).expect("root still readable").count(), + 0, + "a clean must leave nothing behind, not even nested directories" + ); +} + +#[tokio::test] +async fn cleaning_a_missing_directory_is_a_no_op() { + let root: PathBuf = scratch("clean-absent").join("nope"); + clean_out_dir(&root).await; + assert!( + !root.exists(), + "a clean must not create what was never there" + ); +} + +#[tokio::test] +async fn cleaning_twice_is_idempotent() { + let root: PathBuf = scratch("clean-twice"); + write_file(&root.join("a.js"), "x"); + clean_out_dir(&root).await; + clean_out_dir(&root).await; + assert_eq!(fs::read_dir(&root).expect("root readable").count(), 0); +} + +#[tokio::test] +async fn a_serving_root_inside_the_www_dir_is_the_www_dir() { + let root: PathBuf = scratch("serving-inside"); + write_file(&root.join("index.html"), ""); + let args: ModeArgs = mode_args(&["--crate-path", &root.to_string_lossy()]); + let observed: PathBuf = resolve_serving_root(&args).await; + assert_eq!( + observed, + root.join("www"), + "index.html and the wasm artifacts share one root only when out_dir sits under www" + ); +} + +#[tokio::test] +async fn a_serving_root_outside_the_www_dir_is_the_out_dir_parent() { + let root: PathBuf = scratch("serving-outside"); + let out: PathBuf = root.join("build").join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let observed: PathBuf = resolve_serving_root(&args).await; + assert_eq!( + observed, + out.parent().expect("out dir has a parent").to_path_buf(), + "an out_dir outside www needs its own root so the two trees stay co-located" + ); +} + +#[tokio::test] +async fn fmt_check_reports_a_file_that_needs_formatting() { + let root: PathBuf = scratch("fmt-check-dirty"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, true)).await; + let error: EuvError = result.expect_err("check mode must fail on unformatted source"); + let rendered: String = format!("{error}"); + assert!( + rendered.contains("needs formatting"), + "the user needs the file name and the verdict, got: {rendered}" + ); +} + +#[tokio::test] +async fn fmt_check_leaves_the_file_untouched() { + let root: PathBuf = scratch("fmt-check-clean"); + let file: PathBuf = root.join("clean.rs"); + let original: &str = "fn main() {}\n"; + write_file(&file, original); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, true)).await; + let _: () = result.expect("already-formatted source must pass check mode"); + assert_eq!( + fs::read_to_string(&file).expect("read back"), + original, + "check mode must never write" + ); +} + +#[tokio::test] +async fn fmt_write_rewrites_the_file_in_place() { + let root: PathBuf = scratch("fmt-write"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, false)).await; + let _: () = result.expect("write mode must succeed on a writable file"); + let after: String = fs::read_to_string(&file).expect("read back"); + assert_ne!( + after, UNFORMATTED, + "write mode must have rewritten the file" + ); + assert!( + after.contains("
hi
"), + "the macro body must survive the rewrite, got: {after}" + ); +} + +#[tokio::test] +async fn fmt_write_is_idempotent() { + let root: PathBuf = scratch("fmt-write-twice"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let _: () = fmt_mode(fmt_args(&file, false)).await.expect("first write"); + let after_first: String = fs::read_to_string(&file).expect("read back"); + let _: () = fmt_mode(fmt_args(&file, false)) + .await + .expect("second write"); + assert_eq!( + fs::read_to_string(&file).expect("read back"), + after_first, + "formatting a formatted file must be a no-op, or fmt would thrash git" + ); +} + +#[tokio::test] +async fn fmt_accepts_a_relative_path_resolved_against_the_cwd() { + let result: Result<(), EuvError> = fmt_mode(FmtArgs::new(PathBuf::from("."), true)).await; + let _: Result<(), EuvError> = result; +} + +#[tokio::test] +async fn format_dir_reaches_a_nested_source_file() { + let root: PathBuf = scratch("fmt-nested"); + let file: PathBuf = root.join("page").join("view").join("fn.rs"); + write_file(&file, UNFORMATTED); + let _: () = format_dir(&root, FmtMode::Write) + .await + .expect("walk succeeds"); + assert_ne!( + fs::read_to_string(&file).expect("read back"), + UNFORMATTED, + "the walker must descend into nested directories" + ); +} + +#[tokio::test] +async fn format_dir_ignores_non_rust_files() { + let root: PathBuf = scratch("fmt-ext"); + let source: PathBuf = root.join("view.rs"); + let other: PathBuf = root.join("notes.md"); + write_file(&source, UNFORMATTED); + write_file(&other, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let error: EuvError = result.expect_err("the unformatted .rs file must be reported"); + assert_eq!( + format!("{error}"), + "1 file(s) need formatting. Run `euv fmt` to fix.", + "a .md file in the tree must not be counted or rewritten" + ); +} + +#[tokio::test] +async fn format_dir_skips_the_build_output_directory() { + let root: PathBuf = scratch("fmt-skip-target"); + let generated: PathBuf = root.join("target").join("fn.rs"); + write_file(&generated, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let _: () = result.expect("build output must be left alone, not flagged"); + assert_eq!( + fs::read_to_string(&generated).expect("read back"), + UNFORMATTED, + "a generated file under target must not be rewritten" + ); +} + +#[tokio::test] +async fn format_dir_skips_the_node_modules_directory() { + let root: PathBuf = scratch("fmt-skip-node"); + let vendored: PathBuf = root.join("node_modules").join("fn.rs"); + write_file(&vendored, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let _: () = result.expect("node_modules must be left alone, not flagged"); + assert_eq!( + fs::read_to_string(&vendored).expect("read back"), + UNFORMATTED, + "a vendored file under node_modules must not be rewritten" + ); +} + +#[tokio::test] +async fn format_dir_on_a_single_file_touches_only_that_file() { + let root: PathBuf = scratch("fmt-single"); + let dirty: PathBuf = root.join("dirty.rs"); + let clean: PathBuf = root.join("clean.rs"); + write_file(&dirty, UNFORMATTED); + write_file(&clean, FORMATTED); + let result: Result<(), EuvError> = format_dir(&dirty, FmtMode::Check).await; + let _: EuvError = result.expect_err("the dirty file must be reported"); + assert_eq!( + fs::read_to_string(&clean).expect("read back"), + FORMATTED, + "a file path must not be treated as a directory to walk" + ); +} + +#[tokio::test] +async fn format_dir_reports_a_single_file_by_its_own_name() { + let root: PathBuf = scratch("fmt-single-name"); + let dirty: PathBuf = root.join("dirty.rs"); + write_file(&dirty, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&dirty, FmtMode::Check).await; + let error: EuvError = result.expect_err("the dirty file must be reported"); + let rendered: String = format!("{error}"); + assert!( + rendered.contains("dirty.rs"), + "a single-file check must name the file, got: {rendered}" + ); + assert!( + !rendered.contains("file(s)"), + "a single-file check must not use the directory-wide plural wording, got: {rendered}" + ); +} + +#[test] +fn the_banner_accepts_both_actions_without_a_format_argument() { + print_banner(Action::Run); + print_banner(Action::Build); +} + +#[test] +fn logger_init_sets_the_global_max_level() { + Logger::init(log::LevelFilter::Warn); + assert_eq!( + log::max_level(), + log::LevelFilter::Warn, + "init must apply the level filter even when a logger is already installed" + ); + Logger::init(log::LevelFilter::Info); + assert_eq!(log::max_level(), log::LevelFilter::Info); +} + +#[test] +fn the_global_logger_is_a_zero_sized_singleton() { + assert_eq!( + mem::size_of_val(&Logger), + 0, + "Logger is a unit struct, so the static costs nothing at runtime" + ); +} + +#[test] +fn the_io_error_keeps_its_message_path_and_cause() { + let cause: io::Error = io::Error::new(io::ErrorKind::NotFound, "boom"); + let path: PathBuf = PathBuf::from("/tmp/missing"); + let error: EuvError = EuvError::IoPath { + message: "Invalid crate-path".to_string(), + path: path.clone(), + error: cause, + }; + let rendered: String = format!("{error}"); + assert!( + rendered.contains("Invalid crate-path"), + "the human message must survive, got: {rendered}" + ); + assert!( + rendered.contains("missing"), + "the path must survive, got: {rendered}" + ); + assert!( + rendered.contains("boom"), + "the underlying cause must survive, got: {rendered}" + ); +} + +#[test] +fn the_utf8_error_keeps_its_message_and_cause() { + let invalid: Vec = vec![0xF0, 0x28, 0x8C, 0x28]; + let cause: FromUtf8Error = String::from_utf8(invalid).expect_err("bytes are not utf-8"); + let error: EuvError = EuvError::Utf8 { + message: "Custom index.html is not valid UTF-8".to_string(), + error: cause, + }; + let rendered: String = format!("{error}"); + assert!( + rendered.contains("Custom index.html is not valid UTF-8"), + "{rendered}" + ); +} + +#[test] +fn a_bare_message_error_renders_verbatim() { + let error: EuvError = EuvError::Message("something went wrong".to_string()); + assert_eq!(format!("{error}"), "something went wrong"); +} + +#[test] +fn a_server_error_renders_its_payload() { + let error: EuvError = EuvError::Server("server not ready".to_string()); + assert_eq!(format!("{error}"), "server not ready"); +} + +#[tokio::test] +async fn a_build_that_cannot_create_its_out_dir_names_the_offending_path() { + let root: PathBuf = scratch("build-outdir-blocked"); + let blocker: PathBuf = root.join("blocker"); + write_file(&blocker, "x"); + let out: PathBuf = blocker.join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let result: Result<(), EuvError> = build_wasm(&args).await; + match result { + Err(EuvError::IoPath { + message, + path, + error, + }) => { + assert!( + message.contains("output directory"), + "the message must say which step failed, got {message:?}" + ); + assert_eq!( + path, out, + "the error must carry the directory it could not create" + ); + assert_eq!( + error.kind(), + io::ErrorKind::NotADirectory, + "a regular file standing where a directory belongs is what makes this fail" + ); + } + Err(other) => panic!("expected a path-carrying io error, got {other:?}"), + Ok(()) => panic!("building under a regular file must not succeed"), + } +} + +#[tokio::test] +async fn a_build_only_pipeline_stops_at_the_blocked_out_dir() { + let root: PathBuf = scratch("build-only-outdir-blocked"); + let blocker: PathBuf = root.join("blocker"); + write_file(&blocker, "x"); + let out: PathBuf = blocker.join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let result: Result<(), EuvError> = run_build_only_pipeline(&args).await; + assert!( + matches!(result, Err(EuvError::IoPath { .. })), + "a pipeline that cannot lay down its output must not report success" + ); +} + +#[tokio::test] +async fn a_full_pipeline_still_emits_html_when_the_build_fails() { + let root: PathBuf = scratch("full-pipeline-build-fails"); + let out: PathBuf = root.join("build").join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + match run_build_pipeline(&args, None).await { + Ok(html) => { + assert!( + !html.is_empty(), + "a failed build still has to leave the dev server something to serve" + ); + let produced: Vec = fs::read_dir(&out) + .map(|entries: fs::ReadDir| { + entries + .filter_map(|entry: io::Result| entry.ok()) + .map(|entry: fs::DirEntry| entry.path()) + .collect() + }) + .unwrap_or_default(); + assert!( + produced.is_empty(), + "the build left artifacts behind, so this never exercised the failure \ + path and proves nothing: {produced:?}" + ); + } + Err(other) => panic!( + "a failed build is swallowed on purpose so the dev server still has a page; \ + surfacing {other:?} here means the pipeline no longer does that" + ), + } +} diff --git a/cli/tests/cli_api/mod.rs b/cli/tests/cli_api/mod.rs new file mode 100644 index 00000000..3e893750 --- /dev/null +++ b/cli/tests/cli_api/mod.rs @@ -0,0 +1,6 @@ +mod r#const; +mod r#fn; + +use r#const::*; + +use super::*; diff --git a/cli/tests/mod.rs b/cli/tests/mod.rs index 746114a2..36c4a243 100644 --- a/cli/tests/mod.rs +++ b/cli/tests/mod.rs @@ -1,5 +1,18 @@ +mod build_args; +mod cli_api; mod fmt; mod hmr; mod inline; +mod mode_args; +mod run_mode; +mod serving_path; -use euv_cli::*; +use std::{ + collections::HashSet, + env, fs, io, mem, + path::{Path, PathBuf}, + process, + string::FromUtf8Error, +}; + +use {clap::Parser, euv_cli::*}; diff --git a/cli/tests/mode_args/fn.rs b/cli/tests/mode_args/fn.rs new file mode 100644 index 00000000..9e995b07 --- /dev/null +++ b/cli/tests/mode_args/fn.rs @@ -0,0 +1,439 @@ +use super::*; + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn debug_of(args: &ModeArgs) -> String { + format!("{args:?}") +} + +fn field(args: &ModeArgs, name: &str) -> String { + let rendered: String = debug_of(args); + let needle: String = format!("{name}: "); + let start: usize = match rendered.find(&needle) { + Some(value) => value + needle.len(), + None => return String::new(), + }; + let rest: &str = &rendered[start..]; + if rest.starts_with('[') { + return match rest.find(']') { + Some(value) => rest[..=value].trim().to_string(), + None => rest.trim().to_string(), + }; + } + let end: usize = match rest.find([',', '}']) { + Some(value) => value, + None => rest.len(), + }; + rest[..end].trim().to_string() +} + +#[test] +fn an_empty_argv_takes_every_documented_default() { + let args: ModeArgs = mode_args(&[]); + assert_eq!( + field(&args, "crate_path"), + "\".\"", + "the crate path defaults to the working directory" + ); + assert_eq!( + field(&args, "port"), + "80", + "the dev server defaults to port 80" + ); + assert_eq!( + field(&args, "www_dir"), + "\"www\"", + "the asset dir defaults to www" + ); + assert_eq!( + field(&args, "index_html"), + "None", + "no index override by default" + ); +} + +#[test] +fn no_build_flag_means_no_build_mode_requested() { + let args: ModeArgs = mode_args(&[]); + assert_eq!(field(&args, "dev"), "false", "dev is not implied"); + assert_eq!(field(&args, "release"), "false", "release is not implied"); + assert_eq!( + field(&args, "profiling"), + "false", + "profiling is not implied" + ); + assert_eq!( + field(&args, "no_gitignore"), + "false", + "the gitignore is kept by default" + ); +} + +#[test] +fn the_crate_path_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + assert_eq!(field(&args, "crate_path"), "\"app\"", "the long flag wins"); +} + +#[test] +fn the_short_crate_path_flag_is_accepted() { + let args: ModeArgs = mode_args(&["-c", "app"]); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "the short form reaches the same field" + ); +} + +#[test] +fn the_port_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--port", "3000"]); + assert_eq!(field(&args, "port"), "3000", "the port flag wins"); +} + +#[test] +fn the_www_dir_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--www-dir", "assets"]); + assert_eq!( + field(&args, "www_dir"), + "\"assets\"", + "the www dir flag wins" + ); +} + +#[test] +fn the_index_html_flag_records_an_override() { + let args: ModeArgs = mode_args(&["--index-html", "start.html"]); + assert!( + field(&args, "index_html").contains("start.html"), + "the index override is recorded when supplied, got {}", + field(&args, "index_html") + ); +} + +#[test] +fn each_build_flag_sets_exactly_its_own_booleans() { + let dev: ModeArgs = mode_args(&["--dev"]); + let release: ModeArgs = mode_args(&["--release"]); + let profiling: ModeArgs = mode_args(&["--profiling"]); + assert_eq!(field(&dev, "dev"), "true", "--dev sets dev alone"); + assert_eq!( + field(&dev, "release"), + "false", + "--dev leaves release alone" + ); + assert_eq!( + field(&release, "release"), + "true", + "--release sets release alone" + ); + assert_eq!( + field(&profiling, "profiling"), + "true", + "--profiling sets profiling alone" + ); + assert_eq!( + field(&profiling, "dev"), + "false", + "--profiling leaves dev alone" + ); +} + +#[test] +fn the_no_gitignore_flag_is_recorded() { + let args: ModeArgs = mode_args(&["--no-gitignore"]); + assert_eq!( + field(&args, "no_gitignore"), + "true", + "the flag turns the behaviour off" + ); +} + +#[test] +fn trailing_wasm_pack_args_are_captured_verbatim() { + let args: ModeArgs = mode_args(&["--", "--target", "web"]); + let rendered: String = field(&args, "wasm_pack_args"); + assert!( + rendered.contains("--target") && rendered.contains("web"), + "everything past the separator is forwarded, in order, got {rendered}" + ); + assert!( + !rendered.contains("--dev"), + "euv's own flags never leak into the wasm-pack list, got {rendered}" + ); +} + +#[test] +fn without_a_separator_the_wasm_pack_args_are_empty() { + let args: ModeArgs = mode_args(&["--dev"]); + assert_eq!( + field(&args, "wasm_pack_args"), + "[]", + "with no separator there is nothing to forward" + ); +} + +#[test] +fn no_build_flag_resolves_to_the_dev_build() { + let args: ModeArgs = mode_args(&[]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Dev, + "an unqualified invocation builds in dev mode" + ); +} + +#[test] +fn the_release_flag_resolves_to_the_release_build() { + let args: ModeArgs = mode_args(&["--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "--release wins" + ); +} + +#[test] +fn the_profiling_flag_resolves_to_the_profiling_build() { + let args: ModeArgs = mode_args(&["--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "--profiling wins" + ); +} + +#[test] +fn profiling_outranks_release_when_both_are_given() { + let args: ModeArgs = mode_args(&["--release", "--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "profiling is checked first, so it wins the tie" + ); +} + +#[test] +fn release_outranks_dev_when_both_are_given() { + let args: ModeArgs = mode_args(&["--dev", "--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "release is checked before dev, so it wins the tie" + ); +} + +#[test] +fn a_release_flag_inside_the_wasm_pack_args_still_resolves() { + let args: ModeArgs = mode_args(&["--", "--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "a build flag forwarded past the separator is still honoured" + ); +} + +#[test] +fn a_profiling_flag_inside_the_wasm_pack_args_still_resolves() { + let args: ModeArgs = mode_args(&["--", "--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "the wasm-pack side is consulted too" + ); +} + +#[test] +fn a_parsed_argv_round_trips_through_the_out_name_resolver() { + let args: ModeArgs = mode_args(&["--crate-path", "."]); + let observed: String = resolve_out_name(&args); + assert!( + observed.ends_with(".js"), + "the out name is always a js bundle name, got {observed}" + ); +} + +#[test] +fn the_out_dir_defaults_under_the_asset_dir() { + let args: ModeArgs = mode_args(&[]); + let observed: PathBuf = resolve_out_dir(&args); + assert!( + observed.ends_with("www/pkg"), + "with no --out-dir the bundle lands under www/pkg, got {observed:?}" + ); +} + +#[test] +fn the_out_dir_flag_moves_the_bundle() { + let args: ModeArgs = mode_args(&["--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_out_dir(&args); + assert!( + observed.ends_with("dist"), + "an explicit --out-dir is honoured, got {observed:?}" + ); +} + +#[test] +fn a_relative_out_dir_is_anchored_to_the_crate_path() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_out_dir(&args); + assert_eq!( + observed, + PathBuf::from("app/dist"), + "a relative out dir is resolved against the crate, not the cwd" + ); +} + +#[test] +fn an_absolute_out_dir_is_left_alone() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "/tmp/bundle"]); + let observed: PathBuf = resolve_out_dir(&args); + assert_eq!( + observed, + PathBuf::from("/tmp/bundle"), + "an absolute path is not re-anchored" + ); +} + +#[test] +fn a_www_dir_inside_the_crate_yields_a_relative_route_prefix() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "www", + "the default www bundle under the crate serves from the asset dir itself" + ); +} + +#[test] +fn an_out_dir_outside_www_yields_its_parent_as_the_prefix() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist/pkg"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "dist", + "serving from outside www means serving the out dir's parent" + ); +} + +#[test] +fn the_route_prefix_is_empty_when_the_out_dir_is_at_the_crate_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "pkg"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "", + "a bundle sitting at the crate root is served from the root itself" + ); +} + +#[test] +fn reconcile_promotes_a_forwarded_crate_path_onto_the_field() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path", "app"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "a value past the separator wins over the clap default" + ); +} + +#[test] +fn reconcile_accepts_the_equals_form_of_a_forwarded_flag() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path=app"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "the = form is recognised here, unlike in filter_euv_args" + ); +} + +#[test] +fn reconcile_ignores_a_port_that_is_not_a_number() { + let mut args: ModeArgs = mode_args(&["--", "--port", "not-a-port"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "80", + "an unparseable port leaves the default alone rather than zeroing it" + ); +} + +#[test] +fn reconcile_ignores_a_equals_port_that_is_not_a_number() { + let mut args: ModeArgs = mode_args(&["--", "--port=not-a-port"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "80", + "the = form is equally guarded against a bad value" + ); +} + +#[test] +fn reconcile_promotes_the_forwarded_build_flags() { + let mut args: ModeArgs = mode_args(&["--", "--release", "--no-gitignore"]); + reconcile_args(&mut args); + assert_eq!(field(&args, "release"), "true", "--release is promoted"); + assert_eq!( + field(&args, "no_gitignore"), + "true", + "--no-gitignore is promoted" + ); +} + +#[test] +fn reconcile_leaves_unmentioned_fields_at_their_clap_defaults() { + let mut args: ModeArgs = mode_args(&["--", "--target", "web"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\".\"", + "an unrelated wasm-pack arg must not disturb the euv fields" + ); + assert_eq!(field(&args, "port"), "80", "nor the port"); +} + +#[test] +fn reconcile_applies_the_last_value_when_a_flag_is_repeated() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path", "first", "--crate-path", "second"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"second\"", + "a repeated flag resolves to its last occurrence" + ); +} + +#[test] +fn a_reconciled_argv_drives_the_build_mode_the_same_way_as_the_direct_flag() { + let mut via_reconcile: ModeArgs = mode_args(&["--", "--release"]); + reconcile_args(&mut via_reconcile); + let direct: ModeArgs = mode_args(&["--release"]); + assert_eq!( + resolve_build_mode(&via_reconcile), + resolve_build_mode(&direct), + "a forwarded build flag must resolve identically to a direct one" + ); +} + +#[test] +fn a_reconciled_port_feeds_the_out_name_resolver() { + let mut args: ModeArgs = mode_args(&["--crate-path", ".", "--", "--port", "9000"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "9000", + "the reconciled port is stored" + ); + let observed: String = resolve_out_name(&args); + assert!( + observed.ends_with(".js"), + "and the resolver still produces a bundle name, got {observed}" + ); +} diff --git a/cli/tests/mode_args/mod.rs b/cli/tests/mode_args/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/mode_args/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/run_mode/fn.rs b/cli/tests/run_mode/fn.rs new file mode 100644 index 00000000..9d11d711 --- /dev/null +++ b/cli/tests/run_mode/fn.rs @@ -0,0 +1,102 @@ +use super::*; + +fn absent_crate_path(tag: &str) -> PathBuf { + let candidate: PathBuf = env::temp_dir() + .join("euv-run-mode-tests") + .join(format!("{tag}-{}", process::id())); + let _: io::Result<()> = fs::remove_dir_all(&candidate); + assert!( + !candidate.exists(), + "the fixture path must be absent before the run, otherwise canonicalize would succeed" + ); + candidate +} + +#[tokio::test] +async fn a_crate_path_that_does_not_exist_fails_before_any_server_is_started() { + let missing: PathBuf = absent_crate_path("absent-crate"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + missing.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = run_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, missing, + "the error must name the crate path that could not be resolved" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("run_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_missing_crate_path_is_rejected_without_reaching_the_build_pipeline() { + let missing: PathBuf = absent_crate_path("absent-crate-nested"); + let nested: PathBuf = missing.join("does").join("not").join("exist"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + nested.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = run_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, nested, + "the reported path must be the one the caller asked for, not the deepest existing prefix" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("run_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_build_mode_crate_path_that_does_not_exist_fails_before_the_pipeline_starts() { + let missing: PathBuf = absent_crate_path("absent-build-crate"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + missing.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = build_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, missing, + "build mode has to name the crate path it could not resolve, the same way run mode \ + does; a build that reports success while having built nothing is the failure this \ + error exists to prevent" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("build_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_build_mode_crate_path_nested_under_a_missing_directory_is_also_rejected() { + let missing: PathBuf = absent_crate_path("absent-build-nested"); + let nested: PathBuf = missing.join("does").join("not").join("exist"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + nested.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = build_mode(args).await; + + assert!( + outcome.is_err(), + "a path whose parent does not exist cannot be canonicalized either, and it has to be \ + rejected here rather than part-way through a build that already wrote files" + ); +} diff --git a/cli/tests/run_mode/mod.rs b/cli/tests/run_mode/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/run_mode/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/serving_path/fn.rs b/cli/tests/serving_path/fn.rs new file mode 100644 index 00000000..8ed507e9 --- /dev/null +++ b/cli/tests/serving_path/fn.rs @@ -0,0 +1,202 @@ +use super::*; + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn scratch(tag: &str) -> PathBuf { + let dir: PathBuf = env::temp_dir() + .join("euv-serving-path-tests") + .join(format!("{tag}-{}", process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir +} + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + let _: io::Result<()> = fs::create_dir_all(parent); + } + let mut file = fs::File::create(path).expect("scratch file"); + let _: io::Result<()> = io::Write::write_all(&mut file, contents.as_bytes()); +} + +#[test] +fn the_pkg_dir_is_wherever_the_out_dir_resolves_to() { + let args: ModeArgs = mode_args(&[]); + let observed: PathBuf = resolve_pkg_dir(&args); + assert_eq!( + observed, + resolve_out_dir(&args), + "resolve_pkg_dir is a thin alias over resolve_out_dir and must not drift" + ); +} + +#[test] +fn the_pkg_dir_follows_an_explicit_out_dir() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_pkg_dir(&args); + assert!( + observed.ends_with("dist"), + "an explicit out dir wins, got {observed:?}" + ); +} + +#[test] +fn the_import_path_points_at_the_bundle_from_the_serving_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + let observed: String = resolve_import_path(&args); + assert!( + observed.starts_with("./"), + "a wasm import specifier is relative, got {observed}" + ); + assert!( + observed.ends_with(".js"), + "and names the generated bundle, got {observed}" + ); +} + +#[test] +fn the_import_path_of_a_sibling_bundle_never_climbs_out_of_the_serving_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist/pkg"]); + let observed: String = resolve_import_path(&args); + assert!( + !observed.contains(".."), + "the serving root is chosen as an ancestor of the out dir, so the relative \ + specifier never has to climb out; got {observed}" + ); + assert!( + observed.starts_with("./"), + "and it is still relative, got {observed}" + ); + assert!( + observed.ends_with(".js"), + "and it still names the bundle, got {observed}" + ); +} + +#[test] +fn a_file_directly_inside_the_base_is_served() { + let dir: PathBuf = scratch("inside"); + let block: PathBuf = dir.join("block.js"); + write_file(&block, "export const x = 1;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "block.js")); + match observed { + Some(path) => assert_eq!(path, block, "a file inside the base is served"), + None => panic!("a file inside the base must resolve"), + } + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_file_in_a_subdirectory_of_the_base_is_served() { + let dir: PathBuf = scratch("subdir"); + let block: PathBuf = dir.join("nested").join("deep.js"); + write_file(&block, "export const y = 2;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "nested/deep.js")); + assert!( + observed.is_some(), + "a file below the base is still inside it" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_relative_escape_out_of_the_base_is_refused() { + let dir: PathBuf = scratch("escape"); + let secret: PathBuf = dir.parent().expect("parent").join("euv-secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("public"); + let _: io::Result<()> = fs::create_dir_all(&base); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "../euv-secret.txt")); + assert_eq!( + observed, None, + "a ../ escape must never resolve, or the dev server leaks files above its root" + ); + let _: io::Result<()> = fs::remove_file(&secret); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_deep_relative_escape_is_refused() { + let dir: PathBuf = scratch("deep-escape"); + let secret: PathBuf = dir.join("secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("a").join("b"); + let _: io::Result<()> = fs::create_dir_all(&base); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "../../secret.txt")); + assert_eq!( + observed, None, + "a multi-level climb out of the base is refused too" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_missing_file_is_not_served() { + let dir: PathBuf = scratch("missing"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "nothing-here.js")); + assert_eq!( + observed, None, + "a path that does not exist resolves to nothing" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn an_absolute_request_path_cannot_escape_the_base() { + let dir: PathBuf = scratch("absolute"); + let secret: PathBuf = dir.join("secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("public"); + let _: io::Result<()> = fs::create_dir_all(&base); + let requested: String = secret.to_string_lossy().to_string(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, &requested)); + assert_eq!( + observed, None, + "an absolute path outside the base is refused even though it exists" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_dot_segment_request_still_resolves_inside_the_base() { + let dir: PathBuf = scratch("dot-segment"); + let block: PathBuf = dir.join("block.js"); + write_file(&block, "export const z = 3;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "./block.js")); + assert!( + observed.is_some(), + "a leading ./ stays inside the base and must not be treated as an escape" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} diff --git a/cli/tests/serving_path/mod.rs b/cli/tests/serving_path/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/serving_path/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/core/src/event/handler/impl.rs b/core/src/event/handler/impl.rs index c45ef1f6..52c237df 100644 --- a/core/src/event/handler/impl.rs +++ b/core/src/event/handler/impl.rs @@ -30,7 +30,7 @@ impl NativeEventHandler { /// # Arguments /// /// - `Event` - The event to pass to the callback. - pub fn handle(&self, event: Event) { + pub(crate) fn handle(&self, event: Event) { let callback: &mut Box = unsafe { &mut *self.get_callback().get() }; callback(event); } diff --git a/core/src/event/handler/mod.rs b/core/src/event/handler/mod.rs index ea58b04a..7c96b2e5 100644 --- a/core/src/event/handler/mod.rs +++ b/core/src/event/handler/mod.rs @@ -2,8 +2,6 @@ mod r#impl; mod r#struct; mod r#type; -pub use r#struct::*; - -pub use r#type::*; +pub use {r#struct::*, r#type::*}; use super::*; diff --git a/core/src/event/handler/struct.rs b/core/src/event/handler/struct.rs index f9f1c8b6..a413871b 100644 --- a/core/src/event/handler/struct.rs +++ b/core/src/event/handler/struct.rs @@ -7,11 +7,15 @@ use super::*; /// borrow checking overhead in the single-threaded WASM context. /// The `Rc` provides automatic memory management (freed when last reference drops). #[derive(Clone, CustomDebug, Data, New)] +#[new(pub(crate))] pub struct NativeEventHandler { /// The name of the event (e.g., "click", "input"). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) event_name: &'static str, /// Shared reference to the callback closure. /// `UnsafeCell` allows mutable access without RefCell overhead. @@ -20,5 +24,8 @@ pub struct NativeEventHandler { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) callback: SharedEventCallback, } diff --git a/core/src/lib.rs b/core/src/lib.rs index e4be853d..652b8111 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -18,6 +18,7 @@ pub use std::{ collections::{HashMap, HashSet, VecDeque}, fmt::{self, Debug, Display, Formatter}, hash::{Hash, Hasher}, + iter::Iterator, marker::PhantomData, mem::{swap, take, zeroed}, panic::{AssertUnwindSafe, catch_unwind}, @@ -27,22 +28,26 @@ pub use std::{ pub use {js_sys::*, lombok_macros::*, wasm_bindgen::prelude::*, web_sys::*}; -pub use bin_encode_decode::{Charset, EncodeError}; +pub(crate) use bin_encode_decode::{Charset, EncodeError}; /// The wall clock [`now_micros`] reads on a host build. On wasm the clock /// comes from JS instead, so importing this there is an `unused_imports` /// warning — the mirror of the dead-const warning the host build would give /// the other half of the same function. #[cfg(not(target_arch = "wasm32"))] -pub use std::time::{SystemTime, UNIX_EPOCH}; +pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) use renderer::*; use std::{ any::Any, - cell::{Cell, Ref, RefCell, UnsafeCell}, + cell::{Cell, Ref, RefCell, RefMut, UnsafeCell}, num::ParseIntError, rc::Rc, - sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + sync::{ + PoisonError, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + thread::AccessError, vec::Vec, }; diff --git a/core/src/noderef/impl.rs b/core/src/noderef/impl.rs index 2dd95566..9cbd70e1 100644 --- a/core/src/noderef/impl.rs +++ b/core/src/noderef/impl.rs @@ -109,6 +109,7 @@ impl NodeRef { /// `set` call. /// /// [`get`]: NodeRef::get + /// [`get_cloned`]: NodeRef::get_cloned pub fn clear(&self) { let cell: *mut Option = self.get_inner_ref().get(); unsafe { @@ -128,7 +129,7 @@ impl NodeRef { } } -// Blanket impl over the unsized `web_sys::Node` is what most users want, +// Blanket impl over the unsized `Node` is what most users want, // but the macro passes a `JsValue` and the user chooses `T` per use site, // so we don't constrain `T` here — `get_cloned`'s `JsCast` bound is the // single point where the type check happens. diff --git a/core/src/reactive/cache/impl.rs b/core/src/reactive/cache/impl.rs index 27da00f4..0877ee17 100644 --- a/core/src/reactive/cache/impl.rs +++ b/core/src/reactive/cache/impl.rs @@ -159,8 +159,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the entries in most-recently-used-first order. + pub fn iter(&self) -> impl Iterator { // We can't return the VecDeque order directly // because the entries would be in order-deque // order, not MRU-first order. Actually they @@ -177,8 +177,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the keys in most-recently-used-first order. + pub fn keys(&self) -> impl Iterator { self.get_order().iter() } @@ -187,8 +187,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the values in most-recently-used-first order. + pub fn values(&self) -> impl Iterator { self.get_order() .iter() .filter_map(|k: &K| self.get_map().get(k)) diff --git a/core/src/reactive/cache/mod.rs b/core/src/reactive/cache/mod.rs index d2a0a4e2..4dfc79f4 100644 --- a/core/src/reactive/cache/mod.rs +++ b/core/src/reactive/cache/mod.rs @@ -1,5 +1,6 @@ mod r#impl; mod r#struct; -use super::*; pub use r#struct::*; + +use super::*; diff --git a/core/src/reactive/cache/struct.rs b/core/src/reactive/cache/struct.rs index c19fbb0c..f67beaa7 100644 --- a/core/src/reactive/cache/struct.rs +++ b/core/src/reactive/cache/struct.rs @@ -39,14 +39,23 @@ where /// The maximum number of entries before eviction /// kicks in. #[get(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) capacity: usize, /// The current entries, keyed by K. Default-initialised /// via `#[new(skip)]` (`HashMap::new()`). #[new(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) map: HashMap, /// The MRU-first order. Front = most recently used, /// back = least recently used. Default-initialised /// via `#[new(skip)]` (`VecDeque::new()`). #[new(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) order: VecDeque, } diff --git a/core/src/reactive/cast/impl.rs b/core/src/reactive/cast/impl.rs index 5a43a8eb..6d9009c6 100644 --- a/core/src/reactive/cast/impl.rs +++ b/core/src/reactive/cast/impl.rs @@ -198,8 +198,15 @@ impl From for AttributeValue { /// # Arguments /// /// - `NativeEventHandler` - Input value to convert from. + /// + /// This is the base case of the `NativeEventHandler` -> + /// `AttrValueAdapter` -> `NativeEventHandler` + /// conversion cycle. Calling `.into()` on the adapter instead would + /// land back in this impl through the generic + /// `From>` (which unwraps and calls `T::into()`), + /// recursing forever. fn from(handler: NativeEventHandler) -> Self { - AttrValueAdapter::new(handler).into() + AttrValueAdapter::new(handler).into_callback_named(CALLBACK_EVENT_NAME) } } @@ -220,7 +227,9 @@ impl From> for AttributeValue { /// - `Option` - Input value to convert from. fn from(handler: Option) -> Self { match handler { - Some(event_handler) => AttrValueAdapter::new(event_handler).into(), + Some(event_handler) => { + AttrValueAdapter::new(event_handler).into_callback_named(CALLBACK_EVENT_NAME) + } None => AttributeValue::Text(String::new()), } } diff --git a/core/src/reactive/hook/impl.rs b/core/src/reactive/hook/impl.rs index 985cee95..fe171286 100644 --- a/core/src/reactive/hook/impl.rs +++ b/core/src/reactive/hook/impl.rs @@ -58,7 +58,7 @@ impl HookContext { /// # Returns /// /// - `NodeRef` - A `NodeRef` value. - pub fn noderef() -> NodeRef + pub(crate) fn noderef() -> NodeRef where T: ?Sized + 'static, { @@ -129,7 +129,7 @@ impl IntervalHandle { /// /// Panics if `window()` is unavailable on the current platform. pub fn clear(&self) { - if let Some(cleanup_window) = web_sys::window() { + if let Some(cleanup_window) = window() { cleanup_window.clear_interval_with_handle(self.get_interval_id()); } } @@ -206,7 +206,7 @@ impl HookContext { /// # Returns /// /// - `Signal` - A reactive signal containing the initialized or existing value. - pub fn signal(init: F) -> Signal + pub(crate) fn signal(init: F) -> Signal where T: Clone + PartialEq + 'static, F: FnOnce() -> T, @@ -246,7 +246,7 @@ impl HookContext { /// # Arguments /// /// - `F` - The cleanup callback to execute on context teardown. - pub fn cleanup(cleanup: F) + pub(crate) fn cleanup(cleanup: F) where F: FnOnce() + 'static, { @@ -279,7 +279,7 @@ impl HookContext { /// /// - `E` - The event name to listen for (e.g., "hashchange", "popstate", "resize"). /// - `F` - The callback to invoke when the event fires. - pub fn window_event(event_name: E, callback: F) + pub(crate) fn window_event(event_name: E, callback: F) where E: AsRef, F: FnMut() + 'static, @@ -327,7 +327,7 @@ impl HookContext { /// # Panics /// /// Panics if `window()` is unavailable on the current platform. - pub fn interval(millis: i32, callback: F) -> IntervalHandle + pub(crate) fn interval(millis: i32, callback: F) -> IntervalHandle where F: FnMut() + 'static, { @@ -343,11 +343,11 @@ impl HookContext { return *existing; } let closure: Closure = Closure::wrap(Box::new(callback)); - let Some(window) = window() else { + let Some(outer_window) = window() else { closure.forget(); return IntervalHandle::new(0); }; - let Ok(interval_id) = window.set_interval_with_callback_and_timeout_and_arguments_0( + let Ok(interval_id) = outer_window.set_interval_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), millis, ) else { @@ -357,7 +357,7 @@ impl HookContext { closure.forget(); let handle: IntervalHandle = IntervalHandle::new(interval_id); inner.get_mut_cleanups().push(Box::new(move || { - let Some(cleanup_window) = web_sys::window() else { + let Some(cleanup_window) = window() else { return; }; cleanup_window.clear_interval_with_handle(interval_id); diff --git a/core/src/reactive/hook/struct.rs b/core/src/reactive/hook/struct.rs index d71beb89..632700ff 100644 --- a/core/src/reactive/hook/struct.rs +++ b/core/src/reactive/hook/struct.rs @@ -12,8 +12,10 @@ pub struct HookContextInner { #[debug(skip)] pub hooks: Vec>, /// The match arm index from the last render. - #[get(type(copy))] - pub arm_changed: usize, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) arm_changed: usize, /// Current hook index, incremented on each hook call and reset per render. #[get(type(copy))] pub hook_index: usize, @@ -21,7 +23,10 @@ pub struct HookContextInner { /// be executed when the hook context is cleared due to a `match` arm /// switch. #[debug(skip)] - pub cleanups: Vec>, + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) cleanups: Vec>, } /// Manages hook state across render cycles for a DynamicNode. @@ -46,5 +51,8 @@ pub struct HookContext { pub struct IntervalHandle { /// The interval ID assigned by the browser. #[get(type(copy))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) interval_id: i32, } diff --git a/core/src/reactive/schedule/impl.rs b/core/src/reactive/schedule/impl.rs index 7dcee72f..5ca1829d 100644 --- a/core/src/reactive/schedule/impl.rs +++ b/core/src/reactive/schedule/impl.rs @@ -67,7 +67,7 @@ impl Scheduler { /// Whether a JS `Window` is reachable on this host. /// - /// `web_sys::window()` resolves the JS global through a + /// `window()` resolves the JS global through a /// process-wide `once_cell::Lazy` inside `js_sys`. On a non-WASM host /// (where `cargo test` runs) there is no JS global, so the lookup /// **panics** — and because the `Lazy` is process-wide, that one diff --git a/core/src/reactive/schedule/static.rs b/core/src/reactive/schedule/static.rs index 65697c03..87c066e2 100644 --- a/core/src/reactive/schedule/static.rs +++ b/core/src/reactive/schedule/static.rs @@ -4,11 +4,11 @@ use super::*; /// /// Set to `true` when `schedule_update()` queues a microtask, /// and reset to `false` when the dispatch callback fires. -pub static SCHEDULED: AtomicBool = AtomicBool::new(false); +pub(crate) static SCHEDULED: AtomicBool = AtomicBool::new(false); /// Suppress flag to prevent `schedule_update()` from dispatching /// during internal operations such as `batch`. -pub static SUPPRESS_SCHEDULE: AtomicBool = AtomicBool::new(false); +pub(crate) static SUPPRESS_SCHEDULE: AtomicBool = AtomicBool::new(false); thread_local! { /// The currently active `HookContext` for this thread. @@ -22,7 +22,7 @@ thread_local! { /// process with a refcount underflow. Per-thread storage makes the /// save/restore pair atomic with respect to other threads by /// construction. - pub static CURRENT_HOOK_CONTEXT: RefCell> = + pub(crate) static CURRENT_HOOK_CONTEXT: RefCell> = const { RefCell::new(None) }; } @@ -33,7 +33,7 @@ thread_local! { /// can register the dependency. `usize::MAX` means "no tracking active". /// /// SAFETY: Must only be accessed from the main thread (WASM single-threaded context). -pub static CURRENT_TRACKING_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(usize::MAX); +pub(crate) static CURRENT_TRACKING_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(usize::MAX); thread_local! { /// The persistent dispatch `Closure`, kept alive for the lifetime of the @@ -44,7 +44,7 @@ thread_local! { /// `schedule_update` call to schedule a fresh dispatch; if /// this never ran, the flag would stay `true` forever and every reactive /// update would be silently dropped. - pub static DISPATCH_CLOSURE: Closure = + pub(crate) static DISPATCH_CLOSURE: Closure = Closure::wrap(Box::new(|| { SCHEDULED.store(false, Ordering::Relaxed); Scheduler::dispatch_updates(); @@ -63,6 +63,6 @@ thread_local! { /// restoring the borrow check the `unsafe` had bypassed. It holds no /// raw pointer and no `!Send` payload, so thread-local storage is a /// pure win rather than a restriction. - pub static MICROTASK_CACHE: RefCell = + pub(crate) static MICROTASK_CACHE: RefCell = RefCell::new(MicrotaskCache { queue_microtask: None }); } diff --git a/core/src/reactive/schedule/struct.rs b/core/src/reactive/schedule/struct.rs index 26b3b893..63a06bae 100644 --- a/core/src/reactive/schedule/struct.rs +++ b/core/src/reactive/schedule/struct.rs @@ -26,6 +26,9 @@ pub(crate) struct MicrotaskCache { /// browser does not expose `queueMicrotask` (the dispatch path /// then falls through to `setTimeout` / `requestAnimationFrame`). #[debug(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) queue_microtask: Option, } diff --git a/core/src/reactive/signal/mod.rs b/core/src/reactive/signal/mod.rs index c6bba3e9..d530da9a 100644 --- a/core/src/reactive/signal/mod.rs +++ b/core/src/reactive/signal/mod.rs @@ -6,8 +6,6 @@ mod r#type; pub use r#struct::*; -pub(crate) use r#static::*; -pub(crate) use r#trait::*; -pub(crate) use r#type::*; +pub(crate) use {r#static::*, r#trait::*, r#type::*}; use super::*; diff --git a/core/src/reactive/signal/static.rs b/core/src/reactive/signal/static.rs index 305bbf3a..2b6e2330 100644 --- a/core/src/reactive/signal/static.rs +++ b/core/src/reactive/signal/static.rs @@ -23,5 +23,5 @@ thread_local! { /// construction, and `RefCell` restores the aliasing check that the old /// `unsafe` bypassed. - pub static SIGNAL_SLAB: RefCell = RefCell::new(SignalSlab::new()); + pub(crate) static SIGNAL_SLAB: RefCell = RefCell::new(SignalSlab::new()); } diff --git a/core/src/reactive/signal/struct.rs b/core/src/reactive/signal/struct.rs index c5efdcc9..25c622ef 100644 --- a/core/src/reactive/signal/struct.rs +++ b/core/src/reactive/signal/struct.rs @@ -72,6 +72,7 @@ where /// semantics are safe because only the slot index is copied — the actual /// slot is owned by the slab and is never freed or recycled. #[derive(CustomDebug, Data, Eq, Hash, New, Ord, PartialEq, PartialOrd)] +#[new(pub(crate))] pub struct Signal where T: Clone + PartialEq + 'static, @@ -81,6 +82,8 @@ where #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: usize, /// Marker for the generic type parameter (uses fn pointer to be `Copy` /// regardless of `T`). @@ -88,6 +91,9 @@ where #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) _marker: PhantomData T>, } @@ -98,6 +104,7 @@ where /// variable, but concurrent access from multiple threads would be /// undefined behavior. #[derive(CustomDebug, Data, New)] +#[new(pub(crate))] pub struct SignalCell where T: Clone + PartialEq + 'static, @@ -107,6 +114,9 @@ where #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: UnsafeCell>>, } @@ -148,5 +158,8 @@ pub struct FireHandle { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: usize, } diff --git a/core/src/renderer/dom/trait.rs b/core/src/renderer/dom/trait.rs index 97ed6795..64d3d08c 100644 --- a/core/src/renderer/dom/trait.rs +++ b/core/src/renderer/dom/trait.rs @@ -1,9 +1,9 @@ /// Extension trait for `Element` providing DOM attribute/property manipulation methods. /// /// Since Rust's orphan rules prevent adding inherent methods to foreign types like -/// `web_sys::Element`, this trait provides the same functionality through an extension +/// `Element`, this trait provides the same functionality through an extension /// trait pattern. All methods are available on any `Element` reference via trait dispatch. -pub trait ElementExt { +pub(crate) trait ElementExt { /// Removes or clears a DOM attribute/property, depending on the attribute name. /// /// For `value`, sets the DOM property to an empty string rather than calling diff --git a/core/src/renderer/dom_ops/fn.rs b/core/src/renderer/dom_ops/fn.rs index e1ba5633..67cd292b 100644 --- a/core/src/renderer/dom_ops/fn.rs +++ b/core/src/renderer/dom_ops/fn.rs @@ -84,12 +84,11 @@ pub(crate) fn ensure_dom_op_table() -> Option { // Cache the resolved table. A refused borrow only costs one extra // `Reflect::get` on the next patch, so the failure is ignored rather // than propagated. - let _: Result<(), std::thread::AccessError> = - DOM_OP_TABLE.try_with(|cell: &RefCell>| { - if let Ok(mut guard) = cell.try_borrow_mut() { - *guard = Some(table.clone()); - } - }); + let _: Result<(), AccessError> = DOM_OP_TABLE.try_with(|cell: &RefCell>| { + if let Ok(mut guard) = cell.try_borrow_mut() { + *guard = Some(table.clone()); + } + }); Some(table) } @@ -234,7 +233,7 @@ pub(crate) fn is_property_attr(name: &str) -> bool { /// single JS-side function call. /// /// On any failure (table unavailable, JS exception), the function falls -/// back to per-op `web_sys::Element::set_attribute` calls. +/// back to per-op `Element::set_attribute` calls. /// /// # Arguments /// @@ -274,7 +273,7 @@ pub(crate) fn apply_set_attr_batch(element: &Element, ops: &[(String, String)]) /// single JS-side function call. /// /// On any failure (table unavailable, JS exception), the function falls -/// back to per-op `web_sys::Element::remove_attribute` calls. +/// back to per-op `Element::remove_attribute` calls. /// /// # Arguments /// @@ -438,7 +437,7 @@ pub(crate) fn encoded_name_suffix() -> String { } masked }; - let raw: &str = std::str::from_utf8(&printable).unwrap_or(JS_DOM_OP_NAME_FALLBACK_SUFFIX); + let raw: &str = str::from_utf8(&printable).unwrap_or(JS_DOM_OP_NAME_FALLBACK_SUFFIX); let encoded: Result = Charset::new().charset(JS_DOM_OP_NAME_CHARSET).encode(raw); match encoded { diff --git a/core/src/renderer/dom_ops/struct.rs b/core/src/renderer/dom_ops/struct.rs index 3418e7d5..6c088c46 100644 --- a/core/src/renderer/dom_ops/struct.rs +++ b/core/src/renderer/dom_ops/struct.rs @@ -34,7 +34,7 @@ thread_local! { /// `Reflect::get(globalThis, "__euv_dom_ops__")` (or installed if /// missing); subsequent patches reuse the cached functions without any /// further global lookup. - pub static DOM_OP_TABLE: RefCell> = const { RefCell::new(None) }; + pub(crate) static DOM_OP_TABLE: RefCell> = const { RefCell::new(None) }; } /// The per-load set of names the batched DOM-op helpers are published under. diff --git a/core/src/renderer/mod.rs b/core/src/renderer/mod.rs index 1b7fb75a..cd8e4fa1 100644 --- a/core/src/renderer/mod.rs +++ b/core/src/renderer/mod.rs @@ -3,6 +3,6 @@ mod dom_ops; mod registry; mod render; -pub(crate) use {dom::*, registry::*, render::*}; +pub(crate) use {dom::*, dom_ops::*, registry::*, render::*}; use super::*; diff --git a/core/src/renderer/registry/const.rs b/core/src/renderer/registry/const.rs index b83226ba..3801c06e 100644 --- a/core/src/renderer/registry/const.rs +++ b/core/src/renderer/registry/const.rs @@ -1,3 +1,5 @@ +use super::*; + /// The DOM attribute name used to store the unique euv identifier on an element. /// /// This attribute is set on every element that registers an event listener @@ -115,9 +117,9 @@ thread_local! { /// records "looked, and the global is absent". That way a host which /// strips the helper costs one lookup per page instead of one per /// event, and the fallback path can be taken without a second probe. - pub static EVENT_ID_CHAIN_FN: std::cell::RefCell< - Option>, - > = const { std::cell::RefCell::new(None) }; + pub(crate) static EVENT_ID_CHAIN_FN: RefCell< + Option>, + > = const { RefCell::new(None) }; } /// The JS global object name resolved when no direct handle is available. diff --git a/core/src/renderer/registry/fn.rs b/core/src/renderer/registry/fn.rs index 3bc25e12..a8eb5edf 100644 --- a/core/src/renderer/registry/fn.rs +++ b/core/src/renderer/registry/fn.rs @@ -48,7 +48,7 @@ fn global_this() -> Option { { return Some(value); } - let window_value: web_sys::Window = window()?; + let window_value: Window = window()?; Some(window_value.into()) } @@ -101,7 +101,7 @@ fn event_id_chain_fn() -> Option { }) .filter(|value: &JsValue| value.is_function()) .and_then(|value: JsValue| value.dyn_into::().ok()); - let mut slot: std::cell::RefMut<'_, Option>> = cell.borrow_mut(); + let mut slot: RefMut<'_, Option>> = cell.borrow_mut(); *slot = Some(resolved.clone()); resolved }) @@ -174,21 +174,21 @@ pub(crate) fn euv_event_collect_id_chain(event: &JsValue, max_depth: usize) -> F /// /// - `Float64Array` - The parsed `data-euv-id` values in walk order. fn collect_id_chain_rust(event: &JsValue, max_depth: usize) -> Float64Array { - let event_target: Option = + let event_target: Option = js_sys::Reflect::get(event, &JsValue::from_str(EVENT_TARGET_PROP)) .ok() - .and_then(|value: JsValue| value.dyn_into::().ok()); + .and_then(|value: JsValue| value.dyn_into::().ok()); let Some(target) = event_target else { return Float64Array::new_with_length(0); }; let mut ids: Vec = Vec::new(); - let mut node: Option = Some(target.unchecked_into::()); + let mut node: Option = Some(target.unchecked_into::()); let mut depth: usize = 0; while let Some(current) = node { if max_depth != 0 && depth >= max_depth { break; } - let element: Option<&web_sys::Element> = current.dyn_ref::(); + let element: Option<&Element> = current.dyn_ref::(); if let Some(el) = element && let Some(id_str) = el.get_attribute(DATA_EUV_ID) && let Ok(parsed) = id_str.parse::() diff --git a/core/src/renderer/registry/static.rs b/core/src/renderer/registry/static.rs index 5b4df9ba..535cfd67 100644 --- a/core/src/renderer/registry/static.rs +++ b/core/src/renderer/registry/static.rs @@ -1,16 +1,16 @@ use super::*; /// Global auto-incrementing ID counter for DOM elements. -pub static NEXT_EUV_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_EUV_ID: AtomicUsize = AtomicUsize::new(0); /// Global auto-incrementing ID counter for DynamicNode placeholder elements. -pub static NEXT_EUV_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_EUV_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(0); /// Whether `dispatch_updates` is currently executing. -pub static SIGNAL_UPDATE_DISPATCHING: AtomicBool = AtomicBool::new(false); +pub(crate) static SIGNAL_UPDATE_DISPATCHING: AtomicBool = AtomicBool::new(false); /// Global auto-incrementing ID counter for window event handler entries. -pub static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); thread_local! { /// Set of dynamic node IDs marked dirty since the last dispatch drain. @@ -43,7 +43,7 @@ thread_local! { /// `HashSet` corrupted its bucket array. Thread-local storage makes /// each thread's dirty set structurally invisible to the others, and /// `RefCell` restores the borrow check the `unsafe` had bypassed. - pub static DIRTY_UPDATE_IDS: RefCell> = RefCell::new(HashSet::new()); + pub(crate) static DIRTY_UPDATE_IDS: RefCell> = RefCell::new(HashSet::new()); /// Global handler registry, mapping (element_id, event_name) to HandlerEntry. /// @@ -52,21 +52,21 @@ thread_local! { /// `&'static mut HandlerRegistryMap` behind an unsound `unsafe impl /// Sync`, so a parallel test run could observe two live /// `&mut` references to the same map. - pub static HANDLER_REGISTRY: RefCell = RefCell::new(HashMap::new()); + pub(crate) static HANDLER_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global set of event names that have already been delegated at the window level. /// /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashSet` across threads. - pub static DELEGATED_EVENTS: RefCell> = RefCell::new(HashSet::new()); + pub(crate) static DELEGATED_EVENTS: RefCell> = RefCell::new(HashSet::new()); /// Global signal update callback registry, mapping keys to SignalUpdateEntry. /// /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a /// racing initialisation and aliased a `HashMap` across threads. - pub static SIGNAL_UPDATE_REGISTRY: RefCell> = + pub(crate) static SIGNAL_UPDATE_REGISTRY: RefCell> = RefCell::new(HashMap::new()); /// Global window event proxy registry, mapping event names to handler lists. @@ -74,7 +74,7 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static WINDOW_EVENT_REGISTRY: RefCell = + pub(crate) static WINDOW_EVENT_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global `NodeRef` registry used to clear `NodeRef` handles when the @@ -89,7 +89,7 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static NODEREF_REGISTRY: RefCell = RefCell::new(HashMap::new()); + pub(crate) static NODEREF_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global binding-cleanup registry, mapping `euv_id` to the teardown thunks /// of the signal bindings installed on that element. @@ -103,5 +103,5 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static BINDING_CLEANUPS: RefCell = RefCell::new(HashMap::new()); + pub(crate) static BINDING_CLEANUPS: RefCell = RefCell::new(HashMap::new()); } diff --git a/core/src/renderer/registry/struct.rs b/core/src/renderer/registry/struct.rs index cb215f6b..f5d0a9b2 100644 --- a/core/src/renderer/registry/struct.rs +++ b/core/src/renderer/registry/struct.rs @@ -40,6 +40,7 @@ pub(crate) struct SignalUpdateSlot { /// The callback to invoke when signal update events fire. #[debug(skip)] #[get(skip)] + #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) callback: Option>, /// Whether this slot has been marked for removal. diff --git a/core/src/renderer/registry/type.rs b/core/src/renderer/registry/type.rs index 51e3b204..cc92adff 100644 --- a/core/src/renderer/registry/type.rs +++ b/core/src/renderer/registry/type.rs @@ -5,14 +5,14 @@ use super::*; /// Stores a raw pointer to a heap-allocated `HandlerSlot`. The allocation /// is owned by the registry and freed during cleanup. Direct pointer access /// avoids `Rc>` overhead in the event dispatch hot path. -pub type HandlerEntry = *mut HandlerSlot; +pub(crate) type HandlerEntry = *mut HandlerSlot; /// Type alias for the signal update registry value. /// /// Stores a raw pointer to a heap-allocated `SignalUpdateSlot`. The allocation /// is owned by the registry and freed during cleanup or sweep. Direct pointer /// access avoids `Rc>` overhead in the signal dispatch hot path. -pub type SignalUpdateEntry = *mut SignalUpdateSlot; +pub(crate) type SignalUpdateEntry = *mut SignalUpdateSlot; /// Type alias for the handler registry map. /// @@ -20,27 +20,27 @@ pub type SignalUpdateEntry = *mut SignalUpdateSlot; /// single `HashMap::remove(&euv_id)` instead of a full-registry scan. Uses /// `&'static str` for event names to avoid allocation on every dispatch lookup. /// Known event names are compile-time constants; custom names are leaked once via `as_str()`. -pub type HandlerRegistryMap = HashMap>; +pub(crate) type HandlerRegistryMap = HashMap>; /// Type alias for a single window event handler entry in the proxy registry. /// /// Each entry holds a unique handler ID and a raw pointer to a heap-allocated /// callback. The ID allows targeted removal during cleanup without disrupting /// other handlers. -pub type WindowEventHandlerEntry = (usize, *mut Box); +pub(crate) type WindowEventHandlerEntry = (usize, *mut Box); /// Type alias for the window event proxy registry map. /// /// Maps event names to a list of handler entries. All handlers for the same /// event name share a single `window.addEventListener` listener (the proxy), /// which iterates this list and invokes each callback on every event. -pub type WindowEventRegistryMap = HashMap>; +pub(crate) type WindowEventRegistryMap = HashMap>; /// Type alias for a single `NodeRef` registration in the unmount-clear registry. /// /// NP-3: holds a clone of the `NodeRef`'s interior cell so `cleanup_subtree` /// can call `clear()` on every handle that pointed at a now-unmounted element. -pub type NodeRefEntry = Rc>>; +pub(crate) type NodeRefEntry = Rc>>; /// Type alias for the `NodeRef` unmount-clear registry. /// @@ -49,7 +49,7 @@ pub type NodeRefEntry = Rc>>; /// element. Removing an element from the DOM causes a single /// `HashMap::remove(&euv_id)` followed by iterating the entries to call /// `clear()` on each cell. -pub type NodeRefRegistryMap = HashMap>; +pub(crate) type NodeRefRegistryMap = HashMap>; /// Type alias for a single binding-teardown thunk. /// @@ -58,11 +58,11 @@ pub type NodeRefRegistryMap = HashMap>; /// exactly one subscription via [`Signal::unsubscribe`], so removing a DOM /// subtree tears its bindings down without touching the source signal's /// other listeners or its `alive` flag. -pub type BindingCleanup = Box; +pub(crate) type BindingCleanup = Box; /// Type alias for the binding-cleanup registry. /// /// Maps `euv_id` to the teardown thunks of every signal binding installed on /// that element. Drained by `cleanup_subtree` when the element leaves the /// DOM. -pub type BindingCleanupsMap = HashMap>; +pub(crate) type BindingCleanupsMap = HashMap>; diff --git a/core/src/renderer/render/const.rs b/core/src/renderer/render/const.rs index 9e769200..6689c6a6 100644 --- a/core/src/renderer/render/const.rs +++ b/core/src/renderer/render/const.rs @@ -59,6 +59,6 @@ thread_local! { /// Single-threaded cache for the page's `Document`. SAFETY: euv /// runs on the main thread in WASM contexts, and `Document::clone` /// is a cheap reference count bump on the underlying `JsValue`. - pub static DOCUMENT_CACHE: UnsafeCell> = + pub(crate) static DOCUMENT_CACHE: UnsafeCell> = const { UnsafeCell::new(None) }; } diff --git a/core/src/renderer/render/fn.rs b/core/src/renderer/render/fn.rs index c6909893..e868c97e 100644 --- a/core/src/renderer/render/fn.rs +++ b/core/src/renderer/render/fn.rs @@ -189,7 +189,7 @@ where } return; } - let mut iter = nodes.into_iter(); + let mut iter: ::IntoIter = nodes.into_iter(); let Some(first) = iter.next() else { return; }; @@ -496,7 +496,7 @@ pub(crate) fn euv_collect_subtree_ids(root: &Element) -> Float64Array { // typical cleanup_subtree calls (5–30 nodes) the Rust loop pays the // same crossings in aggregate without a separate JS module load. let mut out: Vec = Vec::new(); - let mut stack: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); stack.push(root.clone()); while let Some(node) = stack.pop() { let euv_attr: Option = node.get_attribute(DATA_EUV_ID); diff --git a/core/src/renderer/render/impl.rs b/core/src/renderer/render/impl.rs index 7d850ef8..01f5ec7b 100644 --- a/core/src/renderer/render/impl.rs +++ b/core/src/renderer/render/impl.rs @@ -64,7 +64,7 @@ impl Renderer { /// # Arguments /// /// - `VirtualNode` - The new virtual DOM tree to render. - pub fn render(&mut self, vnode: VirtualNode) { + pub(crate) fn render(&mut self, vnode: VirtualNode) { let new_unwrapped: VirtualNode = Self::unwrap_component_owned(vnode); let old_tree: Option = take(self.get_mut_current_tree()); if let Some(old_vnode) = old_tree.as_ref() { @@ -90,7 +90,7 @@ impl Renderer { /// # Arguments /// /// - `VirtualNode` - The new virtual DOM tree to render. - pub fn render_full_replace(&mut self, vnode: VirtualNode) { + pub(crate) fn render_full_replace(&mut self, vnode: VirtualNode) { let new_unwrapped: VirtualNode = Self::unwrap_component_owned(vnode); while let Some(child) = self.get_root().first_child() { if let Some(element) = child.dyn_ref::() { @@ -366,7 +366,7 @@ impl Renderer { _ => None, }) .any(|old_callback: &SharedEventCallback| { - std::ptr::eq(Rc::as_ptr(old_callback), Rc::as_ptr(new_callback)) + Rc::ptr_eq(old_callback, new_callback) }); if !already_attached { self.attach_event_listener(element, handler); diff --git a/core/src/renderer/render/mod.rs b/core/src/renderer/render/mod.rs index 3ffe0377..e5f38a93 100644 --- a/core/src/renderer/render/mod.rs +++ b/core/src/renderer/render/mod.rs @@ -4,7 +4,6 @@ mod r#fn; mod r#impl; mod r#struct; -pub(crate) use super::dom_ops::*; pub(crate) use {r#const::*, r#enum::*, r#fn::*, r#struct::*}; use super::*; diff --git a/core/src/vdom/attribute/enum.rs b/core/src/vdom/attribute/enum.rs index 5a46b10a..9ad266cb 100644 --- a/core/src/vdom/attribute/enum.rs +++ b/core/src/vdom/attribute/enum.rs @@ -38,7 +38,7 @@ pub enum AttributeValue { /// A raw HTML fragment assigned via the `inner_html:` attribute. /// /// Replaces the element's children wholesale via - /// [`web_sys::Element::set_inner_html`]. Unlike `Text` (which the + /// [`Element::set_inner_html`]. Unlike `Text` (which the /// browser escapes), this variant trusts the input string and runs /// any embedded `