Files
dioxus/packages/fullstack/examples/hackernews/src/main.rs
T
Evan AlmloffandJonathan Kelley 022e4ad203 Suspense boundaries/out of order streaming/anyhow like error handling (#2365)
* create static site generation helpers in the router crate

* work on integrating static site generation into fullstack

* move ssg into a separate crate

* integrate ssg with the launch builder

* simplify ssg example

* fix static_routes for child routes

* move CLI hot reloading websocket code into dioxus-hot-reload

* fix some unused imports

* use the same hot reloading websocket code for fullstack

* fix fullstack hot reloading

* move cli hot reloading logic into the hot reload crate

* ssg example working with dx serve

* add more examples

* fix clippy

* switch to a result for Element

* fix formatting

* fix hot reload doctest imports

* fix axum imports

* add show method to error context

* implement retaining nodes during suspense

* fix unterminated if statements

* communicate between tasks and suspense boundaries

* make suspense placeholders easier to use

* implement IntoDynNode and IntoVNode for more wrappers

* fix clippy examples

* fix rsx tests

* add streaming html utilities to the ssr package

* unify hydration and non-hydration ssr cache

* fix router with Result Element

* don't run server doc tests

* Fix hot reload websocket doc examples

* simple apps working with fullstack streaming

* fix preloading wasm

* Report errors encountered while streaming

* remove async from incremental renderer

* document new VirtualDom suspense methods

* make streaming work with incremental rendering

* fix static site generation

* document suspense structs

* create closure type; allow async event handlers in props; allow shorthand event handlers

* test forwarding event handlers with the shorthand syntax

* fix clippy

* fix imports in spawn async doctest

* fix empty rsx

* fix async result event handlers

* fix mounting router in multiple places

* Fix task dead cancel race condition

* simplify diffing before adding suspense

* fix binary size increase

* fix attribute diffing

* more diffing fixes

* create minimal fullstack feature

* smaller fullstack bundles

* allow mounting nodes that are already created and creating nodes without mounting them

* fix hot reload feature

* fix replacing components

* don't reclaim virtual nodes

* client side suspense working!

* fix CLI

* slightly smaller fullstack builds

* fix multiple suspended scopes

* fix merge errors

* yield back to tokio every few polls to fix suspending on many tasks at once

* remove logs

* document suspense boundary and update suspense example

* fix ssg

* make streaming optional

* fix some router and core tests

* fix suspense example

* fix serialization with out of order server futures

* add incremental streaming hackernews demo

* fix hackernews demo

* fix root hackernews redirect

* fix formatting

* add tests for suspense cases

* slightly smaller binaries

* slightly smaller

* improve error handling docs

* fix errors example link

* fix doc tests

* remove log file

* fix ssr cache type inference

* remove index.html

* fix ssg render template

* fix assigning ids on elements with dynamic attributes

* add desktop feature to the workspace examples

* remove router static generation example; ssg lives in the dioxus-static-generation package

* add a test for effects during suspense

* only run effects on mounted nodes

* fix multiple suspense roots

* fix node iterator

* fix closures without arguments

* fix dioxus-core readme doctest

* remove suspense logs

* fix scope stack

* fix clippy

* remove unused suspense boundary from hackernews

* assert that launch never returns for better compiler errors

* fix static generation launch function

* fix web renderer

* pass context providers into server functions

* add an example for FromContext

* clean up DioxusRouterExt

* fix server function context

* fix fullstack desktop example

* forward CLI serve settings to fullstack

* re-export serve config at the root of fullstack

* forward env directly instead of using a guard

* just set the port in the CLI for fullstack playwright tests

* fix fullstack dioxus-cli-config feature

* fix launch server merge conflicts

* fix fullstack launch context

* Merge branch 'main' into suspense-2.0

* fix fullstack html data

* remove drop virtual dom feature

* add a comment about only_write_templates binary size workaround

* remove explicit dependencies from use_server_future

* make ErrorContext and SuspenseContext more similar

* Tweak: small tweaks to tomls to make diff smaller

* only rerun components under suspense after the initial placeholders are sent to the client

* add module docs for suspense

* keep track of when suspense boundaries are resolved

* start implementing JS out of order streaming

* fix core tests

* implement the server side of suspense with js

* fix streaming ssr with nested suspense

* move streaming ssr code into fullstack

* revert minification changes

* serialize server future data as the html streams

* start loading scripts wasm immediately instead of defering the script

* very basic nested suspense example working with minimal html updates

* clean up some suspense/error docs

* fix hydrating nested pending server futures

* sort resolved boundaries by height

* Fix disconnecting clients while streaming

* fix static generation crate

* don't insert extra divs when hydrating streamed chunks

* wait to swap in the elements until they are hydrated

* remove inline streaming script

* hackernews partially working

* fix spa mode

* banish the open shadow dom

* fix removing placeholder

* set up streaming playwright test

* run web playwright tests on 9999 to avoid port conflicts with other local servers

* remove suspense nodes if the suspense boundary is replaced before the suspense resolves on the server

* ignore hydration of removed suspense boundaries

* use path based indexing to fix hydrating suspense after parent suspense with child is removed

* re-export dioxus error

* remove resolved suspense divs if the suspense boundary has been removed

* Fix client side initialized server futures

* ignore comment nodes while traversing nodes in core to avoid lists getting swapped out with suspense

* Pass initial hydration data to the client

* hide pre nodes

* don't panic if reclaiming an element fails

* fix scope stack when polling tasks

* improve deserialization out of length message

* Ok(VNode::placeholder()) -> VNode::empty()

* fix typo in rsx usage

* restore testing changes from suspense example

* clean up some logs and comments

* fix playwright tests

* clean up more changes in core

* clean up core tests

* remove anymap dependency

* clean up changes to hooks

* clean up changes in the router, rsx, and web

* revert changes to axum-hello-world

* fix use_server_future

* fix clippy in dioxus-core

* check that the next or previous node exist before checking if we should ignore them

* fix formatting

* fix suspense playwright test

* remove unused suspense code

* add more suspense playwright tests

* add more docs for error boundaries

* fix suspense core tests

* fix ErrorBoundary example

* remove a bunch of debug logging in js

* fix router failure_external_navigation

* use absolute paths in the interpreter build.rs

* strip '\r' while hashing ts files

* add a wrapper with a default error boundary and suspense boundary

* restore hot reloading

* ignore non-ts files when hashing

* sort ts files before hashing them

* fix rsx tests

* fix fullstack doc tests

* fix core tests

* fix axum auth example

* update suspense hydration diagram

* longer playwright build limit

* tiny fixes - spelling, formatting

* update diagram link

* remove comment and template nodes for suspense placeholders

* remove comment nodes as we hydrate text

* simplify hackernews example

* clean up hydrating text nodes

* switch to a separate environment variable for the base path for smaller binaries

* clean up file system html trait

* fix form data

* move streaming code into fullstack

* implement serialize and deserialize for CapturedError

* remove waits in the nested suspense playwright spec

* force sequential fullstack builds for CI

* longer nested suspense delay for CI

* fix --force-sequential flag

* wait to launch server until client build is done

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
2024-07-01 20:50:36 -07:00

327 lines
8.2 KiB
Rust

#![allow(non_snake_case, unused)]
use dioxus::prelude::*;
// Define the Hackernews API and types
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::{
fmt::{Display, Formatter},
num::ParseIntError,
str::FromStr,
};
use svg_attributes::to;
fn main() {
#[cfg(feature = "web")]
tracing_wasm::set_as_global_default();
#[cfg(feature = "server")]
tracing_subscriber::fmt::init();
launch(|| rsx! { Router::<Route> {} });
}
#[derive(Clone, Routable)]
enum Route {
#[redirect("/", || Route::Homepage { story: PreviewState { active_story: None } })]
#[route("/:story")]
Homepage { story: PreviewState },
}
pub fn App() -> Element {
rsx! {
Router::<Route> {}
}
}
const STYLE: &str = r#"@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner {
width: 10px;
height: 10px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 2s linear infinite;
}"#;
#[component]
fn Homepage(story: ReadOnlySignal<PreviewState>) -> Element {
rsx! {
style {
{STYLE}
}
div { display: "flex", flex_direction: "row", width: "100%",
div {
width: "50%",
SuspenseBoundary {
fallback: |context: SuspenseContext| rsx! {
"Loading..."
},
Stories {}
}
}
div { width: "50%",
SuspenseBoundary {
fallback: |context: SuspenseContext| rsx! {
"Loading preview..."
},
Preview {
story
}
}
}
}
}
}
#[component]
fn Stories() -> Element {
let stories: Resource<dioxus::Result<Vec<i64>>> = use_server_future(move || async move {
let url = format!("{}topstories.json", BASE_API_URL);
let mut stories_ids = reqwest::get(&url).await?.json::<Vec<i64>>().await?;
stories_ids.truncate(30);
Ok(stories_ids)
})?;
match stories().unwrap() {
Ok(list) => rsx! {
div {
for story in list {
ChildrenOrLoading {
key: "{story}",
StoryListing { story }
}
}
}
},
Err(err) => rsx! {"An error occurred while fetching stories {err}"},
}
}
#[component]
fn StoryListing(story: ReadOnlySignal<i64>) -> Element {
let story = use_server_future(move || get_story(story()))?;
let StoryItem {
title,
url,
by,
score,
time,
kids,
id,
..
} = story().unwrap()?.item;
let url = url.as_deref().unwrap_or_default();
let hostname = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("www.");
let score = format!("{score} {}", if score == 1 { " point" } else { " points" });
let comments = format!(
"{} {}",
kids.len(),
if kids.len() == 1 {
" comment"
} else {
" comments"
}
);
let time = time.format("%D %l:%M %p");
rsx! {
div {
padding: "0.5rem",
position: "relative",
div { font_size: "1.5rem",
Link {
to: Route::Homepage { story: PreviewState { active_story: Some(id) } },
"{title}"
}
a {
color: "gray",
href: "https://news.ycombinator.com/from?site={hostname}",
text_decoration: "none",
" ({hostname})"
}
}
div { display: "flex", flex_direction: "row", color: "gray",
div { "{score}" }
div { padding_left: "0.5rem", "by {by}" }
div { padding_left: "0.5rem", "{time}" }
div { padding_left: "0.5rem", "{comments}" }
}
}
}
}
#[derive(Clone, Debug, Default)]
struct PreviewState {
active_story: Option<i64>,
}
impl FromStr for PreviewState {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let state = i64::from_str(s)?;
Ok(PreviewState {
active_story: Some(state),
})
}
}
impl Display for PreviewState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(id) = &self.active_story {
write!(f, "{id}")?;
}
Ok(())
}
}
#[component]
fn Preview(story: ReadOnlySignal<PreviewState>) -> Element {
let PreviewState {
active_story: Some(id),
} = story()
else {
return rsx! {"Hover over a story to preview it here"};
};
let story = use_server_future(use_reactive!(|id| get_story(id)))?;
let story = story().unwrap()?;
rsx! {
div { padding: "0.5rem",
div { font_size: "1.5rem", a { href: story.item.url, "{story.item.title}" } }
if let Some(text) = &story.item.text { div { dangerous_inner_html: "{text}" } }
for comment in story.item.kids.iter().copied() {
ChildrenOrLoading {
key: "{comment}",
Comment { comment }
}
}
}
}
}
#[component]
fn Comment(comment: i64) -> Element {
let comment: Resource<dioxus::Result<CommentData>> =
use_server_future(use_reactive!(|comment| async move {
let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, comment);
let mut comment = reqwest::get(&url).await?.json::<CommentData>().await?;
Ok(comment)
}))?;
let CommentData {
by,
time,
text,
id,
kids,
..
} = comment().unwrap()?;
rsx! {
div { padding: "0.5rem",
div { color: "gray", "by {by}" }
div { dangerous_inner_html: "{text}" }
for comment in kids.iter().copied() {
ChildrenOrLoading {
key: "{comment}",
Comment { comment }
}
}
}
}
}
pub static BASE_API_URL: &str = "https://hacker-news.firebaseio.com/v0/";
pub static ITEM_API: &str = "item/";
pub static USER_API: &str = "user/";
const COMMENT_DEPTH: i64 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryPageData {
#[serde(flatten)]
pub item: StoryItem,
#[serde(default)]
pub comments: Vec<CommentData>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CommentData {
pub id: i64,
/// there will be no by field if the comment was deleted
#[serde(default)]
pub by: String,
#[serde(default)]
pub text: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub time: DateTime<Utc>,
#[serde(default)]
pub kids: Vec<i64>,
pub r#type: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryItem {
pub id: i64,
pub title: String,
pub url: Option<String>,
pub text: Option<String>,
#[serde(default)]
pub by: String,
#[serde(default)]
pub score: i64,
#[serde(default)]
pub descendants: i64,
#[serde(with = "chrono::serde::ts_seconds")]
pub time: DateTime<Utc>,
#[serde(default)]
pub kids: Vec<i64>,
pub r#type: String,
}
pub async fn get_story(id: i64) -> dioxus::Result<StoryPageData> {
let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id);
Ok(reqwest::get(&url).await?.json::<StoryPageData>().await?)
}
#[component]
fn ChildrenOrLoading(children: Element) -> Element {
rsx! {
SuspenseBoundary {
fallback: |context: SuspenseContext| {
rsx! {
if let Some(placeholder) = context.suspense_placeholder() {
{placeholder}
} else {
LoadingIndicator {}
}
}
},
children
}
}
}
fn LoadingIndicator() -> Element {
rsx! {
div {
class: "spinner",
}
}
}