suspense.rs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. use dioxus::prelude::*;
  2. use dioxus_core::{AttributeValue, ElementId, Mutation};
  3. use pretty_assertions::assert_eq;
  4. use std::future::poll_fn;
  5. use std::task::Poll;
  6. async fn poll_three_times() {
  7. // Poll each task 3 times
  8. let mut count = 0;
  9. poll_fn(|cx| {
  10. println!("polling... {}", count);
  11. if count < 3 {
  12. count += 1;
  13. cx.waker().wake_by_ref();
  14. Poll::Pending
  15. } else {
  16. Poll::Ready(())
  17. }
  18. })
  19. .await;
  20. }
  21. #[test]
  22. fn suspense_resolves_ssr() {
  23. // wait just a moment, not enough time for the boundary to resolve
  24. tokio::runtime::Builder::new_current_thread()
  25. .build()
  26. .unwrap()
  27. .block_on(async {
  28. let mut dom = VirtualDom::new(app);
  29. dom.rebuild_in_place();
  30. dom.wait_for_suspense().await;
  31. dom.render_immediate(&mut dioxus_core::NoOpMutations);
  32. let out = dioxus_ssr::render(&dom);
  33. assert_eq!(out, "<div>Waiting for... child</div>");
  34. });
  35. }
  36. fn app() -> Element {
  37. rsx!(
  38. div {
  39. "Waiting for... "
  40. SuspenseBoundary {
  41. fallback: |_| rsx! { "fallback" },
  42. suspended_child {}
  43. }
  44. }
  45. )
  46. }
  47. fn suspended_child() -> Element {
  48. let mut val = use_signal(|| 0);
  49. // Tasks that are not suspended should never be polled
  50. spawn(async move {
  51. panic!("Non-suspended task was polled");
  52. });
  53. // Memos should still work like normal
  54. let memo = use_memo(move || val * 2);
  55. assert_eq!(memo, val * 2);
  56. if val() < 3 {
  57. let task = spawn(async move {
  58. poll_three_times().await;
  59. println!("waiting... {}", val);
  60. val += 1;
  61. });
  62. suspend(task)?;
  63. }
  64. rsx!("child")
  65. }
  66. /// When switching from a suspense fallback to the real child, the state of that component must be kept
  67. #[test]
  68. fn suspense_keeps_state() {
  69. tokio::runtime::Builder::new_current_thread()
  70. .enable_time()
  71. .build()
  72. .unwrap()
  73. .block_on(async {
  74. let mut dom = VirtualDom::new(app);
  75. dom.rebuild(&mut dioxus_core::NoOpMutations);
  76. dom.render_suspense_immediate().await;
  77. let out = dioxus_ssr::render(&dom);
  78. assert_eq!(out, "fallback");
  79. dom.wait_for_suspense().await;
  80. let out = dioxus_ssr::render(&dom);
  81. assert_eq!(out, "<div>child with future resolved</div>");
  82. });
  83. fn app() -> Element {
  84. rsx! {
  85. SuspenseBoundary {
  86. fallback: |_| rsx! { "fallback" },
  87. Child {}
  88. }
  89. }
  90. }
  91. #[component]
  92. fn Child() -> Element {
  93. let mut future_resolved = use_signal(|| false);
  94. let task = use_hook(|| {
  95. spawn(async move {
  96. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  97. future_resolved.set(true);
  98. })
  99. });
  100. if !future_resolved() {
  101. suspend(task)?;
  102. }
  103. println!("future resolved: {future_resolved:?}");
  104. if future_resolved() {
  105. rsx! {
  106. div { "child with future resolved" }
  107. }
  108. } else {
  109. rsx! {
  110. div { "this should never be rendered" }
  111. }
  112. }
  113. }
  114. }
  115. /// spawn doesn't run in suspense
  116. #[test]
  117. fn suspense_does_not_poll_spawn() {
  118. tokio::runtime::Builder::new_current_thread()
  119. .enable_time()
  120. .build()
  121. .unwrap()
  122. .block_on(async {
  123. let mut dom = VirtualDom::new(app);
  124. dom.rebuild(&mut dioxus_core::NoOpMutations);
  125. dom.wait_for_suspense().await;
  126. let out = dioxus_ssr::render(&dom);
  127. assert_eq!(out, "<div>child with future resolved</div>");
  128. });
  129. fn app() -> Element {
  130. rsx! {
  131. SuspenseBoundary {
  132. fallback: |_| rsx! { "fallback" },
  133. Child {}
  134. }
  135. }
  136. }
  137. #[component]
  138. fn Child() -> Element {
  139. let mut future_resolved = use_signal(|| false);
  140. // futures that are spawned, but not suspended should never be polled
  141. use_hook(|| {
  142. spawn(async move {
  143. panic!("Non-suspended task was polled");
  144. });
  145. });
  146. let task = use_hook(|| {
  147. spawn(async move {
  148. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  149. future_resolved.set(true);
  150. })
  151. });
  152. if !future_resolved() {
  153. suspend(task)?;
  154. }
  155. rsx! {
  156. div { "child with future resolved" }
  157. }
  158. }
  159. }
  160. /// suspended nodes are not mounted, so they should not run effects
  161. #[test]
  162. fn suspended_nodes_dont_trigger_effects() {
  163. tokio::runtime::Builder::new_current_thread()
  164. .enable_time()
  165. .build()
  166. .unwrap()
  167. .block_on(async {
  168. let mut dom = VirtualDom::new(app);
  169. dom.rebuild(&mut dioxus_core::NoOpMutations);
  170. let work = async move {
  171. loop {
  172. dom.wait_for_work().await;
  173. dom.render_immediate(&mut dioxus_core::NoOpMutations);
  174. }
  175. };
  176. tokio::select! {
  177. _ = work => {},
  178. _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
  179. }
  180. });
  181. fn app() -> Element {
  182. rsx! {
  183. SuspenseBoundary {
  184. fallback: |_| rsx! { "fallback" },
  185. Child {}
  186. }
  187. }
  188. }
  189. #[component]
  190. fn RerendersFrequently() -> Element {
  191. let mut count = use_signal(|| 0);
  192. use_future(move || async move {
  193. for _ in 0..100 {
  194. tokio::time::sleep(std::time::Duration::from_millis(10)).await;
  195. count.set(count() + 1);
  196. }
  197. });
  198. rsx! {
  199. div { "rerenders frequently" }
  200. }
  201. }
  202. #[component]
  203. fn Child() -> Element {
  204. let mut future_resolved = use_signal(|| false);
  205. use_effect(|| panic!("effects should not run during suspense"));
  206. let task = use_hook(|| {
  207. spawn(async move {
  208. tokio::time::sleep(std::time::Duration::from_millis(500)).await;
  209. future_resolved.set(true);
  210. })
  211. });
  212. if !future_resolved() {
  213. suspend(task)?;
  214. }
  215. rsx! {
  216. div { "child with future resolved" }
  217. }
  218. }
  219. }
  220. /// Make sure we keep any state of components when we switch from a resolved future to a suspended future
  221. #[test]
  222. fn resolved_to_suspended() {
  223. static SUSPENDED: GlobalSignal<bool> = Signal::global(|| false);
  224. tokio::runtime::Builder::new_current_thread()
  225. .enable_time()
  226. .build()
  227. .unwrap()
  228. .block_on(async {
  229. let mut dom = VirtualDom::new(app);
  230. dom.rebuild(&mut dioxus_core::NoOpMutations);
  231. let out = dioxus_ssr::render(&dom);
  232. assert_eq!(out, "rendered 1 times");
  233. dom.in_runtime(|| ScopeId::APP.in_runtime(|| *SUSPENDED.write() = true));
  234. dom.render_suspense_immediate().await;
  235. let out = dioxus_ssr::render(&dom);
  236. assert_eq!(out, "fallback");
  237. dom.wait_for_suspense().await;
  238. let out = dioxus_ssr::render(&dom);
  239. assert_eq!(out, "rendered 3 times");
  240. });
  241. fn app() -> Element {
  242. rsx! {
  243. SuspenseBoundary {
  244. fallback: |_| rsx! { "fallback" },
  245. Child {}
  246. }
  247. }
  248. }
  249. #[component]
  250. fn Child() -> Element {
  251. let mut render_count = use_signal(|| 0);
  252. render_count += 1;
  253. let mut task = use_hook(|| CopyValue::new(None));
  254. tracing::info!("render_count: {}", render_count.peek());
  255. if SUSPENDED() {
  256. if task().is_none() {
  257. task.set(Some(spawn(async move {
  258. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  259. tracing::info!("task finished");
  260. *SUSPENDED.write() = false;
  261. })));
  262. }
  263. suspend(task().unwrap())?;
  264. }
  265. rsx! {
  266. "rendered {render_count.peek()} times"
  267. }
  268. }
  269. }
  270. /// Make sure suspense tells the renderer that a suspense boundary was resolved
  271. #[test]
  272. fn suspense_tracks_resolved() {
  273. tokio::runtime::Builder::new_current_thread()
  274. .enable_time()
  275. .build()
  276. .unwrap()
  277. .block_on(async {
  278. let mut dom = VirtualDom::new(app);
  279. dom.rebuild(&mut dioxus_core::NoOpMutations);
  280. dom.render_suspense_immediate().await;
  281. dom.wait_for_suspense_work().await;
  282. assert_eq!(
  283. dom.render_suspense_immediate().await,
  284. vec![ScopeId(ScopeId::APP.0 + 1)]
  285. );
  286. });
  287. fn app() -> Element {
  288. rsx! {
  289. SuspenseBoundary {
  290. fallback: |_| rsx! { "fallback" },
  291. Child {}
  292. }
  293. }
  294. }
  295. #[component]
  296. fn Child() -> Element {
  297. let mut resolved = use_signal(|| false);
  298. let task = use_hook(|| {
  299. spawn(async move {
  300. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  301. tracing::info!("task finished");
  302. resolved.set(true);
  303. })
  304. });
  305. if resolved() {
  306. println!("suspense is resolved");
  307. } else {
  308. println!("suspense is not resolved");
  309. suspend(task)?;
  310. }
  311. rsx! {
  312. "child"
  313. }
  314. }
  315. }
  316. // Regression test for https://github.com/DioxusLabs/dioxus/issues/2783
  317. #[test]
  318. fn toggle_suspense() {
  319. use dioxus::prelude::*;
  320. fn app() -> Element {
  321. rsx! {
  322. SuspenseBoundary {
  323. fallback: |_| rsx! { "fallback" },
  324. if generation() % 2 == 0 {
  325. Page {}
  326. } else {
  327. Home {}
  328. }
  329. }
  330. }
  331. }
  332. #[component]
  333. pub fn Home() -> Element {
  334. let _calculation = use_resource(|| async move {
  335. tokio::time::sleep(std::time::Duration::from_secs(1)).await;
  336. 1 + 1
  337. })
  338. .suspend()?;
  339. rsx! {
  340. "hello world"
  341. }
  342. }
  343. #[component]
  344. pub fn Page() -> Element {
  345. rsx! {
  346. "goodbye world"
  347. }
  348. }
  349. tokio::runtime::Builder::new_current_thread()
  350. .enable_time()
  351. .build()
  352. .unwrap()
  353. .block_on(async {
  354. let mut dom = VirtualDom::new(app);
  355. let mutations = dom.rebuild_to_vec().sanitize();
  356. // First create goodbye world
  357. println!("{:#?}", mutations);
  358. assert_eq!(
  359. mutations.edits,
  360. [
  361. Mutation::LoadTemplate { name: "template", index: 0, id: ElementId(1) },
  362. Mutation::AppendChildren { id: ElementId(0), m: 1 }
  363. ]
  364. );
  365. dom.mark_dirty(ScopeId::APP);
  366. let mutations = dom.render_immediate_to_vec().sanitize();
  367. // Then replace that with nothing
  368. println!("{:#?}", mutations);
  369. assert_eq!(
  370. mutations.edits,
  371. [
  372. Mutation::CreatePlaceholder { id: ElementId(2) },
  373. Mutation::ReplaceWith { id: ElementId(1), m: 1 },
  374. ]
  375. );
  376. dom.wait_for_work().await;
  377. let mutations = dom.render_immediate_to_vec().sanitize();
  378. // Then replace it with a placeholder
  379. println!("{:#?}", mutations);
  380. assert_eq!(
  381. mutations.edits,
  382. [
  383. Mutation::LoadTemplate { name: "template", index: 0, id: ElementId(1) },
  384. Mutation::ReplaceWith { id: ElementId(2), m: 1 },
  385. ]
  386. );
  387. dom.wait_for_work().await;
  388. let mutations = dom.render_immediate_to_vec().sanitize();
  389. // Then replace it with the resolved node
  390. println!("{:#?}", mutations);
  391. assert_eq!(
  392. mutations.edits,
  393. [
  394. Mutation::CreatePlaceholder { id: ElementId(2,) },
  395. Mutation::ReplaceWith { id: ElementId(1,), m: 1 },
  396. Mutation::LoadTemplate { name: "template", index: 0, id: ElementId(1) },
  397. Mutation::ReplaceWith { id: ElementId(2), m: 1 },
  398. ]
  399. );
  400. });
  401. }
  402. #[test]
  403. fn nested_suspense_resolves_client() {
  404. use Mutation::*;
  405. async fn poll_three_times() {
  406. // Poll each task 3 times
  407. let mut count = 0;
  408. poll_fn(|cx| {
  409. println!("polling... {}", count);
  410. if count < 3 {
  411. count += 1;
  412. cx.waker().wake_by_ref();
  413. Poll::Pending
  414. } else {
  415. Poll::Ready(())
  416. }
  417. })
  418. .await;
  419. }
  420. fn app() -> Element {
  421. rsx! {
  422. SuspenseBoundary {
  423. fallback: move |_| rsx! {},
  424. LoadTitle {}
  425. }
  426. MessageWithLoader { id: 0 }
  427. }
  428. }
  429. #[component]
  430. fn MessageWithLoader(id: usize) -> Element {
  431. rsx! {
  432. SuspenseBoundary {
  433. fallback: move |_| rsx! {
  434. "Loading {id}..."
  435. },
  436. Message { id }
  437. }
  438. }
  439. }
  440. #[component]
  441. fn LoadTitle() -> Element {
  442. let title = use_resource(move || async_content(0)).suspend()?();
  443. rsx! {
  444. Title { "{title.title}" }
  445. }
  446. }
  447. #[component]
  448. fn Message(id: usize) -> Element {
  449. let message = use_resource(move || async_content(id)).suspend()?();
  450. rsx! {
  451. h2 {
  452. id: "title-{id}",
  453. "{message.title}"
  454. }
  455. p {
  456. id: "body-{id}",
  457. "{message.body}"
  458. }
  459. div {
  460. id: "children-{id}",
  461. padding: "10px",
  462. for child in message.children {
  463. MessageWithLoader { id: child }
  464. }
  465. }
  466. }
  467. }
  468. #[derive(Clone)]
  469. pub struct Content {
  470. title: String,
  471. body: String,
  472. children: Vec<usize>,
  473. }
  474. async fn async_content(id: usize) -> Content {
  475. let content_tree = [
  476. Content {
  477. title: "The robot says hello world".to_string(),
  478. body: "The robot becomes sentient and says hello world".to_string(),
  479. children: vec![1, 2],
  480. },
  481. Content {
  482. title: "The world says hello back".to_string(),
  483. body: "In a stunning turn of events, the world collectively unites and says hello back"
  484. .to_string(),
  485. children: vec![],
  486. },
  487. Content {
  488. title: "Goodbye Robot".to_string(),
  489. body: "The robot says goodbye".to_string(),
  490. children: vec![3],
  491. },
  492. Content {
  493. title: "Goodbye Robot again".to_string(),
  494. body: "The robot says goodbye again".to_string(),
  495. children: vec![],
  496. },
  497. ];
  498. poll_three_times().await;
  499. content_tree[id].clone()
  500. }
  501. // wait just a moment, not enough time for the boundary to resolve
  502. tokio::runtime::Builder::new_current_thread()
  503. .build()
  504. .unwrap()
  505. .block_on(async {
  506. let mut dom = VirtualDom::new(app);
  507. let mutations = dom.rebuild_to_vec();
  508. // Initial loading message and loading title
  509. assert_eq!(
  510. mutations.edits,
  511. vec![
  512. CreatePlaceholder { id: ElementId(1,) },
  513. CreateTextNode { value: "Loading 0...".to_string(), id: ElementId(2,) },
  514. AppendChildren { id: ElementId(0,), m: 2 },
  515. ]
  516. );
  517. dom.wait_for_work().await;
  518. // DOM STATE:
  519. // placeholder // ID: 1
  520. // "Loading 0..." // ID: 2
  521. let mutations = dom.render_immediate_to_vec().sanitize();
  522. // Fill in the contents of the initial message and start loading the nested suspense
  523. // The title also finishes loading
  524. assert_eq!(
  525. mutations.edits,
  526. vec![
  527. // Creating and swapping these placeholders doesn't do anything
  528. // It is just extra work that we are forced to do because mutations are not
  529. // reversible. We start rendering the children and then realize it is suspended.
  530. // Then we need to replace what we just rendered with the suspense placeholder
  531. CreatePlaceholder { id: ElementId(3,) },
  532. ReplaceWith { id: ElementId(1,), m: 1 },
  533. // Replace the pending placeholder with the title placeholder
  534. CreatePlaceholder { id: ElementId(1,) },
  535. ReplaceWith { id: ElementId(3,), m: 1 },
  536. // Replace loading... with a placeholder for us to fill in later
  537. CreatePlaceholder { id: ElementId(3,) },
  538. ReplaceWith { id: ElementId(2,), m: 1 },
  539. // Load the title
  540. LoadTemplate { name: "template", index: 0, id: ElementId(2,) },
  541. HydrateText {
  542. path: &[0,],
  543. value: "The robot says hello world".to_string(),
  544. id: ElementId(4,),
  545. },
  546. SetAttribute {
  547. name: "id",
  548. ns: None,
  549. value: AttributeValue::Text("title-0".to_string()),
  550. id: ElementId(2,),
  551. },
  552. // Then load the body
  553. LoadTemplate { name: "template", index: 1, id: ElementId(5,) },
  554. HydrateText {
  555. path: &[0,],
  556. value: "The robot becomes sentient and says hello world".to_string(),
  557. id: ElementId(6,),
  558. },
  559. SetAttribute {
  560. name: "id",
  561. ns: None,
  562. value: AttributeValue::Text("body-0".to_string()),
  563. id: ElementId(5,),
  564. },
  565. // Then load the suspended children
  566. LoadTemplate { name: "template", index: 2, id: ElementId(7,) },
  567. CreateTextNode { value: "Loading 1...".to_string(), id: ElementId(8,) },
  568. CreateTextNode { value: "Loading 2...".to_string(), id: ElementId(9,) },
  569. ReplacePlaceholder { path: &[0,], m: 2 },
  570. SetAttribute {
  571. name: "id",
  572. ns: None,
  573. value: AttributeValue::Text("children-0".to_string()),
  574. id: ElementId(7,),
  575. },
  576. // Finally replace the loading placeholder in the body with the resolved children
  577. ReplaceWith { id: ElementId(3,), m: 3 },
  578. ]
  579. );
  580. dom.wait_for_work().await;
  581. // DOM STATE:
  582. // placeholder // ID: 1
  583. // h2 // ID: 2
  584. // p // ID: 5
  585. // div // ID: 7
  586. // "Loading 1..." // ID: 8
  587. // "Loading 2..." // ID: 9
  588. let mutations = dom.render_immediate_to_vec().sanitize();
  589. assert_eq!(
  590. mutations.edits,
  591. vec![
  592. // Replace the first loading placeholder with a placeholder for us to fill in later
  593. CreatePlaceholder {
  594. id: ElementId(
  595. 3,
  596. ),
  597. },
  598. ReplaceWith {
  599. id: ElementId(
  600. 8,
  601. ),
  602. m: 1,
  603. },
  604. // Load the nested suspense
  605. LoadTemplate {
  606. name: "template",
  607. index: 0,
  608. id: ElementId(
  609. 8,
  610. ),
  611. },
  612. HydrateText {
  613. path: &[
  614. 0,
  615. ],
  616. value: "The world says hello back".to_string(),
  617. id: ElementId(
  618. 10,
  619. ),
  620. },
  621. SetAttribute {
  622. name: "id",
  623. ns: None,
  624. value: AttributeValue::Text("title-1".to_string()),
  625. id: ElementId(
  626. 8,
  627. ),
  628. },
  629. LoadTemplate {
  630. name: "template",
  631. index: 1,
  632. id: ElementId(
  633. 11,
  634. ),
  635. },
  636. HydrateText {
  637. path: &[
  638. 0,
  639. ],
  640. value: "In a stunning turn of events, the world collectively unites and says hello back".to_string(),
  641. id: ElementId(
  642. 12,
  643. ),
  644. },
  645. SetAttribute {
  646. name: "id",
  647. ns: None,
  648. value: AttributeValue::Text("body-1".to_string()),
  649. id: ElementId(
  650. 11,
  651. ),
  652. },
  653. LoadTemplate {
  654. name: "template",
  655. index: 2,
  656. id: ElementId(
  657. 13,
  658. ),
  659. },
  660. AssignId {
  661. path: &[
  662. 0,
  663. ],
  664. id: ElementId(
  665. 14,
  666. ),
  667. },
  668. SetAttribute {
  669. name: "id",
  670. ns: None,
  671. value: AttributeValue::Text("children-1".to_string()),
  672. id: ElementId(
  673. 13,
  674. ),
  675. },
  676. ReplaceWith {
  677. id: ElementId(
  678. 3,
  679. ),
  680. m: 3,
  681. },
  682. // Replace the second loading placeholder with a placeholder for us to fill in later
  683. CreatePlaceholder {
  684. id: ElementId(
  685. 3,
  686. ),
  687. },
  688. ReplaceWith {
  689. id: ElementId(
  690. 9,
  691. ),
  692. m: 1,
  693. },
  694. LoadTemplate {
  695. name: "template",
  696. index: 0,
  697. id: ElementId(
  698. 9,
  699. ),
  700. },
  701. HydrateText {
  702. path: &[
  703. 0,
  704. ],
  705. value: "Goodbye Robot".to_string(),
  706. id: ElementId(
  707. 15,
  708. ),
  709. },
  710. SetAttribute {
  711. name: "id",
  712. ns: None,
  713. value: AttributeValue::Text("title-2".to_string()),
  714. id: ElementId(
  715. 9,
  716. ),
  717. },
  718. LoadTemplate {
  719. name: "template",
  720. index: 1,
  721. id: ElementId(
  722. 16,
  723. ),
  724. },
  725. HydrateText {
  726. path: &[
  727. 0,
  728. ],
  729. value: "The robot says goodbye".to_string(),
  730. id: ElementId(
  731. 17,
  732. ),
  733. },
  734. SetAttribute {
  735. name: "id",
  736. ns: None,
  737. value: AttributeValue::Text("body-2".to_string()),
  738. id: ElementId(
  739. 16,
  740. ),
  741. },
  742. LoadTemplate {
  743. name: "template",
  744. index: 2,
  745. id: ElementId(
  746. 18,
  747. ),
  748. },
  749. // Create a placeholder for the resolved children
  750. CreateTextNode { value: "Loading 3...".to_string(), id: ElementId(19,) },
  751. ReplacePlaceholder { path: &[0,], m: 1 },
  752. SetAttribute {
  753. name: "id",
  754. ns: None,
  755. value: AttributeValue::Text("children-2".to_string()),
  756. id: ElementId(
  757. 18,
  758. ),
  759. },
  760. // Replace the loading placeholder with the resolved children
  761. ReplaceWith {
  762. id: ElementId(
  763. 3,
  764. ),
  765. m: 3,
  766. },
  767. ]
  768. );
  769. dom.wait_for_work().await;
  770. let mutations = dom.render_immediate_to_vec().sanitize();
  771. assert_eq!(
  772. mutations.edits,
  773. vec![
  774. CreatePlaceholder {
  775. id: ElementId(
  776. 3,
  777. ),
  778. },
  779. ReplaceWith {
  780. id: ElementId(
  781. 19,
  782. ),
  783. m: 1,
  784. },
  785. LoadTemplate {
  786. name: "template",
  787. index: 0,
  788. id: ElementId(
  789. 19,
  790. ),
  791. },
  792. HydrateText {
  793. path: &[
  794. 0,
  795. ],
  796. value: "Goodbye Robot again".to_string(),
  797. id: ElementId(
  798. 20,
  799. ),
  800. },
  801. SetAttribute {
  802. name: "id",
  803. ns: None,
  804. value: AttributeValue::Text("title-3".to_string()),
  805. id: ElementId(
  806. 19,
  807. ),
  808. },
  809. LoadTemplate {
  810. name: "template",
  811. index: 1,
  812. id: ElementId(
  813. 21,
  814. ),
  815. },
  816. HydrateText {
  817. path: &[
  818. 0,
  819. ],
  820. value: "The robot says goodbye again".to_string(),
  821. id: ElementId(
  822. 22,
  823. ),
  824. },
  825. SetAttribute {
  826. name: "id",
  827. ns: None,
  828. value: AttributeValue::Text("body-3".to_string()),
  829. id: ElementId(
  830. 21,
  831. ),
  832. },
  833. LoadTemplate {
  834. name: "template",
  835. index: 2,
  836. id: ElementId(
  837. 23,
  838. ),
  839. },
  840. AssignId {
  841. path: &[
  842. 0,
  843. ],
  844. id: ElementId(
  845. 24,
  846. ),
  847. },
  848. SetAttribute {
  849. name: "id",
  850. ns: None,
  851. value: AttributeValue::Text("children-3".to_string()),
  852. id: ElementId(
  853. 23,
  854. ),
  855. },
  856. ReplaceWith {
  857. id: ElementId(
  858. 3,
  859. ),
  860. m: 3,
  861. },
  862. ]
  863. )
  864. });
  865. }