fix(typeahead): implement component isolation to prevent memory leaks and race conditions

This commit is contained in:
ryan 2025-04-25 17:52:30 +03:00
parent 1fd4131298
commit 0f28394fce

View file

@ -22,70 +22,134 @@ pub fn TypeaheadInput(
) -> impl IntoView {
let (is_initialized, set_initialized) = create_signal(false);
// Create a unique ID for this component instance
let component_id = format!("typeahead-{}", uuid::Uuid::new_v4());
// Flag to track if component is mounted
let is_mounted = Rc::new(RefCell::new(true));
let is_mounted_clone = is_mounted.clone();
// Cleanup function to run when component is unmounted
on_cleanup(move || {
log!("[CLEANUP] TypeaheadInput component unmounting");
*is_mounted_clone.borrow_mut() = false;
});
// Clone necessary values for the async task
let fetch_suggestions_clone = fetch_suggestions.clone();
let on_select_clone = on_select.clone();
let node_ref_clone = node_ref.clone();
let component_id_clone = component_id.clone();
// Create a cancellation token for the async task
let (cancel_token, set_cancel_token) = create_signal(false);
// Spawn the initialization task using spawn_local instead of spawn_local_with_handle
spawn_local(async move {
log!("[INIT] Component mounted");
log!("[INIT] Component mounted: {}", component_id_clone);
let mut retries = 0;
while retries < 10 {
while retries < 10 && !cancel_token.get() {
// Check if component is still mounted before proceeding
if !*is_mounted.borrow() {
log!("[INIT] Component unmounted, aborting initialization");
log!("[INIT] Component unmounted, aborting initialization: {}", component_id_clone);
return;
}
if let Some(input) = node_ref_clone.get() {
log!("[INIT] Input element found");
log!("[INIT] Input element found: {}", component_id_clone);
// Only proceed if component is still mounted
if !*is_mounted.borrow() {
log!("[INIT] Component unmounted after input found, aborting");
if !*is_mounted.borrow() || cancel_token.get() {
log!("[INIT] Component unmounted after input found, aborting: {}", component_id_clone);
return;
}
let bloodhound = initialize_bloodhound(fetch_suggestions_clone.clone());
let bloodhound = initialize_bloodhound(fetch_suggestions_clone.clone(), &component_id_clone);
// Store bloodhound globally
js_sys::Reflect::set(
// Store bloodhound in a component-specific global variable
let bloodhound_var = format!("bloodhoundInstance_{}", component_id_clone.replace("-", "_"));
if let Err(_) = js_sys::Reflect::set(
&js_sys::global(),
&"bloodhoundInstance".into(),
&bloodhound_var.into(),
&bloodhound
).unwrap();
) {
log!("[ERROR] Failed to store bloodhound instance: {}", component_id_clone);
}
// Only proceed if component is still mounted
if !*is_mounted.borrow() {
log!("[INIT] Component unmounted before typeahead init, aborting");
if !*is_mounted.borrow() || cancel_token.get() {
log!("[INIT] Component unmounted before typeahead init, aborting: {}", component_id_clone);
return;
}
initialize_typeahead(&input, bloodhound, on_select_clone.clone(), node_ref_clone.clone());
initialize_typeahead(&input, bloodhound, on_select_clone.clone(), node_ref_clone.clone(), &component_id_clone);
// Only set initialized if component is still mounted
if *is_mounted.borrow() {
set_initialized.set(true);
if *is_mounted.borrow() && !cancel_token.get() {
// Use a try_update to safely update the signal
let _ = try_with_owner(Owner::current().unwrap(), move || {
set_initialized.set(true);
});
}
break;
}
// Check if component is still mounted before sleeping
if !*is_mounted.borrow() || cancel_token.get() {
log!("[INIT] Component unmounted during retry loop, aborting: {}", component_id_clone);
return;
}
gloo_timers::future::sleep(Duration::from_millis(100)).await;
retries += 1;
}
});
// Clone component_id for on_cleanup
let component_id_for_cleanup = component_id.clone();
// Comprehensive cleanup function
on_cleanup(move || {
log!("[CLEANUP] TypeaheadInput component unmounting: {}", component_id_for_cleanup);
// Signal the async task to cancel
set_cancel_token.set(true);
// Mark component as unmounted
*is_mounted_clone.borrow_mut() = false;
// Clean up component-specific global references
let bloodhound_var = format!("bloodhoundInstance_{}", component_id_for_cleanup.replace("-", "_"));
let prepare_fn_var = format!("bloodhoundPrepare_{}", component_id_for_cleanup.replace("-", "_"));
let cleanup_script = format!(r#"
try {{
// Clean up the bloodhound instance
if (window['{bloodhound_var}']) {{
delete window['{bloodhound_var}'];
}}
// Clean up the prepare function
if (window['{prepare_fn_var}']) {{
delete window['{prepare_fn_var}'];
}}
// Clean up any typeahead instances
if (window.typeaheadCleanupFunctions && window.typeaheadCleanupFunctions['{component_id}']) {{
window.typeaheadCleanupFunctions['{component_id}']();
delete window.typeaheadCleanupFunctions['{component_id}'];
}}
console.log('[JS] Global cleanup completed for {component_id}');
}} catch (e) {{
console.error('[JS] Cleanup error for {component_id}:', e);
}}
"#,
bloodhound_var = bloodhound_var,
prepare_fn_var = prepare_fn_var,
component_id = component_id_for_cleanup
);
if let Err(e) = js_sys::eval(&cleanup_script) {
log!("[RUST] Cleanup script eval error: {:?}", e);
}
});
// CSS
let css = r#"
.typeahead.tt-input {
@ -141,6 +205,11 @@ pub fn TypeaheadInput(
}
"#;
// Clone component_id for event handlers
let component_id_for_focus = component_id.clone();
let component_id_for_blur = component_id.clone();
let component_id_for_input = component_id.clone();
view! {
<style>
{css}
@ -151,11 +220,11 @@ pub fn TypeaheadInput(
class="typeahead-input"
prop:value=value
node_ref=node_ref
on:focus=move |_| log!("[FOCUS] Name input focused")
on:blur=move |_| log!("[FOCUS] Name input blurred")
on:focus=move |_| log!("[FOCUS] Name input focused: {}", component_id_for_focus)
on:blur=move |_| log!("[FOCUS] Name input blurred: {}", component_id_for_blur)
on:input=move |ev| {
let value = event_target_value(&ev);
log!("[INPUT] Value changed: {}", value);
log!("[INPUT] Value changed: {} ({})", value, component_id_for_input);
// If this is the last row and we have an on_input callback, call it
if is_last_row && !value.is_empty() {
@ -163,40 +232,32 @@ pub fn TypeaheadInput(
callback.call(value.clone());
}
}
let _ = js_sys::eval("console.log('jQuery version:', $.fn.jquery)");
let _ = js_sys::eval("console.log('Typeahead version:', $.fn.typeahead ? 'loaded' : 'missing')");
}
/>
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = "Bloodhound")]
type Bloodhound;
#[wasm_bindgen(constructor, js_namespace = window)]
fn new(options: &JsValue) -> Bloodhound;
#[wasm_bindgen(method)]
fn initialize(this: &Bloodhound, prefetch: bool);
#[wasm_bindgen(method, js_name = "ttAdapter")]
fn tt_adapter(this: &Bloodhound) -> JsValue;
}
fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>) -> JsValue {
fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>, component_id: &str) -> JsValue {
let bloodhound_options = Object::new();
// Use component-specific names for global functions
let prepare_fn_var = format!("bloodhoundPrepare_{}", component_id.replace("-", "_"));
// Create a closure that will be called by Bloodhound to fetch suggestions
let remote_fn = Closure::wrap(Box::new(move |query: JsValue, sync: Function, async_fn: Function| {
let remote_fn = Closure::wrap(Box::new(move |query: JsValue, sync: Function, _async_fn: Function| {
let query_str = query.as_string().unwrap_or_default();
log!("[BLOODHOUND] Fetching suggestions for: {}", query_str);
// Get suggestions from the callback
let suggestions = fetch.call(query_str.clone());
// Safely call the fetch callback
let suggestions = match try_with_owner(Owner::current().unwrap(), move || {
fetch.call(query_str.clone())
}) {
Ok(suggs) => suggs,
Err(e) => {
log!("[ERROR] Failed to fetch suggestions: {:?}", e);
Vec::new()
}
};
// Create a JavaScript array to hold the suggestions
let js_suggestions = Array::new();
@ -206,19 +267,20 @@ fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>) -> Js
let obj = Object::new();
// Store the original ID, label, and description
Reflect::set(&obj, &"id".into(), &JsValue::from_str(&suggestion.id)).unwrap();
Reflect::set(&obj, &"label".into(), &JsValue::from_str(&suggestion.label)).unwrap();
Reflect::set(&obj, &"description".into(), &JsValue::from_str(&suggestion.description)).unwrap();
Reflect::set(&obj, &"id".into(), &JsValue::from_str(&suggestion.id)).unwrap_or_default();
Reflect::set(&obj, &"label".into(), &JsValue::from_str(&suggestion.label)).unwrap_or_default();
Reflect::set(&obj, &"description".into(), &JsValue::from_str(&suggestion.description)).unwrap_or_default();
// Store the display values directly on the object for easier access
Reflect::set(&obj, &"displayLabel".into(),
&JsValue::from_str(&suggestion.display.label.value)).unwrap();
&JsValue::from_str(&suggestion.display.label.value)).unwrap_or_default();
Reflect::set(&obj, &"displayDescription".into(),
&JsValue::from_str(&suggestion.display.description.value)).unwrap();
&JsValue::from_str(&suggestion.display.description.value)).unwrap_or_default();
// Store the full suggestion for later retrieval
let full_suggestion = JsValue::from_serde(&suggestion).unwrap();
Reflect::set(&obj, &"fullSuggestion".into(), &full_suggestion).unwrap();
if let Ok(full_suggestion) = JsValue::from_serde(&suggestion) {
Reflect::set(&obj, &"fullSuggestion".into(), &full_suggestion).unwrap_or_default();
}
// Add the object to the array
js_suggestions.push(&obj);
@ -236,55 +298,64 @@ fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>) -> Js
// Set transport function to avoid AJAX requests
let transport_fn = js_sys::Function::new_with_args(
"query, syncResults, asyncResults",
r#"
&format!(r#"
// Call our custom prepare function directly
window.bloodhoundPrepare(query, syncResults, asyncResults);
"#
if (window['{prepare_fn_var}']) {{
window['{prepare_fn_var}'](query, syncResults, asyncResults);
}} else {{
console.error('[JS] Prepare function not found: {prepare_fn_var}');
syncResults([]);
}}
"#, prepare_fn_var = prepare_fn_var)
);
Reflect::set(
&remote_config,
&"transport".into(),
&transport_fn
).unwrap();
).unwrap_or_default();
// Set a dummy URL (not actually used with custom transport)
Reflect::set(
&remote_config,
&"url".into(),
&JsValue::from_str("/dummy?query=%QUERY")
).unwrap();
).unwrap_or_default();
// Store our prepare function globally
js_sys::Reflect::set(
// Store our prepare function globally with component-specific name
// Clone prepare_fn_var before using .into() which consumes it
let prepare_fn_var_for_log = prepare_fn_var.clone();
if let Err(_) = js_sys::Reflect::set(
&js_sys::global(),
&"bloodhoundPrepare".into(),
&prepare_fn_var.into(),
remote_fn.as_ref().unchecked_ref()
).unwrap();
) {
log!("[ERROR] Failed to store prepare function: {}", prepare_fn_var_for_log);
}
// Set rate limiting to prevent too many requests
Reflect::set(
&remote_config,
&"rateLimitWait".into(),
&JsValue::from(300)
).unwrap();
).unwrap_or_default();
// Set the wildcard for query replacement
Reflect::set(
&remote_config,
&"wildcard".into(),
&JsValue::from_str("%QUERY")
).unwrap();
).unwrap_or_default();
// Add the remote config to the options
Reflect::set(&bloodhound_options, &"remote".into(), &remote_config).unwrap();
Reflect::set(&bloodhound_options, &"remote".into(), &remote_config).unwrap_or_default();
// Set the tokenizers
let tokenizer = js_sys::Function::new_no_args(
r#"
return function(query) {
return query.trim().split(/\s+/);
}
return function(datum) {
return datum.toString().trim().split(/\s+/);
};
"#
);
@ -292,13 +363,13 @@ fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>) -> Js
&bloodhound_options,
&"datumTokenizer".into(),
&tokenizer
).unwrap();
).unwrap_or_default();
Reflect::set(
&bloodhound_options,
&"queryTokenizer".into(),
&tokenizer
).unwrap();
).unwrap_or_default();
// Create and initialize the Bloodhound instance
let bloodhound = Bloodhound::new(&bloodhound_options.into());
@ -316,52 +387,62 @@ fn initialize_typeahead(
bloodhound: JsValue,
on_select: Callback<WikidataSuggestion>,
node_ref: NodeRef<Input>,
component_id: &str,
) {
log!("[TYPEAHEAD] Initializing for input: {}", input.id());
let input_id = format!("typeahead-{}", uuid::Uuid::new_v4());
log!("[TYPEAHEAD] Initializing for input: {} ({})", input.id(), component_id);
let input_id = format!("typeahead-input-{}", component_id);
input.set_id(&input_id);
// Create selection handler closure
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event, suggestion: JsValue| {
log!("[TYPEAHEAD] Selection made");
// Try to get the full suggestion from the suggestion object
if let Some(full_suggestion) = js_sys::Reflect::get(&suggestion, &"fullSuggestion".into()).ok() {
if let Ok(data) = full_suggestion.into_serde::<WikidataSuggestion>() {
log!("[TYPEAHEAD] Selected suggestion: {:?}", data);
// Safely call the on_select callback
let _ = try_with_owner(Owner::current().unwrap(), move || {
// Try to get the full suggestion from the suggestion object
if let Some(full_suggestion) = js_sys::Reflect::get(&suggestion, &"fullSuggestion".into()).ok() {
if let Ok(data) = full_suggestion.into_serde::<WikidataSuggestion>() {
log!("[TYPEAHEAD] Selected suggestion: {:?}", data);
on_select.call(data.clone());
if let Some(input) = node_ref.get() {
input.set_value(&data.label);
}
return;
}
}
// Fallback: try to deserialize the suggestion directly
if let Ok(data) = suggestion.into_serde::<WikidataSuggestion>() {
log!("[TYPEAHEAD] Selected suggestion (fallback): {:?}", data);
on_select.call(data.clone());
if let Some(input) = node_ref.get() {
input.set_value(&data.label);
}
return;
} else {
log!("[ERROR] Failed to deserialize suggestion");
}
}
// Fallback: try to deserialize the suggestion directly
if let Ok(data) = suggestion.into_serde::<WikidataSuggestion>() {
log!("[TYPEAHEAD] Selected suggestion (fallback): {:?}", data);
on_select.call(data.clone());
if let Some(input) = node_ref.get() {
input.set_value(&data.label);
}
} else {
log!("[ERROR] Failed to deserialize suggestion");
}
});
}) as Box<dyn FnMut(web_sys::Event, JsValue)>);
// Register global handler
// Register global handler with component-specific name
let handler_name = format!("handler_{}", input_id.replace("-", "_"));
log!("[TYPEAHEAD] Registering handler with name: {}", handler_name);
js_sys::Reflect::set(
// Clone handler_name before using it in error log
let handler_name_for_log = handler_name.clone();
if let Err(_) = js_sys::Reflect::set(
&js_sys::global(),
&handler_name.clone().into(),
closure.as_ref().unchecked_ref(),
).unwrap();
) {
log!("[ERROR] Failed to register handler: {}", handler_name_for_log);
}
// We'll clean this up in the component's cleanup function
closure.forget();
// Cleanup code to remove the typeahead when component is unmounted
// Register cleanup function for this specific typeahead instance
let cleanup_script = format!(
r#"
// Store a reference to the cleanup function for this input
@ -369,43 +450,71 @@ fn initialize_typeahead(
window.typeaheadCleanupFunctions = {{}};
}}
window.typeaheadCleanupFunctions['{id}'] = function() {{
window.typeaheadCleanupFunctions['{component_id}'] = function() {{
try {{
$('#{id}').typeahead('destroy');
delete window['{handler}'];
console.log('[JS] Typeahead cleanup for #{id} completed');
// Destroy the typeahead instance
$('#{input_id}').typeahead('destroy');
// Remove the handler
if (window['{handler_name}']) {{
delete window['{handler_name}'];
}}
console.log('[JS] Typeahead cleanup for #{input_id} completed');
}} catch (e) {{
console.error('[JS] Typeahead cleanup error:', e);
console.error('[JS] Typeahead cleanup error for #{input_id}:', e);
}}
}};
"#,
id = input_id,
handler = handler_name
component_id = component_id,
input_id = input_id,
handler_name = handler_name_for_log
);
if let Err(e) = js_sys::eval(&cleanup_script) {
log!("[RUST] Cleanup script eval error: {:?}", e);
}
// Initialization script with enhanced logging
// Get the bloodhound instance from the component-specific global variable
let bloodhound_var = format!("bloodhoundInstance_{}", component_id.replace("-", "_"));
// Initialization script with enhanced logging and error handling
let init_script = format!(
r#"
console.log('[JS] Starting Typeahead init for #{id}');
console.log('[JS] Starting Typeahead init for #{input_id}');
try {{
var bloodhound = window.bloodhoundInstance;
// Get the bloodhound instance from our component-specific variable
var bloodhound = window['{bloodhound_var}'];
if (!bloodhound) {{
throw new Error('Bloodhound instance not found: {bloodhound_var}');
}}
// Define a custom source function that directly uses our Rust callback
var customSource = function(query, syncResults, asyncResults) {{
console.log('[JS] Custom source called with query:', query);
// Call our global prepare function directly
window.bloodhoundPrepare(query, function(suggestions) {{
// Get the prepare function from our component-specific variable
var prepareFn = window['{prepare_fn_var}'];
if (!prepareFn) {{
console.error('[JS] Prepare function not found: {prepare_fn_var}');
syncResults([]);
return;
}}
// Call our prepare function
prepareFn(query, function(suggestions) {{
console.log('[JS] Suggestions from custom source:', suggestions);
syncResults(suggestions);
}}, asyncResults);
}};
$('#{id}').typeahead(
// Initialize typeahead with error handling
var $input = $('#{input_id}');
if ($input.length === 0) {{
throw new Error('Input element not found: #{input_id}');
}}
$input.typeahead(
{{
hint: true,
highlight: true,
@ -414,58 +523,72 @@ fn initialize_typeahead(
{{
name: 'suggestions',
display: function(data) {{
console.log('[JS] Display function called with data:', data);
if (!data) return '';
return data.displayLabel || data.label || '';
}},
source: customSource,
templates: {{
suggestion: function(data) {{
console.log('[JS] Rendering suggestion:', data);
if (!data) return $('<div>').addClass('empty-suggestion').text('Invalid data');
return $('<div>')
.addClass('suggestion-item')
.append($('<div>').addClass('label').text(data.displayLabel || data.label))
.append($('<div>').addClass('description').text(data.displayDescription || data.description));
.append($('<div>').addClass('label').text(data.displayLabel || data.label || ''))
.append($('<div>').addClass('description').text(data.displayDescription || data.description || ''));
}},
empty: function() {{
console.log('[JS] No suggestions found');
return $('<div>').addClass('empty-suggestion').text('No matches found');
}}
}}
}}
)
.on('typeahead:select', function(ev, suggestion) {{
if (!suggestion) {{
console.error('[JS] Selection event received with null suggestion');
return;
}}
console.log('[JS] Selection event received with suggestion:', suggestion);
window['{handler}'](ev, suggestion);
// Get the handler from our component-specific variable
var handler = window['{handler_name}'];
if (!handler) {{
console.error('[JS] Handler function not found: {handler_name}');
return;
}}
// Call the handler
handler(ev, suggestion);
}});
console.log('[JS] Typeahead initialized successfully');
console.log('[JS] Typeahead initialized successfully for #{input_id}');
}} catch (e) {{
console.error('[JS] Typeahead init error:', e);
console.error('[JS] Typeahead init error for #{input_id}:', e);
}}
"#,
id = input_id,
handler = handler_name
input_id = input_id,
bloodhound_var = bloodhound_var,
prepare_fn_var = format!("bloodhoundPrepare_{}", component_id.replace("-", "_")),
handler_name = handler_name_for_log
);
log!("[RUST] Initialization script: {}", init_script);
log!("[RUST] Running initialization script for: {}", input_id);
if let Err(e) = js_sys::eval(&init_script) {
log!("[RUST] Eval error: {:?}", e);
}
// Register cleanup function to run when component is unmounted
on_cleanup(move || {
log!("[CLEANUP] Running typeahead cleanup for input: {}", input_id);
let cleanup_call = format!(
r#"
if (window.typeaheadCleanupFunctions && window.typeaheadCleanupFunctions['{id}']) {{
window.typeaheadCleanupFunctions['{id}']();
delete window.typeaheadCleanupFunctions['{id}'];
}}
"#,
id = input_id
);
if let Err(e) = js_sys::eval(&cleanup_call) {
log!("[RUST] Cleanup call eval error: {:?}", e);
}
});
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = "Bloodhound")]
type Bloodhound;
#[wasm_bindgen(constructor, js_namespace = window)]
fn new(options: &JsValue) -> Bloodhound;
#[wasm_bindgen(method)]
fn initialize(this: &Bloodhound, prefetch: bool);
#[wasm_bindgen(method, js_name = "ttAdapter")]
fn tt_adapter(this: &Bloodhound) -> JsValue;
}