Search code examples
rustcallbacksingle-page-applicationwasm-bindgenyew

abort `wasm_bindgen_futures::spawn_local` callback externally


I am building a Yew manga reader application. The home page is a bookshelf that contains thumbnails, and clicking on the thumbnails brings me to a page rendering the pages of the book.

fn switch(routes: Route) -> Html {
    match routes {
        Route::Home => html! {
            <>
                <h1>{ "Books" }</h1>
                <BookShelf />
            </>
        },
        Route::BookContent { name } => html! {
            <div>
                <BookContent name={name} />
            </div>
        },
        Route::NotFound => html! { <div>{ "You sure that book exist?" }</div> },
    }
}

#[function_component]
fn App() -> Html {
    html! {
        <HashRouter>
            <Switch<Route> render={switch} />
        </HashRouter>
    }
}

The BookShelf gets a list of books I have, fetches the thumbnails individually to render in 2 columns.

#[styled_component]
fn BookShelf() -> Html {
    let books = use_state(|| vec![]);
    {
        let books = books.clone();
        use_effect_with((), move |_| {
            let books = books.clone();
            wasm_bindgen_futures::spawn_local(async move {
                let url = format!("{}/books", BACKEND_URL);
                let fetched_books: Vec<String> = Request::get(&url)
                    .send()
                    .await
                    .unwrap()
                    .json()
                    .await
                    .unwrap();
                books.set(fetched_books);
            });
        })
    }

    html! {
        <>
            {
                for books.chunks(2).map(|books| html! {
                    <div class={css!("display:flex;")}>
                        {
                            books.iter().map(|book| html! {
                                <div class={css!("flex:50%;")}>
                                    <Book name={book.clone()}/>
                                </div>
                            }).collect::<Html>()
                        }
                    </div>
                })
            }
        </>
    }
}

The Book component actually fetches the thumbnail using use_effect_with(). It also switches route to Route::BookContent when it is clicked.

BookContent is similar to BookShelf except it fetches and renders the images in the same column.

The current behaviour of the page is that when I click a thumbnail before all thumbnails are fetched and loaded, it goes to the BookContent page, but it waits all the pending thumbnail GET requests to complete before fetching the book content.

How can I abort all pending get request after the Route::Home component disappears (aka clicking a thumbnail to go to Route::BookContent) so that I can immediately start fetching the book content?

Edit

I just came across Theo's video "You Need React Query Now More Than Ever", which mentions you can send signal to useQuery and abort the query. I am thinking this may be want I need, to early return the callback if route is changed. Probably using a combination of context and use_location / use_route?


Solution

  • So actually the future that I should be aborting is gloo_net::http::Request not wasm_bindgen_futures::spwan_local because the wasm future gets dispatched immediately what it is rendered and it is the Request that is awaiting. And luckily there is an abort mechanism in gloo_net::http::Request by setting AbortSignal.

    By making use of the fact that use_effect_with() calls its return value to cleanup when a component is destroyed, we can abort Request when Image component gets out of scope.

    use gloo_net::http::Request;
    use web_sys::AbortController;
    
    #[derive(Properties, PartialEq)]
    struct ImageProperties {
        url: String,
    }
    
    #[function_component]
    fn Image(props: &ImageProperties) -> Html {
        let image = use_state(|| String::new());
        {
            let image = image.clone();
            let url = props.url.clone();
            let controller = AbortController::new().unwrap();
            let signal = controller.signal();
            use_effect_with((), move |_| {
                wasm_bindgen_futures::spawn_local(async move {
                    let res = match Request::get(&url)
                        .abort_signal(Some(&signal))
                        .send()
                        .await
                    {
                        Ok(res) => res,
                        _ => return,
                    };
                    let type_ = res.headers().get("Content-Type").unwrap();
                    let bytes = res.binary().await.unwrap();
                    let data = b64.encode(bytes);
                    image.set(format!("data:{};base64,{}", type_, data));
                });
    
                move || {
                    controller.abort();
                }
            });
        }
    
        html! {
            <img
                src={(*image).clone()}
                alt={"loading"}
                style="height:auto;width:100%;object-fit:inherit"
            />
        }
    }
    

    Note that if you renders an image with

    html! {
        html! {
            <img src={url} alt={"loading"}/>
        }
    }
    

    The GET request dispatched by img tag won't be aborted even when the component is destroyed.