11#![ cfg( all( target_arch = "wasm32" , not( target_os = "wasi" ) ) ) ]
22
3- use std:: time:: Duration ;
4-
53use gloo:: utils:: document;
64use ssr_e2e_harness:: {
7- fetch_count, fetch_ssr_html, output_element, patch_fetch, push_route, restore_fetch, wait_for,
5+ clear_resource_timings, fetch_ssr_html, navigate, output_element, resource_request_count,
6+ setup_ssr_page, wait_for,
87} ;
98use ssr_router:: { App , AppProps , LINK_ENDPOINT } ;
109use wasm_bindgen_test:: * ;
11- use yew:: platform :: time :: sleep ;
10+ use yew:: Renderer ;
1211
1312wasm_bindgen_test_configure ! ( run_in_browser) ;
1413
@@ -18,6 +17,15 @@ fn endpoint() -> String {
1817 format ! ( "{SERVER_BASE}{LINK_ENDPOINT}" )
1918}
2019
20+ fn make_renderer ( ) -> Renderer < App > {
21+ Renderer :: < App > :: with_root_and_props (
22+ output_element ( ) ,
23+ AppProps {
24+ endpoint : endpoint ( ) . into ( ) ,
25+ } ,
26+ )
27+ }
28+
2129fn get_title_text ( ) -> Option < String > {
2230 document ( )
2331 . query_selector ( "h1.title" )
@@ -26,58 +34,69 @@ fn get_title_text() -> Option<String> {
2634 . map ( |el| el. text_content ( ) . unwrap_or_default ( ) )
2735}
2836
37+ fn post_body_text ( ) -> String {
38+ output_element ( )
39+ . query_selector ( ".section.container" )
40+ . ok ( )
41+ . flatten ( )
42+ . map ( |el| el. text_content ( ) . unwrap_or_default ( ) )
43+ . unwrap_or_default ( )
44+ }
45+
46+ fn extract_text_from_html ( html : & str , selector : & str ) -> Option < String > {
47+ let container = document ( ) . create_element ( "div" ) . unwrap ( ) ;
48+ container. set_inner_html ( html) ;
49+ container
50+ . query_selector ( selector)
51+ . ok ( )
52+ . flatten ( )
53+ . and_then ( |el| el. text_content ( ) )
54+ }
55+
2956#[ wasm_bindgen_test]
30- async fn hydrate_post_page ( ) {
31- let body_html = fetch_ssr_html ( SERVER_BASE , " /posts/0" ) . await ;
57+ async fn ssr_hydration_and_client_navigation ( ) {
58+ // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link --
3259
33- patch_fetch ( ) ;
60+ let ssr_html = fetch_ssr_html ( SERVER_BASE , "/posts/0" ) . await ;
61+ let ssr_title = extract_text_from_html ( & ssr_html, "h1.title" )
62+ . expect ( "SSR HTML for /posts/0 should contain h1.title" ) ;
63+ let ssr_body = extract_text_from_html ( & ssr_html, ".section.container" ) . unwrap_or_default ( ) ;
3464
35- output_element ( ) . set_inner_html ( & body_html) ;
36- push_route ( "/posts/0" ) ;
37- yew:: Renderer :: < App > :: with_root_and_props (
38- output_element ( ) ,
39- AppProps {
40- endpoint : endpoint ( ) . into ( ) ,
41- } ,
42- )
43- . hydrate ( ) ;
65+ clear_resource_timings ( ) ;
66+
67+ output_element ( ) . set_inner_html ( & ssr_html) ;
68+ ssr_e2e_harness:: push_route ( "/posts/0" ) ;
69+ let app = make_renderer ( ) . hydrate ( ) ;
4470
4571 wait_for (
4672 || {
4773 let html = output_element ( ) . inner_html ( ) ;
4874 html. contains ( "<h1 class=\" title\" >" ) && !html. contains ( "Loading post..." )
4975 } ,
5076 5000 ,
51- "post page content" ,
77+ "post page content after SSR hydration " ,
5278 )
5379 . await ;
5480
55- let fc = fetch_count ( ) ;
81+ let link_fetches = resource_request_count ( LINK_ENDPOINT ) ;
5682 let title = get_title_text ( ) ;
5783
58- restore_fetch ( ) ;
59-
6084 assert_eq ! (
61- fc , 0 ,
62- "direct visit to /posts/0 should not trigger any fetch (SSR provides data) "
85+ link_fetches , 0 ,
86+ "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT} "
6387 ) ;
64- let title = title. expect ( "h1.title should be present on the post page" ) ;
65- assert ! ( !title. is_empty( ) , "post title should not be empty" ) ;
66- }
88+ let title = title. expect ( "h1.title should be present on the SSR post page" ) ;
89+ assert ! ( !title. is_empty( ) , "SSR post title should not be empty" ) ;
6790
68- #[ wasm_bindgen_test]
69- async fn hydrate_posts_list ( ) {
70- let body_html = fetch_ssr_html ( SERVER_BASE , "/posts" ) . await ;
91+ // -- Part 2: Navigate to /posts within the same app, then to /posts/0 --
7192
72- output_element ( ) . set_inner_html ( & body_html) ;
73- push_route ( "/posts" ) ;
74- yew:: Renderer :: < App > :: with_root_and_props (
75- output_element ( ) ,
76- AppProps {
77- endpoint : endpoint ( ) . into ( ) ,
78- } ,
79- )
80- . hydrate ( ) ;
93+ // Yield to ensure effects (router history listener) are registered.
94+ gloo:: timers:: future:: sleep ( std:: time:: Duration :: from_millis ( 500 ) ) . await ;
95+
96+ clear_resource_timings ( ) ;
97+
98+ // Navigate to /posts first, then to /posts/0 to trigger a client-side fetch.
99+ navigate ( "/posts" ) ;
81100
82101 wait_for (
83102 || {
@@ -86,41 +105,77 @@ async fn hydrate_posts_list() {
86105 . ok ( )
87106 . flatten ( )
88107 . is_some ( )
108+ && get_title_text ( ) . as_deref ( ) == Some ( "Posts" )
89109 } ,
90- 10000 ,
91- "post links to appear on /posts" ,
110+ 15000 ,
111+ "posts list after client-side navigation to /posts" ,
92112 )
93113 . await ;
94114
95- // TODO: test client-side navigation triggers a fetch to /api/link.
96- // Clicking <a> elements inside wasm-bindgen-test causes real browser navigation
97- // (the test page navigates away from the test runner) because yew's event
98- // delegation (capture-phase listener on the root element) does not intercept the
99- // click. This may be because the root is the wasm-bindgen-test #output <pre>
100- // element, or because headless Firefox handles synthetic clicks differently.
101- // Needs further investigation with getEventListeners or a monkeypatched
102- // addEventListener to confirm whether yew registers its capture handler on the
103- // #output element during hydration.
104- }
115+ clear_resource_timings ( ) ;
105116
106- #[ wasm_bindgen_test]
107- async fn hydrate_home ( ) {
108- let body_html = fetch_ssr_html ( SERVER_BASE , "/" ) . await ;
117+ // HtmlElement.click() on <a> in headless Firefox triggers real browser
118+ // navigation, crashing the test runner. Yew's capture-phase event delegation
119+ // cannot intercept it in the wasm-bindgen-test context. Instead we use
120+ // gloo-history's BrowserHistory::push() which calls pushState and directly
121+ // invokes all registered history callbacks (the same codepath as yew-router's
122+ // <Link> component).
123+ navigate ( "/posts/0" ) ;
109124
110- output_element ( ) . set_inner_html ( & body_html) ;
111- push_route ( "/" ) ;
112- yew:: Renderer :: < App > :: with_root_and_props (
113- output_element ( ) ,
114- AppProps {
115- endpoint : endpoint ( ) . into ( ) ,
125+ wait_for (
126+ || {
127+ // The post page renders a "by <author>" subtitle inside a hero section.
128+ // The posts LIST page subtitle says "All of our quality writing..."
129+ document ( )
130+ . query_selector ( "h2.subtitle" )
131+ . ok ( )
132+ . flatten ( )
133+ . map ( |el| el. text_content ( ) . unwrap_or_default ( ) )
134+ . is_some_and ( |text| text. starts_with ( "by " ) )
116135 } ,
136+ 15000 ,
137+ "post page content after client-side navigation to /posts/0" ,
117138 )
118- . hydrate ( ) ;
139+ . await ;
140+
141+ // -- Part 3: Verify fetch happened and content matches SSR --
142+
143+ let nav_link_fetches = resource_request_count ( LINK_ENDPOINT ) ;
144+ let nav_title = get_title_text ( ) ;
145+ let nav_body = post_body_text ( ) ;
119146
120- sleep ( Duration :: from_secs ( 2 ) ) . await ;
121- let html = output_element ( ) . inner_html ( ) ;
122147 assert ! (
123- html. contains( "Welcome" ) ,
124- "home page should have content after hydration"
148+ nav_link_fetches >= 1 ,
149+ "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \
150+ got {nav_link_fetches}"
125151 ) ;
152+
153+ let nav_title = nav_title. expect ( "h1.title should be present after client-side navigation" ) ;
154+ assert_eq ! (
155+ ssr_title, nav_title,
156+ "post title should match between SSR and client-side navigation"
157+ ) ;
158+ assert_eq ! (
159+ ssr_body, nav_body,
160+ "post body should match between SSR and client-side navigation"
161+ ) ;
162+
163+ app. destroy ( ) ;
164+ output_element ( ) . set_inner_html ( "" ) ;
165+ }
166+
167+ #[ wasm_bindgen_test]
168+ async fn hydrate_home ( ) {
169+ setup_ssr_page ( SERVER_BASE , "/" ) . await ;
170+ let app = make_renderer ( ) . hydrate ( ) ;
171+
172+ wait_for (
173+ || output_element ( ) . inner_html ( ) . contains ( "Welcome" ) ,
174+ 5000 ,
175+ "home page content after hydration" ,
176+ )
177+ . await ;
178+
179+ app. destroy ( ) ;
180+ output_element ( ) . set_inner_html ( "" ) ;
126181}
0 commit comments