diff --git a/.github/workflows/docs stable.yml b/.github/workflows/docs stable.yml index 98245fa89..069d4308a 100644 --- a/.github/workflows/docs stable.yml +++ b/.github/workflows/docs stable.yml @@ -3,6 +3,10 @@ name: docs stable on: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-deploy: runs-on: ubuntu-latest @@ -29,7 +33,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.2 + uses: JamesIves/github-pages-deploy-action@v4.4.3 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d209c0c1a..3a452e770 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,10 @@ on: branches: - master +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-deploy: runs-on: ubuntu-latest @@ -34,7 +38,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.2 + uses: JamesIves/github-pages-deploy-action@v4.4.3 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml deleted file mode 100644 index b80a7b1f5..000000000 --- a/.github/workflows/macos.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: macOS tests - -on: - push: - branches: - - master - paths: - - packages/** - - examples/** - - src/** - - .github/** - - lib.rs - - Cargo.toml - - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: - - master - paths: - - packages/** - - examples/** - - src/** - - .github/** - - lib.rs - - Cargo.toml - -jobs: - test: - if: github.event.pull_request.draft == false - name: Test Suite - runs-on: macos-latest - steps: - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/checkout@v3 - - run: cargo test --all --tests diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b44aab843..0abb1404c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ on: - lib.rs - Cargo.toml - Makefile.toml - - playwrite-tests/** + - playwright-tests/** pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -27,6 +27,10 @@ on: - lib.rs - Cargo.toml +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check: if: github.event.pull_request.draft == false @@ -79,6 +83,69 @@ jobs: - uses: actions/checkout@v3 - run: cargo clippy --workspace --examples --tests -- -D warnings + matrix_test: + runs-on: ${{ matrix.platform.os }} + strategy: + matrix: + platform: + - { + target: x86_64-pc-windows-msvc, + os: windows-latest, + toolchain: '1.70.0', + cross: false, + command: 'test', + args: '--all --tests' + } + - { + target: x86_64-apple-darwin, + os: macos-latest, + toolchain: '1.70.0', + cross: false, + command: 'test', + args: '--all --tests' + } + - { + target: aarch64-apple-ios, + os: macos-latest, + toolchain: '1.70.0', + cross: false, + command: 'build', + args: '--package dioxus-mobile' + } + - { + target: aarch64-linux-android, + os: ubuntu-latest, + toolchain: '1.70.0', + cross: true, + command: 'build', + args: '--package dioxus-mobile' + } + + steps: + - uses: actions/checkout@v2 + + - name: install stable + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.platform.toolchain }} + target: ${{ matrix.platform.target }} + override: true + default: true + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: core -> ../target + save-if: ${{ matrix.features.key == 'all' }} + + - name: test + uses: actions-rs/cargo@v1 + with: + use-cross: ${{ matrix.platform.cross }} + command: ${{ matrix.platform.command }} + args: --target ${{ matrix.platform.target }} ${{ matrix.platform.args }} + + + # Coverage is disabled until we can fix it # coverage: # name: Coverage @@ -99,3 +166,4 @@ jobs: # uses: codecov/codecov-action@v2 # with: # fail_ci_if_error: false + diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml index 73b38dec9..b1d772512 100644 --- a/.github/workflows/miri.yml +++ b/.github/workflows/miri.yml @@ -6,6 +6,13 @@ on: branches: - 'auto' - 'try' + paths: + - packages/** + - examples/** + - src/** + - .github/** + - lib.rs + - Cargo.toml pull_request: types: [opened, synchronize, reopened, ready_for_review] branches: @@ -31,7 +38,9 @@ env: # - tokio-stream/Cargo.toml # rust_min: 1.49.0 - +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: test: @@ -72,7 +81,7 @@ jobs: # #[tokio::main] that calls epoll_create1 that Miri does not support. # run: cargo miri test --features full --lib --no-fail-fast run: | - cargo miri test --package dioxus-core --test miri_stress -- --exact --nocapture + cargo miri test --package dioxus-core -- --exact --nocapture cargo miri test --package dioxus-native-core --test miri_native -- --exact --nocapture # working-directory: tokio diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 84891f403..41fa92fa7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,22 +4,25 @@ on: branches: [ main, master ] pull_request: branches: [ main, master ] +defaults: + run: + working-directory: ./playwright-tests + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: test: if: github.event.pull_request.draft == false timeout-minutes: 60 runs-on: ubuntu-20.04 steps: + # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 16 - - name: Install dependencies - run: npm ci - - name: Install Playwright - run: npm install -D @playwright/test - - name: Install Playwright Browsers - run: npx playwright install --with-deps - name: Install Rust uses: actions-rs/toolchain@v1 with: @@ -29,11 +32,18 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install WASM toolchain run: rustup target add wasm32-unknown-unknown - - name: Install Dioxus CLI - uses: actions-rs/cargo@v1 - with: - command: install - args: --path packages/cli + - name: Install dependencies + run: npm ci + - name: Install Playwright + run: npm install -D @playwright/test + - name: Install Playwright Browsers + run: npx playwright install --with-deps + # Cache the CLI by using cargo run internally + # - name: Install Dioxus CLI + # uses: actions-rs/cargo@v1 + # with: + # command: install + # args: --path packages/cli - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml deleted file mode 100644 index 0dd8a1842..000000000 --- a/.github/workflows/windows.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: windows - -on: - push: - branches: - - master - paths: - - packages/** - - examples/** - - src/** - - .github/** - - lib.rs - - Cargo.toml - - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: - - master - paths: - - packages/** - - examples/** - - src/** - - .github/** - - lib.rs - - Cargo.toml - -jobs: - test: - if: github.event.pull_request.draft == false - runs-on: windows-latest - name: (${{ matrix.target }}, ${{ matrix.cfg_release_channel }}) - env: - CFG_RELEASE_CHANNEL: ${{ matrix.cfg_release_channel }} - strategy: - # https://help.github.com/en/actions/getting-started-with-github-actions/about-github-actions#usage-limits - # There's a limit of 60 concurrent jobs across all repos in the rust-lang organization. - # In order to prevent overusing too much of that 60 limit, we throttle the - # number of rustfmt jobs that will run concurrently. - # max-parallel: - # fail-fast: false - matrix: - target: [x86_64-pc-windows-gnu, x86_64-pc-windows-msvc] - cfg_release_channel: [stable] - - steps: - # The Windows runners have autocrlf enabled by default - # which causes failures for some of rustfmt's line-ending sensitive tests - - name: disable git eol translation - run: git config --global core.autocrlf false - - # Run build - - name: Install Rustup using win.rustup.rs - run: | - # Disable the download progress bar which can cause perf issues - $ProgressPreference = "SilentlyContinue" - Invoke-WebRequest https://win.rustup.rs/ -OutFile rustup-init.exe - .\rustup-init.exe -y --default-host=x86_64-pc-windows-msvc --default-toolchain=none - del rustup-init.exe - rustup target add ${{ matrix.target }} - shell: powershell - - - name: Add mingw64 to path for x86_64-gnu - run: echo "C:\msys64\mingw64\bin" >> $GITHUB_PATH - if: matrix.target == 'x86_64-pc-windows-gnu' && matrix.channel == 'nightly' - shell: bash - - # - name: checkout - # uses: actions/checkout@v3 - # with: - # path: C:/dioxus.git - # fetch-depth: 1 - - # we need to use the C drive as the working directory - - - name: Checkout - run: | - mkdir C:/dioxus.git - git clone https://github.com/dioxuslabs/dioxus.git C:/dioxus.git --depth 1 - - - name: test - working-directory: C:/dioxus.git - run: | - rustc -Vv - cargo -V - set RUST_BACKTRACE=1 - cargo build --all --tests --examples - cargo test --all --tests - shell: cmd diff --git a/.gitignore b/.gitignore index bca0fe28f..b8025753e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /target -/playwrite-tests/web/dist -/playwrite-tests/fullstack/dist +/playwright-tests/web/dist +/playwright-tests/fullstack/dist /dist Cargo.lock .DS_Store diff --git a/Cargo.toml b/Cargo.toml index efaffd22a..722666d95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "packages/core", "packages/cli", "packages/core-macro", + "packages/router-macro", "packages/extension", "packages/router", "packages/html", @@ -29,16 +30,19 @@ members = [ "packages/fullstack/examples/axum-hello-world", "packages/fullstack/examples/axum-router", "packages/fullstack/examples/axum-desktop", + "packages/fullstack/examples/axum-auth", "packages/fullstack/examples/salvo-hello-world", "packages/fullstack/examples/warp-hello-world", + "packages/fullstack/examples/static-hydrated", "docs/guide", + "docs/router", # Full project examples "examples/tailwind", "examples/PWA-example", - # Playwrite tests - "playwrite-tests/liveview", - "playwrite-tests/web", - "playwrite-tests/fullstack", + # Playwright tests + "playwright-tests/liveview", + "playwright-tests/web", + "playwright-tests/fullstack", ] exclude = ["examples/mobile_demo"] @@ -48,6 +52,7 @@ dioxus = { path = "packages/dioxus" } dioxus-core = { path = "packages/core" } dioxus-core-macro = { path = "packages/core-macro" } dioxus-router = { path = "packages/router" } +dioxus-router-macro = { path = "packages/router-macro" } dioxus-html = { path = "packages/html" } dioxus-hooks = { path = "packages/hooks" } dioxus-web = { path = "packages/web" } diff --git a/docs/guide/Cargo.toml b/docs/guide/Cargo.toml index 06b17b0e9..c2527b8b4 100644 --- a/docs/guide/Cargo.toml +++ b/docs/guide/Cargo.toml @@ -3,7 +3,7 @@ name = "dioxus-guide" version = "0.0.1" edition = "2021" description = "Dioxus guide, including testable examples" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" publish = false [dev-dependencies] diff --git a/docs/guide/examples/server_context.rs b/docs/guide/examples/server_context.rs index eb3c5e9c1..172b978e9 100644 --- a/docs/guide/examples/server_context.rs +++ b/docs/guide/examples/server_context.rs @@ -74,10 +74,9 @@ fn app(cx: Scope) -> Element { button { onclick: move |_| { to_owned![count]; - let sc = cx.sc(); async move { // Call the server function just like a local async function - if let Ok(new_count) = double_server(sc, *count.current()).await { + if let Ok(new_count) = double_server(*count.current()).await { count.set(new_count); } } @@ -89,7 +88,8 @@ fn app(cx: Scope) -> Element { // We use the "getcbor" encoding to make caching easier #[server(DoubleServer, "", "getcbor")] -async fn double_server(cx: DioxusServerContext, number: usize) -> Result { +async fn double_server(number: usize) -> Result { + let cx = server_context(); // Perform some expensive computation or access a database on the server tokio::time::sleep(std::time::Duration::from_secs(1)).await; let result = number * 2; diff --git a/docs/guide/examples/server_context_state.rs b/docs/guide/examples/server_context_state.rs index c75496561..0f0b541cc 100644 --- a/docs/guide/examples/server_context_state.rs +++ b/docs/guide/examples/server_context_state.rs @@ -92,10 +92,9 @@ fn app(cx: Scope) -> Element { button { onclick: move |_| { to_owned![count]; - let sc = cx.sc(); async move { // Call the server function just like a local async function - if let Ok(new_count) = double_server(sc, *count.current()).await { + if let Ok(new_count) = double_server(*count.current()).await { count.set(new_count); } } @@ -107,10 +106,11 @@ fn app(cx: Scope) -> Element { // We use the "getcbor" encoding to make caching easier #[server(DoubleServer, "", "getcbor")] -async fn double_server(cx: DioxusServerContext, number: usize) -> Result { +async fn double_server(number: usize) -> Result { // Perform some expensive computation or access a database on the server tokio::time::sleep(std::time::Duration::from_secs(1)).await; let result = number * 2; + let cx = server_context(); println!( "User Agent {:?}", diff --git a/docs/guide/src/en/SUMMARY.md b/docs/guide/src/en/SUMMARY.md index 62ef5adf7..a44439200 100644 --- a/docs/guide/src/en/SUMMARY.md +++ b/docs/guide/src/en/SUMMARY.md @@ -21,10 +21,12 @@ - [Hooks & Component State](interactivity/hooks.md) - [User Input](interactivity/user_input.md) - [Sharing State](interactivity/sharing_state.md) + - [Memoization](interactivity/memoization.md) - [Custom Hooks](interactivity/custom_hooks.md) - [Dynamic Rendering](interactivity/dynamic_rendering.md) - [Routing](interactivity/router.md) - [Async](async/index.md) + - [UseEffect](async/use_effect.md) - [UseFuture](async/use_future.md) - [UseCoroutine](async/use_coroutine.md) - [Spawning Futures](async/spawn.md) diff --git a/docs/guide/src/en/__unused/advanced-guides/12-signals.md b/docs/guide/src/en/__unused/advanced-guides/12-signals.md index df13b91d8..7fbb56de7 100644 --- a/docs/guide/src/en/__unused/advanced-guides/12-signals.md +++ b/docs/guide/src/en/__unused/advanced-guides/12-signals.md @@ -94,8 +94,10 @@ Calling `deref` or `deref_mut` is actually more complex than it seems. When a va Sometimes you want a signal to propagate across your app, either through far-away siblings or through deeply-nested components. In these cases, we use Dirac: Dioxus's first-class state management toolkit. Dirac atoms automatically implement the Signal API. This component will bind the input element to the `TITLE` atom. + ```rust, no_run -const TITLE: Atom = || "".to_string(); +const TITLE: Atom = Atom(|| "".to_string()); + const Provider: Component = |cx|{ let title = use_signal(cx, &TITLE); render!(input { value: title }) @@ -131,7 +133,8 @@ By default, Dioxus is limited when you use iter/map. With the `For` component, y Dioxus automatically understands how to use your signals when mixed with iterators through `Deref`/`DerefMut`. This lets you efficiently map collections while avoiding the re-rendering of lists. In essence, signals act as a hint to Dioxus on how to avoid un-necessary checks and renders, making your app faster. ```rust, no_run -const DICT: AtomFamily = |_| {}; +const DICT: AtomFamily = AtomFamily(|_| {}); + const List: Component = |cx|{ let dict = use_signal(cx, &DICT); cx.render(rsx!( @@ -142,14 +145,6 @@ const List: Component = |cx|{ }; ``` -## Remote Signals - -Apps that use signals will enjoy a pleasant hybrid of server-side and client-side rendering. - -```rust, no_run - -``` - ## How does it work? Signals internally use Dioxus' asynchronous rendering infrastructure to perform updates out of the tree. diff --git a/docs/guide/src/en/__unused/advanced-guides/testing.md b/docs/guide/src/en/__unused/advanced-guides/testing.md index 87e72309c..405c929a9 100644 --- a/docs/guide/src/en/__unused/advanced-guides/testing.md +++ b/docs/guide/src/en/__unused/advanced-guides/testing.md @@ -21,7 +21,7 @@ fn runs_in_browser() { Then, when you run ```console -dioxus test --chrome +dx test --chrome ``` Dioxus will build and test your code using the Chrome browser as a harness. diff --git a/docs/guide/src/en/async/use_coroutine.md b/docs/guide/src/en/async/use_coroutine.md index 8d4b8f99a..d42905be5 100644 --- a/docs/guide/src/en/async/use_coroutine.md +++ b/docs/guide/src/en/async/use_coroutine.md @@ -143,7 +143,7 @@ async fn editor_service(rx: UnboundedReceiver) { We can combine coroutines with [Fermi](https://docs.rs/fermi/latest/fermi/index.html) to emulate Redux Toolkit's Thunk system with much less headache. This lets us store all of our app's state _within_ a task and then simply update the "view" values stored in Atoms. It cannot be understated how powerful this technique is: we get all the perks of native Rust tasks with the optimizations and ergonomics of global state. This means your _actual_ state does not need to be tied up in a system like Fermi or Redux – the only Atoms that need to exist are those that are used to drive the display/UI. ```rust, no_run -static USERNAME: Atom = |_| "default".to_string(); +static USERNAME: Atom = Atom(|_| "default".to_string()); fn app(cx: Scope) -> Element { let atoms = use_atom_root(cx); @@ -156,7 +156,7 @@ fn app(cx: Scope) -> Element { } fn Banner(cx: Scope) -> Element { - let username = use_read(cx, USERNAME); + let username = use_read(cx, &USERNAME); cx.render(rsx!{ h1 { "Welcome back, {username}" } @@ -174,8 +174,8 @@ enum SyncAction { } async fn sync_service(mut rx: UnboundedReceiver, atoms: AtomRoot) { - let username = atoms.write(USERNAME); - let errors = atoms.write(ERRORS); + let username = atoms.write(&USERNAME); + let errors = atoms.write(&ERRORS); while let Ok(msg) = rx.next().await { match msg { diff --git a/docs/guide/src/en/async/use_effect.md b/docs/guide/src/en/async/use_effect.md new file mode 100644 index 000000000..7c20e3589 --- /dev/null +++ b/docs/guide/src/en/async/use_effect.md @@ -0,0 +1,41 @@ +# UseEffect + +[`use_effect`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_effect.html) lets you run a callback that returns a future, which will be re-run when its [dependencies](#dependencies) change. This is useful to syncrhonize with external events. + +## Dependencies + +You can make the callback re-run when some value changes. For example, you might want to fetch a user's data only when the user id changes. You can provide a tuple of "dependencies" to the hook. It will automatically re-run it when any of those dependencies change. + +## Example + +```rust, no_run +#[inline_props] +fn Profile(cx: Scope, id: usize) -> Element { + let name = use_state(cx, || None); + + // Only fetch the user data when the id changes. + use_effect(cx, (id,), |(id,)| { + to_owned![name]; + async move { + let user = fetch_user(id).await; + name.set(user.name); + } + }); + + // Because the dependencies are empty, this will only run once. + // An empty tuple is always equal to an empty tuple. + use_effect(cx, (), |()| async move { + println!("Hello, World!"); + }); + + let name = name.get().clone().unwrap_or("Loading...".to_string()); + + render!( + p { "{name}" } + ) +} + +fn app(cx: Scope) -> Element { + render!(Profile { id: 0 }) +} +``` diff --git a/docs/guide/src/en/best_practices/error_handling.md b/docs/guide/src/en/best_practices/error_handling.md index fad8f1c10..d16d04347 100644 --- a/docs/guide/src/en/best_practices/error_handling.md +++ b/docs/guide/src/en/best_practices/error_handling.md @@ -113,14 +113,14 @@ enum InputError { TooShort, } -static INPUT_ERROR: Atom = |_| InputError::None; +static INPUT_ERROR: Atom = Atom(|_| InputError::None); ``` Then, in our top level component, we want to explicitly handle the possible error state for this part of the tree. ```rust, no_run fn TopLevel(cx: Scope) -> Element { - let error = use_read(cx, INPUT_ERROR); + let error = use_read(cx, &INPUT_ERROR); match error { TooLong => return cx.render(rsx!{ "FAILED: Too long!" }), @@ -134,7 +134,7 @@ Now, whenever a downstream component has an error in its actions, it can simply ```rust, no_run fn Commandline(cx: Scope) -> Element { - let set_error = use_set(cx, INPUT_ERROR); + let set_error = use_set(cx, &INPUT_ERROR); cx.render(rsx!{ input { diff --git a/docs/guide/src/en/contributing/walkthrough_readme.md b/docs/guide/src/en/contributing/walkthrough_readme.md index 5334630c9..5be9d4b5a 100644 --- a/docs/guide/src/en/contributing/walkthrough_readme.md +++ b/docs/guide/src/en/contributing/walkthrough_readme.md @@ -14,13 +14,13 @@ We start will a hello world program. This program renders a desktop app with the ## The rsx! Macro -Before the Rust compiler runs the program, it will expand all macros. Here is what the hello world example looks like expanded: +Before the Rust compiler runs the program, it will expand all [macros](https://doc.rust-lang.org/reference/procedural-macros.html). Here is what the hello world example looks like expanded: ```rust, no_run {{#include ../../../examples/readme_expanded.rs}} ``` -The rsx macro separates the static parts of the rsx (the template) and the dynamic parts (the dynamic_nodes and dynamic_attributes). +The rsx macro separates the static parts of the rsx (the template) and the dynamic parts (the [dynamic_nodes](https://docs.rs/dioxus-core/0.3.2/dioxus_core/prelude/struct.VNode.html#structfield.dynamic_nodes) and [dynamic_attributes](https://docs.rs/dioxus-core/0.3.2/dioxus_core/prelude/struct.VNode.html#structfield.dynamic_attrs)). The static template only contains the parts of the rsx that cannot change at runtime with holes for the dynamic parts: @@ -32,17 +32,17 @@ The dynamic_nodes and dynamic_attributes are the parts of the rsx that can chang ## Launching the App -The app is launched by calling the `launch` function with the root component. Internally, this function will create a new web view using [wry](https://docs.rs/wry/latest/wry/) and create a virtual dom with the root component. This guide will not explain the renderer in-depth, but you can read more about it in the [custom renderer](/guide/custom-renderer) section. +The app is launched by calling the `launch` function with the root component. Internally, this function will create a new web view using [wry](https://docs.rs/wry/latest/wry/) and create a virtual dom with the root component (`fn app()` in the readme example). This guide will not explain the renderer in-depth, but you can read more about it in the [custom renderer](/guide/custom-renderer) section. ## The Virtual DOM -Before we dive into the initial render in the virtual dom, we need to discuss what the virtual dom is. The virtual dom is a representation of the dom that is used to diff the current dom from the new dom. This diff is then used to create a list of mutations that need to be applied to the dom. +Before we dive into the initial render in the virtual DOM, we need to discuss what the virtual DOM is. The virtual DOM is a representation of the DOM that is used to diff the current DOM from the new DOM. This diff is then used to create a list of mutations that need to be applied to the DOM to bring it into sync with the virtual DOM. -The Virtual Dom roughly looks like this: +The Virtual DOM roughly looks like this: ```rust, no_run pub struct VirtualDom { - // All the templates that have been created or set durring hot reloading + // All the templates that have been created or set during hot reloading pub(crate) templates: FxHashMap>>, // A slab of all the scopes that have been created @@ -63,64 +63,74 @@ pub struct VirtualDom { ``` > What is a [slab](https://docs.rs/slab/latest/slab/)? +> > A slab acts like a hashmap with integer keys if you don't care about the value of the keys. It is internally backed by a dense vector which makes it more efficient than a hashmap. When you insert a value into a slab, it returns an integer key that you can use to retrieve the value later. > How does Dioxus use slabs? -> Dioxus uses "synchronized slabs" to communicate between the renderer and the VDOM. When an node is created in the Virtual Dom, a ElementId is passed along with the mutation to the renderer to identify the node. These ids are used by the Virtual Dom to reference that nodes in future mutations like setting an attribute on a node or removing a node. -> When the renderer sends an event to the Virtual Dom, it sends the ElementId of the node that the event was triggered on. The Virtual Dom uses this id to find the node in the slab and then run the necessary event handlers. +> +> Dioxus uses "synchronized slabs" to communicate between the renderer and the VDOM. When a node is created in the Virtual DOM, an (elementId, mutation) pair is passed to the renderer to identify that node, which the renderer will then render in actual DOM. These ids are also used by the Virtual Dom to reference that node in future mutations, like setting an attribute on a node or removing a node. When the renderer sends an event to the Virtual Dom, it sends the ElementId of the node that the event was triggered on. The Virtual DOM uses this id to find that node in the slab and then run the necessary event handlers. -The virtual dom is a tree of scopes. A new scope is created for every component when it is first rendered and recycled when the component is unmounted. +The virtual DOM is a tree of scopes. A new `Scope` is created for every component when it is first rendered and recycled when the component is unmounted. Scopes serve three main purposes: 1. They store the state of hooks used by the component -2. They store the state for the context API -3. They store the current and previous VNode that was rendered for diffing +2. They store the state for the context API (for example: using + [use_shared_state_provider](https://docs.rs/dioxus/latest/dioxus/prelude/fn.use_shared_state_provider.html)). +3. They store the current and previous versions of the `VNode` that was rendered, so they can be + diffed to generate the set of mutations needed to re-render it. ### The Initial Render The root scope is created and rebuilt: 1. The root component is run -2. The root component returns a VNode -3. Mutations for the VNode are created and added to the mutation list (this may involve creating new child components) -4. The VNode is stored in the root scope +2. The root component returns a `VNode` +3. Mutations for this `VNode` are created and added to the mutation list (this may involve creating new child components) +4. The `VNode` is stored in the root's `Scope`. -After the root scope is built, the mutations are sent to the renderer to be applied to the dom. +After the root's `Scope` is built, all generated mutations are sent to the renderer, which applies them to the DOM. -After the initial render, the root scope looks like this: +After the initial render, the root `Scope` looks like this: [![](https://mermaid.ink/img/pako:eNqtVE1P4zAQ_SuzPrWikRpWXCLtBRDisItWsOxhCaqM7RKricdyJrQV8N93QtvQNCkfEnOynydv3nxkHoVCbUQipjnOVSYDwc_L1AFbWd3dB-kzuEQkuFLoDUwDFkCZAek9nGDh0RlHK__atA1GkUUHf45f0YbppAqB_aOzIAvz-t7-chN_Y-1bw1WSJKsglIu2w9tktWXxIIuHURT5XCqTYa5NmDguw2R8c5MKq2GcgF46WTB_jafi9rZL0yi5q4jQTSrf9altO4okCn1Ratwyz55Qxuku2ITlTMgs6HCQimsPmb3PvqVi-L5gjXP3QcnxWnL8JZLrwGvR31n0KV-Bx6-r-oVkT_-3G1S-NQLbk9i8rj7udP2cixed2QcDCitHJiQw7ub3EVlNecrPjudG2-6soFO5VbMECmR9T5OnlUY4-AFxfw9aTFst3McU9TK1Otm6NEn_DubBYlX2_dglLXOz48FgwJmJ5lZTlhz6xWgNaFnyDgpymcARHO0W2a9J_l5w2wYXvHuGPcqaQ-rESBQmFNJq3nCPNZoK3l4sUSR81DLMUpG6Z_aTFeHV0imRUKjMSFReSzKnVnKGhUimMi8ZNdoShl-rlfmyOUfCS_cPcePz_B_Wl4pc?type=png)](https://mermaid.live/edit#pako:eNqtVE1P4zAQ_SuzPrWikRpWXCLtBRDisItWsOxhCaqM7RKricdyJrQV8N93QtvQNCkfEnOynydv3nxkHoVCbUQipjnOVSYDwc_L1AFbWd3dB-kzuEQkuFLoDUwDFkCZAek9nGDh0RlHK__atA1GkUUHf45f0YbppAqB_aOzIAvz-t7-chN_Y-1bw1WSJKsglIu2w9tktWXxIIuHURT5XCqTYa5NmDguw2R8c5MKq2GcgF46WTB_jafi9rZL0yi5q4jQTSrf9altO4okCn1Ratwyz55Qxuku2ITlTMgs6HCQimsPmb3PvqVi-L5gjXP3QcnxWnL8JZLrwGvR31n0KV-Bx6-r-oVkT_-3G1S-NQLbk9i8rj7udP2cixed2QcDCitHJiQw7ub3EVlNecrPjudG2-6soFO5VbMECmR9T5OnlUY4-AFxfw9aTFst3McU9TK1Otm6NEn_DubBYlX2_dglLXOz48FgwJmJ5lZTlhz6xWgNaFnyDgpymcARHO0W2a9J_l5w2wYXvHuGPcqaQ-rESBQmFNJq3nCPNZoK3l4sUSR81DLMUpG6Z_aTFeHV0imRUKjMSFReSzKnVnKGhUimMi8ZNdoShl-rlfmyOUfCS_cPcePz_B_Wl4pc) ### Waiting for Events -The Virtual Dom will only ever rerender a scope if it is marked as dirty. Each hook is responsible for marking the scope as dirty if the state has changed. Hooks can mark a scope as dirty by sending a message to the Virtual Dom's channel. +The Virtual DOM will only ever re-render a `Scope` if it is marked as dirty. Each hook is responsible for marking the `Scope` as dirty if the state has changed. Hooks can mark a scope as dirty by sending a message to the Virtual Dom's channel. You can see the [implementations](https://github.com/DioxusLabs/dioxus/tree/master/packages/hooks) for the hooks dioxus includes by default on how this is done. Calling `needs_update()` on a hook will also cause it to mark its scope as dirty. There are generally two ways a scope is marked as dirty: -1. The renderer triggers an event: This causes an event listener to be called if needed which may mark a component as dirty -2. The renderer calls wait for work: This polls futures which may mark a component as dirty +1. The renderer triggers an event: An event listener on this event may be called, which may mark a + component as dirty, if processing the event resulted in any generated any mutations. +2. The renderer calls + [`wait_for_work`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.VirtualDom.html#method.wait_for_work): + This polls dioxus internal future queue. One of these futures may mark a component as dirty. -Once at least one scope is marked as dirty, the renderer can call `render_with_deadline` to diff the dirty scopes. +Once at least one `Scope` is marked as dirty, the renderer can call [`render_with_deadline`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.VirtualDom.html#method.render_with_deadline) to diff the dirty scopes. ### Diffing Scopes -If the user clicked the "up high" button, the root scope would be marked as dirty by the use_state hook. Once the desktop renderer calls `render_with_deadline`, the root scope would be diffed. +When a user clicks the "up high" button, the root `Scope` will be marked as dirty by the `use_state` hook. The desktop renderer will then call `render_with_deadline`, which will diff the root `Scope`. -To start the diffing process, the component is run. After the root component is run it will look like this: +To start the diffing process, the component function is run. After the root component is run it, the root `Scope` will look like this: [![](https://mermaid.ink/img/pako:eNrFVlFP2zAQ_iuen0BrpCaIl0i8AEJ72KQJtpcRFBnbJVYTn-U4tBXw33dpG5M2CetoBfdkny_ffb67fPIT5SAkjekkhxnPmHXk-3WiCVpZ3T9YZjJyDeDIDQcjycRCQVwmCTOGXEBhQEvtVvG1CWUldwo0-XX-6vVIF5W1GB9cWVbI1_PNL5v8jW3uPFbpmFOc2HK-GfA2WG1ZeJSFx0EQmJxxmUEupE01liEd394mVAkyjolYaFYgfu1P6N1dF8Yzua-cA51WphtTWzsLc872Zan9CnEGUkktuk6fFm_i5NxFRwn9bUimHrIvCT3-N2EBM70j5XBNOTwI5TrxmvQJkr7ELcHx67Jeggz0v92g8q0RaE-iP1193On6NyxecKUeJeFQaSdtTMLu_Xah5ctT_u94Nty2ZwU0zxWfxqQA5PecPq84kq9nfRw7SK0WDiEFZ4O37d34S_-08lFBVfb92KVb5HIrAp0WpjKYKeGyODLz0dohWIkaZNkiJqfkdLvIH6oRaTSoEmm0n06k0a5K0ZdpL61Io0Yt0nfpxc7UQ0_9cJrhyZ8syX-6brS706Mc489Vjja7fbWj3cxDqIdfJJqOaCFtwZTAV8hT7U0ovjBQRmiMS8HsNKGJfsE4Vjm4WWhOY2crOaKVEczJS8WwgAWNJywv0SuFcmB_rJ41y9fNiBqm_wA0MS9_AUuAiy0?type=png)](https://mermaid.live/edit#pako:eNrFVlFP2zAQ_iuen0BrpCaIl0i8AEJ72KQJtpcRFBnbJVYTn-U4tBXw33dpG5M2CetoBfdkny_ffb67fPIT5SAkjekkhxnPmHXk-3WiCVpZ3T9YZjJyDeDIDQcjycRCQVwmCTOGXEBhQEvtVvG1CWUldwo0-XX-6vVIF5W1GB9cWVbI1_PNL5v8jW3uPFbpmFOc2HK-GfA2WG1ZeJSFx0EQmJxxmUEupE01liEd394mVAkyjolYaFYgfu1P6N1dF8Yzua-cA51WphtTWzsLc872Zan9CnEGUkktuk6fFm_i5NxFRwn9bUimHrIvCT3-N2EBM70j5XBNOTwI5TrxmvQJkr7ELcHx67Jeggz0v92g8q0RaE-iP1193On6NyxecKUeJeFQaSdtTMLu_Xah5ctT_u94Nty2ZwU0zxWfxqQA5PecPq84kq9nfRw7SK0WDiEFZ4O37d34S_-08lFBVfb92KVb5HIrAp0WpjKYKeGyODLz0dohWIkaZNkiJqfkdLvIH6oRaTSoEmm0n06k0a5K0ZdpL61Io0Yt0nfpxc7UQ0_9cJrhyZ8syX-6brS706Mc489Vjja7fbWj3cxDqIdfJJqOaCFtwZTAV8hT7U0ovjBQRmiMS8HsNKGJfsE4Vjm4WWhOY2crOaKVEczJS8WwgAWNJywv0SuFcmB_rJ41y9fNiBqm_wA0MS9_AUuAiy0) -Next, the Virtual Dom will compare the new VNode with the previous VNode and only update the parts of the tree that have changed. - -When a component is re-rendered, the Virtual Dom will compare the new VNode with the previous VNode and only update the parts of the tree that have changed. +Next, the Virtual DOM will compare the new VNode with the previous VNode and only update the parts of the tree that have changed. Because of this approach, when a component is re-rendered only the parts of the tree that have changed will be updated in the DOM by the renderer. The diffing algorithm goes through the list of dynamic attributes and nodes and compares them to the previous VNode. If the attribute or node has changed, a mutation that describes the change is added to the mutation list. -Here is what the diffing algorithm looks like for the root scope (red lines indicate that a mutation was generated, and green lines indicate that no mutation was generated) +Here is what the diffing algorithm looks like for the root `Scope` (red lines indicate that a mutation was generated, and green lines indicate that no mutation was generated) [![](https://mermaid.ink/img/pako:eNrFlFFPwjAQx7_KpT7Kko2Elya8qCE-aGLAJ5khpe1Yw9Zbug4k4He3OJjbGPig0T5t17tf_nf777aEo5CEkijBNY-ZsfAwDjW4kxfzhWFZDGNECxOOmYTIYAo2lsCyDG4xzVBLbcv8_RHKSG4V6orSIN0Wxrh8b2RYKr_uTyubd1W92GiWKg7aac6bOU3G803HbVk82xfP_Ok0JEqAT-FeLWJvpFYSOBbaSkMhCMnra5MgtfhWFrPWqHlhL2urT6atbU-oa0PNE8WXFFJ0-nazXakRroddGk9IwYEUnCd5w7Pddr5UTT8ZuVJY5F0fM7ebRLYyXNDgUnprJWxM-9lb7xAQLHe-M2xDYQCD9pD_2hez_kVn-P_rjLq6n3qjYv2iO5qz9DyvPdyv1ETp5eTTJ_7BGvQq8v1TVtl5jXUcRRcrqFh-dI4VtFlBN6t_ynLNkh5JpUmZEm5rbvfhkLiN6H4BQt2jYGYZklC_uzxWWJxsNCfUmkL2SJEJZuWdYs4cKaERS3IXlUJZNI_lGv7cxj2SMf2CeMx5_wBcbK19?type=png)](https://mermaid.live/edit#pako:eNrFlFFPwjAQx7_KpT7Kko2Elya8qCE-aGLAJ5khpe1Yw9Zbug4k4He3OJjbGPig0T5t17tf_nf777aEo5CEkijBNY-ZsfAwDjW4kxfzhWFZDGNECxOOmYTIYAo2lsCyDG4xzVBLbcv8_RHKSG4V6orSIN0Wxrh8b2RYKr_uTyubd1W92GiWKg7aac6bOU3G803HbVk82xfP_Ok0JEqAT-FeLWJvpFYSOBbaSkMhCMnra5MgtfhWFrPWqHlhL2urT6atbU-oa0PNE8WXFFJ0-nazXakRroddGk9IwYEUnCd5w7Pddr5UTT8ZuVJY5F0fM7ebRLYyXNDgUnprJWxM-9lb7xAQLHe-M2xDYQCD9pD_2hez_kVn-P_rjLq6n3qjYv2iO5qz9DyvPdyv1ETp5eTTJ_7BGvQq8v1TVtl5jXUcRRcrqFh-dI4VtFlBN6t_ynLNkh5JpUmZEm5rbvfhkLiN6H4BQt2jYGYZklC_uzxWWJxsNCfUmkL2SJEJZuWdYs4cKaERS3IXlUJZNI_lGv7cxj2SMf2CeMx5_wBcbK19) ## Conclusion -This is only a brief overview of how the Virtual Dom works. There are several aspects not yet covered in this guide including how the Virtual Dom handles async-components, keyed diffing, and how it uses [bump allocation](https://github.com/fitzgen/bumpalo) to efficiently allocate VNodes. If need more information about the Virtual Dom, you can read the code of the [core](https://github.com/DioxusLabs/dioxus/tree/master/packages/core) crate or reach out to us on [Discord](https://discord.gg/XgGxMSkvUM). +This is only a brief overview of how the Virtual Dom works. There are several aspects not yet covered in this guide including: + + * How the Virtual DOM handles async-components + * Keyed diffing + * Using [bump allocation](https://github.com/fitzgen/bumpalo) to efficiently allocate VNodes. + +If you need more information about the Virtual Dom, you can read the code of the [core](https://github.com/DioxusLabs/dioxus/tree/master/packages/core) crate or reach out to us on [Discord](https://discord.gg/XgGxMSkvUM). \ No newline at end of file diff --git a/docs/guide/src/en/fullstack/getting_started.md b/docs/guide/src/en/fullstack/getting_started.md index 9511b8a6c..401af1d43 100644 --- a/docs/guide/src/en/fullstack/getting_started.md +++ b/docs/guide/src/en/fullstack/getting_started.md @@ -76,7 +76,7 @@ Next, we need to modify our `main.rs` to use either hydrate on the client or ren {{#include ../../../examples/hydration.rs}} ``` -Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. You should see the same page as before, but now you can interact with the buttons! +Now, build your client-side bundle with `dx build --features web` and run your server with `cargo run --features ssr`. You should see the same page as before, but now you can interact with the buttons! ## Sycronizing props between the server and client @@ -99,4 +99,4 @@ The only thing we need to change on the client is the props. `dioxus-fullstack` {{#include ../../../examples/hydration_props.rs}} ``` -Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. Navigate to `http://localhost:8080/1` and you should see the counter start at 1. Navigate to `http://localhost:8080/2` and you should see the counter start at 2. +Now, build your client-side bundle with `dx build --features web` and run your server with `cargo run --features ssr`. Navigate to `http://localhost:8080/1` and you should see the counter start at 1. Navigate to `http://localhost:8080/2` and you should see the counter start at 2. diff --git a/docs/guide/src/en/fullstack/server_functions.md b/docs/guide/src/en/fullstack/server_functions.md index 57883885a..581aa382a 100644 --- a/docs/guide/src/en/fullstack/server_functions.md +++ b/docs/guide/src/en/fullstack/server_functions.md @@ -24,7 +24,7 @@ Next, add the server function to your `main.rs`: {{#include ../../../examples/server_function.rs}} ``` -Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. You should see a new button that multiplies the count by 2. +Now, build your client-side bundle with `dx build --features web` and run your server with `cargo run --features ssr`. You should see a new button that multiplies the count by 2. ## Conclusion diff --git a/docs/guide/src/en/getting_started/hot_reload.md b/docs/guide/src/en/getting_started/hot_reload.md index b14de30a4..d207f361a 100644 --- a/docs/guide/src/en/getting_started/hot_reload.md +++ b/docs/guide/src/en/getting_started/hot_reload.md @@ -18,7 +18,7 @@ Hot reloading is automatically enabled when using the web renderer on debug buil 1. Run: ```bash -dioxus serve --hot-reload +dx serve --hot-reload ``` 2. Change some code within a rsx or render macro diff --git a/docs/guide/src/en/getting_started/web.md b/docs/guide/src/en/getting_started/web.md index 7f3d04848..48818bf8f 100644 --- a/docs/guide/src/en/getting_started/web.md +++ b/docs/guide/src/en/getting_started/web.md @@ -59,5 +59,5 @@ Edit your `main.rs`: And to serve our app: ```bash -dioxus serve +dx serve ``` diff --git a/docs/guide/src/en/interactivity/hooks.md b/docs/guide/src/en/interactivity/hooks.md index 6851aacbf..8b571dacd 100644 --- a/docs/guide/src/en/interactivity/hooks.md +++ b/docs/guide/src/en/interactivity/hooks.md @@ -2,15 +2,16 @@ So far our components have had no state like a normal rust functions. However, in a UI component, it is often useful to have stateful functionality to build user interactions. For example, you might want to track whether the user has opened a drop-down, and render different things accordingly. -Hooks allow us to create state in our components. Hooks are Rust functions that take a reference to `ScopeState` (in a component, you can pass `cx`), and provide you with functionality and state. +Hooks allow us to create state in our components. Hooks are Rust functions that take a reference to [`ScopeState`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.ScopeState.html) (in a component, you can pass `cx`), and provide you with functionality and state. ## `use_state` Hook [`use_state`](https://docs.rs/dioxus/latest/dioxus/prelude/fn.use_state.html) is one of the simplest hooks. -- You provide a closure that determines the initial value +- You provide a closure that determines the initial value: `let mut count = use_state(cx, || 0);` - `use_state` gives you the current value, and a way to update it by setting it to something else -- When the value updates, `use_state` makes the component re-render, and provides you with the new value +- When the value updates, `use_state` makes the component re-render (along with any other component + that references it), and then provides you with the new value. For example, you might have seen the counter example, in which state (a number) is tracked using the `use_state` hook: @@ -45,10 +46,11 @@ But how can Dioxus differentiate between multiple hooks in the same component? A This is only possible because the two hooks are always called in the same order, so Dioxus knows which is which. Because the order you call hooks matters, you must follow certain rules when using hooks: 1. Hooks may be only used in components or other hooks (we'll get to that later) -2. On every call to the component function +2. On every call to a component function 1. The same hooks must be called (except in the case of early returns, as explained later in the [Error Handling chapter](../best_practices/error_handling.md)) 2. In the same order -3. Hooks name's should start with `use_` so you don't accidentally confuse them with regular functions +3. Hook names should start with `use_` so you don't accidentally confuse them with regular + functions (`use_state()`, `use_ref()`, `use_future()`, etc...) These rules mean that there are certain things you can't do with hooks: @@ -74,9 +76,12 @@ These rules mean that there are certain things you can't do with hooks: `use_state` is great for tracking simple values. However, you may notice in the [`UseState` API](https://docs.rs/dioxus/latest/dioxus/hooks/struct.UseState.html) that the only way to modify its value is to replace it with something else (e.g., by calling `set`, or through one of the `+=`, `-=` operators). This works well when it is cheap to construct a value (such as any primitive). But what if you want to maintain more complex data in the components state? -For example, suppose we want to maintain a `Vec` of values. If we stored it with `use_state`, the only way to add a new value to the list would be to create a new `Vec` with the additional value, and put it in the state. This is expensive! We want to modify the existing `Vec` instead. +For example, suppose we want to maintain a `Vec` of values. If we stored it with `use_state`, the +only way to add a new value to the list would be to copy the existing `Vec`, add our value to it, +and then replace the existing `Vec` in the state with it. This is expensive! We want to modify the +existing `Vec` instead. -Thankfully, there is another hook for that, `use_ref`! It is similar to `use_state`, but it lets you get a mutable reference to the contained data. +Thankfully, there is another hook for that, `use_ref`! It **is** similar to `use_state`, but it lets you get a mutable reference to the contained data. Here's a simple example that keeps a list of events in a `use_ref`. We can acquire write access to the state with `.with_mut()`, and then just `.push` a new value to the state: @@ -84,4 +89,18 @@ Here's a simple example that keeps a list of events in a `use_ref`. We can acqui {{#include ../../../examples/hooks_use_ref.rs:component}} ``` -> The return values of `use_state` and `use_ref` (`UseState` and `UseRef`, respectively) are in some ways similar to [`Cell`](https://doc.rust-lang.org/std/cell/) and [`RefCell`](https://doc.rust-lang.org/std/cell/struct.RefCell.html) – they provide interior mutability. However, these Dioxus wrappers also ensure that the component gets re-rendered whenever you change the state. +> The return values of `use_state` and `use_ref` ( +> [`UseState`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.UseState.html) and +> [`UseRef`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.UseRef.html), respectively) are in +> some ways similar to [`Cell`](https://doc.rust-lang.org/std/cell/) and +> [`RefCell`](https://doc.rust-lang.org/std/cell/struct.RefCell.html) – they provide interior +> mutability. However, these Dioxus wrappers also ensure that the component gets re-rendered +> whenever you change the state. + + +## Additional Resources + +- [**dioxus_hooks** ](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/) rustdoc + - Documents all hook types included with dioxus by default Most of these are also covered in + later chapters of this guide. +- [Hooks Package](https://github.com/DioxusLabs/dioxus/tree/master/packages/hooks) diff --git a/docs/guide/src/en/interactivity/memoization.md b/docs/guide/src/en/interactivity/memoization.md new file mode 100644 index 000000000..32ec37ce5 --- /dev/null +++ b/docs/guide/src/en/interactivity/memoization.md @@ -0,0 +1,19 @@ +# Memoization + +[`use_memo`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_memo.html) let's you memorize values and thus save computation time. This is useful for expensive calculations. + +```rust, no_run +#[inline_props] +fn Calculator(cx: Scope, number: usize) -> Element { + let bigger_number = use_memo(cx, (number,), |(number,)| { + // This will only be calculated when `number` has changed. + number * 100 + }); + render!( + p { "{bigger_number}" } + ) +} +fn app(cx: Scope) -> Element { + render!(Calculator { number: 0 }) +} +``` diff --git a/docs/guide/src/en/interactivity/sharing_state.md b/docs/guide/src/en/interactivity/sharing_state.md index 4c97e53bf..d71fbdec0 100644 --- a/docs/guide/src/en/interactivity/sharing_state.md +++ b/docs/guide/src/en/interactivity/sharing_state.md @@ -32,7 +32,7 @@ Finally, a third component will render the other two as children. It will be res ![Meme Editor Screenshot: An old plastic skeleton sitting on a park bench. Caption: "me waiting for a language feature"](./images/meme_editor_screenshot.png) -## Using Context +## Using Shared State Sometimes, some state needs to be shared between multiple components far down the tree, and passing it down through props is very inconvenient. @@ -42,7 +42,7 @@ Suppose now that we want to implement a dark mode toggle for our app. To achieve Now, we could write another `use_state` in the top component, and pass `is_dark_mode` down to every component through props. But think about what will happen as the app grows in complexity – almost every component that renders any CSS is going to need to know if dark mode is enabled or not – so they'll all need the same dark mode prop. And every parent component will need to pass it down to them. Imagine how messy and verbose that would get, especially if we had components several levels deep! -Dioxus offers a better solution than this "prop drilling" – providing context. The [`use_context_provider`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_context_provider.html) hook is similar to `use_ref`, but it makes it available through [`use_context`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_context.html) for all children components. +Dioxus offers a better solution than this "prop drilling" – providing context. The [`use_shared_state_provider`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_shared_state_provider.html) hook is similar to `use_ref`, but it makes it available through [`use_shared_state`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_shared_state.html) for all children components. First, we have to create a struct for our dark mode configuration: @@ -62,7 +62,7 @@ As a result, any child component of `App` (direct or not), can access the `DarkM {{#include ../../../examples/meme_editor_dark_mode.rs:use_context}} ``` -> `use_context` returns `Option>` here. If the context has been provided, the value is `Some(UseSharedState)`, which you can call `.read` or `.write` on, similarly to `UseRef`. Otherwise, the value is `None`. +> `use_shared_state` returns `Option>` here. If the context has been provided, the value is `Some(UseSharedState)`, which you can call `.read` or `.write` on, similarly to `UseRef`. Otherwise, the value is `None`. For example, here's how we would implement the dark mode toggle, which both reads the context (to determine what color it should render) and writes to it (to toggle dark mode): diff --git a/docs/guide/src/pt-br/async/use_coroutine.md b/docs/guide/src/pt-br/async/use_coroutine.md index d7edbd8cc..cb9eda3a0 100644 --- a/docs/guide/src/pt-br/async/use_coroutine.md +++ b/docs/guide/src/pt-br/async/use_coroutine.md @@ -105,7 +105,7 @@ async fn editor_service(rx: UnboundedReceiver) { Podemos combinar corrotinas com `Fermi` para emular o sistema `Thunk` do **Redux Toolkit** com muito menos dor de cabeça. Isso nos permite armazenar todo o estado do nosso aplicativo _dentro_ de uma tarefa e, em seguida, simplesmente atualizar os valores de "visualização" armazenados em `Atoms`. Não pode ser subestimado o quão poderosa é essa técnica: temos todas as vantagens das tarefas nativas do Rust com as otimizações e ergonomia do estado global. Isso significa que seu estado _real_ não precisa estar vinculado a um sistema como `Fermi` ou `Redux` – os únicos `Atoms` que precisam existir são aqueles que são usados para controlar a interface. ```rust, no_run -static USERNAME: Atom = |_| "default".to_string(); +static USERNAME: Atom = Atom(|_| "default".to_string()); fn app(cx: Scope) -> Element { let atoms = use_atom_root(cx); @@ -118,7 +118,7 @@ fn app(cx: Scope) -> Element { } fn Banner(cx: Scope) -> Element { - let username = use_read(cx, USERNAME); + let username = use_read(cx, &USERNAME); cx.render(rsx!{ h1 { "Welcome back, {username}" } @@ -134,8 +134,8 @@ enum SyncAction { } async fn sync_service(mut rx: UnboundedReceiver, atoms: AtomRoot) { - let username = atoms.write(USERNAME); - let errors = atoms.write(ERRORS); + let username = atoms.write(&USERNAME); + let errors = atoms.write(&ERRORS); while let Ok(msg) = rx.next().await { match msg { diff --git a/docs/guide/src/pt-br/best_practices/error_handling.md b/docs/guide/src/pt-br/best_practices/error_handling.md index 7e027e9d0..2fec8d88d 100644 --- a/docs/guide/src/pt-br/best_practices/error_handling.md +++ b/docs/guide/src/pt-br/best_practices/error_handling.md @@ -113,14 +113,14 @@ enum InputError { TooShort, } -static INPUT_ERROR: Atom = |_| InputError::None; +static INPUT_ERROR: Atom = Atom(|_| InputError::None); ``` Então, em nosso componente de nível superior, queremos tratar explicitamente o possível estado de erro para esta parte da árvore. ```rust, no_run fn TopLevel(cx: Scope) -> Element { - let error = use_read(cx, INPUT_ERROR); + let error = use_read(cx, &INPUT_ERROR); match error { TooLong => return cx.render(rsx!{ "FAILED: Too long!" }), @@ -134,7 +134,7 @@ Agora, sempre que um componente _downstream_ tiver um erro em suas ações, ele ```rust, no_run fn Commandline(cx: Scope) -> Element { - let set_error = use_set(cx, INPUT_ERROR); + let set_error = use_set(cx, &INPUT_ERROR); cx.render(rsx!{ input { diff --git a/docs/guide/src/pt-br/getting_started/hot_reload.md b/docs/guide/src/pt-br/getting_started/hot_reload.md index 2dabde536..447169055 100644 --- a/docs/guide/src/pt-br/getting_started/hot_reload.md +++ b/docs/guide/src/pt-br/getting_started/hot_reload.md @@ -18,7 +18,7 @@ dioxus = { version = "*", features = ["hot-reload"] } 1. Execute: ``` -dioxus serve --hot-reload +dx serve --hot-reload ``` 2. alterar algum código dentro de uma macro `rsx` diff --git a/docs/reference/.gitignore b/docs/reference/.gitignore deleted file mode 100644 index 7585238ef..000000000 --- a/docs/reference/.gitignore +++ /dev/null @@ -1 +0,0 @@ -book diff --git a/docs/reference/src/README_pt-br.md b/docs/reference/src/README_pt-br.md deleted file mode 100644 index e76f43516..000000000 --- a/docs/reference/src/README_pt-br.md +++ /dev/null @@ -1,25 +0,0 @@ -# Dioxus: Guias Avançados e Referência - -![dioxuslogo](./images/dioxuslogo_full.png) - -**Dioxus** é um framework e ecossistema para desenvolver interfaces rápidas, escaláveis e robustas com a linguagem de Programação Rust. Este guia irá ajudar você a começar com o Dioxus para Web, Desktop, Móvel e mais. - -> Este livro é a Referência e Guias Avançados para o framework Dioxus. Para um tutorial em como de fato _usar_ o Dioxus, procure o [guia oficial](https://dioxuslabs.com/guide/en/). - -## Guias e Referência - -Com a referência nós procuramos manter a documentar a funcionalidade que pode não ter sido mencionada no guia oficial para manter uma carga de informação mínima. Alguns tópicos não estão inclusos pelo guia, mas discutidos nesta referência incluindo: - -- Processo seguro (`ThreadSafe`) da `VirtualDOM` -- Abordagem complete sobre o uso do `rsx!` e funções inclusas -- Padrão `spread` para as propriedades dos componentes -- Testes -- Memoization à fundo -- Elementos personalizados -- Renderizadores personalizados - -## Contribuindo - -Se nesse documento estiver de algum forma confuso, contém erros de digitação ou você gostaria de ajudar a melhorar algo, sinta-se à vontade para fazer um PR no [repositório do Dioxus](https://github.com/DioxusLabs/dioxus/tree/master/docs/reference). - -Todas as contribuições serão licenciadas sob a licença MIT/Apache2. diff --git a/docs/reference/src/SUMMARY_pt-br.md b/docs/reference/src/SUMMARY_pt-br.md deleted file mode 100644 index 866315481..000000000 --- a/docs/reference/src/SUMMARY_pt-br.md +++ /dev/null @@ -1,47 +0,0 @@ -# Summary - -- [Introdução](README.md) - -- [Platformas](platforms/index.md) - - - [Web](platforms/web.md) - - [Renderização por Servidor(SSR)](platforms/ssr.md) - - [Desktop](platforms/desktop.md) - - [Móvel](platforms/mobile.md) - - [TUI](platforms/tui.md) - -- [Guias Avançados](guide/index.md) - - - [RSX à fundo](guide/rsx_in_depth.md) - - [Componentes](guide/components.md) - - [Propriedades](guide/props.md) - - [Memoization](guide/memoization.md) - - [Desempenho](guide/performance.md) - - [Testes](guide/testing.md) - - [Construindo Elementos com o NodeFactory](guide/rsx.md) - - [Elementos Personalizados](guide/custom_elements.md) - - [Renderizadores Personalizados](guide/custom_renderer.md) - - [Componentes Renderizados por Servidor](guide/server_side_components.md) - - [Empacotando e Distribuindo](guide/bundline.md) - - [Recarregamento em Tempo-Real com RSX](guide/hot_reloading.md) - -- [Guia de Referência](reference/reference.md) - - [Anti-padrões](reference/anti.md) - - [Filhos](reference/children.md) - - [Renderização Condicional](reference/conditional.md) - - [Entradas Controladas](reference/controlled.md) - - [Elementos Personalizados](reference/custom.md) - - [Componentes Vazios](reference/empty.md) - - [Tratamento de Errors](reference/error.md) - - [Fragmentos](reference/fragments.md) - - [CSS Globais](reference/global.md) - - [Estilos em Linha](reference/inline.md) - - [Iteradores](reference/iterators.md) - - [Ouvintes](reference/listeners.md) - - [Memoization](reference/memoization.md) - - [Nós de Referência](reference/node.md) - - [Padrão Propagado (Spread)](reference/spread.md) - - [Gerenciamento de Estado](reference/state.md) - - [Suspensão](reference/suspense.md) - - [Tarefas](reference/task.md) - - [Testes](reference/testing.md) diff --git a/docs/reference/src/guide/custom_renderer_pt-br.md b/docs/reference/src/guide/custom_renderer_pt-br.md deleted file mode 100644 index eada5f0a6..000000000 --- a/docs/reference/src/guide/custom_renderer_pt-br.md +++ /dev/null @@ -1,497 +0,0 @@ -# Renderizador Personalizado - -Dioxus é uma estrutura incrivelmente portátil para desenvolvimento de interface do usuário. As lições, conhecimentos, hooks e componentes que você adquire ao longo do tempo sempre podem ser aproveitados ​​para projetos futuros. No entanto, às vezes, esses projetos não podem aproveitar um renderizador compatível ou você precisa implementar seu próprio renderizador melhor. - -Ótimas notícias: o design do renderizador depende inteiramente de você! Nós fornecemos sugestões e inspiração com os renderizadores originais, mas só realmente precisamos processar `DomEdits` e enviar `UserEvents`. - -## Em Detalhes: - -A implementação do renderizador é bastante simples. O renderizador precisa: - -1. Lidar com o fluxo de edições gerado por atualizações no DOM virtual -2. Registrar ouvintes e passe eventos para o sistema de eventos do DOM virtual - -Essencialmente, seu renderizador precisa implementar o traço `RealDom` e gerar objetos `EventTrigger` para atualizar o `VirtualDOM`. A partir daí, você terá tudo o que precisa para renderizar o `VirtualDOM` na tela. - -Internamente, o Dioxus lida com o relacionamento da árvore, `diffing`, gerenciamento de memória e o sistema de eventos, deixando o mínimo necessário para que os renderizadores se implementem. - -Para referência, confira o interpretador JavaScript ou o renderizador TUI como ponto de partida para seu renderizador personalizado. - -## DomEdições - -O tipo "DomEdit" é uma enumeração serializada que representa uma operação atômica que ocorre no `RealDom`. As variantes seguem aproximadamente este conjunto: - -```rust, no_run -enum DomEdit { - PushRoot, - AppendChildren, - ReplaceWith, - InsertAfter, - InsertBefore, - Remove, - CreateTextNode, - CreateElement, - CreateElementNs, - CreatePlaceholder, - NewEventListener, - RemoveEventListener, - SetText, - SetAttribute, - RemoveAttribute, - PopRoot, -} -``` - -O mecanismo de diferenciação do Dioxus opera como uma [máquina de pilha] (https://en.wikipedia.org/wiki/Stack_machine) onde o método `push_root` empilhar um novo nó DOM "real" para a pilha e `append_child` e `replace_with` ambos removem nós da pilha. - -### Um exemplo - -Por uma questão de compreensão, vamos considerar este exemplo - uma declaração de interface do usuário muito simples: - -```rust, no_run -rsx!( h1 {"hello world"} ) -``` - -To get things started, Dioxus must first navigate to the container of this h1 tag. To "navigate" here, the internal diffing algorithm generates the DomEdit `PushRoot` where the ID of the root is the container. - -When the renderer receives this instruction, it pushes the actual Node onto its own stack. The real renderer's stack will look like this: - -```rust, no_run -instructions: [ - PushRoot(Container) -] -stack: [ - ContainerNode, -] -``` - -Em seguida, o Dioxus encontrará o nó `h1`. O algoritmo `diff` decide que este nó precisa ser criado, então o Dioxus irá gerar o DomEdit `CreateElement`. Quando o renderizador receber esta instrução, ele criará um nó desmontado e o enviará para sua própria pilha: - -```rust, no_run -instructions: [ - PushRoot(Container), - CreateElement(h1), -] -stack: [ - ContainerNode, - h1, -] -``` - -Em seguida, Dioxus vê o nó de texto e gera o DomEdit `CreateTextNode`: - -```rust, no_run -instructions: [ - PushRoot(Container), - CreateElement(h1), - CreateTextNode("hello world") -] -stack: [ - ContainerNode, - h1, - "hello world" -] -``` - -Lembre-se, o nó de texto não está anexado a nada (ele está desmontado), então o Dioxus precisa gerar um `Edit` que conecte o nó de texto ao elemento `h1`. Depende da situação, mas neste caso usamos `AppendChildren`. Isso remove o nó de texto da pilha, deixando o elemento `h1` como o próximo elemento na linha. - -```rust, no_run -instructions: [ - PushRoot(Container), - CreateElement(h1), - CreateTextNode("hello world"), - AppendChildren(1) -] -stack: [ - ContainerNode, - h1 -] -``` - -Chamamos `AppendChildren` novamente, retirando o nó `h1` e anexando-o ao pai: - -```rust, no_run -instructions: [ - PushRoot(Container), - CreateElement(h1), - CreateTextNode("hello world"), - AppendChildren(1), - AppendChildren(1) -] -stack: [ - ContainerNode, -] -``` - -Finalmente, o contêiner é aberto, pois não precisamos mais dele. - -```rust, no_run -instructions: [ - PushRoot(Container), - CreateElement(h1), - CreateTextNode("hello world"), - AppendChildren(1), - AppendChildren(1), - PopRoot -] -stack: [] -``` - -Com o tempo, nossa pilha ficou assim: - -```rust, no_run -[] -[Container] -[Container, h1] -[Container, h1, "hello world"] -[Container, h1] -[Container] -[] -``` - -Observe como nossa pilha fica vazia depois que a interface do usuário é montada. Convenientemente, essa abordagem separa completamente o `VirtualDOM` e o `RealDOM`. Além disso, essas edições são serializáveis, o que significa que podemos até gerenciar UIs em uma conexão de rede. Esta pequena máquina de pilha e edições serializadas tornam o Dioxus independente das especificidades da plataforma. - -Dioxus também é muito rápido. Como o Dioxus divide a fase de `diff` e `patch`, ele é capaz de fazer todas as edições no `RealDOM` em um período de tempo muito curto (menos de um único quadro), tornando a renderização muito rápida. Ele também permite que o Dioxus cancele grandes operações de diferenciação se ocorrer um trabalho de prioridade mais alta durante a diferenciação. - -É importante notar que há uma camada de conexão entre o Dioxus e o renderizador. O Dioxus salva e carrega elementos (a edição `PushRoot`) com um ID. Dentro do `VirtualDOM`, isso é rastreado apenas como um `u64`. - -Sempre que uma edição `CreateElement` é gerada durante a comparação, o Dioxus incrementa seu contador de nós e atribui a esse novo elemento seu `NodeCount` atual. O `RealDom` é responsável por lembrar este ID e enviar o nó correto quando `PushRoot(ID)` é gerado. Dioxus recupera os IDs de elementos quando removidos. Para ficar em sincronia com Dioxus, você pode usar um `Sparce Vec` (`Vec>`) com itens possivelmente desocupados. Você pode usar os ids como índices no `Vec` para elementos e aumentar o `Vec` quando um id não existir. - -Esta pequena demonstração serve para mostrar exatamente como um renderizador precisaria processar um stream de edição para construir UIs. Um conjunto de DOMEdits serializados para várias demos está disponível para você testar seu renderizador personalizado. - -## Ciclo de eventos - -Como a maioria das GUIs, o Dioxus conta com um `loop` de eventos para progredir no `VirtualDOM`. O próprio `VirtualDOM` também pode produzir eventos, por isso é importante que seu renderizador personalizado também possa lidar com eles. - -O código para a implementação do `WebSys` é direto, então vamos adicioná-lo aqui para demonstrar como um `loop` de eventos é simples: - -```rust, no_run -pub async fn run(&mut self) -> dioxus_core::error::Result<()> { - // Push the body element onto the WebsysDom's stack machine - let mut websys_dom = crate::new::WebsysDom::new(prepare_websys_dom()); - websys_dom.stack.push(root_node); - - // Rebuild or hydrate the virtualdom - let mutations = self.internal_dom.rebuild(); - websys_dom.apply_mutations(mutations); - - // Wait for updates from the real dom and progress the virtual dom - loop { - let user_input_future = websys_dom.wait_for_event(); - let internal_event_future = self.internal_dom.wait_for_work(); - - match select(user_input_future, internal_event_future).await { - Either::Left((_, _)) => { - let mutations = self.internal_dom.work_with_deadline(|| false); - websys_dom.apply_mutations(mutations); - }, - Either::Right((event, _)) => websys_dom.handle_event(event), - } - - // render - } -} -``` - -É importante que você decodifique os eventos reais do seu sistema de eventos no sistema de eventos sintético do Dioxus (significado sintético abstraído). Isso significa simplesmente combinar seu tipo de evento e criar um tipo Dioxus `UserEvent`. No momento, o sistema `VirtualEvent` é modelado quase inteiramente em torno da especificação HTML, mas estamos interessados em reduzi-lo. - -```rust, no_run -fn virtual_event_from_websys_event(event: &web_sys::Event) -> VirtualEvent { - match event.type_().as_str() { - "keydown" => { - let event: web_sys::KeyboardEvent = event.clone().dyn_into().unwrap(); - UserEvent::KeyboardEvent(UserEvent { - scope_id: None, - priority: EventPriority::Medium, - name: "keydown", - // This should be whatever element is focused - element: Some(ElementId(0)), - data: Arc::new(KeyboardData{ - char_code: event.char_code(), - key: event.key(), - key_code: event.key_code(), - alt_key: event.alt_key(), - ctrl_key: event.ctrl_key(), - meta_key: event.meta_key(), - shift_key: event.shift_key(), - locale: "".to_string(), - location: event.location(), - repeat: event.repeat(), - which: event.which(), - }) - }) - } - _ => todo!() - } -} -``` - -## Elementos brutos personalizados - -Se você precisar ir mais longe a ponto de confiar em elementos personalizados para o seu renderizador - você pode. Isso ainda permitiria que você usasse a natureza reativa do Dioxus, sistema de componentes, estado compartilhado e outros recursos, mas acabará gerando nós diferentes. Todos os atributos e ouvintes para o namespace HTML e SVG são transportados por meio de estruturas auxiliares que essencialmente compilam (não representam sobrecarga de tempo de execução). Você pode colocar seus próprios elementos a qualquer hora que quiser, sem problemas. No entanto, você deve ter certeza absoluta de que seu renderizador pode lidar com o novo tipo, ou ele irá "bater e queimar". - -Esses elementos personalizados são definidos como `unit struct` com implementações de `traits`. - -Por exemplo, o elemento `div` é (aproximadamente!) definido assim: - -```rust, no_run -struct div; -impl div { - /// Some glorious documentation about the class property. - const TAG_NAME: &'static str = "div"; - const NAME_SPACE: Option<&'static str> = None; - // define the class attribute - pub fn class<'a>(&self, cx: NodeFactory<'a>, val: Arguments) -> Attribute<'a> { - cx.attr("class", val, None, false) - } - // more attributes -} -``` - -Você provavelmente notou que muitos elementos nas macros `rsx!` suportam documentação em foco. A abordagem que adotamos para elementos personalizados significa que a estrutura da unidade é criada imediatamente onde o elemento é usado no macro. Quando o macro é expandido, os comentários doc ainda se aplicam à estrutura da unidade, dando toneladas de feedback no editor, mesmo dentro de uma macro procedural. - -# Núcleo Nativo - -Os renderizadores dão muito trabalho. Se você estiver criando um renderizador em Rust, o núcleo nativo fornece alguns utilitários para implementar um renderizador. Ele fornece uma abstração sobre DomEdits e manipula o layout para você. - -## RealDom - -O `RealDom` é uma abstração de nível superior sobre a atualização do DOM. Ele é atualizado com `DomEdits` e fornece uma maneira de atualizar lentamente o estado dos nós com base em quais atributos mudam. - -### Exemplo - -Vamos construir um renderizador de brinquedo com bordas, tamanho e cor do texto. -Antes de começarmos, vamos dar uma olhada em um elemento de exemplo que podemos renderizar: - -```rust, no_run -cx.render(rsx!{ - div{ - color: "red", - p{ - border: "1px solid black", - "hello world" - } - } -}) -``` - -Nesta árvore a cor depende da cor do pai. O tamanho depende do tamanho das crianças, do texto atual e do tamanho do texto. A borda depende apenas do nó atual. - -```mermaid -flowchart TB - subgraph context - text_width(text width) - end - subgraph div - state1(state)-->color1(color) - state1(state)-->border1(border) - border1-.->text_width - linkStyle 2 stroke:#5555ff,stroke-width:4px; - state1(state)-->layout_width1(layout width) - end - subgraph p - state2(state)-->color2(color) - color2-.->color1(color) - linkStyle 5 stroke:#0000ff,stroke-width:4px; - state2(state)-->border2(border) - border2-.->text_width - linkStyle 7 stroke:#5555ff,stroke-width:4px; - state2(state)-->layout_width2(layout width) - layout_width1-.->layout_width2 - linkStyle 9 stroke:#aaaaff,stroke-width:4px; - end - subgraph hello world - state3(state)-->color3(color) - color3-.->color2(color) - linkStyle 11 stroke:#0000ff,stroke-width:4px; - state3(state)-->border3(border) - border3-.->text_width - linkStyle 13 stroke:#5555ff,stroke-width:4px; - state3(state)-->layout_width3(layout width) - layout_width2-.->layout_width3 - linkStyle 15 stroke:#aaaaff,stroke-width:4px; - end -``` - -Para ajudar na construção de um Dom, o núcleo nativo fornece quatro características: `State`, `ChildDepState`, `ParentDepState` e `NodeDepState` e uma estrutura `RealDom`. - -```rust, no_run -use dioxus_native_core::node_ref::*; -use dioxus_native_core::state::{ChildDepState, NodeDepState, ParentDepState, State}; -use dioxus_native_core_macro::{sorted_str_slice, State}; - -#[derive(Default, Copy, Clone)] -struct Size(f32, f32); -// Size only depends on the current node and its children, so it implements ChildDepState -impl ChildDepState for Size { - // Size accepts a font size context - type Ctx = f32; - // Size depends on the Size part of each child - type DepState = Self; - // Size only cares about the width, height, and text parts of the current node - const NODE_MASK: NodeMask = - NodeMask::new_with_attrs(AttributeMask::Static(&sorted_str_slice!(["width", "height"]))).with_text(); - fn reduce<'a>( - &mut self, - node: NodeView, - children: impl Iterator, - ctx: &Self::Ctx, - ) -> bool - where - Self::DepState: 'a, - { - let mut width; - let mut height; - if let Some(text) = node.text() { - // if the node has text, use the text to size our object - width = text.len() as f32 * ctx; - height = ctx; - } else { - // otherwise, the size is the maximum size of the children - width = *children - .reduce(|accum, item| if accum >= item.0 { accum } else { item.0 }) - .unwrap_or(0.0)); - height = *children - .reduce(|accum, item| if accum >= item.1 { accum } else { item.1 }) - .unwrap_or(&0.0); - } - // if the node contains a width or height attribute it overrides the other size - for a in node.attibutes(){ - match a.name{ - "width" => width = a.value.parse().unwrap(), - "height" => height = a.value.parse().unwrap(), - // because Size only depends on the width and height, no other attributes will be passed to the member - _ => panic!() - } - } - // to determine what other parts of the dom need to be updated we return a boolean that marks if this member changed - let changed = (width != self.0) || (height != self.1); - *self = Self(width, height); - changed - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Default)] -struct TextColor { - r: u8, - g: u8, - b: u8, -} -// TextColor only depends on the current node and its parent, so it implements ParentDepState -impl ParentDepState for TextColor { - type Ctx = (); - // TextColor depends on the TextColor part of the parent - type DepState = Self; - // TextColor only cares about the color attribute of the current node - const NODE_MASK: NodeMask = NodeMask::new_with_attrs(AttributeMask::Static(&["color"])); - fn reduce( - &mut self, - node: NodeView, - parent: Option<&Self::DepState>, - _ctx: &Self::Ctx, - ) -> bool { - // TextColor only depends on the color tag, so getting the first tag is equivilent to looking through all tags - let new = match node.attributes().next() { - // if there is a color tag, translate it - Some("red") => TextColor { r: 255, g: 0, b: 0 }, - Some("green") => TextColor { r: 0, g: 255, b: 0 }, - Some("blue") => TextColor { r: 0, g: 0, b: 255 }, - Some(_) => panic!("unknown color"), - // otherwise check if the node has a parent and inherit that color - None => match parent { - Some(parent) => *parent, - None => Self::default(), - }, - }; - // check if the member has changed - let changed = new != *self; - *self = new; - changed - } -} - -#[derive(Debug, Clone, PartialEq, Default)] -struct Border(bool); -// TextColor only depends on the current node, so it implements NodeDepState -impl NodeDepState for Border { - type Ctx = (); - // Border does not depended on any other member in the current node - type DepState = (); - // Border does not depended on any other member in the current node - const NODE_MASK: NodeMask = - NodeMask::new_with_attrs(AttributeMask::Static(&["border"])); - fn reduce(&mut self, node: NodeView, _sibling: &Self::DepState, _ctx: &Self::Ctx) -> bool { - // check if the node contians a border attribute - let new = Self(node.attributes().next().map(|a| a.name == "border").is_some()); - // check if the member has changed - let changed = new != *self; - *self = new; - changed - } -} - -// State provides a derive macro, but anotations on the members are needed in the form #[dep_type(dep_member, CtxType)] -#[derive(State, Default, Clone)] -struct ToyState { - // the color member of it's parent and no context - #[parent_dep_state(color)] - color: TextColor, - // depends on the node, and no context - #[node_dep_state()] - border: Border, - // depends on the layout_width member of children and f32 context (for text size) - #[child_dep_state(size, f32)] - size: Size, -} -``` - -Agora que temos nosso estado, podemos colocá-lo em uso em nosso DOM. Nós podemos atualizar o DOM com `update_state` para atualizar a estrutura do `DOM` (adicionando, removendo e alterando as propriedades dos nós) e então `apply_mutations` para atualizar o `ToyState` para cada um dos nós que foram alterados. - -```rust, no_run -fn main(){ - fn app(cx: Scope) -> Element { - cx.render(rsx!{ - div{ - color: "red", - "hello world" - } - }) - } - let vdom = VirtualDom::new(app); - let rdom: RealDom = RealDom::new(); - - let mutations = dom.rebuild(); - // update the structure of the real_dom tree - let to_update = rdom.apply_mutations(vec![mutations]); - let mut ctx = AnyMap::new(); - // set the font size to 3.3 - ctx.insert(3.3); - // update the ToyState for nodes in the real_dom tree - let _to_rerender = rdom.update_state(&dom, to_update, ctx).unwrap(); - - // we need to run the vdom in a async runtime - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()? - .block_on(async { - loop{ - let wait = vdom.wait_for_work(); - let mutations = vdom.work_with_deadline(|| false); - let to_update = rdom.apply_mutations(mutations); - let mut ctx = AnyMap::new(); - ctx.insert(3.3); - let _to_rerender = rdom.update_state(vdom, to_update, ctx).unwrap(); - - // render... - } - }) -} -``` - -## Disposição - -Para a maioria das plataformas, o layout dos Elementos permanecerá o mesmo. O módulo layout_attributes fornece uma maneira de aplicar atributos `html` a um estilo de layout estendido. - -## Conclusão - -Pronto, é isso! Você deve ter quase todo o conhecimento necessário sobre como implementar seu próprio renderizador. Estamos super interessados em ver os aplicativos Dioxus trazidos para renderizadores de desktop personalizados, renderizador para dispositivos móveis, interface do usuário para videogames e até realidade aumentada! Se você estiver interessado em contribuir para qualquer um desses projetos, não tenha medo de entrar em contato ou se juntar à comunidade. diff --git a/docs/reference/src/guide/hot_reloading_pt-br.md b/docs/reference/src/guide/hot_reloading_pt-br.md deleted file mode 100644 index 475cc38e9..000000000 --- a/docs/reference/src/guide/hot_reloading_pt-br.md +++ /dev/null @@ -1,31 +0,0 @@ -# Recarregamento a Quente - -1. O recarregamento a quente permite tempos de iteração muito mais rápidos dentro de chamadas rsx, interpretando-as e transmitindo as edições. -2. É útil ao alterar o estilo/layout de um programa, mas não ajudará na alteração da lógica de um programa. -3. Atualmente, o cli implementa apenas o recarregamento a quente para o renderizador da web. - -# Configurar - -Instale o [dioxus-cli](https://github.com/DioxusLabs/cli). -Habilite o recurso hot_reload no dioxus: - -```toml -dioxus = { version = "*", features = ["web", "hot_reload"] } -``` - -# Como Usar - -1. run: - -``` -dioxus serve --hot-reload -``` - -2. alterar algum código dentro de uma macro rsx -3. abra seu localhost em um navegador -4. salve e observe a mudança de estilo sem recompilar - -# Limitações - -1. O intérprete só pode usar expressões que existiam na última recompilação completa. Se você introduzir uma nova variável ou expressão na chamada rsx, ela acionará uma recompilação completa para capturar a expressão. -2. Componentes e Iteradores podem conter código de Rust arbitrário e acionarão uma recompilação completa quando alterados. diff --git a/docs/reference/src/guide/index_pt-br.md b/docs/reference/src/guide/index_pt-br.md deleted file mode 100644 index 295fb7582..000000000 --- a/docs/reference/src/guide/index_pt-br.md +++ /dev/null @@ -1 +0,0 @@ -# Guias Avançados diff --git a/docs/reference/src/guide/rsx_in_depth.md b/docs/reference/src/guide/rsx_in_depth.md deleted file mode 100644 index 1a76f7c41..000000000 --- a/docs/reference/src/guide/rsx_in_depth.md +++ /dev/null @@ -1,228 +0,0 @@ -# RSX in Depth - -The RSX macro makes it very easy to assemble complex UIs with a very natural Rust syntax: - -```rust, no_run -rsx!( - div { - button { - onclick: move |e| todos.write().new_todo(), - "Add todo" - } - ul { - class: "todo-list", - todos.iter().map(|(key, todo)| rsx!( - li { - class: "beautiful-todo" - key: "f" - h3 { "{todo.title}" } - p { "{todo.contents}"} - } - )) - } - } -) -``` - -In this section, we'll cover the `rsx!` macro in depth. If you prefer to learn through examples, the `code reference` guide has plenty of examples on how to use `rsx!` effectively. - -### Element structure - -Attributes must come before child elements - -```rust, no_run -div { - hidden: "false", - "some text" - child {} - Component {} // uppercase - component() // lowercase is treated like a function call - (0..10).map(|f| rsx!{ "hi {f}" }) // arbitrary rust expressions -} -``` - -Each element takes a comma-separated list of expressions to build the node. Roughly, here's how they work: - -- `name: value` sets a property on this element. -- `"text"` adds a new text element -- `tag {}` adds a new child element -- `CustomTag {}` adds a new child component -- `{expr}` pastes the `expr` tokens literally. They must be `IntoIterator where T: IntoVnode` to work properly - -Commas are entirely optional, but might be useful to delineate between elements and attributes. - -The `render` function provides an **extremely efficient** allocator for VNodes and text, so try not to use the `format!` macro in your components. Rust's default `ToString` methods pass through the global allocator, but all text in components is allocated inside a manually-managed Bump arena. To push you in the right direction, all text-based attributes take `std::fmt::Arguments` directly, so you'll want to reach for `format_args!` when the built-in `f-string` interpolation just doesn't cut it. - -### Ignoring `cx.render` with `render!(...)` - -Sometimes, writing `cx.render` is a hassle. The `rsx! macro will accept any token followed by a comma as the target to call "render" on: - -```rust, no_run -cx.render(rsx!( div {} )) -// becomes -render!(div {}) -``` - -### Conditional Rendering - -Sometimes, you might not want to render an element given a condition. The rsx! macro will accept any tokens directly contained with curly braces, provided they resolve to a type that implements `IntoIterator`. This lets us write any Rust expression that resolves to a VNode: - -```rust, no_run -rsx!({ - if enabled { - render!(div {"enabled"}) - } else { - render!(li {"disabled"}) - } -}) -``` - -A convenient way of hiding/showing an element is returning an `Option`. When combined with `and_then`, we can succinctly control the display state given some boolean: - -```rust, no_run -rsx!({ - a.and_then(rsx!(div {"enabled"})) -}) -``` - -It's important to note that the expression `rsx!()` is typically lazy - this expression must be _rendered_ to produce a VNode. When using match statements, we must render every arm as to avoid the `no two closures are identical` rule that Rust imposes: - -```rust, no_run -// this will not compile! -match case { - true => rsx!(div {}), - false => rsx!(div {}) -} - -// the nodes must be rendered first -match case { - true => render!(div {}), - false => render!(div {}) -} -``` - -### Lists - -Again, because anything that implements `IntoIterator` is valid, we can use lists directly in our `rsx!`: - -```rust, no_run -let items = vec!["a", "b", "c"]; - -cx.render(rsx!{ - ul { - {items.iter().map(|f| rsx!(li { "a" }))} - } -}) -``` - -Sometimes, it makes sense to render VNodes into a list: - -```rust, no_run -let mut items = vec![]; - -for _ in 0..5 { - items.push(render!(li {} )) -} - -render!({items} ) -``` - -#### Lists and Keys - -When rendering the VirtualDom to the screen, Dioxus needs to know which elements have been added and which have been removed. These changes are determined through a process called "diffing" - an old set of elements is compared to a new set of elements. If an element is removed, then it won't show up in the new elements, and Dioxus knows to remove it. - -However, with lists, Dioxus does not exactly know how to determine which elements have been added or removed if the order changes or if an element is added or removed from the middle of the list. - -In these cases, it is vitally important to specify a "key" alongside the element. Keys should be persistent between renders. - -```rust, no_run -fn render_list(cx: Scope, items: HashMap) -> DomTree { - render!(ul { - {items.iter().map(|key, item| { - li { - key: key, - h2 { "{todo.title}" } - p { "{todo.contents}" } - } - })} - }) -} -``` - -There have been many guides made for keys in React, so we recommend reading up to understand their importance: - -- [React guide on keys](https://reactjs.org/docs/lists-and-keys.html) -- [Importance of keys (Medium)](https://kentcdodds.com/blog/understanding-reacts-key-prop) - -### Complete Reference - -```rust, no_run -let text = "example"; - -cx.render(rsx!{ - div { - h1 { "Example" }, - - {title} - - // fstring interpolation - "{text}" - - p { - // Attributes - tag: "type", - - // Anything that implements display can be an attribute - abc: 123, - - enabled: true, - - // attributes also supports interpolation - // `class` is not a restricted keyword unlike JS and ClassName - class: "big small wide short {text}", - - class: format_args!("attributes take fmt::Arguments. {}", 99), - - tag: {"these tokens are placed directly"} - - // Children - a { "abcder" }, - - // Children with attributes - h2 { "hello", class: "abc-123" }, - - // Child components - CustomComponent { a: 123, b: 456, key: "1" }, - - // Child components with paths - crate::components::CustomComponent { a: 123, b: 456, key: "1" }, - - // Iterators - { (0..3).map(|i| rsx!( h1 {"{:i}"} )) }, - - // More rsx!, or even html! - { rsx! { div { } } }, - { html! {
} }, - - // Matching - // Requires rendering the nodes first. - // rsx! is lazy, and the underlying closures cannot have the same type - // Rendering produces the VNode type - {match rand::gen_range::(1..3) { - 1 => render!(h1 { "big" }) - 2 => render!(h2 { "medium" }) - _ => render!(h3 { "small" }) - }} - - // Optionals - {true.and_then(|f| rsx!( h1 {"Conditional Rendering"} ))} - - // Child nodes - {cx.props.children} - - // Any expression that is `IntoVNode` - {expr} - } - } -}) -``` diff --git a/docs/reference/src/guide/rsx_in_depth_pt-br.md b/docs/reference/src/guide/rsx_in_depth_pt-br.md deleted file mode 100644 index 4c981bd0a..000000000 --- a/docs/reference/src/guide/rsx_in_depth_pt-br.md +++ /dev/null @@ -1,228 +0,0 @@ -# RSX à Fundo - -A macro RSX facilita muito a montagem de interfaces de usuário complexas com uma sintaxe Rust muito natural: - -```rust, no_run -rsx!( - div { - button { - onclick: move |e| todos.write().new_todo(), - "Add todo" - } - ul { - class: "todo-list", - todos.iter().map(|(key, todo)| rsx!( - li { - class: "beautiful-todo" - key: "f" - h3 { "{todo.title}" } - p { "{todo.contents}"} - } - )) - } - } -) -``` - -Nesta seção, abordaremos a macro `rsx!` em profundidade. Se você preferir aprender através de exemplos, o guia `referência de código` tem muitos exemplos sobre como usar `rsx!` efetivamente. - -### Estrutura do elemento - -Os atributos devem vir antes dos elementos filhos - -```rust, no_run -div { - hidden: "false", - "some text" - child {} - Component {} // uppercase - component() // lowercase is treated like a function call - (0..10).map(|f| rsx!{ "hi {f}" }) // arbitrary rust expressions -} -``` - -Cada elemento usa uma lista de expressões separadas por vírgulas para construir o nó. A grosso modo, veja como eles funcionam: - -- `name: value` define uma propriedade neste elemento. -- `text` adiciona um novo elemento de texto -- `tag {}` adiciona um novo elemento filho -- `CustomTag {}` adiciona um novo componente filho -- `{expr}` cola os tokens `expr` literalmente. Eles devem ser `IntoIterator where T: IntoVnode` para funcionar corretamente - -As vírgulas são totalmente opcionais, mas podem ser úteis para delinear entre elementos e atributos. - -A função `render` fornece um alocador **extremamente eficiente** para `VNodes` e `text`, então tente não usar a macro `format!` em seus componentes. Os métodos `ToString` padrão do Rust passam pelo alocador global, mas todo o texto nos componentes é alocado dentro de uma ""arena Bump"" gerenciada manualmente. Para levá-lo na direção certa, todos os atributos baseados em texto recebem `std::fmt::Arguments` diretamente, então você vai querer usar `format_args!` quando a interpolação interna `f-string` simplesmente não funcionar. - -### Ignorando `cx.render` com `render!(...)` - -Às vezes, escrever `cx.render` é um aborrecimento. O `rsx!` macro aceitará qualquer token seguido por uma vírgula como destino para chamar "render" em: - -```rust, no_run -cx.render(rsx!( div {} )) -// becomes -render!(div {}) -``` - -### Renderização Condicional - -Às vezes, você pode não querer renderizar um elemento dada uma condição. O `rsx!` macro aceitará quaisquer tokens contidos diretamente com chaves, desde que resolvam para um tipo que implemente `IntoIterator`. Isso nos permite escrever qualquer expressão Rust que resolva para um `VNode`: - -```rust, no_run -rsx!({ - if enabled { - render!(div {"enabled"}) - } else { - render!(li {"disabled"}) - } -}) -``` - -Uma maneira conveniente de ocultar/mostrar um elemento é retornar um `Option`. Quando combinado com `and_then`, podemos controlar sucintamente o estado de exibição dado alguns booleanos: - -```rust, no_run -rsx!({ - a.and_then(rsx!(div {"enabled"})) -}) -``` - -É importante notar que a expressão `rsx!()` é tipicamente tardia - esta expressão deve ser _renderizada_ para produzir um `VNode`. Ao usar declarações de `match`, devemos renderizar todos os braços para evitar a regra 'não há dois fechamentos idênticos' que o Rust impõe: - -```rust, no_run -// this will not compile! -match case { - true => rsx!(div {}), - false => rsx!(div {}) -} - -// the nodes must be rendered first -match case { - true => render!(div {}), - false => render!(div {}) -} -``` - -### Listas - -Novamente, porque qualquer coisa que implemente `IntoIterator` é válida, podemos usar listas diretamente em nosso `rsx!`: - -```rust, no_run -let items = vec!["a", "b", "c"]; - -cx.render(rsx!{ - ul { - {items.iter().map(|f| rsx!(li { "a" }))} - } -}) -``` - -Às vezes, faz sentido renderizar `VNodes` em uma lista: - -```rust, no_run -let mut items = vec![]; - -for _ in 0..5 { - items.push(render!(li {} )) -} - -render!({items} ) -``` - -#### Listas e chaves - -Ao renderizar o `VirtualDom` na tela, o Dioxus precisa saber quais elementos foram adicionados e quais foram removidos. Essas mudanças são determinadas através de um processo chamado "diffing" - um antigo conjunto de elementos é comparado a um novo conjunto de elementos. Se um elemento for removido, ele não aparecerá nos novos elementos, e Dioxus sabe removê-lo. - -No entanto, com listas, Dioxus não sabe exatamente como determinar quais elementos foram adicionados ou removidos se a ordem mudar ou se um elemento for adicionado ou removido do meio da lista. - -Nesses casos, é de vital importância especificar uma "chave" ao lado do elemento. As chaves devem ser persistentes entre as renderizações. - -```rust, no_run -fn render_list(cx: Scope, items: HashMap) -> DomTree { - render!(ul { - {items.iter().map(|key, item| { - li { - key: key, - h2 { "{todo.title}" } - p { "{todo.contents}" } - } - })} - }) -} -``` - -Existem muitos guias feitos para chaves no React, então recomendamos a leitura para entender sua importância: - -- [React guide on keys](https://reactjs.org/docs/lists-and-keys.html) -- [Importância das chaves (Média)](https://kentcdodds.com/blog/understanding-reacts-key-prop) - -### Referência Completa - -```rust, no_run -let text = "example"; - -cx.render(rsx!{ - div { - h1 { "Example" }, - - {title} - - // fstring interpolation - "{text}" - - p { - // Attributes - tag: "type", - - // Anything that implements display can be an attribute - abc: 123, - - enabled: true, - - // attributes also supports interpolation - // `class` is not a restricted keyword unlike JS and ClassName - class: "big small wide short {text}", - - class: format_args!("attributes take fmt::Arguments. {}", 99), - - tag: {"these tokens are placed directly"} - - // Children - a { "abcder" }, - - // Children with attributes - h2 { "hello", class: "abc-123" }, - - // Child components - CustomComponent { a: 123, b: 456, key: "1" }, - - // Child components with paths - crate::components::CustomComponent { a: 123, b: 456, key: "1" }, - - // Iterators - { (0..3).map(|i| rsx!( h1 {"{:i}"} )) }, - - // More rsx!, or even html! - { rsx! { div { } } }, - { html! {
} }, - - // Matching - // Requires rendering the nodes first. - // rsx! is lazy, and the underlying closures cannot have the same type - // Rendering produces the VNode type - {match rand::gen_range::(1..3) { - 1 => render!(h1 { "big" }) - 2 => render!(h2 { "medium" }) - _ => render!(h3 { "small" }) - }} - - // Optionals - {true.and_then(|f| rsx!( h1 {"Conditional Rendering"} ))} - - // Child nodes - {cx.props.children} - - // Any expression that is `IntoVNode` - {expr} - } - } -}) -``` diff --git a/docs/reference/src/platforms/desktop.md b/docs/reference/src/platforms/desktop.md deleted file mode 100644 index 30fe698ff..000000000 --- a/docs/reference/src/platforms/desktop.md +++ /dev/null @@ -1,45 +0,0 @@ -# Getting Started: Desktop - -One of Dioxus' killer features is the ability to quickly build a native desktop app that looks and feels the same across platforms. Apps built with Dioxus are typically <5mb in size and use existing system resources, so they won't hog extreme amounts of RAM or memory. - -Dioxus Desktop is built off Tauri. Right now there aren't any Dioxus abstractions over the menubar, handling, etc, so you'll want to leverage Tauri - mostly [Wry](http://github.com/tauri-apps/wry/) and [Tao](http://github.com/tauri-apps/tao)) directly. The next major release of Dioxus-Desktop will include components and hooks for notifications, global shortcuts, menubar, etc. - -## Getting Set up - -Getting Set up with Dioxus-Desktop is quite easy. Make sure you have Rust and Cargo installed, and then create a new project: - -```shell -$ cargo new --bin demo -$ cd demo -``` - -Add Dioxus with the `desktop` feature: - -```shell -$ cargo add dioxus --features desktop -``` - -Edit your `main.rs`: - -```rust, no_run -// main.rs -use dioxus::prelude::*; - -fn main() { - dioxus::desktop::launch(app); -} - -fn app(cx: Scope) -> Element { - cx.render(rsx!{ - div { - "hello world!" - } - }) -} -``` - -To configure the webview, menubar, and other important desktop-specific features, checkout out some of the launch configuration in the [API reference](https://docs.rs/dioxus-desktop/). - -## Future Steps - -Make sure to read the [Dioxus Guide](https://dioxuslabs.com/guide/en) if you already haven't! diff --git a/docs/reference/src/platforms/index.md b/docs/reference/src/platforms/index.md deleted file mode 100644 index b69a9fade..000000000 --- a/docs/reference/src/platforms/index.md +++ /dev/null @@ -1,11 +0,0 @@ -# Platforms - -Dioxus supports many different platforms. Below are a list of guides that walk you through setting up Dioxus for a specific platform. - -### Setup Guides - -- [Web](web.md) -- [Server Side Rendering](ssr.md) -- [Desktop](desktop.md) -- [Mobile](mobile.md) -- [TUI](tui.md) diff --git a/docs/router/Cargo.toml b/docs/router/Cargo.toml new file mode 100644 index 000000000..59e8861ad --- /dev/null +++ b/docs/router/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dioxus-router-guide" +version = "0.0.1" +edition = "2021" +description = "Dioxus router guide, including testable examples" +license = "MIT OR Apache-2.0" +publish = false + +[dev-dependencies] +dioxus = { path = "../../packages/dioxus" } +dioxus-desktop = { path = "../../packages/desktop" } +dioxus-web = { path = "../../packages/web" } +dioxus-ssr = { path = "../../packages/ssr" } +dioxus-router = { path = "../../packages/router" } diff --git a/docs/router/README.md b/docs/router/README.md new file mode 100644 index 000000000..7219b47a4 --- /dev/null +++ b/docs/router/README.md @@ -0,0 +1,7 @@ +# The router book + +## How to run the tests +- Navigate your terminal to this directory +- Run `cargo clean` +- Run `cargo build --all --F regex -F serde -F web` +- Run `mdbook test -L ../../target/debug/deps/` diff --git a/docs/router/book.toml b/docs/router/book.toml index 216bc555e..e2000ca82 100644 --- a/docs/router/book.toml +++ b/docs/router/book.toml @@ -4,3 +4,6 @@ language = "en" multilingual = false src = "src" title = "Dioxus Router" + +[rust] +edition = "2021" diff --git a/docs/router/examples/catch_all.rs b/docs/router/examples/catch_all.rs new file mode 100644 index 000000000..38dfa11f1 --- /dev/null +++ b/docs/router/examples/catch_all.rs @@ -0,0 +1,48 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: router +#[derive(Routable, Clone)] +enum Route { + #[route("/")] + Home {}, + // PageNotFound is a catch all route that will match any route and placing the matched segments in the route field + #[route("/:..route")] + PageNotFound { route: Vec }, +} +// ANCHOR_END: router + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} +// ANCHOR_END: app + +// ANCHOR: home +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: home + +// ANCHOR: fallback +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} +// ANCHOR_END: fallback + +fn main() {} diff --git a/docs/router/examples/catch_all_segments.rs b/docs/router/examples/catch_all_segments.rs new file mode 100644 index 000000000..ce84a84db --- /dev/null +++ b/docs/router/examples/catch_all_segments.rs @@ -0,0 +1,24 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: route +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // segments that start with :... are catch all segments + #[route("/blog/:..segments")] + BlogPost { + // You must include catch all segment in child variants + segments: Vec, + }, +} + +// Components must contain the same catch all segments as their corresponding variant +#[inline_props] +fn BlogPost(cx: Scope, segments: Vec) -> Element { + todo!() +} +// ANCHOR_END: route + +fn main() {} diff --git a/docs/router/examples/dynamic_route.rs b/docs/router/examples/dynamic_route.rs new file mode 100644 index 000000000..4ea5b3b45 --- /dev/null +++ b/docs/router/examples/dynamic_route.rs @@ -0,0 +1,115 @@ +#![allow(non_snake_case)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +fn main() { + #[cfg(target_arch = "wasm32")] + dioxus_web::launch(App); + #[cfg(not(target_arch = "wasm32"))] + dioxus_desktop::launch(App); +} + +// ANCHOR: router +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(NavBar)] + #[route("/")] + Home {}, + #[nest("/blog")] + #[layout(Blog)] + #[route("/")] + BlogList {}, + #[route("/blog/:name")] + BlogPost { name: String }, + #[end_layout] + #[end_nest] + #[end_layout] + #[route("/:..route")] + PageNotFound { + route: Vec, + }, +} +// ANCHOR_END: router + +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} + +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + nav { + ul { + li { Link { target: Route::Home {}, "Home" } } + li { Link { target: Route::BlogList {}, "Blog" } } + } + } + Outlet {} + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} + +// ANCHOR: blog +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + h1 { "Blog" } + Outlet {} + } +} +// ANCHOR_END: blog + +// ANCHOR: blog_list +#[inline_props] +fn BlogList(cx: Scope) -> Element { + render! { + h2 { "Choose a post" } + ul { + li { + Link { + target: Route::BlogPost { name: "Blog post 1".into() }, + "Read the first blog post" + } + } + li { + Link { + target: Route::BlogPost { name: "Blog post 2".into() }, + "Read the second blog post" + } + } + } + } +} +// ANCHOR_END: blog_list + +// ANCHOR: blog_post +// The name prop comes from the /:name route segment +#[inline_props] +fn BlogPost(cx: Scope, name: String) -> Element { + render! { + h2 { "Blog Post: {name}"} + } +} +// ANCHOR_END: blog_post + +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} diff --git a/docs/router/examples/dynamic_segments.rs b/docs/router/examples/dynamic_segments.rs new file mode 100644 index 000000000..6aee22e6c --- /dev/null +++ b/docs/router/examples/dynamic_segments.rs @@ -0,0 +1,35 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: route +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // segments that start with : are dynamic segments + #[route("/blog/:name")] + BlogPost { + // You must include dynamic segments in child variants + name: String, + }, + #[route("/document/:id")] + Document { + // You can use any type that implements FromStr + // If the segment can't be parsed, the route will not match + id: usize, + }, +} + +// Components must contain the same dynamic segments as their corresponding variant +#[inline_props] +fn BlogPost(cx: Scope, name: String) -> Element { + todo!() +} + +#[inline_props] +fn Document(cx: Scope, id: usize) -> Element { + todo!() +} +// ANCHOR_END: route + +fn main() {} diff --git a/docs/router/examples/external_link.rs b/docs/router/examples/external_link.rs new file mode 100644 index 000000000..4a4d360a9 --- /dev/null +++ b/docs/router/examples/external_link.rs @@ -0,0 +1,28 @@ +#![allow(non_snake_case, unused)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +#[derive(Routable, Clone)] +enum Route { + #[route("/")] + Home {}, +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + todo!() +} + +fn main() {} + +// ANCHOR: component +fn GoToDioxus(cx: Scope) -> Element { + render! { + Link { + target: NavigationTarget::External("https://dioxuslabs.com".into()), + "ExternalTarget target" + } + } +} +// ANCHOR_END: component diff --git a/docs/router/examples/first_route.rs b/docs/router/examples/first_route.rs new file mode 100644 index 000000000..d13bef93d --- /dev/null +++ b/docs/router/examples/first_route.rs @@ -0,0 +1,36 @@ +// ANCHOR: router +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +/// An enum of all of the possible routes in the app. +#[derive(Routable, Clone)] +enum Route { + // The home page is at the / route + #[route("/")] + // If the name of the component and variant are the same you can omit the component and props name + // If they are different you can specify them like this: + // #[route("/", ComponentName, PropsName)] + Home {}, +} +// ANCHOR_END: router + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} +// ANCHOR_END: app + +// ANCHOR: home +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: home + +fn main() {} diff --git a/docs/router/examples/full_example.rs b/docs/router/examples/full_example.rs new file mode 100644 index 000000000..ff8302926 --- /dev/null +++ b/docs/router/examples/full_example.rs @@ -0,0 +1,112 @@ +#![allow(non_snake_case)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +fn main() { + #[cfg(target_arch = "wasm32")] + dioxus_web::launch(App); + #[cfg(not(target_arch = "wasm32"))] + dioxus_desktop::launch(App); +} + +// ANCHOR: router +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(NavBar)] + #[route("/")] + Home {}, + #[nest("/blog")] + #[layout(Blog)] + #[route("/")] + BlogList {}, + #[route("/blog/:name")] + BlogPost { name: String }, + #[end_layout] + #[end_nest] + #[end_layout] + #[nest("/myblog")] + #[redirect("/", || Route::BlogList {})] + #[redirect("/:name", |name: String| Route::BlogPost { name })] + #[end_nest] + #[route("/:..route")] + PageNotFound { + route: Vec, + }, +} +// ANCHOR_END: router + +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} + +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + nav { + ul { + li { Link { target: Route::Home {}, "Home" } } + li { Link { target: Route::BlogList {}, "Blog" } } + } + } + Outlet {} + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + h1 { "Blog" } + Outlet {} + } +} + +#[inline_props] +fn BlogList(cx: Scope) -> Element { + render! { + h2 { "Choose a post" } + ul { + li { + Link { + target: Route::BlogPost { name: "Blog post 1".into() }, + "Read the first blog post" + } + } + li { + Link { + target: Route::BlogPost { name: "Blog post 2".into() }, + "Read the second blog post" + } + } + } + } +} + +#[inline_props] +fn BlogPost(cx: Scope, name: String) -> Element { + render! { + h2 { "Blog Post: {name}"} + } +} + +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} diff --git a/docs/router/examples/history_buttons.rs b/docs/router/examples/history_buttons.rs new file mode 100644 index 000000000..971549aa5 --- /dev/null +++ b/docs/router/examples/history_buttons.rs @@ -0,0 +1,30 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[route("/")] + Home {}, +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + todo!() +} + +// ANCHOR: history_buttons +fn HistoryNavigation(cx: Scope) -> Element { + render! { + GoBackButton { + "Back to the Past" + } + GoForwardButton { + "Back to the Future" /* You see what I did there? 😉 */ + } + } +} +// ANCHOR_END: history_buttons + +fn main() {} diff --git a/docs/router/examples/history_provider.rs b/docs/router/examples/history_provider.rs new file mode 100644 index 000000000..090019ad0 --- /dev/null +++ b/docs/router/examples/history_provider.rs @@ -0,0 +1,29 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +#[derive(Routable, Clone)] +enum Route { + #[route("/")] + Home {}, +} + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router { + config: || RouterConfig::default().history(WebHistory::default()) + } + } +} +// ANCHOR_END: app + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} + +fn main() {} diff --git a/docs/router/examples/links.rs b/docs/router/examples/links.rs new file mode 100644 index 000000000..c74865927 --- /dev/null +++ b/docs/router/examples/links.rs @@ -0,0 +1,72 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: router +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // All routes under the NavBar layout will be rendered inside of the NavBar Outlet + #[layout(NavBar)] + #[route("/")] + Home {}, + #[end_layout] + #[route("/:..route")] + PageNotFound { route: Vec }, +} +// ANCHOR_END: router + +// ANCHOR: nav +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + nav { + ul { + li { + Link { + // The Link component will navigate to the route specified + // in the target prop which is checked to exist at compile time + target: Route::Home {}, + "Home" + } + } + } + } + Outlet {} + } +} +// ANCHOR_END: nav + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} +// ANCHOR_END: app + +// ANCHOR: home +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: home + +// ANCHOR: fallback +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} +// ANCHOR_END: fallback + +fn main() {} diff --git a/docs/router/examples/navigator.rs b/docs/router/examples/navigator.rs new file mode 100644 index 000000000..4c6249366 --- /dev/null +++ b/docs/router/examples/navigator.rs @@ -0,0 +1,56 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[route("/")] + Home {}, + #[route("/:..route")] + PageNotFound { route: Vec }, +} + +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} + +// ANCHOR: nav +#[inline_props] +fn Home(cx: Scope) -> Element { + let nav = use_navigator(cx); + + // push + nav.push(Route::PageNotFound { route: vec![] }); + + // replace + nav.replace(Route::Home {}); + + // go back + nav.go_back(); + + // go forward + nav.go_forward(); + + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: nav + +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} + +fn main() {} diff --git a/docs/router/examples/nest.rs b/docs/router/examples/nest.rs new file mode 100644 index 000000000..f7fe2137c --- /dev/null +++ b/docs/router/examples/nest.rs @@ -0,0 +1,40 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: route +#[derive(Routable, Clone)] +// Skipping formatting allows you to indent nests +#[rustfmt::skip] +enum Route { + // Start the /blog nest + #[nest("/blog")] + // You can nest as many times as you want + #[nest("/:id")] + #[route("/post")] + PostId { + // You must include parent dynamic segments in child variants + id: usize, + }, + // End nests manually with #[end_nest] + #[end_nest] + #[route("/:id")] + // The absolute route of BlogPost is /blog/:name + BlogPost { + id: usize, + }, + // Or nests are ended automatically at the end of the enum +} + +#[inline_props] +fn BlogPost(cx: Scope, id: usize) -> Element { + todo!() +} + +#[inline_props] +fn PostId(cx: Scope, id: usize) -> Element { + todo!() +} +// ANCHOR_END: route + +fn main() {} diff --git a/docs/router/examples/nested_routes.rs b/docs/router/examples/nested_routes.rs new file mode 100644 index 000000000..7462d8e74 --- /dev/null +++ b/docs/router/examples/nested_routes.rs @@ -0,0 +1,66 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: router +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // All routes under the NavBar layout will be rendered inside of the NavBar Outlet + #[layout(NavBar)] + #[route("/")] + Home {}, + #[end_layout] + #[route("/:..route")] + PageNotFound { route: Vec }, +} +// ANCHOR_END: router + +// ANCHOR: nav +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + nav { + ul { + li { "links" } + } + } + // The Outlet component will render child routes (In this case just the Home component) inside the Outlet component + Outlet {} + } +} +// ANCHOR_END: nav + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} +// ANCHOR_END: app + +// ANCHOR: home +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: home + +// ANCHOR: fallback +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } +} +// ANCHOR_END: fallback + +fn main() {} diff --git a/docs/router/examples/outlet.rs b/docs/router/examples/outlet.rs new file mode 100644 index 000000000..b28a61f66 --- /dev/null +++ b/docs/router/examples/outlet.rs @@ -0,0 +1,46 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: outlet +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(Wrapper)] + #[route("/")] + Index {}, +} + +#[inline_props] +fn Wrapper(cx: Scope) -> Element { + render! { + header { "header" } + // The index route will be rendered here + Outlet { } + footer { "footer" } + } +} + +#[inline_props] +fn Index(cx: Scope) -> Element { + render! { + h1 { "Index" } + } +} +// ANCHOR_END: outlet + +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} + +fn main() { + let mut vdom = VirtualDom::new(App); + let _ = vdom.rebuild(); + let html = dioxus_ssr::render(&vdom); + assert_eq!( + html, + "
header

Index

footer
" + ); +} diff --git a/docs/router/examples/query_segments.rs b/docs/router/examples/query_segments.rs new file mode 100644 index 000000000..1f3c068ac --- /dev/null +++ b/docs/router/examples/query_segments.rs @@ -0,0 +1,24 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: route +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // segments that start with ?: are query segments + #[route("/blog?:name")] + BlogPost { + // You must include query segments in child variants + name: String, + }, +} + +// Components must contain the same query segments as their corresponding variant +#[inline_props] +fn BlogPost(cx: Scope, name: String) -> Element { + todo!() +} +// ANCHOR_END: route + +fn main() {} diff --git a/docs/router/examples/router_cfg.rs b/docs/router/examples/router_cfg.rs new file mode 100644 index 000000000..9b29af75c --- /dev/null +++ b/docs/router/examples/router_cfg.rs @@ -0,0 +1,37 @@ +// ANCHOR: router +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +/// An enum of all of the possible routes in the app. +#[derive(Routable, Clone)] +enum Route { + // The home page is at the / route + #[route("/")] + // If the name of the component and variant are the same you can omit the component and props name + // #[route("/", ComponentName, PropsName)] + Home {}, +} +// ANCHOR_END: router + +// ANCHOR: app +#[inline_props] +fn App(cx: Scope) -> Element { + render! { + Router { + config: || RouterConfig::default().history(WebHistory::default()) + } + } +} +// ANCHOR_END: app + +// ANCHOR: home +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} +// ANCHOR_END: home + +fn main() {} diff --git a/docs/router/examples/routing_update.rs b/docs/router/examples/routing_update.rs new file mode 100644 index 000000000..35097dbaf --- /dev/null +++ b/docs/router/examples/routing_update.rs @@ -0,0 +1,41 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: router +#[derive(Routable, Clone, PartialEq)] +enum Route { + #[route("/")] + Index {}, + #[route("/home")] + Home {}, +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + p { "Home" } + } +} + +#[inline_props] +fn Index(cx: Scope) -> Element { + render! { + p { "Index" } + } +} + +fn app(cx: Scope) -> Element { + render! { + Router { + config: || RouterConfig::default().on_update(|state|{ + (state.current() == Route::Index {}).then_some( + NavigationTarget::Internal(Route::Home {}) + ) + }) + } + } +} +// ANCHOR_END: router + +fn main() {} diff --git a/docs/router/examples/static_segments.rs b/docs/router/examples/static_segments.rs new file mode 100644 index 000000000..54c045765 --- /dev/null +++ b/docs/router/examples/static_segments.rs @@ -0,0 +1,28 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +// ANCHOR: route +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // Routes always start with a slash + #[route("/")] + Home {}, + // You can have multiple segments in a route + #[route("/hello/world")] + HelloWorld {}, +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + todo!() +} + +#[inline_props] +fn HelloWorld(cx: Scope) -> Element { + todo!() +} +// ANCHOR_END: route + +fn main() {} diff --git a/docs/router/src/README.md b/docs/router/src/README.md deleted file mode 100644 index 055c3009b..000000000 --- a/docs/router/src/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Dioxus Router: Introduction - -Whether or not you're building a website, desktop app, or mobile app, organizing your app's views into "pages" can be an effective method for organization and maintainability. - -The `dioxus-router` crate contains the Router module. To add it to your project run: - - cargo add dioxus-router - -> **Be sure to include the `web` feature (`--feature web`) for deployment into a browser!** - -In this book you'll find a short [guide](./guide/index.md) to get up to speed with Dioxus Router, as well as the router's [reference](./reference/index.md). \ No newline at end of file diff --git a/docs/router/src/SUMMARY.md b/docs/router/src/SUMMARY.md index dc8bde93a..19ffb2ca7 100644 --- a/docs/router/src/SUMMARY.md +++ b/docs/router/src/SUMMARY.md @@ -1,11 +1,25 @@ # Summary -- [Introduction](./README.md) +[Introduction](./index.md) -- [Guide](./guide/index.md) - - [Getting Started](./guide/getting-started.md) - - [Creating Our First Route](./guide/first-route.md) - - [Building a Nest](./guide/building-a-nest.md) - - [Redirection Perfection](./guide/redirection-perfection.md) -- [Reference](./reference/index.md) - - [X]() \ No newline at end of file +# Example Project + +- [Overview](./example/index.md) +- [Creating Our First Route](./example/first-route.md) +- [Building a Nest](./example/building-a-nest.md) +- [Navigation Targets](./example/navigation-targets.md) +- [Redirection Perfection](./example/redirection-perfection.md) +- [Full Code](./example/full-code.md) + +# Reference + +- [Adding the Router to Your Application](./reference/index.md) +- [Defining Routes](./reference/routes/index.md) + - [Nested Routes](./reference/routes/nested.md) +- [Layouts](./reference/layouts.md) +- [Navigation](./reference/navigation/index.md) + - [Programmatic Navigation](./reference/navigation/programmatic.md) +- [History Providers](./reference/history-providers.md) +- [History Buttons](./reference/history-buttons.md) +- [Static Generation](./reference/static-generation.md) +- [Routing Update Callback](./reference/routing-update-callback.md) diff --git a/docs/router/src/example/building-a-nest.md b/docs/router/src/example/building-a-nest.md new file mode 100644 index 000000000..426194f48 --- /dev/null +++ b/docs/router/src/example/building-a-nest.md @@ -0,0 +1,99 @@ +# Building a Nest + +In this chapter, we will begin to build the blog portion of our site which will +include links, nested routes, and route parameters. + +## Site Navigation + +Our site visitors won't know all the available pages and blogs on our site so we +should provide a navigation bar for them. Our navbar will be a list of links going between our pages. + +We want our navbar component to be rendered on several different pages on our site. Instead of duplicating the code, we can create a component that wraps all children routes. This is called a layout component. To tell the router where to render the child routes, we use the [`Outlet`] component. + +Let's create a new `NavBar` component: + +```rust, no_run +{{#include ../../examples/nested_routes.rs:nav}} +``` + +Next, let's add our `NavBar` component as a layout to our Route enum: + +```rust, no_run +{{#include ../../examples/nested_routes.rs:router}} +``` + +To add links to our `NavBar`, we could always use an HTML anchor element but that has two issues: + +1. It causes a full-page reload +2. We can accidentally link to a page that doesn't exist + +Instead, we want to use the [`Link`] component provided by Dioxus Router. + +The [`Link`] is similar to a regular `` tag. It takes a target and children. + +Unlike a regular `` tag, we can pass in our Route enum as the target. Because we annotated our routes with the [`route(path)`] attribute, the [`Link`] will know how to generate the correct URL. If we use the Route enum, the rust compiler will prevent us from linking to a page that doesn't exist. + +Let's add our links: + +```rust, no_run +{{#include ../../examples/links.rs:nav}} +``` + +> Using this method, the [`Link`] component only works for links within our +> application. To learn more about navigation targets see +> [here](./navigation-targets.md). + +Now you should see a list of links near the top of your page. Click on one and +you should seamlessly travel between pages. + +## URL Parameters and Nested Routes + +Many websites such as GitHub put parameters in their URL. For example, +`https://github.com/DioxusLabs` utilizes the text after the domain to +dynamically search and display content about an organization. + +We want to store our blogs in a database and load them as needed. We also +want our users to be able to send people a link to a specific blog post. +Instead of listing all of the blog titles at compile time, we can make a dynamic route. + +We could utilize a search page that loads a blog when clicked but then our users +won't be able to share our blogs easily. This is where URL parameters come in. + +The path to our blog will look like `/blog/myBlogPage`, `myBlogPage` being the +URL parameter. + +First, let's create a layout component (similar to the navbar) that wraps the blog content. This allows us to add a heading that tells the user they are on the blog. + +```rust, no_run +{{#include ../../examples/dynamic_route.rs:blog}} +``` + +Now we'll create another index component, that'll be displayed when no blog post +is selected: + +```rust, no_run +{{#include ../../examples/dynamic_route.rs:blog_list}} +``` + +We also need to create a component that displays an actual blog post. This component will accept the URL parameters as props: + +```rust, no_run +{{#include ../../examples/dynamic_route.rs:blog_post}} +``` + +Finally, let's tell our router about those components: + +```rust, no_run +{{#include ../../examples/dynamic_route.rs:router}} +``` + +That's it! If you head to `/blog/1` you should see our sample post. + +## Conclusion + +In this chapter, we utilized Dioxus Router's Link, and Route Parameter +functionality to build the blog portion of our application. In the next chapter, +we will go over how navigation targets (like the one we passed to our links) +work. + +[`Link`]: https://docs.rs/dioxus-router/latest/dioxus_router/prelude/fn.GenericLink.html diff --git a/docs/router/src/example/first-route.md b/docs/router/src/example/first-route.md new file mode 100644 index 000000000..5135f6a8f --- /dev/null +++ b/docs/router/src/example/first-route.md @@ -0,0 +1,62 @@ +# Creating Our First Route + +In this chapter, we will start utilizing Dioxus Router and add a homepage and a +404 page to our project. + +## Fundamentals + +The core of the Dioxus Router is the [`Routable`] macro and the [`Router`] component. + +First, we need an actual page to route to! Let's add a homepage component: + +```rust, no_run +{{#include ../../examples/first_route.rs:home}} +``` + +## Creating Routes + +We want to use Dioxus Router to separate our application into different "pages". +Dioxus Router will then determine which page to render based on the URL path. + +To start using Dioxus Router, we need to use the [`Routable`] macro. + +The [`Routable`] macro takes an enum with all of the possible routes in our application. Each variant of the enum represents a route and must be annotated with the [`route(path)`] attribute. + +```rust, no_run +{{#include ../../examples/first_route.rs:router}} +``` + +All other hooks and components the router provides can only be used as a descendant of a [`Router`] component. + +If you head to your application's browser tab, you should now see the text +`Welcome to Dioxus Blog!` when on the root URL (`http://localhost:8080/`). If +you enter a different path for the URL, nothing should be displayed. + +This is because we told Dioxus Router to render the `Home` component only when +the URL path is `/`. + +## Fallback Route + +In our example, when a route doesn't exist Dioxus Router doesn't render anything. Many sites also have a "404" page when a path does not exist. Let's add one to our site. + +First, we create a new `PageNotFound` component. + +```rust, no_run +{{#include ../../examples/catch_all.rs:fallback}} +``` + +Next, register the route in the Route enum to match if all other routes fail. + +```rust, no_run +{{#include ../../examples/catch_all.rs:router}} +``` + +Now when you go to a route that doesn't exist, you should see the page not found +text. + +## Conclusion + +In this chapter, we learned how to create a route and tell Dioxus Router what +component to render when the URL path is `/`. We also created a 404 page to +handle when a route doesn't exist. Next, we'll create the blog portion of our +site. We will utilize nested routes and URL parameters. diff --git a/docs/router/src/example/full-code.md b/docs/router/src/example/full-code.md new file mode 100644 index 000000000..ca97812a5 --- /dev/null +++ b/docs/router/src/example/full-code.md @@ -0,0 +1,5 @@ +# Full Code + +```rust, no_run +{{#include ../../examples/full_example.rs}} +``` diff --git a/docs/router/src/example/index.md b/docs/router/src/example/index.md new file mode 100644 index 000000000..064c9ac68 --- /dev/null +++ b/docs/router/src/example/index.md @@ -0,0 +1,29 @@ +# Overview + +In this guide, you'll learn to effectively use Dioxus Router whether you're +building a small todo app or the next FAANG company. We will create a small +website with a blog, homepage, and more! + +> To follow along with the router example, you'll need a working Dioxus app. +> Check out the [Dioxus book](https://dioxuslabs.com/docs/0.3/guide/en/) to get started. + +> Make sure to add Dioxus Router as a dependency, as explained in the +> [introduction](../index.md). + +## You'll learn how to + +- Create routes and render "pages". +- Utilize nested routes, create a navigation bar, and render content for a + set of routes. +- Parse URL parameters to dynamically display content. +- Redirect visitors to different routes. + +> **Disclaimer** +> +> The example will only display the features of Dioxus Router. It will not +> include any actual functionality. To keep things simple we will only be using +> a single file, this is not the recommended way of doing things with a real +> application. + +You can find the complete application in the [full code](./full-code.md) +chapter. diff --git a/docs/router/src/example/navigation-targets.md b/docs/router/src/example/navigation-targets.md new file mode 100644 index 000000000..fb12f913f --- /dev/null +++ b/docs/router/src/example/navigation-targets.md @@ -0,0 +1,27 @@ +# Navigation Targets + +In the previous chapter, we learned how to create links to pages within our app. +We told them where to go using the `target` property. This property takes something that can be converted to a [`NavigationTarget`]. + +## What is a navigation target? + +A [`NavigationTarget`] is similar to the `href` of an HTML anchor element. It +tells the router where to navigate to. The Dioxus Router knows two kinds of +navigation targets: + +- [`Internal`]: We used internal links in the previous chapter. It's a link to a page within our + app represented as a Route enum. +- [`External`]: This works exactly like an HTML anchors' `href`. Don't use this for in-app + navigation as it will trigger a page reload by the browser. + +## External navigation + +If we need a link to an external page we can do it like this: + +```rust, no_run +{{#include ../../examples/external_link.rs:component}} +``` + +[`External`]: https://docs.rs/dioxus-router-core/latest/dioxus_router/navigation/enum.NavigationTarget.html#variant.External +[`Internal`]: https://docs.rs/dioxus-router-core/latest/dioxus_router/navigation/enum.NavigationTarget.html#variant.Internal +[`NavigationTarget`]: https://docs.rs/dioxus-router-core/latest/dioxus_router/navigation/enum.NavigationTarget.html diff --git a/docs/router/src/example/redirection-perfection.md b/docs/router/src/example/redirection-perfection.md new file mode 100644 index 000000000..1ad4950ae --- /dev/null +++ b/docs/router/src/example/redirection-perfection.md @@ -0,0 +1,41 @@ +# Redirection Perfection + +You're well on your way to becoming a routing master! + +In this chapter, we will cover creating redirects + +## Creating Redirects + +A redirect is very simple. When dioxus encounters a redirect while finding out +what components to render, it will redirect the user to the target of the +redirect. + +As a simple example, let's say you want user to still land on your blog, even +if they used the path `/myblog` or `/myblog/:name`. + +Redirects are special attributes in the router enum that accept a route and a closure +with the route parameters. The closure should return a route to redirect to. + +Let's add a redirect to our router enum: + +```rust, no_run +{{#include ../../examples/full_example.rs:router}} +``` + +That's it! Now your users will be redirected to the blog. + +### Conclusion + +Well done! You've completed the Dioxus Router guide. You've built a small +application and learned about the many things you can do with Dioxus Router. +To continue your journey, you attempt a challenge listed below, look at the [router examples](https://github.com/DioxusLabs/dioxus/tree/master/packages/router/examples), or +can check out the [API reference](https://docs.rs/dioxus-router/). + +### Challenges + +- Organize your components into separate files for better maintainability. +- Give your app some style if you haven't already. +- Build an about page so your visitors know who you are. +- Add a user system that uses URL parameters. +- Create a simple admin system to create, delete, and edit blogs. +- If you want to go to the max, hook up your application to a rest API and database. diff --git a/docs/router/src/guide/building-a-nest.md b/docs/router/src/guide/building-a-nest.md deleted file mode 100644 index 532af6c3c..000000000 --- a/docs/router/src/guide/building-a-nest.md +++ /dev/null @@ -1,201 +0,0 @@ -# Building a Nest -Not a bird's nest! A nest of routes! - -In this chapter we will begin to build the blog portion of our site which will include links, nested URLs, and URL parameters. We will also explore the use case of rendering components outside of routes. - -### Site Navigation -Our site visitors won't know all the available pages and blogs on our site so we should provide a navigation bar for them. -Let's create a new ``navbar`` component: -```rs -fn navbar(cx: Scope) -> Element { - cx.render(rsx! { - ul { - - } - }) -} -``` -Our navbar will be a list of links going between our pages. We could always use an HTML anchor element but that would cause our page to unnecessarily reload. Instead we want to use the ``Link`` component provided by Dioxus Router. - -The Link component is very similar to the Route component. It takes a path and an element. Add the Link component into your use statement and then add some links: -```rs -use dioxus::{ - prelude::*, - router::{Route, Router, Link}, // UPDATED -}; - -... - -fn navbar(cx: Scope) -> Element { - cx.render(rsx! { - ul { - // NEW - Link { to: "/", "Home"} - br {} - Link { to: "/blog", "Blog"} - } - }) -} -``` ->By default, the Link component only works for links within your application. To link to external sites, add the ``external: true`` property. ->```rs ->Link { to: "https://github.com", external: true, "GitHub"} ->``` - -And finally, use the navbar component in your app component: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - p { "-- Dioxus Blog --" } - self::navbar {} // NEW - Route { to: "/", self::homepage {}} - Route { to: "", self::page_not_found {}} - } - }) -} -``` -Now you should see a list of links near the top of your page. Click on one and you should seamlessly travel between pages. - -##### WIP: Active Link Styling - -### URL Parameters and Nested Routes -Many websites such as GitHub put parameters in their URL. For example, ``github.com/DioxusLabs`` utilizes the text after the domain to dynamically search and display content about an organization. - -We want to store our blogs in a database and load them as needed. This'll help prevent our app from being bloated therefor providing faster load times. We also want our users to be able to send people a link to a specific blog post. -We could utilize a search page that loads a blog when clicked but then our users won't be able to share our blogs easily. This is where URL parameters come in. And finally, we also want our site to tell users they are on a blog page whenever the URL starts with``/blog``. - -The path to our blog will look like ``/blog/myBlogPage``. ``myBlogPage`` being the URL parameter. -Dioxus Router uses the ``:name`` pattern so our route will look like ``/blog/:post``. - -First, lets tell users when they are on a blog page. Add a new route in your app component. -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - p { "-- Dioxus Blog --" } - self::navbar {} - Route { to: "/", self::homepage {}} - // NEW - Route { - to: "/blog", - } - Route { to: "", self::page_not_found {}} - } - }) -} -``` -Routes can take components as parameters and we know that a route is a component. We nest routes by doing exactly what they are called, nesting them: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - p { "-- Dioxus Blog --" } - self::navbar {} - Route { to: "/", self::homepage {}} - Route { - to: "/blog", - Route { to: "/:post", "This is my blog post!" } // NEW - } - Route { to: "", self::page_not_found {}} - } - }) -} -``` -Nesting our route like this isn't too helpful at first, but remember we want to tell users they are on a blog page. Let's move our ``p { "-- Dioxus Blog --" }`` inside of our ``/blog`` route. -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - self::navbar {} - Route { to: "/", self::homepage {}} - Route { - to: "/blog", - p { "-- Dioxus Blog --" } // MOVED - Route { to: "/:post", "This is my blog post!" } - } - Route { to: "", self::page_not_found {}} - } - }) -} -``` -Now our ``-- Dioxus Blog --`` text will be displayed whenever a user is on a path that starts with ``/blog``. Displaying content in a way that is page-agnostic is useful when building navigation menus, footers, and similar. - -All that's left is to handle our URL parameter. We will begin by creating a ``get_blog_post`` function. In a real site, this function would call an API endpoint to get a blog post from the database. However, that is out of the scope of this guide so we will be utilizing static text. -```rs -fn get_blog_post(id: &str) -> String { - match id { - "foo" => "Welcome to the foo blog post!".to_string(), - "bar" => "This is the bar blog post!".to_string(), - id => format!("Blog post '{id}' does not exist!") - } -} - -``` -Now that we have established our helper function, lets create a new ``blog_post`` component. -```rs -fn blog_post(cx: Scope) -> Element { - let blog_text = ""; - - cx.render(rsx! { - p { "{blog_text}" } - }) -} -``` -All that's left is to extract the blog id from the URL and to call our helper function to get the blog text. To do this we need to utilize Dioxus Router's ``use_route`` hook. -First start by adding ``use_route`` to your imports and then utilize the hook in your ``blog_post`` component. -```rs -use dioxus::{ - prelude::*, - router::{use_route, Link, Route, Router}, // UPDATED -}; - -... - -fn blog_post(cx: Scope) -> Element { - let route = use_route(cx); // NEW - let blog_text = ""; - - cx.render(rsx! { - p { "{blog_text}" } - }) -} -``` -Dioxus Router provides built in methods to extract information from a route. We could utilize the ``segments``, ``nth_segment``, or ``last_segment`` method for our case but we'll use the ``segment`` method which extracts a specific URL parameter. -The ``segment`` method also parses the parameter into any type for us. We'll use a match expression that handles a parsing error and on success, uses our helper function to grab the blog post. -```rs -fn blog_post(cx: Scope) -> Element { - let route = use_route(cx); - - // NEW - let blog_text = match route.segment::("post").unwrap() { - Ok(val) => get_blog_post(&val), - Err(_) => "An unknown error occured".to_string(), - }; - - cx.render(rsx! { - p { "{blog_text}" } - }) -} -``` -And finally add the ``blog_post`` component to your ``app`` component: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - self::navbar {} - Route { to: "/", self::homepage {}} - Route { - to: "/blog", - p { "-- Dioxus Blog --" } - Route { to: "/:post", self::blog_post {} } // UPDATED - } - Route { to: "", self::page_not_found {}} - } - }) -} -``` -That's it! If you head to ``/blog/foo`` you should see ``Welcome to the foo blog post!``. - -### Conclusion -In this chapter we utilized Dioxus Router's Link, URL Parameter, and ``use_route`` functionality to build the blog portion of our application. In the next and final chapter, we will go over the ``Redirect`` component to redirect non-authorized users to another page. diff --git a/docs/router/src/guide/first-route.md b/docs/router/src/guide/first-route.md deleted file mode 100644 index 75740a739..000000000 --- a/docs/router/src/guide/first-route.md +++ /dev/null @@ -1,95 +0,0 @@ -# Creating Our First Route -In this chapter, we will continue off of our new Dioxus project to create a homepage and start utilizing Dioxus Router! - -### Fundamentals -Dioxus Router works based on a router and route component. If you've ever used [React Router](https://reactrouter.com/), you should feel at home with Dioxus Router. - -To get started, import the ``Router`` and ``Route`` components. -```rs -use dioxus::{ - prelude::*, - router::{Route, Router} -} -``` -We also need an actual page to route to! Add a homepage component: -```rs -fn homepage(cx: Scope) -> Element { - cx.render(rsx! { - p { "Welcome to Dioxus Blog!" } - }) -} -``` - -### To Route or Not to Route -We want to use Dioxus Router to seperate our application into different "pages". Dioxus Router will then determine which page to render based on the URL path. - -To start using Dioxus Router, we need to use the ``Router`` component. -Replace the ``p { "Hello, wasm!" }`` in your ``app`` component with a Router component: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router {} // NEW - }) -} -``` -Now we have established a router and we can create our first route. We will be creating a route for our homepage component we created earlier. -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - Route { to: "/", self::homepage {}} // NEW - } - }) -} -``` -If you head to your application's browser tab, you should see the text ``Welcome to Dioxus Blog!`` when on the root URL (``http://localhost:8080/``). If you enter a different path for the URL, nothing should be displayed. - -This is because we told Dioxus Router to render the ``homepage`` component only when the URL path is ``/``. You can tell Dioxus Router to render any kind of component such as a ``div {}``. - -### What if a Route Doesn't Exist? -In our example Dioxus Router doesn't render anything. If we wanted to, we could tell Dioxus Router to render a component all the time! Try it out: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - p { "-- Dioxus Blog --" } // NEW - Route { to: "/", self::homepage {}} - } - }) -} -``` -We will go into more detail about this in the next chapter. - -Many sites also have a "404" page for when a URL path leads to nowhere. Dioxus Router can do this too! Create a new ``page_not_found`` component. -```rs -fn page_not_found(cx: Scope) -> Element { - cx.render(rsx! { - p { "Oops! The page you are looking for doesn't exist!" } - }) -} -``` - -Now to tell Dioxus Router to render our new component when no route exists. Create a new route with a path of nothing: -```rs -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - p { "-- Dioxus Blog --" } - Route { to: "/", self::homepage {}} - Route { to: "", self::page_not_found {}} // NEW - } - }) -} -``` -Now when you go to a route that doesn't exist, you should see the page not found text and the text we told Dioxus Router to render all the time. -``` -// localhost:8080/abc - --- Dioxus Blog -- -Oops! The page you are looking for doesn't exist! -``` - -> Make sure you put your empty route at the bottom or else it'll override any routes below it! - -### Conclusion -In this chapter we learned how to create a route and tell Dioxus Router what component to render when the URL path is equal to what we specified. We also created a 404 page to handle when a route doesn't exist. Next, we'll create the blog portion of our site. We will utilize nested routes and URL parameters. \ No newline at end of file diff --git a/docs/router/src/guide/getting-started.md b/docs/router/src/guide/getting-started.md deleted file mode 100644 index 7bce3c30a..000000000 --- a/docs/router/src/guide/getting-started.md +++ /dev/null @@ -1,68 +0,0 @@ -# Getting Started -Before we start utilizing Dioxus Router, we need to initialize a Dioxus web application. - -#### Required Tools -If you haven't already, make sure you install the [dioxus-cli](https://dioxuslabs.com/nightly/cli/) build tool and the rust ``wasm32-unknown-unknown`` target: -``` -$ cargo install dioxus-cli - ... -$ rustup target add wasm32-unkown-unknown - ... -``` - -### Creating the Project -First, create a new cargo binary project: -``` -cargo new --bin dioxus-blog -``` - -Next, we need to add dioxus with the web and router feature to our ``Cargo.toml`` file. -```toml -[package] -name = "dioxus-blog" -version = "0.1.0" -edition = "2021" - -[dependencies] -dioxus = { version = "0.1.8", features = ["web", "router"] } -``` - -Now we can start coding! Create an ``index.html`` file in the root of your project: -```html - - - Dioxus Blog - - -
- - -``` -You can add whatever you want to this file, just ensure that you have a ``div`` with the id of ``main`` in the root of your body element. This is essentially a handle to where Dioxus will render your components. - -Now move to ``src/main.rs`` and replace its contents with: -```rs -use dioxus::prelude::*; - -fn main() { - // Launch Dioxus web app - dioxus_web::launch(app); -} - -// Our root component. -fn app(cx: Scope) -> Element { - // Render "Hello, wasm!" to the screen. - cx.render(rsx! { - p { "Hello, wasm!"} - }) -} -``` - -Our project is now setup! To make sure everything is running correctly, in the root of your project run: -``` -dioxus serve --platform web -``` -Then head to [http://localhost:8080](http://localhost:8080) in your browser, and you should see ``Hello, wasm!`` on your screen. - -#### Conclusion -We setup a new project with Dioxus and got everything running correctly. Next we'll create a small homepage and start our journey with Dioxus Router. diff --git a/docs/router/src/guide/index.md b/docs/router/src/guide/index.md deleted file mode 100644 index 29c8049ee..000000000 --- a/docs/router/src/guide/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Dioxus Router: Guide -In this guide you'll learn to effectively use Dioxus Router whether you're building a small todo app or the next FAANG company. We will create a small website with a blog, homepage, and more! - -#### You'll learn how to -- Create routes and render "pages". -- Utilize nested routes, create a navigation bar, and render content for a set of routes. -- Gather URL parameters to dynamically display content. -- Redirect your visitors wherever you want. - -> Disclaimer -> -> This site will only display the features of Dioxus Router. It will not include any actual functionality. To keep things simple we will only be using a single file, this is not the recommended way of doing things with a real application. - -You can find the complete application [here](https://github.com/DogeDark/dioxus-router-example). \ No newline at end of file diff --git a/docs/router/src/guide/redirection-perfection.md b/docs/router/src/guide/redirection-perfection.md deleted file mode 100644 index 4f42c2b73..000000000 --- a/docs/router/src/guide/redirection-perfection.md +++ /dev/null @@ -1,51 +0,0 @@ -# Redirection Perfection -You're well on your way to becoming a routing master! - -In this chapter we will cover utilizing the ``Redirect`` component so you can take Rickrolling to the next level. We will also provide some optional challenges at the end if you want to continue your practice with not only Dioxus Router but with Dioxus in general. - -### What Is This Redirect Thing? -The ``Redirect`` component is simple! When Dioxus determines that it should be rendered, it will redirect your application visitor to wherever you want. -In this example, let's say that you added a secret page to your site but didn't have time to program in the permission system. As a quick fix you add a redirect. - -As always, let's first create a new component named ``secret_page``. -```rs -fn secret_page(cx: Scope) -> Element { - cx.render(rsx! { - p { "This page is not to be viewed!" } - }) -} -``` -To redirect our visitors, all we have to do is render the ``Redirect`` component. The ``Redirect`` component is very similar to the ``Link`` component. The main difference is it doesn't display anything new. -First import the ``Redirect`` component and then update your ``secret_page`` component: -```rs -use dioxus::{ - prelude::*, - router::{use_route, Link, Redirect, Route, Router}, // UPDATED -}; - -... - -fn secret_page(cx: Scope) -> Element { - cx.render(rsx! { - p { "This page is not to be viewed!" } - Redirect { to: "/" } // NEW - }) -} -``` -That's it! Now your users will be redirected away from the secret page. - ->Similar to the ``Link`` component, the ``Redirect`` component needs to be explicitly set to redirect to an external site. To link to external sites, add the ``external: true`` property. ->```rs ->Redirect { to: "https://github.com", external: true} ->``` - -### Conclusion -Well done! You've completed the Dioxus Router guide book. You've built a small application and learned about the many things you can do with Dioxus Router. To continue your journey, you can find a list of challenges down below, or you can check out the [reference](../reference/index.md). - -### Challenges -- Organize your components into seperate files for better maintainability. -- Give your app some style if you haven't already. -- Build an about page so your visitors know who you are. -- Add a user system that uses URL parameters. -- Create a simple admin system to create, delete, and edit blogs. -- If you want to go to the max, hook up your application to a rest API and database. \ No newline at end of file diff --git a/docs/router/src/index.md b/docs/router/src/index.md new file mode 100644 index 000000000..56a7d5deb --- /dev/null +++ b/docs/router/src/index.md @@ -0,0 +1,27 @@ +# Introduction + +> If you are not familiar with Dioxus itself, check out the [Dioxus book](https://dioxuslabs.com/docs/0.3/guide/en/) first. + +Whether you are building a website, desktop app, or mobile app, +splitting your app's views into "pages" can be an effective method for +organization and maintainability. + +For this purpose, Dioxus provides a router. Use the `cargo add` command to add the dependency: + +```sh +cargo add dioxus-router +``` + +This book is intended to get you up to speed with Dioxus Router. It is split +into two sections: + +1. The [reference](./reference/index.md) section explains individual features in + depth. You can read it from start to finish, or you can read individual chapters + in whatever order you want. +2. If you prefer a learning-by-doing approach, you can check out the + _[example project](./example/index.md)_. It guides you through + creating a dioxus app, setting up the router, and using some of its + functionality. + +> Please note that this is not the only documentation for the Dioxus Router. You +> can also check out the [API Docs](https://docs.rs/dioxus-router/). diff --git a/docs/router/src/lib.rs b/docs/router/src/lib.rs new file mode 100644 index 000000000..97cda56a8 --- /dev/null +++ b/docs/router/src/lib.rs @@ -0,0 +1 @@ +// empty (we only need this crate for the examples) diff --git a/docs/router/src/reference/history-buttons.md b/docs/router/src/reference/history-buttons.md new file mode 100644 index 000000000..ad3ffbe22 --- /dev/null +++ b/docs/router/src/reference/history-buttons.md @@ -0,0 +1,32 @@ +# History Buttons + +Some platforms, like web browsers, provide users with an easy way to navigate +through an app's history. They have UI elements or integrate with the OS. + +However, native platforms usually don't provide such amenities, which means that +apps wanting users to have access to them, need to implement them. For this +reason, the router comes with two components, which emulate a browser's back and +forward buttons: + +- [`GoBackButton`](https://docs.rs/dioxus-router/latest/dioxus_router/components/fn.GoBackButton.html) +- [`GoForwardButton`](https://docs.rs/dioxus-router/latest/dioxus_router/components/fn.GoForwardButton.html) + +> If you want to navigate through the history programmatically, take a look at +> [`programmatic navigation`](./navigation/programmatic.md). + +```rust, no_run +{{#include ../../examples/history_buttons.rs:history_buttons}} +``` + +As you might know, browsers usually disable the back and forward buttons if +there is no history to navigate to. The router's history buttons try to do that +too, but depending on the [history provider] that might not be possible. + +Importantly, neither [`WebHistory`] supports that feature. +This is due to limitations of the browser History API. + +However, in both cases, the router will just ignore button presses, if there is +no history to navigate to. + +Also, when using [`WebHistory`], the history buttons might +navigate a user to a history entry outside your app. diff --git a/docs/router/src/reference/history-providers.md b/docs/router/src/reference/history-providers.md new file mode 100644 index 000000000..6ee11b20f --- /dev/null +++ b/docs/router/src/reference/history-providers.md @@ -0,0 +1,20 @@ +# History Providers + +[`HistoryProvider`]s are used by the router to keep track of the navigation history +and update any external state (e.g. the browser's URL). + +The router provides two [`HistoryProvider`]s, but you can also create your own. +The two default implementations are: + +- The [`MemoryHistory`] is a custom implementation that works in memory. +- The [`WebHistory`] integrates with the browser's URL. + +By default, the router uses the [`MemoryHistory`]. It might be changed to use +[`WebHistory`] when the `web` feature is active, but that is not guaranteed. + +You can override the default history: + +```rust, no_run +{{#include ../../examples/history_provider.rs:app}} +``` + diff --git a/docs/router/src/reference/index.md b/docs/router/src/reference/index.md index 1d29fce21..add681671 100644 --- a/docs/router/src/reference/index.md +++ b/docs/router/src/reference/index.md @@ -1,2 +1,23 @@ -# Dioxus Router: Reference -This section includes a reference to Dioxus Router's API and functionality. \ No newline at end of file +# Adding the Router to Your Application + +In this chapter, we will learn how to add the router to our app. By itself, this +is not very useful. However, it is a prerequisite for all the functionality +described in the other chapters. + +> Make sure you added the `dioxus-router` dependency as explained in the +> [introduction](../index.md). + +In most cases, we want to add the router to the root component of our app. This +way, we can ensure that we have access to all its functionality everywhere. + +First, we define the router with the router macro: + +```rust, no_run +{{#include ../../examples/first_route.rs:router}} +``` + +Then we render the router with the [`Router`] component. + +```rust, no_run +{{#include ../../examples/first_route.rs:app}} +``` diff --git a/docs/router/src/reference/layouts.md b/docs/router/src/reference/layouts.md new file mode 100644 index 000000000..525838fb0 --- /dev/null +++ b/docs/router/src/reference/layouts.md @@ -0,0 +1,19 @@ +# Layouts + +Layouts allow you to wrap all child routes in a component. This can be useful when creating something like a header that will be used in many different routes. + +[`Outlet`] tells the router where to render content in layouts. In the following example, +the Index will be rendered within the [`Outlet`]. + +```rust, no_run +{{#include ../../examples/outlet.rs:outlet}} +``` + +The example above will output the following HTML (line breaks added for +readability): + +```html +
header
+

Index

+
footer
+``` diff --git a/docs/router/src/reference/navigation/index.md b/docs/router/src/reference/navigation/index.md new file mode 100644 index 000000000..2abf8477e --- /dev/null +++ b/docs/router/src/reference/navigation/index.md @@ -0,0 +1,39 @@ +# Links & Navigation + +When we split our app into pages, we need to provide our users with a way to +navigate between them. On regular web pages, we'd use an anchor element for that, +like this: + +```html +
Link to an other page +``` + +However, we cannot do that when using the router for three reasons: + +1. Anchor tags make the browser load a new page from the server. This takes a + lot of time, and it is much faster to let the router handle the navigation + client-side. +2. Navigation using anchor tags only works when the app is running inside a + browser. This means we cannot use them inside apps using Dioxus Desktop. +3. Anchor tags cannot check if the target page exists. This means we cannot + prevent accidentally linking to non-existent pages. + +To solve these problems, the router provides us with a [`Link`] component we can +use like this: + +```rust, no_run +{{#include ../../../examples/links.rs:nav}} +``` + +The `target` in the example above is similar to the `href` of a regular anchor +element. However, it tells the router more about what kind of navigation it +should perform. It accepts something that can be converted into a +[`NavigationTarget`]: + +- The example uses a Internal route. This is the most common type of navigation. + It tells the router to navigate to a page within our app by passing a variant of a [`Routable`] enum. This type of navigation can never fail if the link component is used inside a router component. +- [`External`] allows us to navigate to URLs outside of our app. This is useful + for links to external websites. NavigationTarget::External accepts an URL to navigate to. This type of navigation can fail if the URL is invalid. + +> The [`Link`] accepts several props that modify its behavior. See the API docs +> for more details. diff --git a/docs/router/src/reference/navigation/programmatic.md b/docs/router/src/reference/navigation/programmatic.md new file mode 100644 index 000000000..894dd968e --- /dev/null +++ b/docs/router/src/reference/navigation/programmatic.md @@ -0,0 +1,32 @@ +# Programmatic Navigation + +Sometimes we want our application to navigate to another page without having the +user click on a link. This is called programmatic navigation. + +## Using a Navigator + +We can get a navigator with the [`use_navigator`] hook. This hook returns a [`Navigator`]. + +We can use the [`Navigator`] to trigger four different kinds of navigation: + +- `push` will navigate to the target. It works like a regular anchor tag. +- `replace` works like `push`, except that it replaces the current history entry + instead of adding a new one. This means the prior page cannot be restored with the browser's back button. +- `Go back` works like the browser's back button. +- `Go forward` works like the browser's forward button. + +```rust, no_run +{{#include ../../../examples/navigator.rs:nav}} +``` + +You might have noticed that, like [`Link`], the [`Navigator`]s `push` and +`replace` functions take a [`NavigationTarget`]. This means we can use either +[`Internal`], or [`External`] targets. + +## External Navigation Targets + +Unlike a [`Link`], the [`Navigator`] cannot rely on the browser (or webview) to +handle navigation to external targets via a generated anchor element. + +This means, that under certain conditions, navigation to external targets can +fail. diff --git a/docs/router/src/reference/redirects.md b/docs/router/src/reference/redirects.md new file mode 100644 index 000000000..70ea4d7cd --- /dev/null +++ b/docs/router/src/reference/redirects.md @@ -0,0 +1,13 @@ +# Redirects + +In some cases, we may want to redirect our users to another page whenever they +open a specific path. We can tell the router to do this with the `#[redirect]` +attribute. + +The `#[redirect]` attribute accepts a route and a closure with all of the parameters defined in the route. The closure must return a [`NavigationTarget`]. + +In the following example, we will redirect everybody from `/myblog` and `/myblog/:id` to `/blog` and `/blog/:id` respectively + +```rust, no_run +{{#include ../../examples/full_example.rs:router}} +``` diff --git a/docs/router/src/reference/routes/index.md b/docs/router/src/reference/routes/index.md new file mode 100644 index 000000000..7cd0fe9e5 --- /dev/null +++ b/docs/router/src/reference/routes/index.md @@ -0,0 +1,65 @@ +# Defining Routes + +When creating a [`Routable`] enum, we can define routes for our application using the `route("path")` attribute. + +## Route Segments + +Each route is made up of segments. Most segments are separated by `/` characters in the path. + +There are four fundamental types of segments: + +1. [Static segments](#static-segments) are fixed strings that must be present in the path. +2. [Dynamic segments](#dynamic-segments) are types that can be parsed from a segment. +3. [Catch-all segments](#catch-all-segments) are types that can be parsed from multiple segments. +4. [Query segments](#query-segments) are types that can be parsed from the query string. + +Routes are matched: + +- First, from most specific to least specific (Static then Dynamic then Catch All) (Query is always matched) +- Then, if multiple routes match the same path, the order in which they are defined in the enum is followed. + +## Static segments + +Fixed routes match a specific path. For example, the route `#[route("/about")]` will match the path `/about`. + +```rust, no_run +{{#include ../../../examples/static_segments.rs:route}} +``` + +## Dynamic Segments + +Dynamic segments are in the form of `:name` where `name` is +the name of the field in the route variant. If the segment is parsed +successfully then the route matches, otherwise the matching continues. + +The segment can be of any type that implements `FromStr`. + +```rust, no_run +{{#include ../../../examples/dynamic_segments.rs:route}} +``` + +## Catch All Segments + +Catch All segments are in the form of `:...name` where `name` is the name of the field in the route variant. If the segments are parsed successfully then the route matches, otherwise the matching continues. + +The segment can be of any type that implements `FromSegments`. (Vec implements this by default) + +Catch All segments must be the _last route segment_ in the path (query segments are not counted) and cannot be included in nests. + +```rust, no_run +{{#include ../../../examples/catch_all_segments.rs:route}} +``` + +## Query Segments + +Query segments are in the form of `?:name` where `name` is the name of the field in the route variant. + +Unlike [Dynamic Segments](#dynamic-segments) and [Catch All Segments](#catch-all-segments), parsing a Query segment must not fail. + +The segment can be of any type that implements `FromQuery`. + +Query segments must be the _after all route segments_ and cannot be included in nests. + +```rust, no_run +{{#include ../../../examples/query_segments.rs:route}} +``` diff --git a/docs/router/src/reference/routes/nested.md b/docs/router/src/reference/routes/nested.md new file mode 100644 index 000000000..4860fbb0d --- /dev/null +++ b/docs/router/src/reference/routes/nested.md @@ -0,0 +1,39 @@ +# Nested Routes + +When developing bigger applications we often want to nest routes within each +other. As an example, we might want to organize a settings menu using this +pattern: + +```plain +└ Settings + ├ General Settings (displayed when opening the settings) + ├ Change Password + └ Privacy Settings +``` + +We might want to map this structure to these paths and components: + +```plain +/settings -> Settings { GeneralSettings } +/settings/password -> Settings { PWSettings } +/settings/privacy -> Settings { PrivacySettings } +``` + +Nested routes allow us to do this without repeating /settings in every route. + +## Nesting + +To nest routes, we use the `#[nest("path")]` and `#[end_nest]` attributes. + +The path in nest must not: + +1. Contain a [Catch All Segment](index.md#catch-all-segments) +2. Contain a [Query Segment](index.md#query-segments) + +If you define a dynamic segment in a nest, it will be available to all child routes and layouts. + +To finish a nest, we use the `#[end_nest]` attribute or the end of the enum. + +```rust, no_run +{{#include ../../../examples/nest.rs:route}} +``` diff --git a/docs/router/src/reference/routing-update-callback.md b/docs/router/src/reference/routing-update-callback.md new file mode 100644 index 000000000..f4fdb9a99 --- /dev/null +++ b/docs/router/src/reference/routing-update-callback.md @@ -0,0 +1,25 @@ +# Routing Update Callback + +In some cases, we might want to run custom code when the current route changes. +For this reason, the [`RouterConfig`] exposes an `on_update` field. + +## How does the callback behave? + +The `on_update` is called whenever the current routing information changes. It +is called after the router updated its internal state, but before dependent components and hooks are updated. + +If the callback returns a [`NavigationTarget`], the router will replace the +current location with the specified target. It will not call the +`on_update` again. + +If at any point the router encounters a +[navigation failure](./failures/index.md), it will go to the appropriate state +without calling the `on_update`. It doesn't matter if the invalid target +initiated the navigation, was found as a redirect target, or was returned by the +`on_update` itself. + +## Code Example + +```rust, no_run +{{#include ../../examples/routing_update.rs:router}} +``` diff --git a/docs/router/src/reference/static-generation.md b/docs/router/src/reference/static-generation.md new file mode 100644 index 000000000..c43f9fe4b --- /dev/null +++ b/docs/router/src/reference/static-generation.md @@ -0,0 +1,15 @@ +# Static Generation + +## Getting the Sitemap + +The [`Routable`] trait includes an associated [`SITE_MAP`] constant that contains the map of all of the routes in the enum. + +By default, the sitemap is a tree of (static or dynamic) RouteTypes, but it can be flattened into a list of individual routes with the `.flatten()` method. + +## Generating a Sitemap + +To statically render pages, we need to flatten the route tree and generate a file for each route that contains only static segments: + +```rust, no_run +{{#include ../../../../packages/router/examples/static_generation.rs}} +``` diff --git a/examples/PWA-example/README.md b/examples/PWA-example/README.md index dfd986302..d42a61311 100644 --- a/examples/PWA-example/README.md +++ b/examples/PWA-example/README.md @@ -9,8 +9,8 @@ It is also very much usable as a template for your projects, if you're aiming to Make sure you have Dioxus CLI installed (if you're unsure, run `cargo install dioxus-cli`). -You can run `dioxus serve` in this directory to start the web server locally, or run -`dioxus build --release` to build the project so you can deploy it on a separate web-server. +You can run `dx serve` in this directory to start the web server locally, or run +`dx build --release` to build the project so you can deploy it on a separate web-server. ## Project Structure ``` @@ -41,4 +41,4 @@ For service worker scripting (in JavaScript): * [Service worker guide from PWABuilder](https://docs.pwabuilder.com/#/home/sw-intro) * [Service worker examples, also from PWABuilder](https://github.com/pwa-builder/pwabuilder-serviceworkers) -If you want to stay as close to 100% Rust as possible, you can try using [wasi-worker](https://github.com/dunnock/wasi-worker) to replace the JS service worker file. The JSON manifest will still be required though. \ No newline at end of file +If you want to stay as close to 100% Rust as possible, you can try using [wasi-worker](https://github.com/dunnock/wasi-worker) to replace the JS service worker file. The JSON manifest will still be required though. diff --git a/examples/crm.rs b/examples/crm.rs index e99cfe241..e26f5f724 100644 --- a/examples/crm.rs +++ b/examples/crm.rs @@ -1,11 +1,21 @@ -/* -Tiny CRM: A port of the Yew CRM example to Dioxus. -*/ +//! Tiny CRM: A port of the Yew CRM example to Dioxus. +#![allow(non_snake_case)] use dioxus::prelude::*; -use dioxus_router::{Link, Route, Router}; +use dioxus_router::prelude::*; fn main() { - dioxus_desktop::launch(app); + dioxus_desktop::launch(App); +} + +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[route("/")] + ClientList {}, + #[route("/new")] + ClientAdd {}, + #[route("/settings")] + Settings {}, } #[derive(Clone, Debug, Default)] @@ -15,92 +25,174 @@ pub struct Client { pub description: String, } -fn app(cx: Scope) -> Element { - let clients = use_ref(cx, || vec![] as Vec); - let firstname = use_state(cx, String::new); - let lastname = use_state(cx, String::new); +type ClientContext = Vec; + +fn App(cx: Scope) -> Element { + use_shared_state_provider::(cx, Default::default); + + render! { + link { + rel: "stylesheet", + href: "https://unpkg.com/purecss@2.0.6/build/pure-min.css", + integrity: "sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5", + crossorigin: "anonymous", + } + + style { " + .red {{ + background-color: rgb(202, 60, 60) !important; + }} + " } + + h1 { "Dioxus CRM Example" } + + Router {} + } +} + +#[inline_props] +fn ClientList(cx: Scope) -> Element { + let clients = use_shared_state::(cx).unwrap(); + + cx.render(rsx! { + h2 { "List of Clients" } + + Link { + target: Route::ClientAdd {}, + class: "pure-button pure-button-primary", + "Add Client" + } + Link { + target: Route::Settings {}, + class: "pure-button", + "Settings" + } + + clients.read().iter().map(|client| rsx! { + div { + class: "client", + style: "margin-bottom: 50px", + + p { "Name: {client.first_name} {client.last_name}" } + p { "Description: {client.description}" } + } + }) + }) +} + +#[inline_props] +fn ClientAdd(cx: Scope) -> Element { + let clients = use_shared_state::(cx).unwrap(); + let first_name = use_state(cx, String::new); + let last_name = use_state(cx, String::new); let description = use_state(cx, String::new); - cx.render(rsx!( - body { - margin_left: "35%", - link { - rel: "stylesheet", - href: "https://unpkg.com/purecss@2.0.6/build/pure-min.css", - integrity: "sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5", - crossorigin: "anonymous", - } - h1 { "Dioxus CRM Example" } - Router { - Route { to: "/", - div { class: "crm", - h2 { margin_bottom: "10px", "List of clients" } - div { class: "clients", margin_left: "10px", - clients.read().iter().map(|client| rsx!( - div { class: "client", style: "margin-bottom: 50px", - p { "First Name: {client.first_name}" } - p { "Last Name: {client.last_name}" } - p { "Description: {client.description}" } - }) - ) - } - Link { to: "/new", class: "pure-button pure-button-primary", "Add New" } - Link { to: "/settings", class: "pure-button", "Settings" } + let navigator = use_navigator(cx); + + cx.render(rsx! { + h2 { "Add new Client" } + + form { + class: "pure-form pure-form-aligned", + onsubmit: move |_| { + let mut clients = clients.write(); + + clients.push(Client { + first_name: first_name.to_string(), + last_name: last_name.to_string(), + description: description.to_string(), + }); + + navigator.push(Route::ClientList {}); + }, + + fieldset { + div { + class: "pure-control-group", + label { + "for": "first_name", + "First Name" + } + input { + id: "first_name", + "type": "text", + placeholder: "First Name…", + required: "", + value: "{first_name}", + oninput: move |e| first_name.set(e.value.clone()) } } - Route { to: "/new", - div { class: "crm", - h2 { margin_bottom: "10px", "Add new client" } - form { class: "pure-form", - input { - class: "new-client firstname", - placeholder: "First name", - value: "{firstname}", - oninput: move |e| firstname.set(e.value.clone()) - } - input { - class: "new-client lastname", - placeholder: "Last name", - value: "{lastname}", - oninput: move |e| lastname.set(e.value.clone()) - } - textarea { - class: "new-client description", - placeholder: "Description", - value: "{description}", - oninput: move |e| description.set(e.value.clone()) - } - } - button { - class: "pure-button pure-button-primary", - onclick: move |_| { - clients.write().push(Client { - description: description.to_string(), - first_name: firstname.to_string(), - last_name: lastname.to_string(), - }); - description.set(String::new()); - firstname.set(String::new()); - lastname.set(String::new()); - }, - "Add New" - } - Link { to: "/", class: "pure-button", "Go Back" } + + div { + class: "pure-control-group", + label { + "for": "last_name", + "Last Name" + } + input { + id: "last_name", + "type": "text", + placeholder: "Last Name…", + required: "", + value: "{last_name}", + oninput: move |e| last_name.set(e.value.clone()) } } - Route { to: "/settings", - div { - h2 { margin_bottom: "10px", "Settings" } - button { - background: "rgb(202, 60, 60)", - class: "pure-button pure-button-primary", - onclick: move |_| clients.write().clear(), - "Remove all clients" - } - Link { to: "/", class: "pure-button pure-button-primary", "Go Back" } + + div { + class: "pure-control-group", + label { + "for": "description", + "Description" + } + textarea { + id: "description", + placeholder: "Description…", + value: "{description}", + oninput: move |e| description.set(e.value.clone()) + } + } + + div { + class: "pure-controls", + button { + "type": "submit", + class: "pure-button pure-button-primary", + "Save" + } + Link { + target: Route::ClientList {}, + class: "pure-button pure-button-primary red", + "Cancel" } } } + + } - )) + }) +} + +#[inline_props] +fn Settings(cx: Scope) -> Element { + let clients = use_shared_state::(cx).unwrap(); + + cx.render(rsx! { + h2 { "Settings" } + + button { + class: "pure-button pure-button-primary red", + onclick: move |_| { + let mut clients = clients.write(); + clients.clear(); + }, + "Remove all Clients" + } + + Link { + target: Route::ClientList {}, + class: "pure-button", + "Go back" + } + }) } diff --git a/examples/dog_app.rs b/examples/dog_app.rs index 107251cd7..243e4bbca 100644 --- a/examples/dog_app.rs +++ b/examples/dog_app.rs @@ -10,7 +10,7 @@ struct ListBreeds { message: HashMap>, } -async fn app_root(cx: Scope<'_>) -> Element { +fn app_root(cx: Scope<'_>) -> Element { let breed = use_state(cx, || "deerhound".to_string()); let breeds = use_future!(cx, || async move { @@ -21,13 +21,13 @@ async fn app_root(cx: Scope<'_>) -> Element { .await }); - match breeds.await { - Ok(breeds) => cx.render(rsx! { + match breeds.value()? { + Ok(breed_list) => cx.render(rsx! { div { height: "500px", h1 { "Select a dog breed!" } div { display: "flex", ul { flex: "50%", - for cur_breed in breeds.message.keys().take(10) { + for cur_breed in breed_list.message.keys().take(10) { li { key: "{cur_breed}", button { onclick: move |_| breed.set(cur_breed.clone()), @@ -50,7 +50,7 @@ struct DogApi { } #[inline_props] -async fn breed_pic(cx: Scope, breed: String) -> Element { +fn breed_pic(cx: Scope, breed: String) -> Element { let fut = use_future!(cx, |breed| async move { reqwest::get(format!("https://dog.ceo/api/breed/{breed}/images/random")) .await @@ -59,7 +59,7 @@ async fn breed_pic(cx: Scope, breed: String) -> Element { .await }); - match fut.await { + match fut.value()? { Ok(resp) => render! { div { button { diff --git a/examples/fermi.rs b/examples/fermi.rs index 001890892..a15fc807f 100644 --- a/examples/fermi.rs +++ b/examples/fermi.rs @@ -7,11 +7,11 @@ fn main() { dioxus_desktop::launch(app) } -static NAME: Atom = |_| "world".to_string(); +static NAME: Atom = Atom(|_| "world".to_string()); fn app(cx: Scope) -> Element { use_init_atom_root(cx); - let name = use_read(cx, NAME); + let name = use_read(cx, &NAME); cx.render(rsx! { div { "hello {name}!" } @@ -21,7 +21,7 @@ fn app(cx: Scope) -> Element { } fn Child(cx: Scope) -> Element { - let set_name = use_set(cx, NAME); + let set_name = use_set(cx, &NAME); cx.render(rsx! { button { @@ -31,10 +31,10 @@ fn Child(cx: Scope) -> Element { }) } -static NAMES: AtomRef> = |_| vec!["world".to_string()]; +static NAMES: AtomRef> = AtomRef(|_| vec!["world".to_string()]); fn ChildWithRef(cx: Scope) -> Element { - let names = use_atom_ref(cx, NAMES); + let names = use_atom_ref(cx, &NAMES); cx.render(rsx! { div { diff --git a/examples/file_upload.rs b/examples/file_upload.rs index b23648b50..c7358c685 100644 --- a/examples/file_upload.rs +++ b/examples/file_upload.rs @@ -1,32 +1,47 @@ #![allow(non_snake_case)] use dioxus::prelude::*; +use tokio::time::sleep; fn main() { dioxus_desktop::launch(App); } fn App(cx: Scope) -> Element { + let enable_directory_upload = use_state(cx, || false); let files_uploaded: &UseRef> = use_ref(cx, Vec::new); cx.render(rsx! { + label { + input { + r#type: "checkbox", + checked: "{enable_directory_upload}", + oninput: move |evt| { + enable_directory_upload.set(evt.value.parse().unwrap()); + }, + }, + "Enable directory upload" + } + input { r#type: "file", - accept: ".txt, .rs", + accept: ".txt,.rs", multiple: true, + directory: **enable_directory_upload, onchange: |evt| { to_owned![files_uploaded]; async move { if let Some(file_engine) = &evt.files { let files = file_engine.files(); - for file_name in &files { - if let Some(file) = file_engine.read_file_to_string(file_name).await{ - files_uploaded.write().push(file); - } + for file_name in files { + sleep(std::time::Duration::from_secs(1)).await; + files_uploaded.write().push(file_name); } } } }, - } + }, + + div { "progress: {files_uploaded.read().len()}" }, ul { for file in files_uploaded.read().iter() { diff --git a/examples/flat_router.rs b/examples/flat_router.rs index 4de9e7a7d..80764b9dd 100644 --- a/examples/flat_router.rs +++ b/examples/flat_router.rs @@ -1,6 +1,8 @@ +#![allow(non_snake_case)] + use dioxus::prelude::*; use dioxus_desktop::{tao::dpi::LogicalSize, Config, WindowBuilder}; -use dioxus_router::{Link, Route, Router}; +use dioxus_router::prelude::*; fn main() { env_logger::init(); @@ -15,24 +17,63 @@ fn main() { } fn app(cx: Scope) -> Element { - cx.render(rsx! { - div { - Router { - Route { to: "/", "Home" } - Route { to: "/games", "Games" } - Route { to: "/play", "Play" } - Route { to: "/settings", "Settings" } + render! { + Router {} + } +} - p { "----" } - nav { - ul { - Link { to: "/", li { "Home" } } - Link { to: "/games", li { "Games" } } - Link { to: "/play", li { "Play" } } - Link { to: "/settings", li { "Settings" } } - } +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(Footer)] + #[route("/")] + Home {}, + #[route("/games")] + Games {}, + #[route("/play")] + Play {}, + #[route("/settings")] + Settings {}, +} + +#[inline_props] +fn Footer(cx: Scope) -> Element { + render! { + div { + Outlet { } + + p { + "----" + } + + nav { + ul { + li { Link { target: Route::Home {}, "Home" } } + li { Link { target: Route::Games {}, "Games" } } + li { Link { target: Route::Play {}, "Play" } } + li { Link { target: Route::Settings {}, "Settings" } } } } } - }) + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render!("Home") +} + +#[inline_props] +fn Games(cx: Scope) -> Element { + render!("Games") +} + +#[inline_props] +fn Play(cx: Scope) -> Element { + render!("Play") +} + +#[inline_props] +fn Settings(cx: Scope) -> Element { + render!("Settings") } diff --git a/examples/link.rs b/examples/link.rs index afef99cf2..4a099be01 100644 --- a/examples/link.rs +++ b/examples/link.rs @@ -1,5 +1,7 @@ +#![allow(non_snake_case)] + use dioxus::prelude::*; -use dioxus_router::{Link, Route, Router}; +use dioxus_router::prelude::*; fn main() { dioxus_desktop::launch(app); @@ -21,15 +23,39 @@ fn app(cx: Scope) -> Element { } } div { - Router { - Route { to: "/", h1 { "Home" } }, - Route { to: "/settings", h1 { "settings" } }, - p { "----"} - ul { - Link { to: "/", li { "Router link to home" } }, - Link { to: "/settings", li { "Router link to settings" } }, - } - } + Router {} } )) } + +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(Header)] + #[route("/")] + Home {}, + #[route("/settings")] + Settings {}, +} + +#[inline_props] +fn Header(cx: Scope) -> Element { + render! { + h1 { "Your app here" } + ul { + li { Link { target: Route::Home {}, "home" } } + li { Link { target: Route::Settings {}, "settings" } } + } + Outlet {} + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render!(h1 { "Home" }) +} + +#[inline_props] +fn Settings(cx: Scope) -> Element { + render!(h1 { "Settings" }) +} diff --git a/examples/router.rs b/examples/router.rs index 5766b8e1c..ff8302926 100644 --- a/examples/router.rs +++ b/examples/router.rs @@ -1,67 +1,112 @@ #![allow(non_snake_case)] use dioxus::prelude::*; -use dioxus_router::{Link, Route, Router}; -use serde::Deserialize; +use dioxus_router::prelude::*; fn main() { - dioxus_desktop::launch(app); + #[cfg(target_arch = "wasm32")] + dioxus_web::launch(App); + #[cfg(not(target_arch = "wasm32"))] + dioxus_desktop::launch(App); } -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { +// ANCHOR: router +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(NavBar)] + #[route("/")] + Home {}, + #[nest("/blog")] + #[layout(Blog)] + #[route("/")] + BlogList {}, + #[route("/blog/:name")] + BlogPost { name: String }, + #[end_layout] + #[end_nest] + #[end_layout] + #[nest("/myblog")] + #[redirect("/", || Route::BlogList {})] + #[redirect("/:name", |name: String| Route::BlogPost { name })] + #[end_nest] + #[route("/:..route")] + PageNotFound { + route: Vec, + }, +} +// ANCHOR_END: router + +fn App(cx: Scope) -> Element { + render! { + Router {} + } +} + +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + nav { ul { - Link { to: "/", li { "Go home!" } } - Link { to: "/users", li { "List all users" } } - Link { to: "/blog", li { "Blog posts" } } - - Link { to: "/users/bill", li { "List all users" } } - Link { to: "/blog/5", li { "Blog post 5" } } - } - Route { to: "/", "Home" } - Route { to: "/users", "User list" } - Route { to: "/users/:name", User {} } - Route { to: "/blog", "Blog list" } - Route { to: "/blog/:post", BlogPost {} } - Route { to: "", "Err 404 Route Not Found" } - } - }) -} - -fn BlogPost(cx: Scope) -> Element { - let post = dioxus_router::use_route(cx).last_segment().unwrap(); - - cx.render(rsx! { - div { - h1 { "Reading blog post: {post}" } - p { "example blog post" } - } - }) -} - -#[derive(Deserialize)] -struct Query { - bold: bool, -} - -fn User(cx: Scope) -> Element { - let post = dioxus_router::use_route(cx).last_segment().unwrap(); - - let query = dioxus_router::use_route(cx) - .query::() - .unwrap_or(Query { bold: false }); - - cx.render(rsx! { - div { - h1 { "Reading blog post: {post}" } - p { "example blog post" } - - if query.bold { - rsx!{ b { "bold" } } - } else { - rsx!{ i { "italic" } } + li { Link { target: Route::Home {}, "Home" } } + li { Link { target: Route::BlogList {}, "Blog" } } } } - }) + Outlet {} + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + h1 { "Welcome to the Dioxus Blog!" } + } +} + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + h1 { "Blog" } + Outlet {} + } +} + +#[inline_props] +fn BlogList(cx: Scope) -> Element { + render! { + h2 { "Choose a post" } + ul { + li { + Link { + target: Route::BlogPost { name: "Blog post 1".into() }, + "Read the first blog post" + } + } + li { + Link { + target: Route::BlogPost { name: "Blog post 2".into() }, + "Read the second blog post" + } + } + } + } +} + +#[inline_props] +fn BlogPost(cx: Scope, name: String) -> Element { + render! { + h2 { "Blog Post: {name}"} + } +} + +#[inline_props] +fn PageNotFound(cx: Scope, route: Vec) -> Element { + render! { + h1 { "Page not found" } + p { "We are terribly sorry, but the page you requested doesn't exist." } + pre { + color: "red", + "log:\nattemped to navigate to: {route:?}" + } + } } diff --git a/examples/shared_state.rs b/examples/shared_state.rs new file mode 100644 index 000000000..d129f75d1 --- /dev/null +++ b/examples/shared_state.rs @@ -0,0 +1,77 @@ +#![allow(non_snake_case)] + +use std::collections::HashMap; + +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(App); +} + +#[derive(Default)] +struct CoolData { + data: HashMap, +} + +impl CoolData { + pub fn new(data: HashMap) -> Self { + Self { data } + } + + pub fn view(&self, id: &usize) -> Option<&String> { + self.data.get(id) + } + + pub fn set(&mut self, id: usize, data: String) { + self.data.insert(id, data); + } +} + +#[rustfmt::skip] +pub fn App(cx: Scope) -> Element { + use_shared_state_provider(cx, || CoolData::new(HashMap::from([ + (0, "Hello, World!".to_string()), + (1, "Dioxus is amazing!".to_string()) + ]))); + + render!( + DataEditor { + id: 0 + } + DataEditor { + id: 1 + } + DataView { + id: 0 + } + DataView { + id: 1 + } + ) +} + +#[inline_props] +fn DataEditor(cx: Scope, id: usize) -> Element { + let cool_data = use_shared_state::(cx).unwrap().read(); + + let my_data = &cool_data.view(id).unwrap(); + + render!(p { + "{my_data}" + }) +} + +#[inline_props] +fn DataView(cx: Scope, id: usize) -> Element { + let cool_data = use_shared_state::(cx).unwrap(); + + let oninput = |e: FormEvent| cool_data.write().set(*id, e.value.clone()); + + let cool_data = cool_data.read(); + let my_data = &cool_data.view(id).unwrap(); + + render!(input { + oninput: oninput, + value: "{my_data}" + }) +} diff --git a/examples/simple_desktop.rs b/examples/simple_desktop.rs index 74bfb28c6..ce31979c7 100644 --- a/examples/simple_desktop.rs +++ b/examples/simple_desktop.rs @@ -1,12 +1,11 @@ #![allow(non_snake_case)] use dioxus::prelude::*; -use dioxus_router::*; +use dioxus_router::prelude::*; fn main() { simple_logger::SimpleLogger::new() .with_level(log::LevelFilter::Debug) - .with_module_level("dioxus_router", log::LevelFilter::Trace) .with_module_level("dioxus", log::LevelFilter::Trace) .init() .unwrap(); @@ -14,49 +13,69 @@ fn main() { } fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - h1 { "Your app here" } - ul { - Link { to: "/", li { "home" } } - Link { to: "/blog", li { "blog" } } - Link { to: "/blog/tim", li { "tims' blog" } } - Link { to: "/blog/bill", li { "bills' blog" } } - Link { to: "/blog/james", - li { "james amazing' blog" } - } - Link { to: "/apples", li { "go to apples" } } - } - Route { to: "/", Home {} } - Route { to: "/blog/", BlogList {} } - Route { to: "/blog/:id/", BlogPost {} } - Route { to: "/oranges", "Oranges are not apples!" } - Redirect { from: "/apples", to: "/oranges" } - } - }) + render! { + Router {} + } } +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + #[layout(NavBar)] + #[route("/")] + Home {}, + #[nest("/new")] + #[route("/")] + BlogList {}, + #[route("/:post")] + BlogPost { + post: String, + }, + #[end_nest] + #[route("/oranges")] + Oranges {}, +} + +#[inline_props] +fn NavBar(cx: Scope) -> Element { + render! { + h1 { "Your app here" } + ul { + li { Link { target: Route::Home {}, "home" } } + li { Link { target: Route::BlogList {}, "blog" } } + li { Link { target: Route::BlogPost { post: "tim".into() }, "tims' blog" } } + li { Link { target: Route::BlogPost { post: "bill".into() }, "bills' blog" } } + li { Link { target: Route::BlogPost { post: "james".into() }, "james amazing' blog" } } + } + Outlet {} + } +} + +#[inline_props] fn Home(cx: Scope) -> Element { log::debug!("rendering home {:?}", cx.scope_id()); - cx.render(rsx! { h1 { "Home" } }) + render! { h1 { "Home" } } } +#[inline_props] fn BlogList(cx: Scope) -> Element { log::debug!("rendering blog list {:?}", cx.scope_id()); - cx.render(rsx! { div { "Blog List" } }) + render! { div { "Blog List" } } } -fn BlogPost(cx: Scope) -> Element { - let Some(id) = use_route(cx).segment("id") else { - return cx.render(rsx! { div { "No blog post id" } }) - }; +#[inline_props] +fn BlogPost(cx: Scope, post: String) -> Element { + log::debug!("rendering blog post {}", post); - log::debug!("rendering blog post {}", id); - - cx.render(rsx! { + render! { div { - h3 { "blog post: {id:?}" } - Link { to: "/blog/", "back to blog list" } + h3 { "blog post: {post}" } + Link { target: Route::BlogList {}, "back to blog list" } } - }) + } +} + +#[inline_props] +fn Oranges(cx: Scope) -> Element { + render!("Oranges are not apples!") } diff --git a/examples/tailwind/README.md b/examples/tailwind/README.md index 0498ecbd3..f2a08eba9 100644 --- a/examples/tailwind/README.md +++ b/examples/tailwind/README.md @@ -122,7 +122,7 @@ npx tailwindcss -i ./input.css -o ./public/tailwind.css --watch - Run the following command in the root of the project to start the dioxus dev server: ```bash -dioxus serve --hot-reload +dx serve --hot-reload ``` - Open the browser to http://localhost:8080 diff --git a/notes/FAQ.md b/notes/FAQ.md index 4e9ea00e4..a94071130 100644 --- a/notes/FAQ.md +++ b/notes/FAQ.md @@ -18,7 +18,7 @@ There are plenty Rust Elm-like frameworks in the world - we were not interested The `RSX` DSL is _barely_ a DSL. Rustaceans will find the DSL very similar to simply assembling nested structs, but without the syntactical overhead of "Default" everywhere or having to jump through hoops with the builder pattern. Between RSX, HTML, the Raw Factory API, and the NodeBuilder syntax, there's plenty of options to choose from. ### What are the build times like? Why on earth would I choose Rust instead of JS/TS/Elm? -Dioxus builds as roughly as fast as a complex WebPack-TypeScript site. Compile times will be slower than an equivalent TypeScript site, but not unbearably slow. The Wasm compiler backend for Rust is very fast. Iterating on small components is basically instant and larger apps takes a few seconds. In practice, the compiler guarantees of Rust balance out the rebuild times. +dx builds as roughly as fast as a complex WebPack-TypeScript site. Compile times will be slower than an equivalent TypeScript site, but not unbearably slow. The Wasm compiler backend for Rust is very fast. Iterating on small components is basically instant and larger apps takes a few seconds. In practice, the compiler guarantees of Rust balance out the rebuild times. ### What about Yew/Seed/Sycamore/Dominator/Dodrio/Percy? - Yew and Seed use an Elm-like pattern and don't support SSR or any alternate rendering platforms diff --git a/packages/autofmt/Cargo.toml b/packages/autofmt/Cargo.toml index 254b60093..7cebf25bc 100644 --- a/packages/autofmt/Cargo.toml +++ b/packages/autofmt/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" edition = "2021" authors = ["Jonathan Kelley"] description = "Autofomatter for Dioxus RSX" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] @@ -14,9 +14,9 @@ keywords = ["dom", "ui", "gui", "react"] dioxus-rsx = { workspace = true } proc-macro2 = { version = "1.0.6", features = ["span-locations"] } quote = "1.0" -syn = { version = "1.0.11", features = ["full", "extra-traits"] } +syn = { version = "2.0", features = ["full", "extra-traits", "visit"] } serde = { version = "1.0.136", features = ["derive"] } -prettyplease = { package = "prettier-please", version = "0.1.16", features = [ +prettyplease = { package = "prettier-please", version = "0.2", features = [ "verbatim", ] } diff --git a/packages/autofmt/src/collect_macros.rs b/packages/autofmt/src/collect_macros.rs index ebb6314e2..10e0d185c 100644 --- a/packages/autofmt/src/collect_macros.rs +++ b/packages/autofmt/src/collect_macros.rs @@ -3,178 +3,24 @@ //! Returns all macros that match a pattern. You can use this information to autoformat them later use proc_macro2::LineColumn; -use syn::{Block, Expr, File, Item, Macro, Stmt}; +use syn::{visit::Visit, File, Macro}; type CollectedMacro<'a> = &'a Macro; pub fn collect_from_file<'a>(file: &'a File, macros: &mut Vec>) { - for item in file.items.iter() { - collect_from_item(item, macros); - } + MacroCollector::visit_file(&mut MacroCollector { macros }, file); } -pub fn collect_from_item<'a>(item: &'a Item, macros: &mut Vec>) { - match item { - Item::Fn(f) => collect_from_block(&f.block, macros), - - // Ignore macros if they're not rsx or render - Item::Macro(macro_) => { - if macro_.mac.path.segments[0].ident == "rsx" - || macro_.mac.path.segments[0].ident == "render" - { - macros.push(¯o_.mac); - } - } - - // Currently disabled since we're not focused on autoformatting these - Item::Impl(_imp) => {} - Item::Trait(_) => {} - - // Global-ish things - Item::Static(f) => collect_from_expr(&f.expr, macros), - Item::Const(f) => collect_from_expr(&f.expr, macros), - Item::Mod(s) => { - if let Some((_, block)) = &s.content { - for item in block { - collect_from_item(item, macros); - } - } - } - - // None of these we can really do anything with at the item level - Item::Macro2(_) - | Item::Enum(_) - | Item::ExternCrate(_) - | Item::ForeignMod(_) - | Item::TraitAlias(_) - | Item::Type(_) - | Item::Struct(_) - | Item::Union(_) - | Item::Use(_) - | Item::Verbatim(_) => {} - _ => {} - } +struct MacroCollector<'a, 'b> { + macros: &'a mut Vec>, } -pub fn collect_from_block<'a>(block: &'a Block, macros: &mut Vec>) { - for stmt in &block.stmts { - match stmt { - Stmt::Item(item) => collect_from_item(item, macros), - Stmt::Local(local) => { - if let Some((_eq, init)) = &local.init { - collect_from_expr(init, macros); - } - } - Stmt::Expr(exp) | Stmt::Semi(exp, _) => collect_from_expr(exp, macros), - } +impl<'a, 'b> Visit<'b> for MacroCollector<'a, 'b> { + fn visit_macro(&mut self, i: &'b Macro) { + self.macros.push(i); } } -pub fn collect_from_expr<'a>(expr: &'a Expr, macros: &mut Vec>) { - // collect an expr from the exprs, descending into blocks - match expr { - Expr::Macro(macro_) => { - if macro_.mac.path.segments[0].ident == "rsx" - || macro_.mac.path.segments[0].ident == "render" - { - macros.push(¯o_.mac); - } - } - - Expr::MethodCall(e) => { - collect_from_expr(&e.receiver, macros); - for expr in e.args.iter() { - collect_from_expr(expr, macros); - } - } - Expr::Assign(exp) => { - collect_from_expr(&exp.left, macros); - collect_from_expr(&exp.right, macros); - } - - Expr::Async(b) => collect_from_block(&b.block, macros), - Expr::Block(b) => collect_from_block(&b.block, macros), - Expr::Closure(c) => collect_from_expr(&c.body, macros), - Expr::Let(l) => collect_from_expr(&l.expr, macros), - Expr::Unsafe(u) => collect_from_block(&u.block, macros), - Expr::Loop(l) => collect_from_block(&l.body, macros), - - Expr::Call(c) => { - collect_from_expr(&c.func, macros); - for expr in c.args.iter() { - collect_from_expr(expr, macros); - } - } - - Expr::ForLoop(b) => { - collect_from_expr(&b.expr, macros); - collect_from_block(&b.body, macros); - } - Expr::If(f) => { - collect_from_expr(&f.cond, macros); - collect_from_block(&f.then_branch, macros); - if let Some((_, else_branch)) = &f.else_branch { - collect_from_expr(else_branch, macros); - } - } - Expr::Yield(y) => { - if let Some(expr) = &y.expr { - collect_from_expr(expr, macros); - } - } - - Expr::Return(r) => { - if let Some(expr) = &r.expr { - collect_from_expr(expr, macros); - } - } - - Expr::Match(l) => { - collect_from_expr(&l.expr, macros); - for arm in l.arms.iter() { - if let Some((_, expr)) = &arm.guard { - collect_from_expr(expr, macros); - } - - collect_from_expr(&arm.body, macros); - } - } - - Expr::While(w) => { - collect_from_expr(&w.cond, macros); - collect_from_block(&w.body, macros); - } - - // don't both formatting these for now - Expr::Array(_) - | Expr::AssignOp(_) - | Expr::Await(_) - | Expr::Binary(_) - | Expr::Box(_) - | Expr::Break(_) - | Expr::Cast(_) - | Expr::Continue(_) - | Expr::Field(_) - | Expr::Group(_) - | Expr::Index(_) - | Expr::Lit(_) - | Expr::Paren(_) - | Expr::Path(_) - | Expr::Range(_) - | Expr::Reference(_) - | Expr::Repeat(_) - | Expr::Struct(_) - | Expr::Try(_) - | Expr::TryBlock(_) - | Expr::Tuple(_) - | Expr::Type(_) - | Expr::Unary(_) - | Expr::Verbatim(_) => {} - - _ => {} - }; -} - pub fn byte_offset(input: &str, location: LineColumn) -> usize { let mut offset = 0; for _ in 1..location.line { diff --git a/packages/autofmt/src/component.rs b/packages/autofmt/src/component.rs index d5d92b79a..8cf36811d 100644 --- a/packages/autofmt/src/component.rs +++ b/packages/autofmt/src/component.rs @@ -165,7 +165,14 @@ impl Writer<'_> { match &field.content { ContentField::ManExpr(exp) => { let out = prettyplease::unparse_expr(exp); - write!(self.out, "{name}: {out}")?; + let mut lines = out.split('\n').peekable(); + let first = lines.next().unwrap(); + write!(self.out, "{name}: {first}")?; + for line in lines { + self.out.new_line()?; + self.out.indented_tab()?; + write!(self.out, "{line}")?; + } } ContentField::Formatted(s) => { write!( diff --git a/packages/autofmt/src/element.rs b/packages/autofmt/src/element.rs index 50a991897..dbc336401 100644 --- a/packages/autofmt/src/element.rs +++ b/packages/autofmt/src/element.rs @@ -48,7 +48,6 @@ impl Writer<'_> { key, attributes, children, - _is_static, brace, } = el; @@ -217,7 +216,26 @@ impl Writer<'_> { } ElementAttr::AttrExpression { name, value } => { let out = prettyplease::unparse_expr(value); - write!(self.out, "{name}: {out}")?; + let mut lines = out.split('\n').peekable(); + let first = lines.next().unwrap(); + + // a one-liner for whatever reason + // Does not need a new line + if lines.peek().is_none() { + write!(self.out, "{name}: {first}")?; + } else { + writeln!(self.out, "{name}: {first}")?; + + while let Some(line) = lines.next() { + self.out.indented_tab()?; + write!(self.out, "{line}")?; + if lines.peek().is_none() { + write!(self.out, "")?; + } else { + writeln!(self.out)?; + } + } + } } ElementAttr::CustomAttrText { name, value } => { diff --git a/packages/autofmt/src/expr.rs b/packages/autofmt/src/expr.rs index ddc0892f8..b0257f8c3 100644 --- a/packages/autofmt/src/expr.rs +++ b/packages/autofmt/src/expr.rs @@ -27,41 +27,29 @@ impl Writer<'_> { // If the expr is multiline, we want to collect all of its lines together and write them out properly // This involves unshifting the first line if it's aligned let first_line = &self.src[start.line - 1]; - write!( - self.out, - "{}", - &first_line[start.column - 1..first_line.len()].trim() - )?; + write!(self.out, "{}", &first_line[start.column - 1..].trim_start())?; - let first_prefix = &self.src[start.line - 1][..start.column]; - let offset = match first_prefix.trim() { - "" => 0, - _ => first_prefix - .chars() - .rev() - .take_while(|c| c.is_whitespace()) - .count() as isize, - }; + let prev_block_indent_level = crate::leading_whitespaces(first_line) / 4; for (id, line) in self.src[start.line..end.line].iter().enumerate() { writeln!(self.out)?; - // trim the leading whitespace - let line = match id { - x if x == (end.line - start.line) - 1 => &line[..end.column], - _ => line, + // check if this is the last line + let line = { + if id == (end.line - start.line) - 1 { + &line[..end.column] + } else { + line + } }; - if offset < 0 { - for _ in 0..-offset { - write!(self.out, " ")?; - } + // trim the leading whitespace + let previous_indent = crate::leading_whitespaces(line) / 4; + let offset = previous_indent.saturating_sub(prev_block_indent_level); + let required_indent = self.out.indent + offset; + self.out.write_tabs(required_indent)?; - write!(self.out, "{line}")?; - } else { - let offset = offset as usize; - let right = &line[offset..]; - write!(self.out, "{right}")?; - } + let line = line.trim_start(); + write!(self.out, "{line}")?; } Ok(()) diff --git a/packages/autofmt/src/lib.rs b/packages/autofmt/src/lib.rs index 0d2426a0b..8ec33f0f0 100644 --- a/packages/autofmt/src/lib.rs +++ b/packages/autofmt/src/lib.rs @@ -58,7 +58,7 @@ pub fn fmt_file(contents: &str) -> Vec { let mut writer = Writer::new(contents); - // Dont parse nested macros + // Don't parse nested macros let mut end_span = LineColumn { column: 0, line: 0 }; for item in macros { let macro_path = &item.path.segments[0].ident; @@ -68,16 +68,11 @@ pub fn fmt_file(contents: &str) -> Vec { continue; } - // item.parse_body::(); let body = item.parse_body::().unwrap(); let rsx_start = macro_path.span().start(); - writer.out.indent = &writer.src[rsx_start.line - 1] - .chars() - .take_while(|c| *c == ' ') - .count() - / 4; + writer.out.indent = leading_whitespaces(writer.src[rsx_start.line - 1]) / 4; write_body(&mut writer, &body); @@ -91,7 +86,8 @@ pub fn fmt_file(contents: &str) -> Vec { MacroDelimiter::Paren(b) => b.span, MacroDelimiter::Brace(b) => b.span, MacroDelimiter::Bracket(b) => b.span, - }; + } + .join(); let mut formatted = String::new(); @@ -230,3 +226,14 @@ pub(crate) fn write_ifmt(input: &IfmtInput, writable: &mut impl Write) -> std::f let display = DisplayIfmt(input); write!(writable, "{}", display) } + +pub fn leading_whitespaces(input: &str) -> usize { + input + .chars() + .map_while(|c| match c { + ' ' => Some(1), + '\t' => Some(4), + _ => None, + }) + .sum() +} diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 535c0fd1c..16b44ebc4 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Jonathan Kelley"] edition = "2021" description = "CLI tool for developing, testing, and publishing Dioxus apps" repository = "https://github.com/DioxusLabs/dioxus/" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] # cli core @@ -57,7 +57,7 @@ flate2 = "1.0.22" tar = "0.4.38" zip = "0.6.2" tower = "0.4.12" -syn = { version = "1.0", features = ["full", "extra-traits"] } +syn = { version = "2.0", features = ["full", "extra-traits"] } proc-macro2 = { version = "1.0", features = ["span-locations"] } lazy_static = "1.4.0" @@ -81,18 +81,13 @@ rsx-rosetta = { workspace = true } dioxus-rsx = { workspace = true } dioxus-html = { workspace = true, features = ["hot-reload-context"] } dioxus-core = { workspace = true, features = ["serialize"] } +dioxus-hot-reload = { workspace = true } +interprocess-docfix = { version = "1.2.2" } [features] default = [] plugin = ["mlua"] -# install path dx and dioxus as the same command -# so, they're not really aliases -# eventually dx will defer to the right version of dioxus -[[bin]] -path = "src/main.rs" -name = "dioxus" - [[bin]] path = "src/main.rs" name = "dx" diff --git a/packages/cli/Dioxus.toml b/packages/cli/Dioxus.toml index c3e0dd8a0..792a15165 100644 --- a/packages/cli/Dioxus.toml +++ b/packages/cli/Dioxus.toml @@ -4,7 +4,7 @@ name = "dioxus-cli" # default platfrom -# you can also use `dioxus serve/build --platform XXX` to use other platform +# you can also use `dx serve/build --platform XXX` to use other platform # value: web | desktop default_platform = "desktop" @@ -42,4 +42,4 @@ script = [] # use binaryen.wasm-opt for output Wasm file # binaryen just will trigger in `web` platform -binaryen = { wasm_opt = true } \ No newline at end of file +binaryen = { wasm_opt = true } diff --git a/packages/cli/README.md b/packages/cli/README.md index 6f0826e10..3167a6746 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,7 +24,7 @@ cargo install --path . --debug ## Get Started -Use `dioxus create project-name` to initialize a new Dioxus project.
+Use `dx create project-name` to initialize a new Dioxus project.
It will be cloned from the [dioxus-template](https://github.com/DioxusLabs/dioxus-template) repository. @@ -33,7 +33,7 @@ It will be cloned from the [dioxus-template](https://github.com/DioxusLabs/dioxu Alternatively, you can specify the template path: ``` -dioxus create hello --template gh:dioxuslabs/dioxus-template +dx create hello --template gh:dioxuslabs/dioxus-template ``` ## Dioxus Config File diff --git a/packages/cli/docs/src/cmd/README.md b/packages/cli/docs/src/cmd/README.md index dae226577..2de7cc138 100644 --- a/packages/cli/docs/src/cmd/README.md +++ b/packages/cli/docs/src/cmd/README.md @@ -2,14 +2,14 @@ In this chapter we will introduce all `dioxus-cli` commands. -> You can also use `dioxus --help` to get cli help info. +> You can also use `dx --help` to get cli help info. ``` -dioxus +dx Build, bundle, & ship your Dioxus app USAGE: - dioxus [OPTIONS] + dx [OPTIONS] OPTIONS: -h, --help Print help information @@ -23,4 +23,4 @@ SUBCOMMANDS: help Print this message or the help of the given subcommand(s) serve Build, watch & serve the Rust WASM app and all of its assets translate Translate some html file into a Dioxus component -``` \ No newline at end of file +``` diff --git a/packages/cli/docs/src/cmd/build.md b/packages/cli/docs/src/cmd/build.md index 1fb892475..6109d14f1 100644 --- a/packages/cli/docs/src/cmd/build.md +++ b/packages/cli/docs/src/cmd/build.md @@ -1,24 +1,25 @@ # Build -The `dioxus build` command can help you `pack & build` a dioxus project. +The `dx build` command can help you `pack & build` a dioxus project. ``` -dioxus-build +dioxus-build Build the Rust WASM app and all of its assets USAGE: - dioxus build [OPTIONS] + dx build [OPTIONS] OPTIONS: --example [default: ""] --platform [default: "default_platform"] --release [default: false] + --bin [default: None] ``` You can use this command to build a project: ``` -dioxus build --release +dx build --release ``` ## Target platform @@ -27,14 +28,22 @@ Use the `platform` option to choose your target platform: ``` # for desktop project -dioxus build --platform desktop +dx build --platform desktop ``` `platform` currently only supports `desktop` & `web`. ``` # for web project -dioxus build --platform web +dx build --platform web +``` + +## Specify workspace bin + +You can add the `--bin` option to select which crate you want Dioxus to build: + +``` +dx build --bin app ``` ## Build Example @@ -43,5 +52,5 @@ You can use the `example` option to select a example to build: ``` # build the `test` example -dioxus build --exmaple test -``` \ No newline at end of file +dx build --exmaple test +``` diff --git a/packages/cli/docs/src/cmd/clean.md b/packages/cli/docs/src/cmd/clean.md index bbb3a132e..ef938d245 100644 --- a/packages/cli/docs/src/cmd/clean.md +++ b/packages/cli/docs/src/cmd/clean.md @@ -1,18 +1,27 @@ # Clean -`dioxus clean` will clear the build artifacts (the out_dir and the cargo cache) +`dx clean` will clear the build artifacts (the out_dir and the cargo cache) ``` -dioxus-clean +dioxus-clean Clean build artifacts USAGE: - dioxus clean + dx clean [OPTIONS] + +OPTIONS: + --bin [default: None] ``` # Example ``` -dioxus clean +dx clean ``` +# Specify workspace bin +You can add the `--bin` option to select which crate you want Dioxus to clean artifacts from: + +``` +dx clean --bin app +``` diff --git a/packages/cli/docs/src/cmd/serve.md b/packages/cli/docs/src/cmd/serve.md index 41abf5350..d8b4e0d42 100644 --- a/packages/cli/docs/src/cmd/serve.md +++ b/packages/cli/docs/src/cmd/serve.md @@ -1,25 +1,26 @@ # Serve -The `dioxus serve` can start a dev server with hot-reloading +The `dx serve` can start a dev server with hot-reloading ``` -dioxus-serve +dioxus-serve Build, watch & serve the Rust WASM app and all of its assets USAGE: - dioxus serve [OPTIONS] + dx serve [OPTIONS] OPTIONS: --example [default: ""] --platform [default: "default_platform"] --release [default: false] - --hot-reload [default: false]ß + --hot-reload [default: false] + --bin [default: None] ``` You can use this command to build project and start a dev server: ``` -dioxus serve +dx serve ``` ## Serve Example @@ -28,7 +29,15 @@ You can use the `example` option to serve a example: ``` # serve the `test` example -dioxus serve --exmaple test +dx serve --exmaple test +``` + +## Specify workspace bin + +You can add the `--bin` option to select which crate you want Dioxus to build and serve: + +``` +dx serve --bin app ``` ## Open Browser @@ -36,7 +45,7 @@ dioxus serve --exmaple test You can add the `--open` option to open system default browser when server startup: ``` -dioxus serve --open +dx serve --open ``` ## RSX Hot Reloading @@ -44,7 +53,7 @@ dioxus serve --open You can add the `--hot-reload` flag to enable [rsx hot reloading](https://dioxuslabs.com/docs/0.3/guide/en/getting_started/hot_reload.html). This will allow you to reload some rsx changes without a full recompile: ``` -dioxus serve --open +dx serve --open ``` ## Cross Origin Policy @@ -57,5 +66,5 @@ You can add the `cross-origin-policy` option to change cross-origin header to: ``` ``` -dioxus serve --corss-origin-policy -``` \ No newline at end of file +dx serve --corss-origin-policy +``` diff --git a/packages/cli/docs/src/cmd/translate.md b/packages/cli/docs/src/cmd/translate.md index 7237e1dcf..b03b4422c 100644 --- a/packages/cli/docs/src/cmd/translate.md +++ b/packages/cli/docs/src/cmd/translate.md @@ -1,13 +1,13 @@ # Translate -`dioxus translate` can translate some `html` file into a Dioxus compoent +`dx translate` can translate some `html` file into a Dioxus compoent ``` -dioxus-translate +dioxus-translate Translate some source file into a Dioxus component USAGE: - dioxus translate [OPTIONS] [OUTPUT] + dx translate [OPTIONS] [OUTPUT] ARGS: Output file, defaults to stdout if not present @@ -22,7 +22,7 @@ OPTIONS: You can use the `file` option to set path to the `html` file to translate: ``` -dioxus transtale --file ./index.html +dx transtale --file ./index.html ``` ## Output rsx to a file @@ -30,7 +30,7 @@ dioxus transtale --file ./index.html You can pass a file to the traslate command to set the path to write the output of the command to: ``` -dioxus translate --file ./index.html component.rsx +dx translate --file ./index.html component.rsx ``` ## Output rsx to a file @@ -38,7 +38,7 @@ dioxus translate --file ./index.html component.rsx Setting the `component` option will create a compoent from the HTML: ``` -dioxus translate --file ./index.html --component +dx translate --file ./index.html --component ``` ## Example @@ -65,4 +65,4 @@ fn component(cx: Scope) -> Element { } }) } -``` \ No newline at end of file +``` diff --git a/packages/cli/docs/src/configure.md b/packages/cli/docs/src/configure.md index 35904caae..8da66d9ec 100644 --- a/packages/cli/docs/src/configure.md +++ b/packages/cli/docs/src/configure.md @@ -29,8 +29,8 @@ General application confiration: # default: web default_platform = "web" ``` - if you change this to `desktop`, the `dioxus build` will default building a desktop app -3. ***out_dir*** - The directory to place the build artifacts from `dioxus build` or `dioxus service` into. This is also where the `assets` directory will be copied to + if you change this to `desktop`, the `dx build` will default building a desktop app +3. ***out_dir*** - The directory to place the build artifacts from `dx build` or `dx service` into. This is also where the `assets` directory will be copied to ``` out_dir = "dist" ``` diff --git a/packages/cli/docs/src/creating.md b/packages/cli/docs/src/creating.md index 5bb98fd10..6c2d5754c 100644 --- a/packages/cli/docs/src/creating.md +++ b/packages/cli/docs/src/creating.md @@ -4,10 +4,10 @@ Once you have the Dioxus CLI tool installed, you can use it to create dioxus pro ## Initializing a default project -First, run the `dioxus create` command to create a new project ready to be used with Dioxus and the Dioxus CLI: +First, run the `dx create` command to create a new project ready to be used with Dioxus and the Dioxus CLI: ``` -dioxus create hello-dioxus +dx create hello-dioxus ``` > It will clone a default template from github template: [DioxusLabs/dioxus-template](https://github.com/DioxusLabs/dioxus-template) @@ -15,7 +15,7 @@ dioxus create hello-dioxus > > You can choose to create your project from a different template by passing the `template` argument: > ``` -> dioxus init hello-dioxus --template=gh:dioxuslabs/dioxus-template +> dx init hello-dioxus --template=gh:dioxuslabs/dioxus-template > ``` Next, move the current directory into your new project: @@ -33,7 +33,7 @@ cd hello-dioxus Finally, create serve your project with the Dioxus CLI: ``` -dioxus serve +dx serve ``` By default, the CLI serve your site at: [`http://127.0.0.1:8080/`](http://127.0.0.1:8080/) diff --git a/packages/cli/docs/src/plugin/README.md b/packages/cli/docs/src/plugin/README.md index 9983f024b..0a1a7b606 100644 --- a/packages/cli/docs/src/plugin/README.md +++ b/packages/cli/docs/src/plugin/README.md @@ -2,7 +2,7 @@ > For Cli 0.2.0 we will add `plugin-develop` support. -Before the 0.2.0 we use `dioxus tool` to use & install some plugin, but we think that is not good for extend cli program, some people want tailwind support, some people want sass support, we can't add all this thing in to the cli source code and we don't have time to maintain a lot of tools that user request, so maybe user make plugin by themself is a good choice. +Before the 0.2.0 we use `dx tool` to use & install some plugin, but we think that is not good for extend cli program, some people want tailwind support, some people want sass support, we can't add all this thing in to the cli source code and we don't have time to maintain a lot of tools that user request, so maybe user make plugin by themself is a good choice. ### Why Lua ? @@ -76,4 +76,4 @@ end manager.serve.interval = 1000 return manager -``` \ No newline at end of file +``` diff --git a/packages/cli/src/assets/dioxus.toml b/packages/cli/src/assets/dioxus.toml index 3827d0f8f..dfdeb9f92 100644 --- a/packages/cli/src/assets/dioxus.toml +++ b/packages/cli/src/assets/dioxus.toml @@ -4,7 +4,7 @@ name = "{{project-name}}" # default platfrom -# you can also use `dioxus serve/build --platform XXX` to use other platform +# you can also use `dx serve/build --platform XXX` to use other platform # value: web | desktop default_platform = "{{default-platform}}" diff --git a/packages/cli/src/builder.rs b/packages/cli/src/builder.rs index 6f50fc49d..b25576120 100644 --- a/packages/cli/src/builder.rs +++ b/packages/cli/src/builder.rs @@ -12,7 +12,6 @@ use std::{ io::Read, panic, path::PathBuf, - process::Command, time::Duration, }; use wasm_bindgen_cli_support::Bindgen; @@ -161,7 +160,7 @@ pub fn build(config: &CrateConfig, quiet: bool) -> Result { } } else { log::warn!( - "Binaryen tool not found, you can use `dioxus tool add binaryen` to install it." + "Binaryen tool not found, you can use `dx tool add binaryen` to install it." ); } } @@ -200,7 +199,7 @@ pub fn build(config: &CrateConfig, quiet: bool) -> Result { } } else { log::warn!( - "Tailwind tool not found, you can use `dioxus tool add tailwindcss` to install it." + "Tailwind tool not found, you can use `dx tool add tailwindcss` to install it." ); } } @@ -244,128 +243,125 @@ pub fn build(config: &CrateConfig, quiet: bool) -> Result { }) } -pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<()> { +pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result { log::info!("🚅 Running build [Desktop] command..."); + let t_start = std::time::Instant::now(); let ignore_files = build_assets(config)?; - let mut cmd = Command::new("cargo"); - cmd.current_dir(&config.crate_dir) + let mut cmd = subprocess::Exec::cmd("cargo") + .cwd(&config.crate_dir) .arg("build") - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); + .arg("--message-format=json"); if config.release { - cmd.arg("--release"); + cmd = cmd.arg("--release"); } if config.verbose { - cmd.arg("--verbose"); + cmd = cmd.arg("--verbose"); } if config.custom_profile.is_some() { let custom_profile = config.custom_profile.as_ref().unwrap(); - cmd.arg("--profile"); - cmd.arg(custom_profile); + cmd = cmd.arg("--profile").arg(custom_profile); } if config.features.is_some() { let features_str = config.features.as_ref().unwrap().join(" "); - cmd.arg("--features"); - cmd.arg(features_str); + cmd = cmd.arg("--features").arg(features_str); } - match &config.executable { + let cmd = match &config.executable { crate::ExecutableType::Binary(name) => cmd.arg("--bin").arg(name), crate::ExecutableType::Lib(name) => cmd.arg("--lib").arg(name), crate::ExecutableType::Example(name) => cmd.arg("--example").arg(name), }; - let output = cmd.output()?; + let warning_messages = prettier_build(cmd)?; - if !output.status.success() { - return Err(Error::BuildFailed("Program build failed.".into())); - } + let release_type = match config.release { + true => "release", + false => "debug", + }; - if output.status.success() { - let release_type = match config.release { - true => "release", - false => "debug", - }; - - let file_name: String; - let mut res_path = match &config.executable { - crate::ExecutableType::Binary(name) | crate::ExecutableType::Lib(name) => { - file_name = name.clone(); - config.target_dir.join(release_type).join(name) - } - crate::ExecutableType::Example(name) => { - file_name = name.clone(); - config - .target_dir - .join(release_type) - .join("examples") - .join(name) - } - }; - - let target_file = if cfg!(windows) { - res_path.set_extension("exe"); - format!("{}.exe", &file_name) - } else { - file_name - }; - - if !config.out_dir.is_dir() { - create_dir_all(&config.out_dir)?; + let file_name: String; + let mut res_path = match &config.executable { + crate::ExecutableType::Binary(name) | crate::ExecutableType::Lib(name) => { + file_name = name.clone(); + config.target_dir.join(release_type).join(name) } - copy(res_path, &config.out_dir.join(target_file))?; + crate::ExecutableType::Example(name) => { + file_name = name.clone(); + config + .target_dir + .join(release_type) + .join("examples") + .join(name) + } + }; - // this code will copy all public file to the output dir - if config.asset_dir.is_dir() { - let copy_options = fs_extra::dir::CopyOptions { - overwrite: true, - skip_exist: false, - buffer_size: 64000, - copy_inside: false, - content_only: false, - depth: 0, - }; + let target_file = if cfg!(windows) { + res_path.set_extension("exe"); + format!("{}.exe", &file_name) + } else { + file_name + }; - for entry in std::fs::read_dir(&config.asset_dir)? { - let path = entry?.path(); - if path.is_file() { - std::fs::copy(&path, &config.out_dir.join(path.file_name().unwrap()))?; - } else { - match fs_extra::dir::copy(&path, &config.out_dir, ©_options) { - Ok(_) => {} - Err(e) => { - log::warn!("Error copying dir: {}", e); - } + if !config.out_dir.is_dir() { + create_dir_all(&config.out_dir)?; + } + copy(res_path, &config.out_dir.join(target_file))?; + + // this code will copy all public file to the output dir + if config.asset_dir.is_dir() { + let copy_options = fs_extra::dir::CopyOptions { + overwrite: true, + skip_exist: false, + buffer_size: 64000, + copy_inside: false, + content_only: false, + depth: 0, + }; + + for entry in std::fs::read_dir(&config.asset_dir)? { + let path = entry?.path(); + if path.is_file() { + std::fs::copy(&path, &config.out_dir.join(path.file_name().unwrap()))?; + } else { + match fs_extra::dir::copy(&path, &config.out_dir, ©_options) { + Ok(_) => {} + Err(e) => { + log::warn!("Error copying dir: {}", e); } - for ignore in &ignore_files { - let ignore = ignore.strip_prefix(&config.asset_dir).unwrap(); - let ignore = config.out_dir.join(ignore); - if ignore.is_file() { - std::fs::remove_file(ignore)?; - } + } + for ignore in &ignore_files { + let ignore = ignore.strip_prefix(&config.asset_dir).unwrap(); + let ignore = config.out_dir.join(ignore); + if ignore.is_file() { + std::fs::remove_file(ignore)?; } } } } - - log::info!( - "🚩 Build completed: [./{}]", - config - .dioxus_config - .application - .out_dir - .clone() - .unwrap_or_else(|| PathBuf::from("dist")) - .display() - ); } - Ok(()) + log::info!( + "🚩 Build completed: [./{}]", + config + .dioxus_config + .application + .out_dir + .clone() + .unwrap_or_else(|| PathBuf::from("dist")) + .display() + ); + + println!("build desktop done"); + + Ok(BuildResult { + warnings: warning_messages, + elapsed_time: (t_start - std::time::Instant::now()).as_millis(), + }) } fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result> { diff --git a/packages/cli/src/cli/autoformat/mod.rs b/packages/cli/src/cli/autoformat.rs similarity index 89% rename from packages/cli/src/cli/autoformat/mod.rs rename to packages/cli/src/cli/autoformat.rs index 257e237f2..a0c0a8066 100644 --- a/packages/cli/src/cli/autoformat/mod.rs +++ b/packages/cli/src/cli/autoformat.rs @@ -78,7 +78,7 @@ impl Autoformat { /// /// Doesn't do mod-descending, so it will still try to format unreachable files. TODO. async fn autoformat_project(check: bool) -> Result<()> { - let crate_config = crate::CrateConfig::new()?; + let crate_config = crate::CrateConfig::new(None)?; let mut files_to_format = vec![]; collect_rs_files(&crate_config.crate_dir, &mut files_to_format); @@ -109,11 +109,17 @@ async fn autoformat_project(check: bool) -> Result<()> { }) .await; - if res.is_err() { - eprintln!("error formatting file: {}", _path.display()); + match res { + Err(err) => { + eprintln!("error formatting file: {}\n{err}", _path.display()); + None + } + Ok(Err(err)) => { + eprintln!("error formatting file: {}\n{err}", _path.display()); + None + } + Ok(Ok(res)) => Some(res), } - - res }) .collect::>() .collect::>() @@ -122,7 +128,7 @@ async fn autoformat_project(check: bool) -> Result<()> { let files_formatted: usize = counts .into_iter() .map(|f| match f { - Ok(Ok(res)) => res, + Some(res) => res, _ => 0, }) .sum(); @@ -136,12 +142,16 @@ async fn autoformat_project(check: bool) -> Result<()> { } fn collect_rs_files(folder: &Path, files: &mut Vec) { - let Ok(folder) = folder.read_dir() else { return }; + let Ok(folder) = folder.read_dir() else { + return; + }; // load the gitignore for entry in folder { - let Ok(entry) = entry else { continue; }; + let Ok(entry) = entry else { + continue; + }; let path = entry.path(); diff --git a/packages/cli/src/cli/build/mod.rs b/packages/cli/src/cli/build.rs similarity index 77% rename from packages/cli/src/cli/build/mod.rs rename to packages/cli/src/cli/build.rs index b4df33a42..3053a6e5a 100644 --- a/packages/cli/src/cli/build/mod.rs +++ b/packages/cli/src/cli/build.rs @@ -1,3 +1,4 @@ +use crate::cfg::Platform; #[cfg(feature = "plugin")] use crate::plugin::PluginManager; @@ -12,8 +13,8 @@ pub struct Build { } impl Build { - pub fn build(self) -> Result<()> { - let mut crate_config = crate::CrateConfig::new()?; + pub fn build(self, bin: Option) -> Result<()> { + let mut crate_config = crate::CrateConfig::new(bin)?; // change the release state. crate_config.with_release(self.build.release); @@ -31,27 +32,21 @@ impl Build { crate_config.set_features(self.build.features.unwrap()); } - let platform = self.build.platform.unwrap_or_else(|| { - crate_config - .dioxus_config - .application - .default_platform - .clone() - }); + let platform = self + .build + .platform + .unwrap_or(crate_config.dioxus_config.application.default_platform); #[cfg(feature = "plugin")] let _ = PluginManager::on_build_start(&crate_config, &platform); - match platform.as_str() { - "web" => { + match platform { + Platform::Web => { crate::builder::build(&crate_config, false)?; } - "desktop" => { + Platform::Desktop => { crate::builder::build_desktop(&crate_config, false)?; } - _ => { - return custom_error!("Unsupported platform target."); - } } let temp = gen_page(&crate_config.dioxus_config, false); diff --git a/packages/cli/src/cli/cfg.rs b/packages/cli/src/cli/cfg.rs index 30ca89ce9..edc32f346 100644 --- a/packages/cli/src/cli/cfg.rs +++ b/packages/cli/src/cli/cfg.rs @@ -1,3 +1,6 @@ +use clap::ValueEnum; +use serde::Serialize; + use super::*; /// Config options for the build system. @@ -26,8 +29,8 @@ pub struct ConfigOptsBuild { pub profile: Option, /// Build platform: support Web & Desktop [default: "default_platform"] - #[clap(long)] - pub platform: Option, + #[clap(long, value_enum)] + pub platform: Option, /// Space separated list of features to activate #[clap(long)] @@ -69,8 +72,8 @@ pub struct ConfigOptsServe { pub profile: Option, /// Build platform: support Web & Desktop [default: "default_platform"] - #[clap(long)] - pub platform: Option, + #[clap(long, value_enum)] + pub platform: Option, /// Build with hot reloading rsx [default: false] #[clap(long)] @@ -88,6 +91,16 @@ pub struct ConfigOptsServe { pub features: Option>, } +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize, Debug)] +pub enum Platform { + #[clap(name = "web")] + #[serde(rename = "web")] + Web, + #[clap(name = "desktop")] + #[serde(rename = "desktop")] + Desktop, +} + /// Ensure the given value for `--public-url` is formatted correctly. pub fn parse_public_url(val: &str) -> String { let prefix = if !val.starts_with('/') { "/" } else { "" }; diff --git a/packages/cli/src/cli/clean/mod.rs b/packages/cli/src/cli/clean.rs similarity index 86% rename from packages/cli/src/cli/clean/mod.rs rename to packages/cli/src/cli/clean.rs index d380f5265..a1e29127f 100644 --- a/packages/cli/src/cli/clean/mod.rs +++ b/packages/cli/src/cli/clean.rs @@ -6,8 +6,8 @@ use super::*; pub struct Clean {} impl Clean { - pub fn clean(self) -> Result<()> { - let crate_config = crate::CrateConfig::new()?; + pub fn clean(self, bin: Option) -> Result<()> { + let crate_config = crate::CrateConfig::new(bin)?; let output = Command::new("cargo") .arg("clean") diff --git a/packages/cli/src/cli/config/mod.rs b/packages/cli/src/cli/config.rs similarity index 88% rename from packages/cli/src/cli/config/mod.rs rename to packages/cli/src/cli/config.rs index 5f9fa20d0..cde29ef6a 100644 --- a/packages/cli/src/cli/config/mod.rs +++ b/packages/cli/src/cli/config.rs @@ -41,19 +41,19 @@ impl Config { return Ok(()); } let mut file = File::create(conf_path)?; - let content = String::from(include_str!("../../assets/dioxus.toml")) + let content = String::from(include_str!("../assets/dioxus.toml")) .replace("{{project-name}}", &name) .replace("{{default-platform}}", &platform); file.write_all(content.as_bytes())?; log::info!("🚩 Init config file completed."); } Config::FormatPrint {} => { - println!("{:#?}", crate::CrateConfig::new()?.dioxus_config); + println!("{:#?}", crate::CrateConfig::new(None)?.dioxus_config); } Config::CustomHtml {} => { let html_path = crate_root.join("index.html"); let mut file = File::create(html_path)?; - let content = include_str!("../../assets/index.html"); + let content = include_str!("../assets/index.html"); file.write_all(content.as_bytes())?; log::info!("🚩 Create custom html file done."); } diff --git a/packages/cli/src/cli/create/mod.rs b/packages/cli/src/cli/create.rs similarity index 100% rename from packages/cli/src/cli/create/mod.rs rename to packages/cli/src/cli/create.rs diff --git a/packages/cli/src/cli/mod.rs b/packages/cli/src/cli/mod.rs index 0dff0feff..55f79e7eb 100644 --- a/packages/cli/src/cli/mod.rs +++ b/packages/cli/src/cli/mod.rs @@ -36,6 +36,10 @@ pub struct Cli { /// Enable verbose logging. #[clap(short)] pub v: bool, + + /// Specify bin target + #[clap(global = true, long)] + pub bin: Option, } #[derive(Parser)] diff --git a/packages/cli/src/cli/plugin/mod.rs b/packages/cli/src/cli/plugin.rs similarity index 91% rename from packages/cli/src/cli/plugin/mod.rs rename to packages/cli/src/cli/plugin.rs index 812c3ae1c..9f514edec 100644 --- a/packages/cli/src/cli/plugin/mod.rs +++ b/packages/cli/src/cli/plugin.rs @@ -31,7 +31,7 @@ impl Plugin { } } Plugin::Add { name: _ } => { - log::info!("You can use `dioxus plugin app-path` to get Installation position"); + log::info!("You can use `dx plugin app-path` to get Installation position"); } } Ok(()) diff --git a/packages/cli/src/cli/serve/mod.rs b/packages/cli/src/cli/serve.rs similarity index 54% rename from packages/cli/src/cli/serve/mod.rs rename to packages/cli/src/cli/serve.rs index 2e0477d42..433d611b7 100644 --- a/packages/cli/src/cli/serve/mod.rs +++ b/packages/cli/src/cli/serve.rs @@ -1,10 +1,5 @@ use super::*; -use std::{ - fs::create_dir_all, - io::Write, - path::PathBuf, - process::{Command, Stdio}, -}; +use std::{fs::create_dir_all, io::Write, path::PathBuf}; /// Run the WASM project on dev-server #[derive(Clone, Debug, Parser)] @@ -15,8 +10,8 @@ pub struct Serve { } impl Serve { - pub async fn serve(self) -> Result<()> { - let mut crate_config = crate::CrateConfig::new()?; + pub async fn serve(self, bin: Option) -> Result<()> { + let mut crate_config = crate::CrateConfig::new(bin)?; // change the relase state. crate_config.with_hot_reload(self.serve.hot_reload); @@ -39,41 +34,24 @@ impl Serve { // Subdirectories don't work with the server crate_config.dioxus_config.web.app.base_path = None; - let platform = self.serve.platform.unwrap_or_else(|| { - crate_config - .dioxus_config - .application - .default_platform - .clone() - }); + let platform = self + .serve + .platform + .unwrap_or(crate_config.dioxus_config.application.default_platform); - if platform.as_str() == "desktop" { - crate::builder::build_desktop(&crate_config, true)?; + match platform { + cfg::Platform::Web => { + // generate dev-index page + Serve::regen_dev_page(&crate_config)?; - match &crate_config.executable { - crate::ExecutableType::Binary(name) - | crate::ExecutableType::Lib(name) - | crate::ExecutableType::Example(name) => { - let mut file = crate_config.out_dir.join(name); - if cfg!(windows) { - file.set_extension("exe"); - } - Command::new(file.to_str().unwrap()) - .stdout(Stdio::inherit()) - .output()?; - } + // start the develop server + server::web::startup(self.serve.port, crate_config.clone(), self.serve.open) + .await?; + } + cfg::Platform::Desktop => { + server::desktop::startup(crate_config.clone()).await?; } - return Ok(()); - } else if platform != "web" { - return custom_error!("Unsupported platform target."); } - - // generate dev-index page - Serve::regen_dev_page(&crate_config)?; - - // start the develop server - server::startup(self.serve.port, crate_config.clone(), self.serve.open).await?; - Ok(()) } diff --git a/packages/cli/src/cli/tool/mod.rs b/packages/cli/src/cli/tool.rs similarity index 100% rename from packages/cli/src/cli/tool/mod.rs rename to packages/cli/src/cli/tool.rs diff --git a/packages/cli/src/cli/translate/mod.rs b/packages/cli/src/cli/translate.rs similarity index 98% rename from packages/cli/src/cli/translate/mod.rs rename to packages/cli/src/cli/translate.rs index 81f0da3e6..a922d428d 100644 --- a/packages/cli/src/cli/translate/mod.rs +++ b/packages/cli/src/cli/translate.rs @@ -130,7 +130,7 @@ fn determine_input(file: Option, raw: Option) -> Result #[test] fn generates_svgs() { - let st = include_str!("../../../tests/svg.html"); + let st = include_str!("../../tests/svg.html"); let out = convert_html_to_formatted_rsx(&html_parser::Dom::parse(st).unwrap(), true); diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index f9117f6e5..e46b4f0fe 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -1,6 +1,9 @@ -use crate::error::Result; +use crate::{cfg::Platform, error::Result}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DioxusConfig { @@ -17,29 +20,49 @@ fn default_plugin() -> toml::Value { } impl DioxusConfig { - pub fn load() -> crate::error::Result> { - let Ok(crate_dir) = crate::cargo::crate_root() else { return Ok(None); }; + pub fn load(bin: Option) -> crate::error::Result> { + let crate_dir = crate::cargo::crate_root(); + + let crate_dir = match crate_dir { + Ok(dir) => { + if let Some(bin) = bin { + dir.join(bin) + } else { + dir + } + } + Err(_) => return Ok(None), + }; + let crate_dir = crate_dir.as_path(); - // we support either `Dioxus.toml` or `Cargo.toml` let Some(dioxus_conf_file) = acquire_dioxus_toml(crate_dir) else { return Ok(None); }; + let dioxus_conf_file = dioxus_conf_file.as_path(); toml::from_str::(&std::fs::read_to_string(dioxus_conf_file)?) - .map_err(|_| crate::Error::Unique("Dioxus.toml parse failed".into())) + .map_err(|err| { + let error_location = dioxus_conf_file + .strip_prefix(crate_dir) + .unwrap_or(dioxus_conf_file) + .display(); + crate::Error::Unique(format!("{error_location} {err}")) + }) .map(Some) } } -fn acquire_dioxus_toml(dir: PathBuf) -> Option { +fn acquire_dioxus_toml(dir: &Path) -> Option { // prefer uppercase - if dir.join("Dioxus.toml").is_file() { - return Some(dir.join("Dioxus.toml")); + let uppercase_conf = dir.join("Dioxus.toml"); + if uppercase_conf.is_file() { + return Some(uppercase_conf); } // lowercase is fine too - if dir.join("dioxus.toml").is_file() { - return Some(dir.join("Dioxus.toml")); + let lowercase_conf = dir.join("dioxus.toml"); + if lowercase_conf.is_file() { + return Some(lowercase_conf); } None @@ -50,7 +73,7 @@ impl Default for DioxusConfig { Self { application: ApplicationConfig { name: "dioxus".into(), - default_platform: "web".to_string(), + default_platform: Platform::Web, out_dir: Some(PathBuf::from("dist")), asset_dir: Some(PathBuf::from("public")), @@ -92,7 +115,7 @@ impl Default for DioxusConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApplicationConfig { pub name: String, - pub default_platform: String, + pub default_platform: Platform, pub out_dir: Option, pub asset_dir: Option, @@ -176,14 +199,19 @@ pub enum ExecutableType { } impl CrateConfig { - pub fn new() -> Result { - let dioxus_config = DioxusConfig::load()?.unwrap_or_default(); + pub fn new(bin: Option) -> Result { + let dioxus_config = DioxusConfig::load(bin.clone())?.unwrap_or_default(); + + let crate_root = crate::cargo::crate_root()?; let crate_dir = if let Some(package) = &dioxus_config.application.sub_package { - crate::cargo::crate_root()?.join(package) + crate_root.join(package) + } else if let Some(bin) = bin { + crate_root.join(bin) } else { - crate::cargo::crate_root()? + crate_root }; + let meta = crate::cargo::Metadata::get()?; let workspace_dir = meta.workspace_root; let target_dir = meta.target_directory; @@ -202,8 +230,9 @@ impl CrateConfig { let manifest = cargo_toml::Manifest::from_path(cargo_def).unwrap(); - let output_filename = { - match &manifest.package.as_ref().unwrap().default_run { + let mut output_filename = String::from("dioxus_app"); + if let Some(package) = &manifest.package.as_ref() { + output_filename = match &package.default_run { Some(default_run_target) => default_run_target.to_owned(), None => manifest .bin @@ -216,9 +245,10 @@ impl CrateConfig { .or(manifest.bin.first()) .or(manifest.lib.as_ref()) .and_then(|prod| prod.name.clone()) - .expect("No executable or library found from cargo metadata."), - } - }; + .unwrap_or(String::from("dioxus_app")), + }; + } + let executable = ExecutableType::Binary(output_filename); let release = false; diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index f2007aa43..45881f906 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use anyhow::anyhow; use clap::Parser; use dioxus_cli::*; @@ -7,14 +9,54 @@ use dioxus_cli::plugin::PluginManager; use Commands::*; +fn get_bin(bin: Option) -> Result> { + const ERR_MESSAGE: &str = "The `--bin` flag has to be ran in a Cargo workspace."; + + if let Some(ref bin) = bin { + let manifest = cargo_toml::Manifest::from_path("./Cargo.toml") + .map_err(|_| Error::CargoError(ERR_MESSAGE.to_string()))?; + + if let Some(workspace) = manifest.workspace { + for item in workspace.members.iter() { + let path = PathBuf::from(item); + + if !path.exists() { + continue; + } + + if !path.is_dir() { + continue; + } + + if path.ends_with(bin.clone()) { + return Ok(Some(path)); + } + } + } else { + return Err(Error::CargoError(ERR_MESSAGE.to_string())); + } + } + + // If the bin exists but we couldn't find it + if bin.is_some() { + return Err(Error::CargoError( + "The specified bin does not exist.".to_string(), + )); + } + + Ok(None) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Cli::parse(); set_up_logging(); - let _dioxus_config = DioxusConfig::load() - .map_err(|e| anyhow!("Failed to load `Dioxus.toml` because: {e}"))? + let bin = get_bin(args.bin)?; + + let _dioxus_config = DioxusConfig::load(bin.clone()) + .map_err(|e| anyhow!("Failed to load Dioxus config because: {e}"))? .unwrap_or_else(|| { log::warn!("You appear to be creating a Dioxus project from scratch; we will use the default config"); DioxusConfig::default() @@ -30,15 +72,15 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow!("🚫 Translation of HTML into RSX failed: {}", e)), Build(opts) => opts - .build() + .build(bin.clone()) .map_err(|e| anyhow!("🚫 Building project failed: {}", e)), Clean(opts) => opts - .clean() + .clean(bin.clone()) .map_err(|e| anyhow!("🚫 Cleaning project failed: {}", e)), Serve(opts) => opts - .serve() + .serve(bin.clone()) .await .map_err(|e| anyhow!("🚫 Serving project failed: {}", e)), diff --git a/packages/cli/src/server/desktop/mod.rs b/packages/cli/src/server/desktop/mod.rs new file mode 100644 index 000000000..9b79bdea9 --- /dev/null +++ b/packages/cli/src/server/desktop/mod.rs @@ -0,0 +1,248 @@ +use crate::{ + server::{ + output::{print_console_info, PrettierOptions}, + setup_file_watcher, setup_file_watcher_hot_reload, + }, + BuildResult, CrateConfig, Result, +}; + +use dioxus_hot_reload::HotReloadMsg; +use dioxus_html::HtmlCtx; +use dioxus_rsx::hot_reload::*; +use interprocess_docfix::local_socket::LocalSocketListener; +use std::{ + process::{Child, Command}, + sync::{Arc, Mutex, RwLock}, +}; +use tokio::sync::broadcast::{self}; + +#[cfg(feature = "plugin")] +use plugin::PluginManager; + +pub async fn startup(config: CrateConfig) -> Result<()> { + // ctrl-c shutdown checker + let _crate_config = config.clone(); + let _ = ctrlc::set_handler(move || { + #[cfg(feature = "plugin")] + let _ = PluginManager::on_serve_shutdown(&_crate_config); + std::process::exit(0); + }); + + match config.hot_reload { + true => serve_hot_reload(config).await?, + false => serve_default(config).await?, + } + + Ok(()) +} + +/// Start the server without hot reload +pub async fn serve_default(config: CrateConfig) -> Result<()> { + let (child, first_build_result) = start_desktop(&config)?; + let currently_running_child: RwLock = RwLock::new(child); + + log::info!("🚀 Starting development server..."); + + // We got to own watcher so that it exists for the duration of serve + // Otherwise full reload won't work. + let _watcher = setup_file_watcher( + { + let config = config.clone(); + + move || { + let mut current_child = currently_running_child.write().unwrap(); + current_child.kill()?; + let (child, result) = start_desktop(&config)?; + *current_child = child; + Ok(result) + } + }, + &config, + None, + ) + .await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + None, + ); + + std::future::pending::<()>().await; + + Ok(()) +} + +/// Start the server without hot reload + +/// Start dx serve with hot reload +pub async fn serve_hot_reload(config: CrateConfig) -> Result<()> { + let (_, first_build_result) = start_desktop(&config)?; + + println!("🚀 Starting development server..."); + + // Setup hot reload + let FileMapBuildResult { map, errors } = + FileMap::::create(config.crate_dir.clone()).unwrap(); + + println!("🚀 Starting development server..."); + + for err in errors { + log::error!("{}", err); + } + + let file_map = Arc::new(Mutex::new(map)); + + let (hot_reload_tx, mut hot_reload_rx) = broadcast::channel(100); + + // States + // The open interprocess sockets + let channels = Arc::new(Mutex::new(Vec::new())); + + // Setup file watcher + // We got to own watcher so that it exists for the duration of serve + // Otherwise hot reload won't work. + let _watcher = setup_file_watcher_hot_reload( + &config, + hot_reload_tx, + file_map.clone(), + { + let config = config.clone(); + + let channels = channels.clone(); + move || { + for channel in &mut *channels.lock().unwrap() { + send_msg(HotReloadMsg::Shutdown, channel); + } + Ok(start_desktop(&config)?.1) + } + }, + None, + ) + .await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + None, + ); + + clear_paths(); + + match LocalSocketListener::bind("@dioxusin") { + Ok(local_socket_stream) => { + let aborted = Arc::new(Mutex::new(false)); + + // listen for connections + std::thread::spawn({ + let file_map = file_map.clone(); + let channels = channels.clone(); + let aborted = aborted.clone(); + let _ = local_socket_stream.set_nonblocking(true); + move || { + loop { + if let Ok(mut connection) = local_socket_stream.accept() { + // send any templates than have changed before the socket connected + let templates: Vec<_> = { + file_map + .lock() + .unwrap() + .map + .values() + .filter_map(|(_, template_slot)| *template_slot) + .collect() + }; + for template in templates { + if !send_msg( + HotReloadMsg::UpdateTemplate(template), + &mut connection, + ) { + continue; + } + } + channels.lock().unwrap().push(connection); + println!("Connected to hot reloading 🚀"); + } + if *aborted.lock().unwrap() { + break; + } + } + } + }); + + while let Ok(template) = hot_reload_rx.recv().await { + let channels = &mut *channels.lock().unwrap(); + let mut i = 0; + while i < channels.len() { + let channel = &mut channels[i]; + if send_msg(HotReloadMsg::UpdateTemplate(template), channel) { + i += 1; + } else { + channels.remove(i); + } + } + } + } + Err(error) => println!("failed to connect to hot reloading\n{error}"), + } + + Ok(()) +} + +fn clear_paths() { + if cfg!(target_os = "macos") { + // On unix, if you force quit the application, it can leave the file socket open + // This will cause the local socket listener to fail to open + // We check if the file socket is already open from an old session and then delete it + let paths = ["./dioxusin", "./@dioxusin"]; + for path in paths { + let path = std::path::PathBuf::from(path); + if path.exists() { + let _ = std::fs::remove_file(path); + } + } + } +} + +fn send_msg(msg: HotReloadMsg, channel: &mut impl std::io::Write) -> bool { + if let Ok(msg) = serde_json::to_string(&msg) { + if channel.write_all(msg.as_bytes()).is_err() { + return false; + } + if channel.write_all(&[b'\n']).is_err() { + return false; + } + true + } else { + false + } +} + +pub fn start_desktop(config: &CrateConfig) -> Result<(Child, BuildResult)> { + // Run the desktop application + let result = crate::builder::build_desktop(config, true)?; + + match &config.executable { + crate::ExecutableType::Binary(name) + | crate::ExecutableType::Lib(name) + | crate::ExecutableType::Example(name) => { + let mut file = config.out_dir.join(name); + if cfg!(windows) { + file.set_extension("exe"); + } + let child = Command::new(file.to_str().unwrap()).spawn()?; + + Ok((child, result)) + } + } +} diff --git a/packages/cli/src/server/mod.rs b/packages/cli/src/server/mod.rs index 3bd434005..19dc44b75 100644 --- a/packages/cli/src/server/mod.rs +++ b/packages/cli/src/server/mod.rs @@ -1,449 +1,27 @@ -use crate::{builder, serve::Serve, BuildResult, CrateConfig, Result}; -use axum::{ - body::{Full, HttpBody}, - extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade}, - http::{ - header::{HeaderName, HeaderValue}, - Method, Response, StatusCode, - }, - response::IntoResponse, - routing::{get, get_service}, - Router, -}; -use axum_server::tls_rustls::RustlsConfig; +use crate::{BuildResult, CrateConfig, Result}; + use cargo_metadata::diagnostic::Diagnostic; use dioxus_core::Template; use dioxus_html::HtmlCtx; use dioxus_rsx::hot_reload::*; use notify::{RecommendedWatcher, Watcher}; use std::{ - net::UdpSocket, path::PathBuf, - process::Command, sync::{Arc, Mutex}, }; -use tokio::sync::broadcast::{self, Sender}; -use tower::ServiceBuilder; -use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; -use tower_http::{ - cors::{Any, CorsLayer}, - ServiceBuilderExt, -}; - -#[cfg(feature = "plugin")] -use plugin::PluginManager; - -mod proxy; - -mod hot_reload; -use hot_reload::*; +use tokio::sync::broadcast::Sender; mod output; use output::*; - -pub struct BuildManager { - config: CrateConfig, - reload_tx: broadcast::Sender<()>, -} - -impl BuildManager { - fn rebuild(&self) -> Result { - log::info!("🪁 Rebuild project"); - let result = builder::build(&self.config, true)?; - // change the websocket reload state to true; - // the page will auto-reload. - if self - .config - .dioxus_config - .web - .watcher - .reload_html - .unwrap_or(false) - { - let _ = Serve::regen_dev_page(&self.config); - } - let _ = self.reload_tx.send(()); - Ok(result) - } -} - -struct WsReloadState { - update: broadcast::Sender<()>, -} - -pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> { - // ctrl-c shutdown checker - let _crate_config = config.clone(); - let _ = ctrlc::set_handler(move || { - #[cfg(feature = "plugin")] - let _ = PluginManager::on_serve_shutdown(&_crate_config); - std::process::exit(0); - }); - - let ip = get_ip().unwrap_or(String::from("0.0.0.0")); - - match config.hot_reload { - true => serve_hot_reload(ip, port, config, start_browser).await?, - false => serve_default(ip, port, config, start_browser).await?, - } - - Ok(()) -} - -/// Start the server without hot reload -pub async fn serve_default( - ip: String, - port: u16, - config: CrateConfig, - start_browser: bool, -) -> Result<()> { - let first_build_result = crate::builder::build(&config, false)?; - - log::info!("🚀 Starting development server..."); - - // WS Reload Watching - let (reload_tx, _) = broadcast::channel(100); - - // We got to own watcher so that it exists for the duration of serve - // Otherwise full reload won't work. - let _watcher = setup_file_watcher(&config, port, ip.clone(), reload_tx.clone()).await?; - - let ws_reload_state = Arc::new(WsReloadState { - update: reload_tx.clone(), - }); - - // HTTPS - // Before console info so it can stop if mkcert isn't installed or fails - let rustls_config = get_rustls(&config).await?; - - // Print serve info - print_console_info( - &ip, - port, - &config, - PrettierOptions { - changed: vec![], - warnings: first_build_result.warnings, - elapsed_time: first_build_result.elapsed_time, - }, - ); - - // Router - let router = setup_router(config, ws_reload_state, None).await?; - - // Start server - start_server(port, router, start_browser, rustls_config).await?; - - Ok(()) -} - -/// Start dx serve with hot reload -pub async fn serve_hot_reload( - ip: String, - port: u16, - config: CrateConfig, - start_browser: bool, -) -> Result<()> { - let first_build_result = crate::builder::build(&config, false)?; - - log::info!("🚀 Starting development server..."); - - // Setup hot reload - let (reload_tx, _) = broadcast::channel(100); - let FileMapBuildResult { map, errors } = - FileMap::::create(config.crate_dir.clone()).unwrap(); - - for err in errors { - log::error!("{}", err); - } - - let file_map = Arc::new(Mutex::new(map)); - let build_manager = Arc::new(BuildManager { - config: config.clone(), - reload_tx: reload_tx.clone(), - }); - - let hot_reload_tx = broadcast::channel(100).0; - - // States - let hot_reload_state = Arc::new(HotReloadState { - messages: hot_reload_tx.clone(), - build_manager: build_manager.clone(), - file_map: file_map.clone(), - watcher_config: config.clone(), - }); - - let ws_reload_state = Arc::new(WsReloadState { - update: reload_tx.clone(), - }); - - // Setup file watcher - // We got to own watcher so that it exists for the duration of serve - // Otherwise hot reload won't work. - let _watcher = setup_file_watcher_hot_reload( - &config, - port, - ip.clone(), - hot_reload_tx, - file_map, - build_manager, - ) - .await?; - - // HTTPS - // Before console info so it can stop if mkcert isn't installed or fails - let rustls_config = get_rustls(&config).await?; - - // Print serve info - print_console_info( - &ip, - port, - &config, - PrettierOptions { - changed: vec![], - warnings: first_build_result.warnings, - elapsed_time: first_build_result.elapsed_time, - }, - ); - - // Router - let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?; - - // Start server - start_server(port, router, start_browser, rustls_config).await?; - - Ok(()) -} - -const DEFAULT_KEY_PATH: &str = "ssl/key.pem"; -const DEFAULT_CERT_PATH: &str = "ssl/cert.pem"; - -/// Returns an enum of rustls config and a bool if mkcert isn't installed -async fn get_rustls(config: &CrateConfig) -> Result> { - let web_config = &config.dioxus_config.web.https; - if web_config.enabled != Some(true) { - return Ok(None); - } - - let (cert_path, key_path) = match web_config.mkcert { - // mkcert, use it - Some(true) => { - // Get paths to store certs, otherwise use ssl/item.pem - let key_path = web_config - .key_path - .clone() - .unwrap_or(DEFAULT_KEY_PATH.to_string()); - - let cert_path = web_config - .cert_path - .clone() - .unwrap_or(DEFAULT_CERT_PATH.to_string()); - - // Create ssl directory if using defaults - if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH { - _ = fs::create_dir("ssl"); - } - - let cmd = Command::new("mkcert") - .args([ - "-install", - "-key-file", - &key_path, - "-cert-file", - &cert_path, - "localhost", - "::1", - "127.0.0.1", - ]) - .spawn(); - - match cmd { - Err(e) => { - match e.kind() { - io::ErrorKind::NotFound => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."), - e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()), - }; - return Err("failed to generate mkcert certificates".into()); - } - Ok(mut cmd) => { - cmd.wait()?; - } - } - - (cert_path, key_path) - } - // not mkcert - Some(false) => { - // get paths to cert & key - if let (Some(key), Some(cert)) = - (web_config.key_path.clone(), web_config.cert_path.clone()) - { - (cert, key) - } else { - // missing cert or key - return Err("https is enabled but cert or key path is missing".into()); - } - } - // other - _ => return Ok(None), - }; - - Ok(Some( - RustlsConfig::from_pem_file(cert_path, key_path).await?, - )) -} - -/// Sets up and returns a router -async fn setup_router( - config: CrateConfig, - ws_reload: Arc, - hot_reload: Option>, -) -> Result { - // Setup cors - let cors = CorsLayer::new() - // allow `GET` and `POST` when accessing the resource - .allow_methods([Method::GET, Method::POST]) - // allow requests from any origin - .allow_origin(Any) - .allow_headers(Any); - - let (coep, coop) = if config.cross_origin_policy { - ( - HeaderValue::from_static("require-corp"), - HeaderValue::from_static("same-origin"), - ) - } else { - ( - HeaderValue::from_static("unsafe-none"), - HeaderValue::from_static("unsafe-none"), - ) - }; - - // Create file service - let file_service_config = config.clone(); - let file_service = ServiceBuilder::new() - .override_response_header( - HeaderName::from_static("cross-origin-embedder-policy"), - coep, - ) - .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop) - .and_then( - move |response: Response| async move { - let response = if file_service_config - .dioxus_config - .web - .watcher - .index_on_404 - .unwrap_or(false) - && response.status() == StatusCode::NOT_FOUND - { - let body = Full::from( - // TODO: Cache/memoize this. - std::fs::read_to_string( - file_service_config - .crate_dir - .join(file_service_config.out_dir) - .join("index.html"), - ) - .ok() - .unwrap(), - ) - .map_err(|err| match err {}) - .boxed(); - Response::builder() - .status(StatusCode::OK) - .body(body) - .unwrap() - } else { - response.map(|body| body.boxed()) - }; - Ok(response) - }, - ) - .service(ServeDir::new(config.crate_dir.join(&config.out_dir))); - - // Setup websocket - let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); - - // Setup proxy - for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() { - router = proxy::add_proxy(router, &proxy_config)?; - } - - // Route file service - router = router.fallback(get_service(file_service).handle_error( - |error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }, - )); - - // Setup routes - router = router - .route("/_dioxus/hot_reload", get(hot_reload_handler)) - .layer(cors) - .layer(Extension(ws_reload)); - - if let Some(hot_reload) = hot_reload { - router = router.layer(Extension(hot_reload)) - } - - Ok(router) -} - -/// Starts dx serve with no hot reload -async fn start_server( - port: u16, - router: Router, - start_browser: bool, - rustls: Option, -) -> Result<()> { - // If plugins, call on_serve_start event - #[cfg(feature = "plugin")] - PluginManager::on_serve_start(&config)?; - - // Parse address - let addr = format!("0.0.0.0:{}", port).parse().unwrap(); - - // Open the browser - if start_browser { - match rustls { - Some(_) => _ = open::that(format!("https://{}", addr)), - None => _ = open::that(format!("http://{}", addr)), - } - } - - // Start the server with or without rustls - match rustls { - Some(rustls) => { - axum_server::bind_rustls(addr, rustls) - .serve(router.into_make_service()) - .await? - } - None => { - axum::Server::bind(&addr) - .serve(router.into_make_service()) - .await? - } - } - - Ok(()) -} +pub mod desktop; +pub mod web; /// Sets up a file watcher -async fn setup_file_watcher( +async fn setup_file_watcher Result + Send + 'static>( + build_with: F, config: &CrateConfig, - port: u16, - watcher_ip: String, - reload_tx: Sender<()>, + web_info: Option, ) -> Result { - let build_manager = BuildManager { - config: config.clone(), - reload_tx, - }; - let mut last_update_time = chrono::Local::now().timestamp(); // file watcher: check file change @@ -460,20 +38,19 @@ async fn setup_file_watcher( let config = watcher_config.clone(); if let Ok(e) = info { if chrono::Local::now().timestamp() > last_update_time { - match build_manager.rebuild() { + match build_with() { Ok(res) => { last_update_time = chrono::Local::now().timestamp(); #[allow(clippy::redundant_clone)] print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: e.paths.clone(), warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); #[cfg(feature = "plugin")] @@ -502,13 +79,12 @@ async fn setup_file_watcher( // Todo: reduce duplication and merge with setup_file_watcher() /// Sets up a file watcher with hot reload -async fn setup_file_watcher_hot_reload( +async fn setup_file_watcher_hot_reload Result + Send + 'static>( config: &CrateConfig, - port: u16, - watcher_ip: String, hot_reload_tx: Sender>, file_map: Arc>>, - build_manager: Arc, + build_with: F, + web_info: Option, ) -> Result { // file watcher: check file change let allow_watch_path = config @@ -533,17 +109,16 @@ async fn setup_file_watcher_hot_reload( for path in evt.paths.clone() { // if this is not a rust file, rebuild the whole project if path.extension().and_then(|p| p.to_str()) != Some("rs") { - match build_manager.rebuild() { + match build_with() { Ok(res) => { print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: evt.paths, warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); } Err(err) => { @@ -560,17 +135,16 @@ async fn setup_file_watcher_hot_reload( messages.extend(msgs); } Ok(UpdateResult::NeedsRebuild) => { - match build_manager.rebuild() { + match build_with() { Ok(res) => { print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: evt.paths, warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); } Err(err) => { @@ -606,50 +180,3 @@ async fn setup_file_watcher_hot_reload( Ok(watcher) } - -/// Get the network ip -fn get_ip() -> Option { - let socket = match UdpSocket::bind("0.0.0.0:0") { - Ok(s) => s, - Err(_) => return None, - }; - - match socket.connect("8.8.8.8:80") { - Ok(()) => (), - Err(_) => return None, - }; - - match socket.local_addr() { - Ok(addr) => Some(addr.ip().to_string()), - Err(_) => None, - } -} - -/// Handle websockets -async fn ws_handler( - ws: WebSocketUpgrade, - _: Option>, - Extension(state): Extension>, -) -> impl IntoResponse { - ws.on_upgrade(|mut socket| async move { - let mut rx = state.update.subscribe(); - let reload_watcher = tokio::spawn(async move { - loop { - rx.recv().await.unwrap(); - // ignore the error - if socket - .send(Message::Text(String::from("reload"))) - .await - .is_err() - { - break; - } - - // flush the errors after recompling - rx = rx.resubscribe(); - } - }); - - reload_watcher.await.unwrap(); - }) -} diff --git a/packages/cli/src/server/output.rs b/packages/cli/src/server/output.rs index 4c6e1f3b2..ce0f55064 100644 --- a/packages/cli/src/server/output.rs +++ b/packages/cli/src/server/output.rs @@ -11,7 +11,17 @@ pub struct PrettierOptions { pub elapsed_time: u128, } -pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: PrettierOptions) { +#[derive(Debug, Clone)] +pub struct WebServerInfo { + pub ip: String, + pub port: u16, +} + +pub fn print_console_info( + config: &CrateConfig, + options: PrettierOptions, + web_info: Option, +) { if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") { "cls" } else { @@ -70,26 +80,28 @@ pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: ); } - if config.dioxus_config.web.https.enabled == Some(true) { - println!( - "\t> Local : {}", - format!("https://localhost:{}/", port).blue() - ); - println!( - "\t> Network : {}", - format!("https://{}:{}/", ip, port).blue() - ); - println!("\t> HTTPS : {}", "Enabled".to_string().green()); - } else { - println!( - "\t> Local : {}", - format!("http://localhost:{}/", port).blue() - ); - println!( - "\t> Network : {}", - format!("http://{}:{}/", ip, port).blue() - ); - println!("\t> HTTPS : {}", "Disabled".to_string().red()); + if let Some(WebServerInfo { ip, port }) = web_info { + if config.dioxus_config.web.https.enabled == Some(true) { + println!( + "\t> Local : {}", + format!("https://localhost:{}/", port).blue() + ); + println!( + "\t> Network : {}", + format!("https://{}:{}/", ip, port).blue() + ); + println!("\t> HTTPS : {}", "Enabled".to_string().green()); + } else { + println!( + "\t> Local : {}", + format!("http://localhost:{}/", port).blue() + ); + println!( + "\t> Network : {}", + format!("http://{}:{}/", ip, port).blue() + ); + println!("\t> HTTPS : {}", "Disabled".to_string().red()); + } } println!(); println!("\t> Profile : {}", profile.green()); diff --git a/packages/cli/src/server/hot_reload.rs b/packages/cli/src/server/web/hot_reload.rs similarity index 96% rename from packages/cli/src/server/hot_reload.rs rename to packages/cli/src/server/web/hot_reload.rs index 167e712af..49cce38d4 100644 --- a/packages/cli/src/server/hot_reload.rs +++ b/packages/cli/src/server/web/hot_reload.rs @@ -10,12 +10,10 @@ use dioxus_html::HtmlCtx; use dioxus_rsx::hot_reload::FileMap; use tokio::sync::broadcast; -use super::BuildManager; use crate::CrateConfig; pub struct HotReloadState { pub messages: broadcast::Sender>, - pub build_manager: Arc, pub file_map: Arc>>, pub watcher_config: CrateConfig, } diff --git a/packages/cli/src/server/web/mod.rs b/packages/cli/src/server/web/mod.rs new file mode 100644 index 000000000..e08d2297a --- /dev/null +++ b/packages/cli/src/server/web/mod.rs @@ -0,0 +1,490 @@ +use crate::{ + builder, + serve::Serve, + server::{ + output::{print_console_info, PrettierOptions, WebServerInfo}, + setup_file_watcher, setup_file_watcher_hot_reload, + }, + BuildResult, CrateConfig, Result, +}; +use axum::{ + body::{Full, HttpBody}, + extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade}, + http::{ + header::{HeaderName, HeaderValue}, + Method, Response, StatusCode, + }, + response::IntoResponse, + routing::{get, get_service}, + Router, +}; +use axum_server::tls_rustls::RustlsConfig; + +use dioxus_html::HtmlCtx; +use dioxus_rsx::hot_reload::*; +use std::{ + net::UdpSocket, + process::Command, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast::{self, Sender}; +use tower::ServiceBuilder; +use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; +use tower_http::{ + cors::{Any, CorsLayer}, + ServiceBuilderExt, +}; + +#[cfg(feature = "plugin")] +use plugin::PluginManager; + +mod proxy; + +mod hot_reload; +use hot_reload::*; + +struct WsReloadState { + update: broadcast::Sender<()>, +} + +pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> { + // ctrl-c shutdown checker + let _crate_config = config.clone(); + let _ = ctrlc::set_handler(move || { + #[cfg(feature = "plugin")] + let _ = PluginManager::on_serve_shutdown(&_crate_config); + std::process::exit(0); + }); + + let ip = get_ip().unwrap_or(String::from("0.0.0.0")); + + match config.hot_reload { + true => serve_hot_reload(ip, port, config, start_browser).await?, + false => serve_default(ip, port, config, start_browser).await?, + } + + Ok(()) +} + +/// Start the server without hot reload +pub async fn serve_default( + ip: String, + port: u16, + config: CrateConfig, + start_browser: bool, +) -> Result<()> { + let first_build_result = crate::builder::build(&config, false)?; + + log::info!("🚀 Starting development server..."); + + // WS Reload Watching + let (reload_tx, _) = broadcast::channel(100); + + // We got to own watcher so that it exists for the duration of serve + // Otherwise full reload won't work. + let _watcher = setup_file_watcher( + { + let config = config.clone(); + let reload_tx = reload_tx.clone(); + move || build(&config, &reload_tx) + }, + &config, + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ) + .await?; + + let ws_reload_state = Arc::new(WsReloadState { + update: reload_tx.clone(), + }); + + // HTTPS + // Before console info so it can stop if mkcert isn't installed or fails + let rustls_config = get_rustls(&config).await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + Some(crate::server::output::WebServerInfo { + ip: ip.clone(), + port, + }), + ); + + // Router + let router = setup_router(config, ws_reload_state, None).await?; + + // Start server + start_server(port, router, start_browser, rustls_config).await?; + + Ok(()) +} + +/// Start dx serve with hot reload +pub async fn serve_hot_reload( + ip: String, + port: u16, + config: CrateConfig, + start_browser: bool, +) -> Result<()> { + let first_build_result = crate::builder::build(&config, false)?; + + log::info!("🚀 Starting development server..."); + + // Setup hot reload + let (reload_tx, _) = broadcast::channel(100); + let FileMapBuildResult { map, errors } = + FileMap::::create(config.crate_dir.clone()).unwrap(); + + for err in errors { + log::error!("{}", err); + } + + let file_map = Arc::new(Mutex::new(map)); + + let hot_reload_tx = broadcast::channel(100).0; + + // States + let hot_reload_state = Arc::new(HotReloadState { + messages: hot_reload_tx.clone(), + file_map: file_map.clone(), + watcher_config: config.clone(), + }); + + let ws_reload_state = Arc::new(WsReloadState { + update: reload_tx.clone(), + }); + + // Setup file watcher + // We got to own watcher so that it exists for the duration of serve + // Otherwise hot reload won't work. + let _watcher = setup_file_watcher_hot_reload( + &config, + hot_reload_tx, + file_map, + { + let config = config.clone(); + let reload_tx = reload_tx.clone(); + move || build(&config, &reload_tx) + }, + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ) + .await?; + + // HTTPS + // Before console info so it can stop if mkcert isn't installed or fails + let rustls_config = get_rustls(&config).await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ); + + // Router + let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?; + + // Start server + start_server(port, router, start_browser, rustls_config).await?; + + Ok(()) +} + +const DEFAULT_KEY_PATH: &str = "ssl/key.pem"; +const DEFAULT_CERT_PATH: &str = "ssl/cert.pem"; + +/// Returns an enum of rustls config and a bool if mkcert isn't installed +async fn get_rustls(config: &CrateConfig) -> Result> { + let web_config = &config.dioxus_config.web.https; + if web_config.enabled != Some(true) { + return Ok(None); + } + + let (cert_path, key_path) = match web_config.mkcert { + // mkcert, use it + Some(true) => { + // Get paths to store certs, otherwise use ssl/item.pem + let key_path = web_config + .key_path + .clone() + .unwrap_or(DEFAULT_KEY_PATH.to_string()); + + let cert_path = web_config + .cert_path + .clone() + .unwrap_or(DEFAULT_CERT_PATH.to_string()); + + // Create ssl directory if using defaults + if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH { + _ = fs::create_dir("ssl"); + } + + let cmd = Command::new("mkcert") + .args([ + "-install", + "-key-file", + &key_path, + "-cert-file", + &cert_path, + "localhost", + "::1", + "127.0.0.1", + ]) + .spawn(); + + match cmd { + Err(e) => { + match e.kind() { + io::ErrorKind::NotFound => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."), + e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()), + }; + return Err("failed to generate mkcert certificates".into()); + } + Ok(mut cmd) => { + cmd.wait()?; + } + } + + (cert_path, key_path) + } + // not mkcert + Some(false) => { + // get paths to cert & key + if let (Some(key), Some(cert)) = + (web_config.key_path.clone(), web_config.cert_path.clone()) + { + (cert, key) + } else { + // missing cert or key + return Err("https is enabled but cert or key path is missing".into()); + } + } + // other + _ => return Ok(None), + }; + + Ok(Some( + RustlsConfig::from_pem_file(cert_path, key_path).await?, + )) +} + +/// Sets up and returns a router +async fn setup_router( + config: CrateConfig, + ws_reload: Arc, + hot_reload: Option>, +) -> Result { + // Setup cors + let cors = CorsLayer::new() + // allow `GET` and `POST` when accessing the resource + .allow_methods([Method::GET, Method::POST]) + // allow requests from any origin + .allow_origin(Any) + .allow_headers(Any); + + let (coep, coop) = if config.cross_origin_policy { + ( + HeaderValue::from_static("require-corp"), + HeaderValue::from_static("same-origin"), + ) + } else { + ( + HeaderValue::from_static("unsafe-none"), + HeaderValue::from_static("unsafe-none"), + ) + }; + + // Create file service + let file_service_config = config.clone(); + let file_service = ServiceBuilder::new() + .override_response_header( + HeaderName::from_static("cross-origin-embedder-policy"), + coep, + ) + .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop) + .and_then( + move |response: Response| async move { + let response = if file_service_config + .dioxus_config + .web + .watcher + .index_on_404 + .unwrap_or(false) + && response.status() == StatusCode::NOT_FOUND + { + let body = Full::from( + // TODO: Cache/memoize this. + std::fs::read_to_string( + file_service_config + .crate_dir + .join(file_service_config.out_dir) + .join("index.html"), + ) + .ok() + .unwrap(), + ) + .map_err(|err| match err {}) + .boxed(); + Response::builder() + .status(StatusCode::OK) + .body(body) + .unwrap() + } else { + response.map(|body| body.boxed()) + }; + Ok(response) + }, + ) + .service(ServeDir::new(config.crate_dir.join(&config.out_dir))); + + // Setup websocket + let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); + + // Setup proxy + for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() { + router = proxy::add_proxy(router, &proxy_config)?; + } + + // Route file service + router = router.fallback(get_service(file_service).handle_error( + |error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }, + )); + + // Setup routes + router = router + .route("/_dioxus/hot_reload", get(hot_reload_handler)) + .layer(cors) + .layer(Extension(ws_reload)); + + if let Some(hot_reload) = hot_reload { + router = router.layer(Extension(hot_reload)) + } + + Ok(router) +} + +/// Starts dx serve with no hot reload +async fn start_server( + port: u16, + router: Router, + start_browser: bool, + rustls: Option, +) -> Result<()> { + // If plugins, call on_serve_start event + #[cfg(feature = "plugin")] + PluginManager::on_serve_start(&config)?; + + // Parse address + let addr = format!("0.0.0.0:{}", port).parse().unwrap(); + + // Open the browser + if start_browser { + match rustls { + Some(_) => _ = open::that(format!("https://{}", addr)), + None => _ = open::that(format!("http://{}", addr)), + } + } + + // Start the server with or without rustls + match rustls { + Some(rustls) => { + axum_server::bind_rustls(addr, rustls) + .serve(router.into_make_service()) + .await? + } + None => { + axum::Server::bind(&addr) + .serve(router.into_make_service()) + .await? + } + } + + Ok(()) +} + +/// Get the network ip +fn get_ip() -> Option { + let socket = match UdpSocket::bind("0.0.0.0:0") { + Ok(s) => s, + Err(_) => return None, + }; + + match socket.connect("8.8.8.8:80") { + Ok(()) => (), + Err(_) => return None, + }; + + match socket.local_addr() { + Ok(addr) => Some(addr.ip().to_string()), + Err(_) => None, + } +} + +/// Handle websockets +async fn ws_handler( + ws: WebSocketUpgrade, + _: Option>, + Extension(state): Extension>, +) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + let mut rx = state.update.subscribe(); + let reload_watcher = tokio::spawn(async move { + loop { + rx.recv().await.unwrap(); + // ignore the error + if socket + .send(Message::Text(String::from("reload"))) + .await + .is_err() + { + break; + } + + // flush the errors after recompling + rx = rx.resubscribe(); + } + }); + + reload_watcher.await.unwrap(); + }) +} + +fn build(config: &CrateConfig, reload_tx: &Sender<()>) -> Result { + let result = builder::build(config, true)?; + // change the websocket reload state to true; + // the page will auto-reload. + if config + .dioxus_config + .web + .watcher + .reload_html + .unwrap_or(false) + { + let _ = Serve::regen_dev_page(config); + } + let _ = reload_tx.send(()); + Ok(result) +} diff --git a/packages/cli/src/server/proxy.rs b/packages/cli/src/server/web/proxy.rs similarity index 100% rename from packages/cli/src/server/proxy.rs rename to packages/cli/src/server/web/proxy.rs diff --git a/packages/core-macro/Cargo.toml b/packages/core-macro/Cargo.toml index 92408725a..3044cfd68 100644 --- a/packages/core-macro/Cargo.toml +++ b/packages/core-macro/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" authors = ["Jonathan Kelley"] edition = "2021" description = "Core macro for Dioxus Virtual DOM" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] @@ -15,7 +15,7 @@ proc-macro = true [dependencies] proc-macro2 = { version = "1.0" } quote = "1.0" -syn = { version = "1.0", features = ["full", "extra-traits"] } +syn = { version = "2.0", features = ["full", "extra-traits"] } dioxus-rsx = { workspace = true } # testing diff --git a/packages/core-macro/src/inlineprops.rs b/packages/core-macro/src/inlineprops.rs index 814724b56..ccc00f4f9 100644 --- a/packages/core-macro/src/inlineprops.rs +++ b/packages/core-macro/src/inlineprops.rs @@ -135,7 +135,7 @@ impl ToTokens for InlinePropsBody { quote! { <#struct_generics> }, ) } else { - let lifetime: LifetimeDef = parse_quote! { 'a }; + let lifetime: LifetimeParam = parse_quote! { 'a }; let mut fn_generics = generics.clone(); fn_generics diff --git a/packages/core-macro/src/props/mod.rs b/packages/core-macro/src/props/mod.rs index 5f3de9dc9..6a0d24a95 100644 --- a/packages/core-macro/src/props/mod.rs +++ b/packages/core-macro/src/props/mod.rs @@ -166,8 +166,9 @@ mod field_info { use crate::props::type_from_inside_option; use proc_macro2::TokenStream; use quote::quote; - use syn::parse::Error; use syn::spanned::Spanned; + use syn::Expr; + use syn::{parse::Error, punctuated::Punctuated}; use super::util::{ expr_to_single_string, ident_to_type, path_to_single_string, strip_raw_ident_prefix, @@ -260,31 +261,32 @@ mod field_info { pub fn with(mut self, attrs: &[syn::Attribute]) -> Result { let mut skip_tokens = None; for attr in attrs { - if path_to_single_string(&attr.path).as_deref() != Some("props") { + if path_to_single_string(attr.path()).as_deref() != Some("props") { continue; } - if attr.tokens.is_empty() { - continue; - } - - let as_expr: syn::Expr = syn::parse2(attr.tokens.clone())?; - match as_expr { - syn::Expr::Paren(body) => { - self.apply_meta(*body.expr)?; - } - syn::Expr::Tuple(body) => { - for expr in body.elems.into_iter() { - self.apply_meta(expr)?; + match &attr.meta { + syn::Meta::List(list) => { + if list.tokens.is_empty() { + continue; } } _ => { - return Err(Error::new_spanned(attr.tokens.clone(), "Expected (<...>)")); + continue; } } + + let as_expr = attr.parse_args_with( + Punctuated::::parse_separated_nonempty, + )?; + + for expr in as_expr.into_iter() { + self.apply_meta(expr)?; + } + // Stash its span for later (we don’t yet know if it’ll be an error) if self.skip && skip_tokens.is_none() { - skip_tokens = Some(attr.tokens.clone()); + skip_tokens = Some(attr.meta.clone()); } } @@ -461,6 +463,8 @@ mod struct_info { use proc_macro2::TokenStream; use quote::quote; use syn::parse::Error; + use syn::punctuated::Punctuated; + use syn::Expr; use super::field_info::{FieldBuilderAttr, FieldInfo}; use super::util::{ @@ -1082,28 +1086,28 @@ Finally, call `.build()` to create the instance of `{name}`. pub fn new(attrs: &[syn::Attribute]) -> Result { let mut result = TypeBuilderAttr::default(); for attr in attrs { - if path_to_single_string(&attr.path).as_deref() != Some("builder") { + if path_to_single_string(attr.path()).as_deref() != Some("builder") { continue; } - if attr.tokens.is_empty() { - continue; - } - let as_expr: syn::Expr = syn::parse2(attr.tokens.clone())?; - - match as_expr { - syn::Expr::Paren(body) => { - result.apply_meta(*body.expr)?; - } - syn::Expr::Tuple(body) => { - for expr in body.elems.into_iter() { - result.apply_meta(expr)?; + match &attr.meta { + syn::Meta::List(list) => { + if list.tokens.is_empty() { + continue; } } _ => { - return Err(Error::new_spanned(attr.tokens.clone(), "Expected (<...>)")); + continue; } } + + let as_expr = attr.parse_args_with( + Punctuated::::parse_separated_nonempty, + )?; + + for expr in as_expr.into_iter() { + result.apply_meta(expr)?; + } } Ok(result) diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 0b668f43d..beb65c9ef 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.3" authors = ["Jonathan Kelley"] edition = "2018" description = "Core functionality for Dioxus - a concurrent renderer-agnostic Virtual DOM for interactive user experiences" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] @@ -33,13 +33,12 @@ log = { workspace = true } # Serialize the Edits for use in Webview/Liveview instances serde = { version = "1", features = ["derive"], optional = true } -bumpslab = { version = "0.2.0" } - [dev-dependencies] tokio = { workspace = true, features = ["full"] } dioxus = { workspace = true } pretty_assertions = "1.3.0" rand = "0.8.5" +dioxus-ssr = { workspace = true } [features] default = [] diff --git a/packages/core/src/any_props.rs b/packages/core/src/any_props.rs index 7014a2771..00ccda597 100644 --- a/packages/core/src/any_props.rs +++ b/packages/core/src/any_props.rs @@ -1,11 +1,10 @@ -use std::{marker::PhantomData, panic::AssertUnwindSafe}; - use crate::{ innerlude::Scoped, - nodes::{ComponentReturn, RenderReturn}, + nodes::RenderReturn, scopes::{Scope, ScopeState}, Element, }; +use std::panic::AssertUnwindSafe; /// A trait that essentially allows VComponentProps to be used generically /// @@ -18,19 +17,15 @@ pub(crate) unsafe trait AnyProps<'a> { unsafe fn memoize(&self, other: &dyn AnyProps) -> bool; } -pub(crate) struct VProps<'a, P, A, F: ComponentReturn<'a, A> = Element<'a>> { - pub render_fn: fn(Scope<'a, P>) -> F, +pub(crate) struct VProps<'a, P> { + pub render_fn: fn(Scope<'a, P>) -> Element<'a>, pub memo: unsafe fn(&P, &P) -> bool, pub props: P, - _marker: PhantomData, } -impl<'a, P, A, F> VProps<'a, P, A, F> -where - F: ComponentReturn<'a, A>, -{ +impl<'a, P> VProps<'a, P> { pub(crate) fn new( - render_fn: fn(Scope<'a, P>) -> F, + render_fn: fn(Scope<'a, P>) -> Element<'a>, memo: unsafe fn(&P, &P) -> bool, props: P, ) -> Self { @@ -38,15 +33,11 @@ where render_fn, memo, props, - _marker: PhantomData, } } } -unsafe impl<'a, P, A, F> AnyProps<'a> for VProps<'a, P, A, F> -where - F: ComponentReturn<'a, A>, -{ +unsafe impl<'a, P> AnyProps<'a> for VProps<'a, P> { fn props_ptr(&self) -> *const () { &self.props as *const _ as *const () } @@ -69,12 +60,12 @@ where scope: cx, }); - (self.render_fn)(scope).into_return(cx) + (self.render_fn)(scope) })); match res { - Ok(e) => e, - Err(_) => RenderReturn::default(), + Ok(Some(e)) => RenderReturn::Ready(e), + _ => RenderReturn::default(), } } } diff --git a/packages/core/src/arena.rs b/packages/core/src/arena.rs index 04ffb50d1..b6254136d 100644 --- a/packages/core/src/arena.rs +++ b/packages/core/src/arena.rs @@ -91,21 +91,21 @@ impl VirtualDom { // Note: This will not remove any ids from the arena pub(crate) fn drop_scope(&mut self, id: ScopeId, recursive: bool) { self.dirty_scopes.remove(&DirtyScope { - height: self.scopes[id].height, + height: self.scopes[id.0].height, id, }); self.ensure_drop_safety(id); if recursive { - if let Some(root) = self.scopes[id].try_root_node() { + if let Some(root) = self.scopes[id.0].try_root_node() { if let RenderReturn::Ready(node) = unsafe { root.extend_lifetime_ref() } { self.drop_scope_inner(node) } } } - let scope = &mut self.scopes[id]; + let scope = &mut self.scopes[id.0]; // Drop all the hooks once the children are dropped // this means we'll drop hooks bottom-up @@ -116,7 +116,7 @@ impl VirtualDom { scope.tasks.remove(task_id); } - self.scopes.remove(id); + self.scopes.remove(id.0); } fn drop_scope_inner(&mut self, node: &VNode) { @@ -137,7 +137,7 @@ impl VirtualDom { /// Descend through the tree, removing any borrowed props and listeners pub(crate) fn ensure_drop_safety(&self, scope_id: ScopeId) { - let scope = &self.scopes[scope_id]; + let scope = &self.scopes[scope_id.0]; // make sure we drop all borrowed props manually to guarantee that their drop implementation is called before we // run the hooks (which hold an &mut Reference) diff --git a/packages/core/src/create.rs b/packages/core/src/create.rs index 80561e937..6159c882b 100644 --- a/packages/core/src/create.rs +++ b/packages/core/src/create.rs @@ -5,10 +5,9 @@ use crate::mutations::Mutation::*; use crate::nodes::VNode; use crate::nodes::{DynamicNode, TemplateNode}; use crate::virtual_dom::VirtualDom; -use crate::{AttributeValue, ElementId, RenderReturn, ScopeId, SuspenseContext, Template}; +use crate::{AttributeValue, ElementId, RenderReturn, ScopeId, Template}; use std::cell::Cell; use std::iter::Peekable; -use std::rc::Rc; use TemplateNode::*; #[cfg(debug_assertions)] @@ -89,8 +88,7 @@ impl<'b> VirtualDom { } // Intialize the root nodes slice - node.root_ids - .intialize(vec![ElementId(0); node.template.get().roots.len()].into_boxed_slice()); + *node.root_ids.borrow_mut() = vec![ElementId(0); node.template.get().roots.len()]; // The best renderers will have templates prehydrated and registered // Just in case, let's create the template using instructions anyways @@ -329,7 +327,7 @@ impl<'b> VirtualDom { fn load_template_root(&mut self, template: &VNode, root_idx: usize) -> ElementId { // Get an ID for this root since it's a real root let this_id = self.next_root(template, root_idx); - template.root_ids.set(root_idx, this_id); + template.root_ids.borrow_mut()[root_idx] = this_id; self.mutations.push(LoadTemplate { name: template.template.get().name, @@ -445,7 +443,7 @@ impl<'b> VirtualDom { match node { Text(text) => self.create_dynamic_text(template, text, idx), Placeholder(place) => self.create_placeholder(place, template, idx), - Component(component) => self.create_component_node(template, component, idx), + Component(component) => self.create_component_node(template, component), Fragment(frag) => frag.iter().map(|child| self.create(child)).sum(), } } @@ -502,7 +500,6 @@ impl<'b> VirtualDom { &mut self, template: &'b VNode<'b>, component: &'b VComponent<'b>, - idx: usize, ) -> usize { use RenderReturn::*; @@ -512,9 +509,9 @@ impl<'b> VirtualDom { component.scope.set(Some(scope)); match unsafe { self.run_scope(scope).extend_lifetime_ref() } { - Ready(t) => self.mount_component(scope, template, t, idx), + // Create the component's root element + Ready(t) => self.create_scope(scope, t), Aborted(t) => self.mount_aborted(template, t), - Pending(_) => self.mount_async(template, idx, scope), } } @@ -530,60 +527,6 @@ impl<'b> VirtualDom { .unwrap_or_else(|| component.scope.get().unwrap()) } - fn mount_component( - &mut self, - scope: ScopeId, - parent: &'b VNode<'b>, - new: &'b VNode<'b>, - idx: usize, - ) -> usize { - // Keep track of how many mutations are in the buffer in case we need to split them out if a suspense boundary - // is encountered - let mutations_to_this_point = self.mutations.edits.len(); - - // Create the component's root element - let created = self.create_scope(scope, new); - - // If there are no suspense leaves below us, then just don't bother checking anything suspense related - if self.collected_leaves.is_empty() { - return created; - } - - // If running the scope has collected some leaves and *this* component is a boundary, then handle the suspense - let boundary = match self.scopes[scope].has_context::>() { - Some(boundary) => boundary, - _ => return created, - }; - - // Since this is a boundary, use its placeholder within the template as the placeholder for the suspense tree - let new_id = self.next_element(new, parent.template.get().node_paths[idx]); - - // Now connect everything to the boundary - self.scopes[scope].placeholder.set(Some(new_id)); - - // This involves breaking off the mutations to this point, and then creating a new placeholder for the boundary - // Note that we break off dynamic mutations only - since static mutations aren't rendered immediately - let split_off = unsafe { - std::mem::transmute::, Vec>( - self.mutations.edits.split_off(mutations_to_this_point), - ) - }; - boundary.mutations.borrow_mut().edits.extend(split_off); - boundary.created_on_stack.set(created); - boundary - .waiting_on - .borrow_mut() - .extend(self.collected_leaves.drain(..)); - - // Now assign the placeholder in the DOM - self.mutations.push(AssignId { - id: new_id, - path: &parent.template.get().node_paths[idx][1..], - }); - - 0 - } - fn mount_aborted(&mut self, parent: &'b VNode<'b>, placeholder: &VPlaceholder) -> usize { let id = self.next_element(parent, &[]); self.mutations.push(Mutation::CreatePlaceholder { id }); @@ -591,24 +534,6 @@ impl<'b> VirtualDom { 1 } - /// Take the rendered nodes from a component and handle them if they were async - /// - /// IE simply assign an ID to the placeholder - fn mount_async(&mut self, template: &VNode, idx: usize, scope: ScopeId) -> usize { - let new_id = self.next_element(template, template.template.get().node_paths[idx]); - - // Set the placeholder of the scope - self.scopes[scope].placeholder.set(Some(new_id)); - - // Since the placeholder is already in the DOM, we don't create any new nodes - self.mutations.push(AssignId { - id: new_id, - path: &template.template.get().node_paths[idx][1..], - }); - - 0 - } - fn set_slot( &mut self, template: &'b VNode<'b>, diff --git a/packages/core/src/diff.rs b/packages/core/src/diff.rs index a8b913440..7865224aa 100644 --- a/packages/core/src/diff.rs +++ b/packages/core/src/diff.rs @@ -15,7 +15,7 @@ use DynamicNode::*; impl<'b> VirtualDom { pub(super) fn diff_scope(&mut self, scope: ScopeId) { - let scope_state = &mut self.scopes[scope]; + let scope_state = &mut self.scopes[scope.0]; self.scope_stack.push(scope); unsafe { @@ -30,7 +30,7 @@ impl<'b> VirtualDom { .try_load_node() .expect("Call rebuild before diffing"); - use RenderReturn::{Aborted, Pending, Ready}; + use RenderReturn::{Aborted, Ready}; match (old, new) { // Normal pathway @@ -42,29 +42,14 @@ impl<'b> VirtualDom { // Just move over the placeholder (Aborted(l), Aborted(r)) => r.id.set(l.id.get()), - // Becomes async, do nothing while we wait - (Ready(_nodes), Pending(_fut)) => self.diff_ok_to_async(_nodes, scope), - // Placeholder becomes something // We should also clear the error now (Aborted(l), Ready(r)) => self.replace_placeholder(l, [r]), - - (Aborted(_), Pending(_)) => todo!("async should not resolve here"), - (Pending(_), Ready(_)) => todo!("async should not resolve here"), - (Pending(_), Aborted(_)) => todo!("async should not resolve here"), - (Pending(_), Pending(_)) => { - // All suspense should resolve before we diff it again - panic!("Should not roll from suspense to suspense."); - } }; } self.scope_stack.pop(); } - fn diff_ok_to_async(&mut self, _new: &'b VNode<'b>, _scope: ScopeId) { - // - } - fn diff_ok_to_err(&mut self, l: &'b VNode<'b>, p: &'b VPlaceholder) { let id = self.next_null(); p.id.set(Some(id)); @@ -139,18 +124,19 @@ impl<'b> VirtualDom { .dynamic_nodes .iter() .zip(right_template.dynamic_nodes.iter()) - .enumerate() - .for_each(|(idx, (left_node, right_node))| { - self.diff_dynamic_node(left_node, right_node, right_template, idx); + .for_each(|(left_node, right_node)| { + self.diff_dynamic_node(left_node, right_node, right_template); }); // Make sure the roots get transferred over while we're here - right_template.root_ids.transfer(&left_template.root_ids); + *right_template.root_ids.borrow_mut() = left_template.root_ids.borrow().clone(); + + let root_ids = right_template.root_ids.borrow(); // Update the node refs - for i in 0..right_template.root_ids.len() { - if let Some(root_id) = right_template.root_ids.get(i) { - self.update_template(root_id, right_template); + for i in 0..root_ids.len() { + if let Some(root_id) = root_ids.get(i) { + self.update_template(*root_id, right_template); } } } @@ -160,13 +146,12 @@ impl<'b> VirtualDom { left_node: &'b DynamicNode<'b>, right_node: &'b DynamicNode<'b>, node: &'b VNode<'b>, - idx: usize, ) { match (left_node, right_node) { (Text(left), Text(right)) => self.diff_vtext(left, right, node), (Fragment(left), Fragment(right)) => self.diff_non_empty_fragment(left, right), (Placeholder(left), Placeholder(right)) => right.id.set(left.id.get()), - (Component(left), Component(right)) => self.diff_vcomponent(left, right, node, idx), + (Component(left), Component(right)) => self.diff_vcomponent(left, right, node), (Placeholder(left), Fragment(right)) => self.replace_placeholder(left, *right), (Fragment(left), Placeholder(right)) => self.node_to_placeholder(left, right), _ => todo!("This is an usual custom case for dynamic nodes. We don't know how to handle it yet."), @@ -190,7 +175,6 @@ impl<'b> VirtualDom { left: &'b VComponent<'b>, right: &'b VComponent<'b>, right_template: &'b VNode<'b>, - idx: usize, ) { if std::ptr::eq(left, right) { return; @@ -198,7 +182,7 @@ impl<'b> VirtualDom { // Replace components that have different render fns if left.render_fn != right.render_fn { - return self.replace_vcomponent(right_template, right, idx, left); + return self.replace_vcomponent(right_template, right, left); } // Make sure the new vcomponent has the right scopeid associated to it @@ -207,7 +191,7 @@ impl<'b> VirtualDom { right.scope.set(Some(scope_id)); // copy out the box for both - let old = self.scopes[scope_id].props.as_ref(); + let old = self.scopes[scope_id.0].props.as_ref(); let new: Box = right.props.take().unwrap(); let new: Box = unsafe { std::mem::transmute(new) }; @@ -219,14 +203,14 @@ impl<'b> VirtualDom { } // First, move over the props from the old to the new, dropping old props in the process - self.scopes[scope_id].props = Some(new); + self.scopes[scope_id.0].props = Some(new); // Now run the component and diff it self.run_scope(scope_id); self.diff_scope(scope_id); self.dirty_scopes.remove(&DirtyScope { - height: self.scopes[scope_id].height, + height: self.scopes[scope_id.0].height, id: scope_id, }); } @@ -235,10 +219,9 @@ impl<'b> VirtualDom { &mut self, right_template: &'b VNode<'b>, right: &'b VComponent<'b>, - idx: usize, left: &'b VComponent<'b>, ) { - let m = self.create_component_node(right_template, right, idx); + let m = self.create_component_node(right_template, right); let pre_edits = self.mutations.edits.len(); @@ -297,8 +280,7 @@ impl<'b> VirtualDom { None => self.replace(left, [right]), Some(components) => components .into_iter() - .enumerate() - .for_each(|(idx, (l, r))| self.diff_vcomponent(l, r, right, idx)), + .for_each(|(l, r)| self.diff_vcomponent(l, r, right)), } } @@ -706,7 +688,7 @@ impl<'b> VirtualDom { Some(node) => node, None => { self.mutations.push(Mutation::PushRoot { - id: node.root_ids.get(idx).unwrap(), + id: node.root_ids.borrow()[idx], }); return 1; } @@ -732,10 +714,9 @@ impl<'b> VirtualDom { Component(comp) => { let scope = comp.scope.get().unwrap(); - match unsafe { self.scopes[scope].root_node().extend_lifetime_ref() } { + match unsafe { self.scopes[scope.0].root_node().extend_lifetime_ref() } { RenderReturn::Ready(node) => self.push_all_real_nodes(node), RenderReturn::Aborted(_node) => todo!(), - _ => todo!(), } } } @@ -842,7 +823,7 @@ impl<'b> VirtualDom { if let Some(dy) = node.dynamic_root(idx) { self.remove_dynamic_node(dy, gen_muts); } else { - let id = node.root_ids.get(idx).unwrap(); + let id = node.root_ids.borrow()[idx]; if gen_muts { self.mutations.push(Mutation::Remove { id }); } @@ -934,14 +915,13 @@ impl<'b> VirtualDom { .expect("VComponents to always have a scope"); // Remove the component from the dom - match unsafe { self.scopes[scope].root_node().extend_lifetime_ref() } { + match unsafe { self.scopes[scope.0].root_node().extend_lifetime_ref() } { RenderReturn::Ready(t) => self.remove_node(t, gen_muts), RenderReturn::Aborted(placeholder) => self.remove_placeholder(placeholder, gen_muts), - _ => todo!(), }; // Restore the props back to the vcomponent in case it gets rendered again - let props = self.scopes[scope].props.take(); + let props = self.scopes[scope.0].props.take(); *comp.props.borrow_mut() = unsafe { std::mem::transmute(props) }; // Now drop all the resouces @@ -950,13 +930,13 @@ impl<'b> VirtualDom { fn find_first_element(&self, node: &'b VNode<'b>) -> ElementId { match node.dynamic_root(0) { - None => node.root_ids.get(0).unwrap(), + None => node.root_ids.borrow()[0], Some(Text(t)) => t.id.get().unwrap(), Some(Fragment(t)) => self.find_first_element(&t[0]), Some(Placeholder(t)) => t.id.get().unwrap(), Some(Component(comp)) => { let scope = comp.scope.get().unwrap(); - match unsafe { self.scopes[scope].root_node().extend_lifetime_ref() } { + match unsafe { self.scopes[scope.0].root_node().extend_lifetime_ref() } { RenderReturn::Ready(t) => self.find_first_element(t), _ => todo!("cannot handle nonstandard nodes"), } @@ -966,13 +946,13 @@ impl<'b> VirtualDom { fn find_last_element(&self, node: &'b VNode<'b>) -> ElementId { match node.dynamic_root(node.template.get().roots.len() - 1) { - None => node.root_ids.last().unwrap(), + None => *node.root_ids.borrow().last().unwrap(), Some(Text(t)) => t.id.get().unwrap(), Some(Fragment(t)) => self.find_last_element(t.last().unwrap()), Some(Placeholder(t)) => t.id.get().unwrap(), Some(Component(comp)) => { let scope = comp.scope.get().unwrap(); - match unsafe { self.scopes[scope].root_node().extend_lifetime_ref() } { + match unsafe { self.scopes[scope.0].root_node().extend_lifetime_ref() } { RenderReturn::Ready(t) => self.find_last_element(t), _ => todo!("cannot handle nonstandard nodes"), } diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index 304197dab..565ae12be 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -72,9 +72,8 @@ pub(crate) mod innerlude { pub use crate::innerlude::{ fc_to_builder, AnyValue, Attribute, AttributeValue, BorrowedAttributeValue, CapturedError, Component, DynamicNode, Element, ElementId, Event, Fragment, IntoDynNode, LazyNodes, Mutation, - Mutations, Properties, RenderReturn, Scope, ScopeId, ScopeState, Scoped, SuspenseContext, - TaskId, Template, TemplateAttribute, TemplateNode, VComponent, VNode, VPlaceholder, VText, - VirtualDom, + Mutations, Properties, RenderReturn, Scope, ScopeId, ScopeState, Scoped, TaskId, Template, + TemplateAttribute, TemplateNode, VComponent, VNode, VPlaceholder, VText, VirtualDom, }; /// The purpose of this module is to alleviate imports of many common types diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index dabb479cb..197550a3b 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -5,9 +5,8 @@ use bumpalo::boxed::Box as BumpBox; use bumpalo::Bump; use std::{ any::{Any, TypeId}, - cell::{Cell, RefCell, UnsafeCell}, + cell::{Cell, RefCell}, fmt::{Arguments, Debug}, - future::Future, }; pub type TemplateId = &'static str; @@ -28,9 +27,6 @@ pub enum RenderReturn<'a> { /// In its place we've produced a placeholder to locate its spot in the dom when /// it recovers. Aborted(VPlaceholder), - - /// An ongoing future that will resolve to a [`Element`] - Pending(BumpBox<'a, dyn Future> + 'a>), } impl<'a> Default for RenderReturn<'a> { @@ -58,7 +54,7 @@ pub struct VNode<'a> { /// The IDs for the roots of this template - to be used when moving the template around and removing it from /// the actual Dom - pub root_ids: BoxedCellSlice, + pub root_ids: RefCell>, /// The dynamic parts of the template pub dynamic_nodes: &'a [DynamicNode<'a>], @@ -67,112 +63,13 @@ pub struct VNode<'a> { pub dynamic_attrs: &'a [Attribute<'a>], } -// Saftey: There is no way to get references to the internal data of this struct so no refrences will be invalidated by mutating the data with a immutable reference (The same principle behind Cell) -#[derive(Debug, Default)] -pub struct BoxedCellSlice(UnsafeCell>>); - -impl Clone for BoxedCellSlice { - fn clone(&self) -> Self { - Self(UnsafeCell::new(unsafe { (*self.0.get()).clone() })) - } -} - -impl BoxedCellSlice { - pub fn last(&self) -> Option { - unsafe { - (*self.0.get()) - .as_ref() - .and_then(|inner| inner.as_ref().last().copied()) - } - } - - pub fn get(&self, idx: usize) -> Option { - unsafe { - (*self.0.get()) - .as_ref() - .and_then(|inner| inner.as_ref().get(idx).copied()) - } - } - - pub unsafe fn get_unchecked(&self, idx: usize) -> Option { - (*self.0.get()) - .as_ref() - .and_then(|inner| inner.as_ref().get(idx).copied()) - } - - pub fn set(&self, idx: usize, new: ElementId) { - unsafe { - if let Some(inner) = &mut *self.0.get() { - inner[idx] = new; - } - } - } - - pub fn intialize(&self, contents: Box<[ElementId]>) { - unsafe { - *self.0.get() = Some(contents); - } - } - - pub fn transfer(&self, other: &Self) { - unsafe { - *self.0.get() = (*other.0.get()).clone(); - } - } - - pub fn take_from(&self, other: &Self) { - unsafe { - *self.0.get() = (*other.0.get()).take(); - } - } - - pub fn len(&self) -> usize { - unsafe { - (*self.0.get()) - .as_ref() - .map(|inner| inner.len()) - .unwrap_or(0) - } - } -} - -impl<'a> IntoIterator for &'a BoxedCellSlice { - type Item = ElementId; - - type IntoIter = BoxedCellSliceIter<'a>; - - fn into_iter(self) -> Self::IntoIter { - BoxedCellSliceIter { - index: 0, - borrow: self, - } - } -} - -pub struct BoxedCellSliceIter<'a> { - index: usize, - borrow: &'a BoxedCellSlice, -} - -impl Iterator for BoxedCellSliceIter<'_> { - type Item = ElementId; - - fn next(&mut self) -> Option { - let result = self.borrow.get(self.index); - if result.is_some() { - self.index += 1; - } - result - } -} - impl<'a> VNode<'a> { /// Create a template with no nodes that will be skipped over during diffing pub fn empty() -> Element<'a> { Some(VNode { key: None, parent: None, - root_ids: BoxedCellSlice::default(), + root_ids: Default::default(), dynamic_nodes: &[], dynamic_attrs: &[], template: Cell::new(Template { @@ -688,32 +585,6 @@ impl AnyValue for T { } } -#[doc(hidden)] -pub trait ComponentReturn<'a, A = ()> { - fn into_return(self, cx: &'a ScopeState) -> RenderReturn<'a>; -} - -impl<'a> ComponentReturn<'a> for Element<'a> { - fn into_return(self, _cx: &ScopeState) -> RenderReturn<'a> { - match self { - Some(node) => RenderReturn::Ready(node), - None => RenderReturn::default(), - } - } -} - -#[doc(hidden)] -pub struct AsyncMarker; -impl<'a, F> ComponentReturn<'a, AsyncMarker> for F -where - F: Future> + 'a, -{ - fn into_return(self, cx: &'a ScopeState) -> RenderReturn<'a> { - let f: &mut dyn Future> = cx.bump().alloc(self); - RenderReturn::Pending(unsafe { BumpBox::from_raw(f) }) - } -} - impl<'a> RenderReturn<'a> { pub(crate) unsafe fn extend_lifetime_ref<'c>(&self) -> &'c RenderReturn<'c> { unsafe { std::mem::transmute(self) } diff --git a/packages/core/src/properties.rs b/packages/core/src/properties.rs index f8be8a0dd..834b0cabd 100644 --- a/packages/core/src/properties.rs +++ b/packages/core/src/properties.rs @@ -70,6 +70,6 @@ impl EmptyBuilder { /// This utility function launches the builder method so rsx! and html! macros can use the typed-builder pattern /// to initialize a component's props. -pub fn fc_to_builder<'a, A, T: Properties + 'a>(_: fn(Scope<'a, T>) -> A) -> T::Builder { +pub fn fc_to_builder<'a, T: Properties + 'a>(_: fn(Scope<'a, T>) -> Element<'a>) -> T::Builder { T::builder() } diff --git a/packages/core/src/scheduler/mod.rs b/packages/core/src/scheduler/mod.rs index 8fb476fbe..a13958363 100644 --- a/packages/core/src/scheduler/mod.rs +++ b/packages/core/src/scheduler/mod.rs @@ -1,11 +1,9 @@ use crate::ScopeId; use slab::Slab; -mod suspense; mod task; mod wait; -pub use suspense::*; pub use task::*; /// The type of message that can be sent to the scheduler. @@ -18,9 +16,6 @@ pub(crate) enum SchedulerMsg { /// A task has woken and needs to be progressed TaskNotified(TaskId), - - /// A task has woken and needs to be progressed - SuspenseNotified(SuspenseId), } use std::{cell::RefCell, rc::Rc}; @@ -30,9 +25,6 @@ pub(crate) struct Scheduler { /// Tasks created with cx.spawn pub tasks: RefCell>, - - /// Async components - pub leaves: RefCell>, } impl Scheduler { @@ -40,7 +32,6 @@ impl Scheduler { Rc::new(Scheduler { sender, tasks: RefCell::new(Slab::new()), - leaves: RefCell::new(Slab::new()), }) } } diff --git a/packages/core/src/scheduler/suspense.rs b/packages/core/src/scheduler/suspense.rs index 39e404198..77c2981ae 100644 --- a/packages/core/src/scheduler/suspense.rs +++ b/packages/core/src/scheduler/suspense.rs @@ -11,17 +11,10 @@ use std::{ collections::HashSet, }; -/// An ID representing an ongoing suspended component -#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] -pub(crate) struct SuspenseId(pub usize); - /// A boundary in the VirtualDom that captures all suspended components below it pub struct SuspenseContext { pub(crate) id: ScopeId, - pub(crate) waiting_on: RefCell>, - pub(crate) mutations: RefCell>, - pub(crate) placeholder: Cell>, - pub(crate) created_on_stack: Cell, + pub(crate) waiting_on: RefCell>, } impl SuspenseContext { @@ -30,29 +23,10 @@ impl SuspenseContext { Self { id, waiting_on: Default::default(), - mutations: RefCell::new(Mutations::default()), - placeholder: Cell::new(None), - created_on_stack: Cell::new(0), } } -} -pub(crate) struct SuspenseLeaf { - pub(crate) scope_id: ScopeId, - pub(crate) notified: Cell, - pub(crate) task: *mut dyn Future>, - pub(crate) waker: Waker, -} - -pub struct SuspenseHandle { - pub(crate) id: SuspenseId, - pub(crate) tx: futures_channel::mpsc::UnboundedSender, -} - -impl ArcWake for SuspenseHandle { - fn wake_by_ref(arc_self: &Arc) { - _ = arc_self - .tx - .unbounded_send(SchedulerMsg::SuspenseNotified(arc_self.id)); + pub fn mark_suspend(&self, id: ScopeId) { + self.waiting_on.borrow_mut().insert(id); } } diff --git a/packages/core/src/scheduler/wait.rs b/packages/core/src/scheduler/wait.rs index 77bea3a18..b475c6751 100644 --- a/packages/core/src/scheduler/wait.rs +++ b/packages/core/src/scheduler/wait.rs @@ -1,16 +1,5 @@ -use futures_util::FutureExt; -use std::{ - rc::Rc, - task::{Context, Poll}, -}; - -use crate::{ - innerlude::{Mutation, Mutations, SuspenseContext}, - nodes::RenderReturn, - ScopeId, TaskId, VNode, VirtualDom, -}; - -use super::SuspenseId; +use crate::{TaskId, VirtualDom}; +use std::task::Context; impl VirtualDom { /// Handle notifications by tasks inside the scheduler @@ -31,81 +20,11 @@ impl VirtualDom { // If the task completes... if task.task.borrow_mut().as_mut().poll(&mut cx).is_ready() { // Remove it from the scope so we dont try to double drop it when the scope dropes - let scope = &self.scopes[task.scope]; + let scope = &self.scopes[task.scope.0]; scope.spawned_tasks.borrow_mut().remove(&id); // Remove it from the scheduler tasks.try_remove(id.0); } } - - pub(crate) fn acquire_suspense_boundary(&self, id: ScopeId) -> Rc { - self.scopes[id] - .consume_context::>() - .unwrap() - } - - pub(crate) fn handle_suspense_wakeup(&mut self, id: SuspenseId) { - let leaves = self.scheduler.leaves.borrow_mut(); - let leaf = leaves.get(id.0).unwrap(); - - let scope_id = leaf.scope_id; - - // todo: cache the waker - let mut cx = Context::from_waker(&leaf.waker); - - // Safety: the future is always pinned to the bump arena - let mut pinned = unsafe { std::pin::Pin::new_unchecked(&mut *leaf.task) }; - let as_pinned_mut = &mut pinned; - - // the component finished rendering and gave us nodes - // we should attach them to that component and then render its children - // continue rendering the tree until we hit yet another suspended component - if let Poll::Ready(new_nodes) = as_pinned_mut.poll_unpin(&mut cx) { - let fiber = self.acquire_suspense_boundary(leaf.scope_id); - - let scope = &self.scopes[scope_id]; - let arena = scope.current_frame(); - - let ret = arena.bump().alloc(match new_nodes { - Some(new) => RenderReturn::Ready(new), - None => RenderReturn::default(), - }); - - arena.node.set(ret); - - fiber.waiting_on.borrow_mut().remove(&id); - - if let RenderReturn::Ready(template) = ret { - let mutations_ref = &mut fiber.mutations.borrow_mut(); - let mutations = &mut **mutations_ref; - let template: &VNode = unsafe { std::mem::transmute(template) }; - let mutations: &mut Mutations = unsafe { std::mem::transmute(mutations) }; - - std::mem::swap(&mut self.mutations, mutations); - - let place_holder_id = scope.placeholder.get().unwrap(); - self.scope_stack.push(scope_id); - - drop(leaves); - - let created = self.create(template); - self.scope_stack.pop(); - mutations.push(Mutation::ReplaceWith { - id: place_holder_id, - m: created, - }); - - for leaf in self.collected_leaves.drain(..) { - fiber.waiting_on.borrow_mut().insert(leaf); - } - - std::mem::swap(&mut self.mutations, mutations); - - if fiber.waiting_on.borrow().is_empty() { - self.finished_fibers.push(fiber.id); - } - } - } - } } diff --git a/packages/core/src/scope_arena.rs b/packages/core/src/scope_arena.rs index a8a7319aa..b298a8996 100644 --- a/packages/core/src/scope_arena.rs +++ b/packages/core/src/scope_arena.rs @@ -2,18 +2,10 @@ use crate::{ any_props::AnyProps, bump_frame::BumpFrame, innerlude::DirtyScope, - innerlude::{SuspenseHandle, SuspenseId, SuspenseLeaf}, nodes::RenderReturn, scopes::{ScopeId, ScopeState}, virtual_dom::VirtualDom, }; -use futures_util::FutureExt; -use std::{ - mem, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; impl VirtualDom { pub(super) fn new_scope( @@ -24,32 +16,32 @@ impl VirtualDom { let parent = self.acquire_current_scope_raw(); let entry = self.scopes.vacant_entry(); let height = unsafe { parent.map(|f| (*f).height + 1).unwrap_or(0) }; - let id = entry.key(); + let id = ScopeId(entry.key()); - entry.insert(ScopeState { + entry.insert(Box::new(ScopeState { parent, id, height, name, props: Some(props), tasks: self.scheduler.clone(), - placeholder: Default::default(), node_arena_1: BumpFrame::new(0), node_arena_2: BumpFrame::new(0), spawned_tasks: Default::default(), + suspended: Default::default(), render_cnt: Default::default(), hooks: Default::default(), hook_idx: Default::default(), shared_contexts: Default::default(), borrowed_props: Default::default(), attributes_to_drop: Default::default(), - }) + })) } fn acquire_current_scope_raw(&self) -> Option<*const ScopeState> { let id = self.scope_stack.last().copied()?; - let scope = self.scopes.get(id)?; - Some(scope) + let scope = self.scopes.get(id.0)?; + Some(scope.as_ref()) } pub(crate) fn run_scope(&mut self, scope_id: ScopeId) -> &RenderReturn { @@ -58,75 +50,22 @@ impl VirtualDom { // Remove all the outdated listeners self.ensure_drop_safety(scope_id); - let mut new_nodes = unsafe { - self.scopes[scope_id].previous_frame().bump_mut().reset(); + let new_nodes = unsafe { + self.scopes[scope_id.0].previous_frame().bump_mut().reset(); - let scope = &self.scopes[scope_id]; + let scope = &self.scopes[scope_id.0]; + scope.suspended.set(false); scope.hook_idx.set(0); // safety: due to how we traverse the tree, we know that the scope is not currently aliased let props: &dyn AnyProps = scope.props.as_ref().unwrap().as_ref(); - let props: &dyn AnyProps = mem::transmute(props); + let props: &dyn AnyProps = std::mem::transmute(props); props.render(scope).extend_lifetime() }; - // immediately resolve futures that can be resolved - if let RenderReturn::Pending(task) = &mut new_nodes { - let mut leaves = self.scheduler.leaves.borrow_mut(); - - let entry = leaves.vacant_entry(); - let suspense_id = SuspenseId(entry.key()); - - let leaf = SuspenseLeaf { - scope_id, - task: task.as_mut(), - notified: Default::default(), - waker: futures_util::task::waker(Arc::new(SuspenseHandle { - id: suspense_id, - tx: self.scheduler.sender.clone(), - })), - }; - - let mut cx = Context::from_waker(&leaf.waker); - - // safety: the task is already pinned in the bump arena - let mut pinned = unsafe { Pin::new_unchecked(task.as_mut()) }; - - // Keep polling until either we get a value or the future is not ready - loop { - match pinned.poll_unpin(&mut cx) { - // If nodes are produced, then set it and we can break - Poll::Ready(nodes) => { - new_nodes = match nodes { - Some(nodes) => RenderReturn::Ready(nodes), - None => RenderReturn::default(), - }; - - break; - } - - // If no nodes are produced but the future woke up immediately, then try polling it again - // This circumvents things like yield_now, but is important is important when rendering - // components that are just a stream of immediately ready futures - _ if leaf.notified.get() => { - leaf.notified.set(false); - continue; - } - - // If no nodes are produced, then we need to wait for the future to be woken up - // Insert the future into fiber leaves and break - _ => { - entry.insert(leaf); - self.collected_leaves.push(suspense_id); - break; - } - }; - } - }; - - let scope = &self.scopes[scope_id]; + let scope = &self.scopes[scope_id.0]; // We write on top of the previous frame and then make it the current by pushing the generation forward let frame = scope.previous_frame(); @@ -144,6 +83,14 @@ impl VirtualDom { id: scope.id, }); + if scope.suspended.get() { + if matches!(allocated, RenderReturn::Aborted(_)) { + self.suspended_scopes.insert(scope.id); + } + } else if !self.suspended_scopes.is_empty() { + _ = self.suspended_scopes.remove(&scope.id); + } + // rebind the lifetime now that its stored internally unsafe { allocated.extend_lifetime_ref() } } diff --git a/packages/core/src/scopes.rs b/packages/core/src/scopes.rs index ff94c456d..9470c3d0d 100644 --- a/packages/core/src/scopes.rs +++ b/packages/core/src/scopes.rs @@ -1,24 +1,20 @@ use crate::{ any_props::AnyProps, any_props::VProps, - arena::ElementId, bump_frame::BumpFrame, innerlude::{DynamicNode, EventHandler, VComponent, VText}, innerlude::{ErrorBoundary, Scheduler, SchedulerMsg}, lazynodes::LazyNodes, - nodes::{ComponentReturn, IntoAttributeValue, IntoDynNode, RenderReturn}, + nodes::{IntoAttributeValue, IntoDynNode, RenderReturn}, AnyValue, Attribute, AttributeValue, Element, Event, Properties, TaskId, }; use bumpalo::{boxed::Box as BumpBox, Bump}; -use bumpslab::{BumpSlab, Slot}; use rustc_hash::FxHashSet; -use slab::{Slab, VacantEntry}; use std::{ any::{Any, TypeId}, cell::{Cell, RefCell, UnsafeCell}, fmt::{Arguments, Debug}, future::Future, - ops::{Index, IndexMut}, rc::Rc, sync::Arc, }; @@ -66,95 +62,6 @@ impl<'a, T> std::ops::Deref for Scoped<'a, T> { #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] pub struct ScopeId(pub usize); -/// A thin wrapper around a BumpSlab that uses ids to index into the slab. -pub(crate) struct ScopeSlab { - slab: BumpSlab, - // a slab of slots of stable pointers to the ScopeState in the bump slab - entries: Slab>, -} - -impl Drop for ScopeSlab { - fn drop(&mut self) { - // Bump slab doesn't drop its contents, so we need to do it manually - for slot in self.entries.drain() { - self.slab.remove(slot); - } - } -} - -impl Default for ScopeSlab { - fn default() -> Self { - Self { - slab: BumpSlab::new(), - entries: Slab::new(), - } - } -} - -impl ScopeSlab { - pub(crate) fn get(&self, id: ScopeId) -> Option<&ScopeState> { - self.entries.get(id.0).map(|slot| unsafe { &*slot.ptr() }) - } - - pub(crate) fn get_mut(&mut self, id: ScopeId) -> Option<&mut ScopeState> { - self.entries - .get(id.0) - .map(|slot| unsafe { &mut *slot.ptr_mut() }) - } - - pub(crate) fn vacant_entry(&mut self) -> ScopeSlabEntry { - let entry = self.entries.vacant_entry(); - ScopeSlabEntry { - slab: &mut self.slab, - entry, - } - } - - pub(crate) fn remove(&mut self, id: ScopeId) { - self.slab.remove(self.entries.remove(id.0)); - } - - pub(crate) fn contains(&self, id: ScopeId) -> bool { - self.entries.contains(id.0) - } - - pub(crate) fn iter(&self) -> impl Iterator { - self.entries.iter().map(|(_, slot)| unsafe { &*slot.ptr() }) - } -} - -pub(crate) struct ScopeSlabEntry<'a> { - slab: &'a mut BumpSlab, - entry: VacantEntry<'a, Slot<'static, ScopeState>>, -} - -impl<'a> ScopeSlabEntry<'a> { - pub(crate) fn key(&self) -> ScopeId { - ScopeId(self.entry.key()) - } - - pub(crate) fn insert(self, scope: ScopeState) -> &'a ScopeState { - let slot = self.slab.push(scope); - // this is safe because the slot is only ever accessed with the lifetime of the borrow of the slab - let slot = unsafe { std::mem::transmute(slot) }; - let entry = self.entry.insert(slot); - unsafe { &*entry.ptr() } - } -} - -impl Index for ScopeSlab { - type Output = ScopeState; - fn index(&self, id: ScopeId) -> &Self::Output { - self.get(id).unwrap() - } -} - -impl IndexMut for ScopeSlab { - fn index_mut(&mut self, id: ScopeId) -> &mut Self::Output { - self.get_mut(id).unwrap() - } -} - /// A component's state separate from its props. /// /// This struct exists to provide a common interface for all scopes without relying on generics. @@ -169,6 +76,7 @@ pub struct ScopeState { pub(crate) id: ScopeId, pub(crate) height: u32, + pub(crate) suspended: Cell, pub(crate) hooks: RefCell>>>, pub(crate) hook_idx: Cell, @@ -182,7 +90,6 @@ pub struct ScopeState { pub(crate) attributes_to_drop: RefCell>>, pub(crate) props: Option>>, - pub(crate) placeholder: Cell>, } impl<'src> ScopeState { @@ -574,9 +481,9 @@ impl<'src> ScopeState { /// fn(Scope) -> Element; /// async fn(Scope>) -> Element; /// ``` - pub fn component>( + pub fn component

( &'src self, - component: fn(Scope<'src, P>) -> F, + component: fn(Scope<'src, P>) -> Element<'src>, props: P, fn_name: &'static str, ) -> DynamicNode<'src> @@ -655,6 +562,12 @@ impl<'src> ScopeState { None } + /// Mark this component as suspended and then return None + pub fn suspend(&self) -> Option { + self.suspended.set(true); + None + } + /// Store a value between renders. The foundational hook for all other hooks. /// /// Accepts an `initializer` closure, which is run on the first use of the hook (typically the initial render). The return value of this closure is stored for the lifetime of the component, and a mutable reference to it is provided on every render as the return value of `use_hook`. @@ -688,13 +601,13 @@ impl<'src> ScopeState { raw_ref.downcast_mut::() }) .expect( - r###" + r#" Unable to retrieve the hook that was initialized at this index. Consult the `rules of hooks` to understand how to use hooks properly. You likely used the hook in a conditional. Hooks rely on consistent ordering between renders. Functions prefixed with "use" should never be called conditionally. - "###, + "#, ) } } diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index b118cd59a..68a4e5a1b 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -5,18 +5,17 @@ use crate::{ any_props::VProps, arena::{ElementId, ElementRef}, - innerlude::{DirtyScope, ErrorBoundary, Mutations, Scheduler, SchedulerMsg, ScopeSlab}, + innerlude::{DirtyScope, ErrorBoundary, Mutations, Scheduler, SchedulerMsg}, mutations::Mutation, nodes::RenderReturn, nodes::{Template, TemplateId}, - scheduler::SuspenseId, scopes::{ScopeId, ScopeState}, - AttributeValue, Element, Event, Scope, SuspenseContext, + AttributeValue, Element, Event, Scope, }; use futures_util::{pin_mut, StreamExt}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use slab::Slab; -use std::{any::Any, borrow::BorrowMut, cell::Cell, collections::BTreeSet, future::Future, rc::Rc}; +use std::{any::Any, cell::Cell, collections::BTreeSet, future::Future, rc::Rc}; /// A virtual node system that progresses user events and diffs UI trees. /// @@ -177,7 +176,7 @@ use std::{any::Any, borrow::BorrowMut, cell::Cell, collections::BTreeSet, future pub struct VirtualDom { // Maps a template path to a map of byteindexes to templates pub(crate) templates: FxHashMap>>, - pub(crate) scopes: ScopeSlab, + pub(crate) scopes: Slab>, pub(crate) dirty_scopes: BTreeSet, pub(crate) scheduler: Rc, @@ -186,11 +185,9 @@ pub struct VirtualDom { // While diffing we need some sort of way of breaking off a stream of suspended mutations. pub(crate) scope_stack: Vec, - pub(crate) collected_leaves: Vec, - // Whenever a suspense tree is finished, we push its boundary onto this stack. - // When "render_with_deadline" is called, we pop the stack and return the mutations - pub(crate) finished_fibers: Vec, + // Currently suspended scopes + pub(crate) suspended_scopes: FxHashSet, pub(crate) rx: futures_channel::mpsc::UnboundedReceiver, @@ -262,8 +259,7 @@ impl VirtualDom { elements: Default::default(), scope_stack: Vec::new(), dirty_scopes: BTreeSet::new(), - collected_leaves: Vec::new(), - finished_fibers: Vec::new(), + suspended_scopes: FxHashSet::default(), mutations: Mutations::default(), }; @@ -272,12 +268,6 @@ impl VirtualDom { "app", ); - // The root component is always a suspense boundary for any async children - // This could be unexpected, so we might rethink this behavior later - // - // We *could* just panic if the suspense boundary is not found - root.provide_context(Rc::new(SuspenseContext::new(ScopeId(0)))); - // Unlike react, we provide a default error boundary that just renders the error as a string root.provide_context(Rc::new(ErrorBoundary::new(ScopeId(0)))); @@ -291,14 +281,14 @@ impl VirtualDom { /// /// This is useful for inserting or removing contexts from a scope, or rendering out its root node pub fn get_scope(&self, id: ScopeId) -> Option<&ScopeState> { - self.scopes.get(id) + self.scopes.get(id.0).map(|f| f.as_ref()) } /// Get the single scope at the top of the VirtualDom tree that will always be around /// /// This scope has a ScopeId of 0 and is the root of the tree pub fn base_scope(&self) -> &ScopeState { - self.scopes.get(ScopeId(0)).unwrap() + self.get_scope(ScopeId(0)).unwrap() } /// Build the virtualdom with a global context inserted into the base scope @@ -313,31 +303,12 @@ impl VirtualDom { /// /// Whenever the VirtualDom "works", it will re-render this scope pub fn mark_dirty(&mut self, id: ScopeId) { - if let Some(scope) = self.scopes.get(id) { + if let Some(scope) = self.get_scope(id) { let height = scope.height; self.dirty_scopes.insert(DirtyScope { height, id }); } } - /// Determine whether or not a scope is currently in a suspended state - /// - /// This does not mean the scope is waiting on its own futures, just that the tree that the scope exists in is - /// currently suspended. - pub fn is_scope_suspended(&self, id: ScopeId) -> bool { - !self.scopes[id] - .consume_context::>() - .unwrap() - .waiting_on - .borrow() - .is_empty() - } - - /// Determine if the tree is at all suspended. Used by SSR and other outside mechanisms to determine if the tree is - /// ready to be rendered. - pub fn has_suspended_work(&self) -> bool { - !self.scheduler.leaves.borrow().is_empty() - } - /// Call a listener inside the VirtualDom with data from outside the VirtualDom. /// /// This method will identify the appropriate element. The data must match up with the listener delcared. Note that @@ -485,7 +456,6 @@ impl VirtualDom { Some(msg) => match msg { SchedulerMsg::Immediate(id) => self.mark_dirty(id), SchedulerMsg::TaskNotified(task) => self.handle_task_wakeup(task), - SchedulerMsg::SuspenseNotified(id) => self.handle_suspense_wakeup(id), }, // If they're not ready, then we should wait for them to be ready @@ -495,7 +465,7 @@ impl VirtualDom { Ok(None) => return, Err(_) => { // If we have any dirty scopes, or finished fiber trees then we should exit - if !self.dirty_scopes.is_empty() || !self.finished_fibers.is_empty() { + if !self.dirty_scopes.is_empty() || !self.suspended_scopes.is_empty() { return; } @@ -513,7 +483,6 @@ impl VirtualDom { match msg { SchedulerMsg::Immediate(id) => self.mark_dirty(id), SchedulerMsg::TaskNotified(task) => self.handle_task_wakeup(task), - SchedulerMsg::SuspenseNotified(id) => self.handle_suspense_wakeup(id), } } } @@ -527,7 +496,7 @@ impl VirtualDom { pub fn replace_template(&mut self, template: Template<'static>) { self.register_template_first_byte_index(template); // iterating a slab is very inefficient, but this is a rare operation that will only happen during development so it's fine - for scope in self.scopes.iter() { + for (_, scope) in self.scopes.iter() { if let Some(RenderReturn::Ready(sync)) = scope.try_root_node() { if sync.template.get().name.rsplit_once(':').unwrap().0 == template.name.rsplit_once(':').unwrap().0 @@ -574,7 +543,6 @@ impl VirtualDom { } // If an error occurs, we should try to render the default error component and context where the error occured RenderReturn::Aborted(_placeholder) => panic!("Cannot catch errors during rebuild"), - RenderReturn::Pending(_) => unreachable!("Root scope cannot be an async component"), } self.finalize() @@ -598,6 +566,24 @@ impl VirtualDom { } } + /// Render the virtual dom, waiting for all suspense to be finished + /// + /// The mutations will be thrown out, so it's best to use this method for things like SSR that have async content + pub async fn wait_for_suspense(&mut self) { + loop { + // println!("waiting for suspense {:?}", self.suspended_scopes); + if self.suspended_scopes.is_empty() { + return; + } + + // println!("waiting for suspense"); + self.wait_for_work().await; + + // println!("Rendered immediately"); + _ = self.render_immediate(); + } + } + /// Render what you can given the timeline and then move on /// /// It's generally a good idea to put some sort of limit on the suspense process in case a future is having issues. @@ -609,70 +595,19 @@ impl VirtualDom { self.process_events(); loop { - // first, unload any complete suspense trees - for finished_fiber in self.finished_fibers.drain(..) { - let scope = &self.scopes[finished_fiber]; - let context = scope.has_context::>().unwrap(); - - self.mutations - .templates - .append(&mut context.mutations.borrow_mut().templates); - - self.mutations - .edits - .append(&mut context.mutations.borrow_mut().edits); - - // TODO: count how many nodes are on the stack? - self.mutations.push(Mutation::ReplaceWith { - id: context.placeholder.get().unwrap(), - m: 1, - }) - } - // Next, diff any dirty scopes // We choose not to poll the deadline since we complete pretty quickly anyways if let Some(dirty) = self.dirty_scopes.iter().next().cloned() { self.dirty_scopes.remove(&dirty); // If the scope doesn't exist for whatever reason, then we should skip it - if !self.scopes.contains(dirty.id) { + if !self.scopes.contains(dirty.id.0) { continue; } - // if the scope is currently suspended, then we should skip it, ignoring any tasks calling for an update - if self.is_scope_suspended(dirty.id) { - continue; - } - - // Save the current mutations length so we can split them into boundary - let mutations_to_this_point = self.mutations.edits.len(); - // Run the scope and get the mutations self.run_scope(dirty.id); self.diff_scope(dirty.id); - - // If suspended leaves are present, then we should find the boundary for this scope and attach things - // No placeholder necessary since this is a diff - if !self.collected_leaves.is_empty() { - let mut boundary = self.scopes[dirty.id] - .consume_context::>() - .unwrap(); - - let boundary_mut = boundary.borrow_mut(); - - // Attach mutations - boundary_mut - .mutations - .borrow_mut() - .edits - .extend(self.mutations.edits.split_off(mutations_to_this_point)); - - // Attach suspended leaves - boundary - .waiting_on - .borrow_mut() - .extend(self.collected_leaves.drain(..)); - } } // If there's more work, then just continue, plenty of work to do @@ -680,11 +615,6 @@ impl VirtualDom { continue; } - // If there's no pending suspense, then we have no reason to wait for anything - if self.scheduler.leaves.borrow().is_empty() { - return self.finalize(); - } - // Poll the suspense leaves in the meantime let mut work = self.wait_for_work(); diff --git a/packages/core/tests/fuzzing.rs b/packages/core/tests/fuzzing.rs index 3c617dc02..f193aa2f7 100644 --- a/packages/core/tests/fuzzing.rs +++ b/packages/core/tests/fuzzing.rs @@ -1,3 +1,5 @@ +#![cfg(not(miri))] + use dioxus::prelude::Props; use dioxus_core::*; use std::{cell::Cell, collections::HashSet}; @@ -314,6 +316,7 @@ fn create_random_element(cx: Scope) -> Element { } // test for panics when creating random nodes and templates +#[cfg(not(miri))] #[test] fn create() { for _ in 0..1000 { @@ -325,6 +328,7 @@ fn create() { // test for panics when diffing random nodes // This test will change the template every render which is not very realistic, but it helps stress the system +#[cfg(not(miri))] #[test] fn diff() { for _ in 0..100000 { diff --git a/packages/core/tests/safety.rs b/packages/core/tests/safety.rs index 8c125f4c6..7a5c0dc2a 100644 --- a/packages/core/tests/safety.rs +++ b/packages/core/tests/safety.rs @@ -1,9 +1,6 @@ //! Tests related to safety of the library. -use std::rc::Rc; - use dioxus::prelude::*; -use dioxus_core::SuspenseContext; /// Ensure no issues with not calling rebuild #[test] @@ -17,8 +14,4 @@ fn root_node_isnt_null() { // The height should be 0 assert_eq!(scope.height(), 0); - - // There should be a default suspense context - // todo: there should also be a default error boundary - assert!(scope.has_context::>().is_some()); } diff --git a/packages/core/tests/suspense.rs b/packages/core/tests/suspense.rs index 06bb7b1a4..7b2516447 100644 --- a/packages/core/tests/suspense.rs +++ b/packages/core/tests/suspense.rs @@ -1,100 +1,43 @@ -use dioxus::core::ElementId; -use dioxus::core::{Mutation::*, SuspenseContext}; use dioxus::prelude::*; -use std::future::IntoFuture; -use std::rc::Rc; -use std::time::Duration; #[test] fn it_works() { // wait just a moment, not enough time for the boundary to resolve tokio::runtime::Builder::new_current_thread() - .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); + _ = dom.rebuild(); + dom.wait_for_suspense().await; + let out = dioxus_ssr::pre_render(&dom); - { - let mutations = dom.rebuild().santize(); + assert_eq!(out, "

Waiting for... child
"); - // We should at least get the top-level template in before pausing for the children - // note: we dont test template edits anymore - // assert_eq!( - // mutations.templates, - // [ - // CreateElement { name: "div" }, - // CreateStaticText { value: "Waiting for child..." }, - // CreateStaticPlaceholder, - // AppendChildren { m: 2 }, - // SaveTemplate { name: "template", m: 1 } - // ] - // ); - - // And we should load it in and assign the placeholder properly - assert_eq!( - mutations.edits, - [ - LoadTemplate { name: "template", index: 0, id: ElementId(1) }, - // hmmmmmmmmm.... with suspense how do we guarantee that IDs increase linearly? - // can we even? - AssignId { path: &[1], id: ElementId(3) }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ); - } - - dom.wait_for_work().await; + dbg!(out); }); } fn app(cx: Scope) -> Element { cx.render(rsx!( div { - "Waiting for child..." - suspense_boundary {} + "Waiting for... " + suspended_child {} } )) } -fn suspense_boundary(cx: Scope) -> Element { - cx.use_hook(|| { - cx.provide_context(Rc::new(SuspenseContext::new(cx.scope_id()))); - }); +fn suspended_child(cx: Scope) -> Element { + let val = use_state(cx, || 0); - // Ensure the right types are found - cx.has_context::>().unwrap(); + if **val < 3 { + let mut val = val.clone(); + cx.spawn(async move { + val += 1; + }); + return cx.suspend()?; + } - cx.render(rsx!(async_child {})) -} - -async fn async_child(cx: Scope<'_>) -> Element { - use_future!(cx, || tokio::time::sleep(Duration::from_millis(10))).await; - cx.render(rsx!(async_text {})) -} - -async fn async_text(cx: Scope<'_>) -> Element { - let username = use_future!(cx, || async { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - "async child 1" - }); - - let age = use_future!(cx, || async { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - 1234 - }); - - let (_user, _age) = use_future!(cx, || async { - tokio::join!( - tokio::time::sleep(std::time::Duration::from_secs(1)), - tokio::time::sleep(std::time::Duration::from_secs(2)) - ); - ("async child 1", 1234) - }) - .await; - - let (username, age) = tokio::join!(username.into_future(), age.into_future()); - - cx.render(rsx!( div { "Hello! {username}, you are {age}, {_user} {_age}" } )) + render!("child") } diff --git a/packages/core/tests/task.rs b/packages/core/tests/task.rs index 2704c5eb2..1d190ccaf 100644 --- a/packages/core/tests/task.rs +++ b/packages/core/tests/task.rs @@ -1,10 +1,11 @@ //! Verify that tasks get polled by the virtualdom properly, and that we escape wait_for_work safely use dioxus::prelude::*; -use std::time::Duration; +use std::{sync::atomic::AtomicUsize, time::Duration}; -static mut POLL_COUNT: usize = 0; +static POLL_COUNT: AtomicUsize = AtomicUsize::new(0); +#[cfg(not(miri))] #[tokio::test] async fn it_works() { let mut dom = VirtualDom::new(app); @@ -18,7 +19,10 @@ async fn it_works() { // By the time the tasks are finished, we should've accumulated ticks from two tasks // Be warned that by setting the delay to too short, tokio might not schedule in the tasks - assert_eq!(unsafe { POLL_COUNT }, 135); + assert_eq!( + POLL_COUNT.fetch_add(0, std::sync::atomic::Ordering::Relaxed), + 135 + ); } fn app(cx: Scope) -> Element { @@ -26,14 +30,14 @@ fn app(cx: Scope) -> Element { cx.spawn(async { for x in 0..10 { tokio::time::sleep(Duration::from_micros(50)).await; - unsafe { POLL_COUNT += x } + POLL_COUNT.fetch_add(x, std::sync::atomic::Ordering::Relaxed); } }); cx.spawn(async { for x in 0..10 { tokio::time::sleep(Duration::from_micros(25)).await; - unsafe { POLL_COUNT += x * 2 } + POLL_COUNT.fetch_add(x * 2, std::sync::atomic::Ordering::Relaxed); } }); }); diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index c537aaed8..ed99fbfd4 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" authors = ["Jonathan Kelley"] edition = "2018" description = "WebView renderer for Dioxus" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/desktop.html" keywords = ["dom", "ui", "gui", "react"] diff --git a/packages/desktop/src/file_upload.rs b/packages/desktop/src/file_upload.rs index d09783bcf..7b5f08fc3 100644 --- a/packages/desktop/src/file_upload.rs +++ b/packages/desktop/src/file_upload.rs @@ -8,6 +8,7 @@ pub(crate) struct FileDialogRequest { #[serde(default)] accept: Option, multiple: bool, + directory: bool, pub event: String, pub target: usize, pub bubbles: bool, @@ -36,30 +37,52 @@ pub(crate) fn get_file_event(_request: &FileDialogRequest) -> Vec { target_os = "openbsd" ))] pub(crate) fn get_file_event(request: &FileDialogRequest) -> Vec { - let mut dialog = rfd::FileDialog::new(); + fn get_file_event_for_folder( + request: &FileDialogRequest, + dialog: rfd::FileDialog, + ) -> Vec { + if request.multiple { + dialog.pick_folders().into_iter().flatten().collect() + } else { + dialog.pick_folder().into_iter().collect() + } + } - let filters: Vec<_> = request - .accept - .as_deref() - .unwrap_or_default() - .split(',') - .filter_map(|s| Filters::from_str(s).ok()) - .collect(); + fn get_file_event_for_file( + request: &FileDialogRequest, + mut dialog: rfd::FileDialog, + ) -> Vec { + let filters: Vec<_> = request + .accept + .as_deref() + .unwrap_or_default() + .split(',') + .filter_map(|s| Filters::from_str(s).ok()) + .collect(); - let file_extensions: Vec<_> = filters - .iter() - .flat_map(|f| f.as_extensions().into_iter()) - .collect(); + let file_extensions: Vec<_> = filters + .iter() + .flat_map(|f| f.as_extensions().into_iter()) + .collect(); - dialog = dialog.add_filter("name", file_extensions.as_slice()); + dialog = dialog.add_filter("name", file_extensions.as_slice()); - let files: Vec<_> = if request.multiple { - dialog.pick_files().into_iter().flatten().collect() + let files: Vec<_> = if request.multiple { + dialog.pick_files().into_iter().flatten().collect() + } else { + dialog.pick_file().into_iter().collect() + }; + + files + } + + let dialog = rfd::FileDialog::new(); + + if request.directory { + get_file_event_for_folder(request, dialog) } else { - dialog.pick_file().into_iter().collect() - }; - - files + get_file_event_for_file(request, dialog) + } } enum Filters { diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 6be5d08b4..89ce50a48 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -24,7 +24,7 @@ fn module_loader(root_name: &str) -> String { let target_id = find_real_id(target); if (target_id !== null) { const send = (event_name) => { - const message = serializeIpcMessage("file_diolog", { accept: target.getAttribute("accept"), multiple: target.hasAttribute("multiple"), target: parseInt(target_id), bubbles: event_bubbles(event_name), event: event_name }); + const message = serializeIpcMessage("file_diolog", { accept: target.getAttribute("accept"), directory: target.getAttribute("webkitdirectory") === "true", multiple: target.hasAttribute("multiple"), target: parseInt(target_id), bubbles: event_bubbles(event_name), event: event_name }); window.ipc.postMessage(message); }; send("change&input"); diff --git a/packages/dioxus-tui/Cargo.toml b/packages/dioxus-tui/Cargo.toml index ec8e29d32..914d22e07 100644 --- a/packages/dioxus-tui/Cargo.toml +++ b/packages/dioxus-tui/Cargo.toml @@ -7,7 +7,7 @@ description = "TUI-based renderer for Dioxus" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/tui.html" keywords = ["dom", "ui", "gui", "react", "terminal"] -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] dioxus = { workspace = true } diff --git a/packages/dioxus-tui/examples/colorpicker.rs b/packages/dioxus-tui/examples/colorpicker.rs index 1de6b8209..00f8ef7e0 100644 --- a/packages/dioxus-tui/examples/colorpicker.rs +++ b/packages/dioxus-tui/examples/colorpicker.rs @@ -20,7 +20,7 @@ fn app(cx: Scope) -> Element { background_color: "hsl({hue}, 70%, {brightness}%)", onmousemove: move |evt| { if let RenderReturn::Ready(node) = cx.root_node() { - if let Some(id) = node.root_ids.get(0){ + if let Some(id) = node.root_ids.borrow().get(0).cloned() { let node = tui_query.get(mapping.get_node_id(id).unwrap()); let Size{width, height} = node.size().unwrap(); let pos = evt.inner().element_coordinates(); diff --git a/packages/extension/package-lock.json b/packages/extension/package-lock.json index 6d3a8b27f..2a51fc2e8 100644 --- a/packages/extension/package-lock.json +++ b/packages/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "dioxus", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dioxus", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "dioxus-ext": "./pkg", @@ -3720,9 +3720,9 @@ "dev": true }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -6444,9 +6444,9 @@ "dev": true }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true }, "wrappy": { diff --git a/packages/fermi/Cargo.toml b/packages/fermi/Cargo.toml index aede371cc..54eb2f706 100644 --- a/packages/fermi/Cargo.toml +++ b/packages/fermi/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" authors = ["Jonathan Kelley"] edition = "2018" description = "Global state management for Dioxus" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react", "state-management"] diff --git a/packages/fermi/README.md b/packages/fermi/README.md index f276dbff5..ff6635b0f 100644 --- a/packages/fermi/README.md +++ b/packages/fermi/README.md @@ -1,4 +1,3 @@ -

Fermi ⚛

@@ -6,7 +5,6 @@

-
------ +--- Fermi is a global state management solution for Dioxus that's as easy as `use_state`. Inspired by atom-based state management solutions, all state in Fermi starts as an `atom`: ```rust, ignore -static NAME: Atom<&str> = |_| "Dioxus"; +static NAME: Atom<&str> = Atom(|_| "Dioxus"); ``` From anywhere in our app, we can read the value of our atom: -```rust, ignores +```rust, ignore fn NameCard(cx: Scope) -> Element { - let name = use_read(cx, NAME); + let name = use_read(cx, &NAME); cx.render(rsx!{ h1 { "Hello, {name}"} }) } ``` @@ -53,7 +51,7 @@ We can also set the value of our atom, also from anywhere in our app: ```rust, ignore fn NameCard(cx: Scope) -> Element { - let set_name = use_set(cx, NAME); + let set_name = use_set(cx, &NAME); cx.render(rsx!{ button { onclick: move |_| set_name("Fermi"), @@ -66,10 +64,10 @@ fn NameCard(cx: Scope) -> Element { If needed, we can update the atom's value, based on itself: ```rust, ignore -static COUNT: Atom = |_| 0; +static COUNT: Atom = Atom(|_| 0); fn Counter(cx: Scope) -> Element { - let mut count = use_atom_state(cx, COUNT); + let mut count = use_atom_state(cx, &COUNT); cx.render(rsx!{ p { @@ -86,6 +84,7 @@ fn Counter(cx: Scope) -> Element { It's that simple! ## Installation + Fermi is currently under construction, so you have to use the `master` branch to get started. ```toml @@ -93,10 +92,10 @@ Fermi is currently under construction, so you have to use the `master` branch to fermi = { git = "https://github.com/dioxuslabs/dioxus" } ``` - ## Running examples The examples here use Dioxus Desktop to showcase their functionality. To run an example, use + ```sh $ cargo run --example fermi ``` @@ -104,6 +103,7 @@ $ cargo run --example fermi ## Features Broadly our feature set required to be released includes: + - [x] Support for Atoms - [x] Support for AtomRef (for values that aren't `Clone`) - [ ] Support for Atom Families diff --git a/packages/fermi/src/atoms/atom.rs b/packages/fermi/src/atoms/atom.rs index 81e04ce0b..bba1f532f 100644 --- a/packages/fermi/src/atoms/atom.rs +++ b/packages/fermi/src/atoms/atom.rs @@ -1,24 +1,21 @@ use crate::{AtomId, AtomRoot, Readable, Writable}; -pub type Atom = fn(AtomBuilder) -> T; +pub struct Atom(pub fn(AtomBuilder) -> T); pub struct AtomBuilder; -impl Readable for Atom { +impl Readable for &'static Atom { fn read(&self, _root: AtomRoot) -> Option { todo!() } fn init(&self) -> V { - (*self)(AtomBuilder) + self.0(AtomBuilder) } fn unique_id(&self) -> AtomId { - AtomId { - ptr: *self as *const (), - type_id: std::any::TypeId::of::(), - } + *self as *const Atom as *const () } } -impl Writable for Atom { +impl Writable for &'static Atom { fn write(&self, _root: AtomRoot, _value: V) { todo!() } @@ -26,6 +23,22 @@ impl Writable for Atom { #[test] fn atom_compiles() { - static TEST_ATOM: Atom<&str> = |_| "hello"; - dbg!(TEST_ATOM.init()); + static TEST_ATOM: Atom<&str> = Atom(|_| "hello"); + dbg!((&TEST_ATOM).init()); +} + +#[test] +fn atom_is_unique() { + static TEST_ATOM_1: Atom<&str> = Atom(|_| "hello"); + static TEST_ATOM_2: Atom<&str> = Atom(|_| "hello"); + assert_eq!((&TEST_ATOM_1).unique_id(), (&TEST_ATOM_1).unique_id()); + assert_ne!((&TEST_ATOM_1).unique_id(), (&TEST_ATOM_2).unique_id()); +} + +#[test] +fn atom_is_unique_2() { + struct S(String); + static TEST_ATOM_1: Atom> = Atom(|_| Vec::new()); + static TEST_ATOM_2: Atom> = Atom(|_| Vec::new()); + assert_ne!((&TEST_ATOM_1).unique_id(), (&TEST_ATOM_2).unique_id()); } diff --git a/packages/fermi/src/atoms/atomfamily.rs b/packages/fermi/src/atoms/atomfamily.rs index 29cecd4b2..45f6b8feb 100644 --- a/packages/fermi/src/atoms/atomfamily.rs +++ b/packages/fermi/src/atoms/atomfamily.rs @@ -2,26 +2,23 @@ use crate::{AtomId, AtomRoot, Readable, Writable}; use im_rc::HashMap as ImMap; pub struct AtomFamilyBuilder; -pub type AtomFamily = fn(AtomFamilyBuilder) -> ImMap; +pub struct AtomFamily(pub fn(AtomFamilyBuilder) -> ImMap); -impl Readable> for AtomFamily { +impl Readable> for &'static AtomFamily { fn read(&self, _root: AtomRoot) -> Option> { todo!() } fn init(&self) -> ImMap { - (*self)(AtomFamilyBuilder) + self.0(AtomFamilyBuilder) } fn unique_id(&self) -> AtomId { - AtomId { - ptr: *self as *const (), - type_id: std::any::TypeId::of::(), - } + *self as *const AtomFamily as *const () } } -impl Writable> for AtomFamily { +impl Writable> for &'static AtomFamily { fn write(&self, _root: AtomRoot, _value: ImMap) { todo!() } diff --git a/packages/fermi/src/atoms/atomref.rs b/packages/fermi/src/atoms/atomref.rs index c103fa662..5e863ade2 100644 --- a/packages/fermi/src/atoms/atomref.rs +++ b/packages/fermi/src/atoms/atomref.rs @@ -2,27 +2,24 @@ use crate::{AtomId, AtomRoot, Readable}; use std::cell::RefCell; pub struct AtomRefBuilder; -pub type AtomRef = fn(AtomRefBuilder) -> T; +pub struct AtomRef(pub fn(AtomRefBuilder) -> T); -impl Readable> for AtomRef { +impl Readable> for &'static AtomRef { fn read(&self, _root: AtomRoot) -> Option> { todo!() } fn init(&self) -> RefCell { - RefCell::new((*self)(AtomRefBuilder)) + RefCell::new(self.0(AtomRefBuilder)) } fn unique_id(&self) -> AtomId { - AtomId { - ptr: *self as *const (), - type_id: std::any::TypeId::of::(), - } + *self as *const AtomRef as *const () } } #[test] fn atom_compiles() { - static TEST_ATOM: AtomRef> = |_| vec![]; - dbg!(TEST_ATOM.init()); + static TEST_ATOM: AtomRef> = AtomRef(|_| vec![]); + dbg!((&TEST_ATOM).init()); } diff --git a/packages/fermi/src/hooks/atom_ref.rs b/packages/fermi/src/hooks/atom_ref.rs index 95732c491..74dd9268b 100644 --- a/packages/fermi/src/hooks/atom_ref.rs +++ b/packages/fermi/src/hooks/atom_ref.rs @@ -13,7 +13,10 @@ use std::{ /// /// /// -pub fn use_atom_ref(cx: &ScopeState, atom: AtomRef) -> &UseAtomRef { +pub fn use_atom_ref<'a, T: 'static>( + cx: &'a ScopeState, + atom: &'static AtomRef, +) -> &'a UseAtomRef { let root = use_atom_root(cx); &cx.use_hook(|| { diff --git a/packages/fermi/src/hooks/state.rs b/packages/fermi/src/hooks/state.rs index 15310cff2..071b18059 100644 --- a/packages/fermi/src/hooks/state.rs +++ b/packages/fermi/src/hooks/state.rs @@ -19,7 +19,7 @@ use std::{ /// static COUNT: Atom = |_| 0; /// /// fn Example(cx: Scope) -> Element { -/// let mut count = use_atom_state(cx, COUNT); +/// let mut count = use_atom_state(cx, &COUNT); /// /// cx.render(rsx! { /// div { diff --git a/packages/fermi/src/root.rs b/packages/fermi/src/root.rs index 99e97bfbb..f77930c75 100644 --- a/packages/fermi/src/root.rs +++ b/packages/fermi/src/root.rs @@ -1,21 +1,11 @@ -use std::{ - any::{Any, TypeId}, - cell::RefCell, - collections::HashMap, - rc::Rc, - sync::Arc, -}; +use std::{any::Any, cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; use dioxus_core::ScopeId; use im_rc::HashSet; use crate::Readable; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct AtomId { - pub ptr: *const (), - pub type_id: TypeId, -} +pub type AtomId = *const (); pub struct AtomRoot { pub atoms: RefCell>, @@ -54,15 +44,7 @@ impl AtomRoot { // initialize the value if it's not already initialized if let Some(slot) = atoms.get_mut(&f.unique_id()) { slot.subscribers.insert(scope); - match slot.value.clone().downcast() { - Ok(res) => res, - Err(e) => panic!( - "Downcasting atom failed: {:?}. Has typeid of {:?} but needs typeid of {:?}", - f.unique_id(), - e.type_id(), - TypeId::of::() - ), - } + slot.value.clone().downcast().unwrap() } else { let value = Rc::new(f.init()); let mut subscribers = HashSet::new(); diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml index ef3337c67..57f414194 100644 --- a/packages/fullstack/Cargo.toml +++ b/packages/fullstack/Cargo.toml @@ -3,35 +3,40 @@ name = "dioxus-fullstack" version = "0.1.0" edition = "2021" description = "Fullstack Dioxus Utilities" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react", "ssr", "fullstack"] [dependencies] # server functions -server_fn = { git = "https://github.com/leptos-rs/leptos", rev = "15a4e54435eb5a539afb75891292bcccd2cc8e85", default-features = false, features = ["stable"] } +server_fn = { git = "https://github.com/leptos-rs/leptos", rev = "6b90e1babd425c9a324181c86e3fd1b942c9b10f", default-features = false } dioxus_server_macro = { path = "server-macro" } # warp -warp = { version = "0.3.3", optional = true } +warp = { version = "0.3.5", features = ["compression-gzip"], optional = true } http-body = { version = "0.4.5", optional = true } +http-body-util = "0.1.0-rc.2" # axum -axum = { version = "0.6.1", features = ["ws"], optional = true } -tower-http = { version = "0.4.0", optional = true, features = ["fs"] } +axum = { version = "0.6.1", features = ["ws", "macros"], optional = true } +tower-http = { version = "0.4.0", optional = true, features = ["fs", "compression-gzip"] } +tower = { version = "0.4.13", features = ["util"], optional = true } axum-macros = "0.3.7" # salvo -salvo = { version = "0.37.7", optional = true, features = ["serve-static", "ws"] } +salvo = { version = "0.46.0", optional = true, features = ["serve-static", "websocket", "compression"] } serde = "1.0.159" # Dioxus + SSR -dioxus-core = { workspace = true } +dioxus = { workspace = true } dioxus-ssr = { workspace = true, optional = true } hyper = { version = "0.14.25", optional = true } http = { version = "0.2.9", optional = true } +# Router Intigration +dioxus-router = { workspace = true, optional = true } + log = { workspace = true } once_cell = "1.17.1" thiserror = { workspace = true } @@ -43,9 +48,13 @@ serde_json = { version = "1.0.95", optional = true } tokio-stream = { version = "0.1.12", features = ["sync"], optional = true } futures-util = { workspace = true, optional = true } postcard = { version = "1.0.4", features = ["use-std"] } -yazi = "0.1.5" base64 = "0.21.0" +pin-project = "1.1.2" +async-trait = "0.1.71" +bytes = "1.4.0" +tower-layer = "0.3.2" + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] dioxus-hot-reload = { workspace = true } @@ -54,10 +63,14 @@ web-sys = { version = "0.3.61", features = ["Window", "Document", "Element", "Ht [features] default = ["hot-reload", "default-tls"] -hot-reload = ["serde_json", "tokio-stream", "futures-util"] -warp = ["dep:warp", "http-body", "ssr"] +router = ["dioxus-router"] +hot-reload = ["serde_json", "futures-util"] +warp = ["dep:warp", "ssr"] axum = ["dep:axum", "tower-http", "ssr"] salvo = ["dep:salvo", "ssr"] -ssr = ["server_fn/ssr", "tokio", "dioxus-ssr", "hyper", "http"] +ssr = ["server_fn/ssr", "tokio", "dioxus-ssr", "tower", "hyper", "http", "http-body", "dioxus-router/ssr", "tokio-stream"] default-tls = ["server_fn/default-tls"] rustls = ["server_fn/rustls"] + +[dev-dependencies] +dioxus-fullstack = { path = ".", features = ["router"] } diff --git a/packages/fullstack/examples/axum-auth/.gitignore b/packages/fullstack/examples/axum-auth/.gitignore new file mode 100644 index 000000000..b2a16319f --- /dev/null +++ b/packages/fullstack/examples/axum-auth/.gitignore @@ -0,0 +1,3 @@ +dist +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/axum-auth/Cargo.toml b/packages/fullstack/examples/axum-auth/Cargo.toml new file mode 100644 index 000000000..94a0c6357 --- /dev/null +++ b/packages/fullstack/examples/axum-auth/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "axum-auth" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } +dioxus = { workspace = true } +dioxus-fullstack = { workspace = true } +axum = { version = "0.6.12", optional = true } +tokio = { workspace = true, features = ["full"], optional = true } +serde = "1.0.159" +execute = "0.2.12" +tower-http = { version = "0.4.1", features = ["auth"], optional = true } +simple_logger = { version = "4.2.0", optional = true } +async-trait = { version = "0.1.71", optional = true } +sqlx = { version = "0.7.0", features = [ + "macros", + "migrate", + "postgres", + "sqlite", + "_unstable-all-types", + "tls-rustls", + "runtime-tokio", +], optional = true } +anyhow = "1.0.71" +http = { version = "0.2.9", optional = true } +tower = { version = "0.4.13", optional = true } + +[dependencies.axum_session] +version = "0.3.0" +features = ["sqlite-rustls"] +optional = true + +[dependencies.axum_session_auth] +version = "0.3.0" +features = ["sqlite-rustls"] +optional = true + +[features] +default = [] +ssr = ["axum", "tokio", "dioxus-fullstack/axum", "tower-http", "simple_logger", "async-trait", "sqlx", "axum_session", "axum_session_auth", "http", "tower"] +web = ["dioxus-web"] + +[profile.release] +lto = true +panic = "abort" +opt-level = 'z' +strip = true +codegen-units = 1 diff --git a/packages/fullstack/examples/axum-auth/src/auth.rs b/packages/fullstack/examples/axum-auth/src/auth.rs new file mode 100644 index 000000000..56eddbd8e --- /dev/null +++ b/packages/fullstack/examples/axum-auth/src/auth.rs @@ -0,0 +1,262 @@ +use async_trait::async_trait; +use axum::{ + http::Method, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use axum_session::{SessionConfig, SessionLayer, SessionSqlitePool, SessionStore}; +use axum_session_auth::*; +use core::pin::Pin; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use std::error::Error; +use std::future::Future; +use std::{collections::HashSet, net::SocketAddr, str::FromStr}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: i32, + pub anonymous: bool, + pub username: String, + pub permissions: HashSet, +} + +#[derive(sqlx::FromRow, Clone)] +pub struct SqlPermissionTokens { + pub token: String, +} + +impl Default for User { + fn default() -> Self { + let mut permissions = HashSet::new(); + + permissions.insert("Category::View".to_owned()); + + Self { + id: 1, + anonymous: true, + username: "Guest".into(), + permissions, + } + } +} + +#[async_trait] +impl Authentication for User { + async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { + let pool = pool.unwrap(); + + User::get_user(userid, pool) + .await + .ok_or_else(|| anyhow::anyhow!("Could not load user")) + } + + fn is_authenticated(&self) -> bool { + !self.anonymous + } + + fn is_active(&self) -> bool { + !self.anonymous + } + + fn is_anonymous(&self) -> bool { + self.anonymous + } +} + +#[async_trait] +impl HasPermission for User { + async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool { + self.permissions.contains(perm) + } +} + +impl User { + pub async fn get_user(id: i64, pool: &SqlitePool) -> Option { + let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .ok()?; + + //lets just get all the tokens the user can use, we will only use the full permissions if modifing them. + let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>( + "SELECT token FROM user_permissions WHERE user_id = $1;", + ) + .bind(id) + .fetch_all(pool) + .await + .ok()?; + + Some(sqluser.into_user(Some(sql_user_perms))) + } + + pub async fn create_user_tables(pool: &SqlitePool) { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + "id" INTEGER PRIMARY KEY, + "anonymous" BOOLEAN NOT NULL, + "username" VARCHAR(256) NOT NULL + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS user_permissions ( + "user_id" INTEGER NOT NULL, + "token" VARCHAR(256) NOT NULL + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO users + (id, anonymous, username) SELECT 1, true, 'Guest' + ON CONFLICT(id) DO UPDATE SET + anonymous = EXCLUDED.anonymous, + username = EXCLUDED.username + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO users + (id, anonymous, username) SELECT 2, false, 'Test' + ON CONFLICT(id) DO UPDATE SET + anonymous = EXCLUDED.anonymous, + username = EXCLUDED.username + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO user_permissions + (user_id, token) SELECT 2, 'Category::View' + "#, + ) + .execute(pool) + .await + .unwrap(); + } +} + +#[derive(sqlx::FromRow, Clone)] +pub struct SqlUser { + pub id: i32, + pub anonymous: bool, + pub username: String, +} + +impl SqlUser { + pub fn into_user(self, sql_user_perms: Option>) -> User { + User { + id: self.id, + anonymous: self.anonymous, + username: self.username, + permissions: if let Some(user_perms) = sql_user_perms { + user_perms + .into_iter() + .map(|x| x.token) + .collect::>() + } else { + HashSet::::new() + }, + } + } +} + +pub async fn connect_to_database() -> SqlitePool { + let connect_opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + + SqlitePoolOptions::new() + .max_connections(5) + .connect_with(connect_opts) + .await + .unwrap() +} + +pub struct Session( + pub axum_session_auth::AuthSession< + crate::auth::User, + i64, + axum_session_auth::SessionSqlitePool, + sqlx::SqlitePool, + >, +); + +impl std::ops::Deref for Session { + type Target = axum_session_auth::AuthSession< + crate::auth::User, + i64, + axum_session_auth::SessionSqlitePool, + sqlx::SqlitePool, + >; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Session { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Debug)] +pub struct AuthSessionLayerNotFound; + +impl std::fmt::Display for AuthSessionLayerNotFound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "AuthSessionLayer was not found") + } +} + +impl std::error::Error for AuthSessionLayerNotFound {} + +impl IntoResponse for AuthSessionLayerNotFound { + fn into_response(self) -> Response { + ( + http::status::StatusCode::INTERNAL_SERVER_ERROR, + "AuthSessionLayer was not found", + ) + .into_response() + } +} + +#[async_trait] +impl axum::extract::FromRequestParts for Session { + type Rejection = AuthSessionLayerNotFound; + + async fn from_request_parts( + parts: &mut http::request::Parts, + state: &S, + ) -> Result { + axum_session_auth::AuthSession::< + crate::auth::User, + i64, + axum_session_auth::SessionSqlitePool, + sqlx::SqlitePool, + >::from_request_parts(parts, state) + .await + .map(Session) + .map_err(|_| AuthSessionLayerNotFound) + } +} diff --git a/packages/fullstack/examples/axum-auth/src/main.rs b/packages/fullstack/examples/axum-auth/src/main.rs new file mode 100644 index 000000000..fe94c9628 --- /dev/null +++ b/packages/fullstack/examples/axum-auth/src/main.rs @@ -0,0 +1,164 @@ +//! Run with: +//! +//! ```sh +//! dx build --features web +//! cargo run --features ssr +//! ``` + +#![allow(non_snake_case, unused)] + +#[cfg(feature = "ssr")] +mod auth; + +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + // Hydrate the application on the client + dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); + + #[cfg(feature = "ssr")] + { + use crate::auth::*; + use axum::routing::*; + use axum_session::SessionConfig; + use axum_session::SessionStore; + use axum_session_auth::AuthConfig; + use axum_session_auth::SessionSqlitePool; + simple_logger::SimpleLogger::new().init().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let pool = connect_to_database().await; + + //This Defaults as normal Cookies. + //To enable Private cookies for integrity, and authenticity please check the next Example. + let session_config = SessionConfig::default().with_table_name("test_table"); + let auth_config = AuthConfig::::default().with_anonymous_user_id(Some(1)); + let session_store = SessionStore::::new( + Some(pool.clone().into()), + session_config, + ) + .await + .unwrap(); + + //Create the Database table for storing our Session Data. + session_store.initiate().await.unwrap(); + User::create_user_tables(&pool).await; + + // build our application with some routes + let app = Router::new() + // Server side render the application, serve static assets, and register server functions + .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) + .layer( + axum_session_auth::AuthSessionLayer::< + crate::auth::User, + i64, + axum_session_auth::SessionSqlitePool, + sqlx::SqlitePool, + >::new(Some(pool)) + .with_config(auth_config), + ) + .layer(axum_session::SessionLayer::new(session_store)); + + // run it + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); + + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let user_name = use_state(cx, || "?".to_string()); + let permissions = use_state(cx, || "?".to_string()); + + cx.render(rsx! { + div { + button { + onclick: move |_| { + async move { + login().await.unwrap(); + } + }, + "Login Test User" + } + } + div { + button { + onclick: move |_| { + to_owned![user_name]; + async move { + if let Ok(data) = get_user_name().await { + user_name.set(data); + } + } + }, + "Get User Name" + } + "User name: {user_name}" + } + div { + button { + onclick: move |_| { + to_owned![permissions]; + async move { + if let Ok(data) = get_permissions().await { + permissions.set(data); + } + } + }, + "Get Permissions" + } + "Permissions: {permissions}" + } + }) +} + +#[server(GetUserName)] +pub async fn get_user_name() -> Result { + let session: crate::auth::Session = extract().await?; + Ok(session.0.current_user.unwrap().username.to_string()) +} + +#[server(Login)] +pub async fn login() -> Result<(), ServerFnError> { + let auth: crate::auth::Session = extract().await?; + auth.login_user(2); + Ok(()) +} + +#[server(Permissions)] +pub async fn get_permissions() -> Result { + let method: axum::http::Method = extract().await?; + let auth: crate::auth::Session = extract().await?; + let current_user = auth.current_user.clone().unwrap_or_default(); + + // lets check permissions only and not worry about if they are anon or not + if !axum_session_auth::Auth::::build( + [axum::http::Method::POST], + false, + ) + .requires(axum_session_auth::Rights::any([ + axum_session_auth::Rights::permission("Category::View"), + axum_session_auth::Rights::permission("Admin::View"), + ])) + .validate(¤t_user, &method, None) + .await + { + return Ok(format!( + "User {}, Does not have permissions needed to view this page please login", + current_user.username + )); + } + + Ok(format!( + "User has Permissions needed. Here are the Users permissions: {:?}", + current_user.permissions + )) +} diff --git a/packages/fullstack/examples/axum-desktop/src/client.rs b/packages/fullstack/examples/axum-desktop/src/client.rs index 8cb5b6cff..938fe9ac9 100644 --- a/packages/fullstack/examples/axum-desktop/src/client.rs +++ b/packages/fullstack/examples/axum-desktop/src/client.rs @@ -1,6 +1,6 @@ // Run with: // ```bash -// cargo run --bin client --features="desktop" +// cargo run --bin client --features desktop // ``` use axum_desktop::*; @@ -8,6 +8,6 @@ use dioxus_fullstack::prelude::server_fn::set_server_url; fn main() { // Set the url of the server where server functions are hosted. - set_server_url("http://localhost:8080"); + set_server_url("http://127.0.0.0:8080"); dioxus_desktop::launch(app) } diff --git a/packages/fullstack/examples/axum-desktop/src/server.rs b/packages/fullstack/examples/axum-desktop/src/server.rs index 6098ae3f4..f4a9520f4 100644 --- a/packages/fullstack/examples/axum-desktop/src/server.rs +++ b/packages/fullstack/examples/axum-desktop/src/server.rs @@ -1,9 +1,8 @@ // Run with: // ```bash -// cargo run --bin server --features="ssr" +// cargo run --bin server --features ssr // ``` -use axum_desktop::*; use dioxus_fullstack::prelude::*; #[tokio::main] diff --git a/packages/fullstack/examples/axum-hello-world/.gitignore b/packages/fullstack/examples/axum-hello-world/.gitignore index 6047329c6..b2a16319f 100644 --- a/packages/fullstack/examples/axum-hello-world/.gitignore +++ b/packages/fullstack/examples/axum-hello-world/.gitignore @@ -1,2 +1,3 @@ dist -target \ No newline at end of file +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/axum-hello-world/Cargo.toml b/packages/fullstack/examples/axum-hello-world/Cargo.toml index b41225206..9761864bb 100644 --- a/packages/fullstack/examples/axum-hello-world/Cargo.toml +++ b/packages/fullstack/examples/axum-hello-world/Cargo.toml @@ -7,15 +7,24 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -dioxus-web = { workspace = true, features=["hydrate"], optional = true } +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } dioxus = { workspace = true } dioxus-fullstack = { workspace = true } axum = { version = "0.6.12", optional = true } tokio = { workspace = true, features = ["full"], optional = true } serde = "1.0.159" execute = "0.2.12" +tower-http = { version = "0.4.1", features = ["auth"] } +simple_logger = "4.2.0" [features] -default = ["web"] +default = [] ssr = ["axum", "tokio", "dioxus-fullstack/axum"] web = ["dioxus-web"] + +[profile.release] +lto = true +panic = "abort" +opt-level = 'z' +strip = true +codegen-units = 1 diff --git a/packages/fullstack/examples/axum-hello-world/src/main.rs b/packages/fullstack/examples/axum-hello-world/src/main.rs index 5ded85070..e00b9ae05 100644 --- a/packages/fullstack/examples/axum-hello-world/src/main.rs +++ b/packages/fullstack/examples/axum-hello-world/src/main.rs @@ -1,56 +1,15 @@ //! Run with: //! //! ```sh -//! dioxus build --features web -//! cargo run --features ssr --no-default-features +//! dx build --features web +//! cargo run --features ssr //! ``` -#![allow(non_snake_case)] +#![allow(non_snake_case, unused)] use dioxus::prelude::*; -use dioxus_fullstack::prelude::*; +use dioxus_fullstack::{launch, prelude::*}; use serde::{Deserialize, Serialize}; -fn main() { - #[cfg(feature = "web")] - dioxus_web::launch_with_props( - app, - get_root_props_from_document().unwrap_or_default(), - dioxus_web::Config::new().hydrate(true), - ); - #[cfg(feature = "ssr")] - { - // Start hot reloading - hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { - execute::shell("dioxus build --features web") - .spawn() - .unwrap() - .wait() - .unwrap(); - execute::shell("cargo run --features ssr --no-default-features") - .spawn() - .unwrap(); - true - })); - - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async move { - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); - axum::Server::bind(&addr) - .serve( - axum::Router::new() - .serve_dioxus_application( - "", - ServeConfigBuilder::new(app, AppProps { count: 12345 }).build(), - ) - .into_make_service(), - ) - .await - .unwrap(); - }); - } -} - #[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] struct AppProps { count: i32, @@ -67,12 +26,11 @@ fn app(cx: Scope) -> Element { button { onclick: move |_| { to_owned![text]; - let sc = cx.sc(); async move { if let Ok(data) = get_server_data().await { println!("Client received: {}", data); text.set(data.clone()); - post_server_data(sc, data).await.unwrap(); + post_server_data(data).await.unwrap(); } } }, @@ -83,12 +41,10 @@ fn app(cx: Scope) -> Element { } #[server(PostServerData)] -async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { - // The server context contains information about the current request and allows you to modify the response. - cx.response_headers_mut() - .insert("Set-Cookie", "foo=bar".parse().unwrap()); +async fn post_server_data(data: String) -> Result<(), ServerFnError> { + let axum::extract::Host(host): axum::extract::Host = extract()?; println!("Server received: {}", data); - println!("Request parts are {:?}", cx.request_parts()); + println!("{:?}", host); Ok(()) } @@ -97,3 +53,9 @@ async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), S async fn get_server_data() -> Result { Ok("Hello from the server!".to_string()) } + +fn main() { + launch!(@([127, 0, 0, 1], 8080), app, { + serve_cfg: ServeConfigBuilder::new(app, AppProps { count: 0 }), + }); +} diff --git a/packages/fullstack/examples/axum-router/.gitignore b/packages/fullstack/examples/axum-router/.gitignore index 6047329c6..b2a16319f 100644 --- a/packages/fullstack/examples/axum-router/.gitignore +++ b/packages/fullstack/examples/axum-router/.gitignore @@ -1,2 +1,3 @@ dist -target \ No newline at end of file +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/axum-router/Cargo.toml b/packages/fullstack/examples/axum-router/Cargo.toml index fbd59bfc0..ee788bc05 100644 --- a/packages/fullstack/examples/axum-router/Cargo.toml +++ b/packages/fullstack/examples/axum-router/Cargo.toml @@ -7,18 +7,15 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -dioxus-web = { workspace = true, features=["hydrate"], optional = true } +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } dioxus = { workspace = true } -dioxus-router = { workspace = true } -dioxus-fullstack = { workspace = true } +dioxus-router = { workspace = true} +dioxus-fullstack = { workspace = true, features = ["router"] } axum = { version = "0.6.12", optional = true } -tokio = { workspace = true, features = ["full"], optional = true } -serde = "1.0.159" -tower-http = { version = "0.4.0", features = ["fs"], optional = true } -http = { version = "0.2.9", optional = true } -execute = "0.2.12" +tokio = {workspace = true, features = ["full"], optional = true } +serde = { version = "1.0.159", features = ["derive"] } [features] -default = ["web"] -ssr = ["axum", "tokio", "dioxus-fullstack/axum", "tower-http", "http"] +default = [] +ssr = ["axum", "tokio", "dioxus-fullstack/axum"] web = ["dioxus-web", "dioxus-router/web"] diff --git a/packages/fullstack/examples/axum-router/src/main.rs b/packages/fullstack/examples/axum-router/src/main.rs index 2b98ad842..54ce11769 100644 --- a/packages/fullstack/examples/axum-router/src/main.rs +++ b/packages/fullstack/examples/axum-router/src/main.rs @@ -1,122 +1,61 @@ //! Run with: //! //! ```sh -//! dioxus build --features web -//! cargo run --features ssr --no-default-features +//! dx build --features web +//! cargo run --features ssr //! ``` #![allow(non_snake_case)] + use dioxus::prelude::*; use dioxus_fullstack::prelude::*; -use dioxus_router::*; -use serde::{Deserialize, Serialize}; +use dioxus_router::prelude::*; fn main() { - #[cfg(feature = "web")] - dioxus_web::launch_with_props( - App, - AppProps { route: None }, - dioxus_web::Config::new().hydrate(true), - ); - #[cfg(feature = "ssr")] - { - // Start hot reloading - hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { - execute::shell("dioxus build --features web") - .spawn() - .unwrap() - .wait() - .unwrap(); - execute::shell("cargo run --features ssr --no-default-features") - .spawn() - .unwrap(); - true - })); - - use axum::extract::State; - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async move { - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); - - axum::Server::bind(&addr) - .serve( - axum::Router::new() - // Serve the dist/assets folder with the javascript and WASM files created by the CLI - .serve_static_assets("./dist") - // Register server functions - .register_server_fns("") - // Connect to the hot reload server - .connect_hot_reload() - // If the path is unknown, render the application - .fallback( - move |uri: http::uri::Uri, State(ssr_state): State| { - let rendered = ssr_state.render( - &ServeConfigBuilder::new( - App, - AppProps { - route: Some(format!("http://{addr}{uri}")), - }, - ) - .build(), - ); - async move { axum::body::Full::from(rendered) } - }, - ) - .with_state(SSRState::default()) - .into_make_service(), - ) - .await - .unwrap(); - }); - } + launch_router!(@([127, 0, 0, 1], 8080), Route, { + incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)), + }); } -#[derive(Clone, Debug, Props, PartialEq, Serialize, Deserialize)] -struct AppProps { - route: Option, +#[derive(Clone, Routable, Debug, PartialEq)] +enum Route { + #[route("/")] + Home {}, + #[route("/blog/:id")] + Blog { id: i32 }, } -fn App(cx: Scope) -> Element { - cx.render(rsx! { - Router { - initial_url: cx.props.route.clone(), - - Route { to: "/blog", - Link { - to: "/", - "Go to counter" - } - table { - tbody { - for _ in 0..100 { - tr { - for _ in 0..100 { - td { "hello world!" } - } - } +#[inline_props] +fn Blog(cx: Scope, id: i32) -> Element { + render! { + Link { target: Route::Home {}, "Go to counter" } + table { + tbody { + for _ in 0..*id { + tr { + for _ in 0..*id { + td { "hello world!" } } } } - }, - // Fallback - Route { to: "", - Counter {} - }, + } } - }) + } } -fn Counter(cx: Scope) -> Element { +#[inline_props] +fn Home(cx: Scope) -> Element { let mut count = use_state(cx, || 0); let text = use_state(cx, || "...".to_string()); cx.render(rsx! { Link { - to: "/blog", + target: Route::Blog { + id: *count.get() + }, "Go to blog" } - div{ + div { h1 { "High-Five counter: {count}" } button { onclick: move |_| count += 1, "Up high!" } button { onclick: move |_| count -= 1, "Down low!" } @@ -131,7 +70,7 @@ fn Counter(cx: Scope) -> Element { } } }, - "Run a server function" + "Run server function!" } "Server said: {text}" } diff --git a/packages/fullstack/examples/salvo-hello-world/.gitignore b/packages/fullstack/examples/salvo-hello-world/.gitignore index 6047329c6..b2a16319f 100644 --- a/packages/fullstack/examples/salvo-hello-world/.gitignore +++ b/packages/fullstack/examples/salvo-hello-world/.gitignore @@ -1,2 +1,3 @@ dist -target \ No newline at end of file +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/salvo-hello-world/Cargo.toml b/packages/fullstack/examples/salvo-hello-world/Cargo.toml index 6895b85f6..b85651f06 100644 --- a/packages/fullstack/examples/salvo-hello-world/Cargo.toml +++ b/packages/fullstack/examples/salvo-hello-world/Cargo.toml @@ -16,6 +16,6 @@ salvo = { version = "0.37.9", optional = true } execute = "0.2.12" [features] -default = ["web"] +default = [] ssr = ["salvo", "tokio", "dioxus-fullstack/salvo"] web = ["dioxus-web"] diff --git a/packages/fullstack/examples/salvo-hello-world/src/main.rs b/packages/fullstack/examples/salvo-hello-world/src/main.rs index 01b67ff99..bf6c2872a 100644 --- a/packages/fullstack/examples/salvo-hello-world/src/main.rs +++ b/packages/fullstack/examples/salvo-hello-world/src/main.rs @@ -1,50 +1,19 @@ //! Run with: //! //! ```sh -//! dioxus build --features web -//! cargo run --features ssr --no-default-features +//! dx build --features web +//! cargo run --features ssr //! ``` -#![allow(non_snake_case)] +#![allow(non_snake_case, unused)] use dioxus::prelude::*; use dioxus_fullstack::prelude::*; use serde::{Deserialize, Serialize}; fn main() { - #[cfg(feature = "web")] - dioxus_web::launch_with_props( - app, - get_root_props_from_document().unwrap_or_default(), - dioxus_web::Config::new().hydrate(true), - ); - #[cfg(feature = "ssr")] - { - // Start hot reloading - hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { - execute::shell("dioxus build --features web") - .spawn() - .unwrap() - .wait() - .unwrap(); - execute::shell("cargo run --features ssr --no-default-features") - .spawn() - .unwrap(); - true - })); - - use salvo::prelude::*; - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async move { - let router = Router::new().serve_dioxus_application( - "", - ServeConfigBuilder::new(app, AppProps { count: 12345 }), - ); - Server::new(TcpListener::bind("127.0.0.1:8080")) - .serve(router) - .await; - }); - } + launch!(@([127, 0, 0, 1], 8080), app, (AppProps { count: 5 }), { + incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)), + }); } #[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] @@ -55,7 +24,6 @@ struct AppProps { fn app(cx: Scope) -> Element { let mut count = use_state(cx, || cx.props.count); let text = use_state(cx, || "...".to_string()); - let server_context = cx.sc(); cx.render(rsx! { h1 { "High-Five counter: {count}" } @@ -63,12 +31,12 @@ fn app(cx: Scope) -> Element { button { onclick: move |_| count -= 1, "Down low!" } button { onclick: move |_| { - to_owned![text, server_context]; + to_owned![text]; async move { if let Ok(data) = get_server_data().await { println!("Client received: {}", data); text.set(data.clone()); - post_server_data(server_context, data).await.unwrap(); + post_server_data(data).await.unwrap(); } } }, @@ -79,10 +47,9 @@ fn app(cx: Scope) -> Element { } #[server(PostServerData)] -async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { +async fn post_server_data(data: String) -> Result<(), ServerFnError> { // The server context contains information about the current request and allows you to modify the response. - cx.response_headers_mut() - .insert("Set-Cookie", "foo=bar".parse().unwrap()); + let cx = server_context(); println!("Server received: {}", data); println!("Request parts are {:?}", cx.request_parts()); diff --git a/packages/fullstack/examples/static-hydrated/.gitignore b/packages/fullstack/examples/static-hydrated/.gitignore new file mode 100644 index 000000000..c7f237de8 --- /dev/null +++ b/packages/fullstack/examples/static-hydrated/.gitignore @@ -0,0 +1,3 @@ +docs +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/static-hydrated/Cargo.toml b/packages/fullstack/examples/static-hydrated/Cargo.toml new file mode 100644 index 000000000..97776dc54 --- /dev/null +++ b/packages/fullstack/examples/static-hydrated/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "static-hydrated" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } +dioxus = { workspace = true } +dioxus-fullstack = { workspace = true, features = ["router"] } +dioxus-router = { workspace = true} +tokio = { workspace = true, features = ["full"], optional = true } +serde = "1.0.159" + +[features] +default = [] +ssr = ["tokio", "dioxus-fullstack/ssr"] +web = ["dioxus-web"] diff --git a/packages/fullstack/examples/static-hydrated/Dioxus.toml b/packages/fullstack/examples/static-hydrated/Dioxus.toml new file mode 100644 index 000000000..7a44ff71f --- /dev/null +++ b/packages/fullstack/examples/static-hydrated/Dioxus.toml @@ -0,0 +1,46 @@ +[application] + +# App (Project) Name +name = "Dioxus" + +# Dioxus App Default Platform +# desktop, web, mobile, ssr +default_platform = "web" + +# `build` & `serve` dist path +out_dir = "docs" + +# resource (public) file folder +asset_dir = "public" + +[web.app] + +# HTML title tag content +title = "dioxus | ⛺" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "public"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = ["tailwind.css"] + +# Javascript code file +script = [] + +[web.resource.dev] + +# serve: [dev-server] only + +# CSS style file +style = [] + +# Javascript code file +script = [] diff --git a/packages/fullstack/examples/static-hydrated/src/main.rs b/packages/fullstack/examples/static-hydrated/src/main.rs new file mode 100644 index 000000000..f89b01cce --- /dev/null +++ b/packages/fullstack/examples/static-hydrated/src/main.rs @@ -0,0 +1,86 @@ +//! Run with: +//! +//! ```sh +//! dx build --features web +//! cargo run --features ssr +//! ``` + +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_fullstack::{launch, prelude::*}; +use dioxus_router::prelude::*; +use serde::{Deserialize, Serialize}; + +// Generate all routes and output them to the docs path +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() { + pre_cache_static_routes_with_props( + &ServeConfigBuilder::new_with_router(dioxus_fullstack::router::FullstackRouterConfig::< + Route, + >::default()) + .assets_path("docs") + .incremental(IncrementalRendererConfig::default().static_dir("docs")) + .build(), + ) + .await + .unwrap(); +} + +// Hydrate the page +#[cfg(feature = "web")] +fn main() { + dioxus_web::launch_with_props( + dioxus_fullstack::router::RouteWithCfg::, + dioxus_fullstack::prelude::get_root_props_from_document() + .expect("Failed to get root props from document"), + dioxus_web::Config::default().hydrate(true), + ); +} + +#[cfg(not(any(feature = "web", feature = "ssr")))] +fn main() {} + +#[derive(Clone, Routable, Debug, PartialEq, Serialize, Deserialize)] +enum Route { + #[route("/")] + Home {}, + #[route("/blog")] + Blog, +} + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + Link { target: Route::Home {}, "Go to counter" } + table { + tbody { + for _ in 0..100 { + tr { + for _ in 0..100 { + td { "hello world!" } + } + } + } + } + } + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + let text = use_state(cx, || "...".to_string()); + + cx.render(rsx! { + Link { + target: Route::Blog {}, + "Go to blog" + } + div { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + } + }) +} diff --git a/packages/fullstack/examples/warp-hello-world/.gitignore b/packages/fullstack/examples/warp-hello-world/.gitignore index 6047329c6..b2a16319f 100644 --- a/packages/fullstack/examples/warp-hello-world/.gitignore +++ b/packages/fullstack/examples/warp-hello-world/.gitignore @@ -1,2 +1,3 @@ dist -target \ No newline at end of file +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/warp-hello-world/Cargo.toml b/packages/fullstack/examples/warp-hello-world/Cargo.toml index 58b842853..3c21372a9 100644 --- a/packages/fullstack/examples/warp-hello-world/Cargo.toml +++ b/packages/fullstack/examples/warp-hello-world/Cargo.toml @@ -16,6 +16,6 @@ warp = { version = "0.3.3", optional = true } execute = "0.2.12" [features] -default = ["web"] +default = [] ssr = ["warp", "tokio", "dioxus-fullstack/warp"] web = ["dioxus-web"] diff --git a/packages/fullstack/examples/warp-hello-world/src/main.rs b/packages/fullstack/examples/warp-hello-world/src/main.rs index 34fc24c77..bf6c2872a 100644 --- a/packages/fullstack/examples/warp-hello-world/src/main.rs +++ b/packages/fullstack/examples/warp-hello-world/src/main.rs @@ -1,47 +1,19 @@ //! Run with: //! //! ```sh -//! dioxus build --features web -//! cargo run --features ssr --no-default-features +//! dx build --features web +//! cargo run --features ssr //! ``` -#![allow(non_snake_case)] +#![allow(non_snake_case, unused)] use dioxus::prelude::*; use dioxus_fullstack::prelude::*; use serde::{Deserialize, Serialize}; fn main() { - #[cfg(feature = "web")] - dioxus_web::launch_with_props( - app, - get_root_props_from_document().unwrap_or_default(), - dioxus_web::Config::new().hydrate(true), - ); - #[cfg(feature = "ssr")] - { - // Start hot reloading - hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { - execute::shell("dioxus build --features web") - .spawn() - .unwrap() - .wait() - .unwrap(); - execute::shell("cargo run --features ssr --no-default-features") - .spawn() - .unwrap(); - true - })); - - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async move { - let routes = serve_dioxus_application( - "", - ServeConfigBuilder::new(app, AppProps { count: 12345 }), - ); - warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; - }); - } + launch!(@([127, 0, 0, 1], 8080), app, (AppProps { count: 5 }), { + incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)), + }); } #[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] @@ -52,20 +24,19 @@ struct AppProps { fn app(cx: Scope) -> Element { let mut count = use_state(cx, || cx.props.count); let text = use_state(cx, || "...".to_string()); - let server_context = cx.sc(); cx.render(rsx! { h1 { "High-Five counter: {count}" } - button { onclick: move |_| count += 10, "Up high!" } + button { onclick: move |_| count += 1, "Up high!" } button { onclick: move |_| count -= 1, "Down low!" } button { onclick: move |_| { - to_owned![text, server_context]; + to_owned![text]; async move { if let Ok(data) = get_server_data().await { println!("Client received: {}", data); text.set(data.clone()); - post_server_data(server_context, data).await.unwrap(); + post_server_data(data).await.unwrap(); } } }, @@ -76,10 +47,9 @@ fn app(cx: Scope) -> Element { } #[server(PostServerData)] -async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { +async fn post_server_data(data: String) -> Result<(), ServerFnError> { // The server context contains information about the current request and allows you to modify the response. - cx.response_headers_mut() - .insert("Set-Cookie", "foo=bar".parse().unwrap()); + let cx = server_context(); println!("Server received: {}", data); println!("Request parts are {:?}", cx.request_parts()); diff --git a/packages/fullstack/server-macro/Cargo.toml b/packages/fullstack/server-macro/Cargo.toml index cb039057b..3dae2a403 100644 --- a/packages/fullstack/server-macro/Cargo.toml +++ b/packages/fullstack/server-macro/Cargo.toml @@ -6,9 +6,11 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +proc-macro2 = "1.0.63" quote = "1.0.26" -server_fn_macro = { git = "https://github.com/leptos-rs/leptos", rev = "15a4e54435eb5a539afb75891292bcccd2cc8e85", features = ["stable"] } +server_fn_macro = { git = "https://github.com/leptos-rs/leptos", rev = "6b90e1babd425c9a324181c86e3fd1b942c9b10f" } syn = { version = "2", features = ["full"] } +convert_case = "0.6.0" [lib] proc-macro = true diff --git a/packages/fullstack/server-macro/src/lib.rs b/packages/fullstack/server-macro/src/lib.rs index 9a1a2c9c7..d00c009d0 100644 --- a/packages/fullstack/server-macro/src/lib.rs +++ b/packages/fullstack/server-macro/src/lib.rs @@ -1,6 +1,12 @@ +use convert_case::{Case, Converter}; use proc_macro::TokenStream; -use quote::ToTokens; +use proc_macro2::Literal; +use quote::{ToTokens, __private::TokenStream as TokenStream2}; use server_fn_macro::*; +use syn::{ + parse::{Parse, ParseStream}, + Ident, ItemFn, Token, +}; /// Declares that a function is a [server function](dioxus_fullstack). This means that /// its body will only run on the server, i.e., when the `ssr` feature is enabled. @@ -54,18 +60,147 @@ use server_fn_macro::*; /// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client. #[proc_macro_attribute] pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { - let context = ServerContext { - ty: syn::parse_quote!(DioxusServerContext), - path: syn::parse_quote!(::dioxus_fullstack::prelude::DioxusServerContext), + // before we pass this off to the server function macro, we apply extractors and middleware + let mut function: syn::ItemFn = match syn::parse(s).map_err(|e| e.to_compile_error()) { + Ok(f) => f, + Err(e) => return e.into(), }; + + // extract all #[middleware] attributes + let mut middlewares: Vec = vec![]; + function.attrs.retain(|attr| { + if attr.meta.path().is_ident("middleware") { + if let Ok(middleware) = attr.parse_args() { + middlewares.push(middleware); + false + } else { + true + } + } else { + true + } + }); + + let ItemFn { + attrs, + vis, + sig, + block, + } = function; + let mapped_body = quote::quote! { + #(#attrs)* + #vis #sig { + #block + } + }; + + let server_fn_path: syn::Path = syn::parse_quote!(::dioxus_fullstack::prelude::server_fn); + let trait_obj_wrapper: syn::Type = + syn::parse_quote!(::dioxus_fullstack::prelude::ServerFnTraitObj); + let mut args: ServerFnArgs = match syn::parse(args) { + Ok(args) => args, + Err(e) => return e.to_compile_error().into(), + }; + if args.struct_name.is_none() { + let upper_cammel_case_name = Converter::new() + .from_case(Case::Snake) + .to_case(Case::UpperCamel) + .convert(&sig.ident.to_string()); + args.struct_name = Some(Ident::new(&upper_cammel_case_name, sig.ident.span())); + } + let struct_name = args.struct_name.as_ref().unwrap(); match server_macro_impl( - args.into(), - s.into(), - syn::parse_quote!(::dioxus_fullstack::prelude::ServerFnTraitObj), - Some(context), - Some(syn::parse_quote!(::dioxus_fullstack::prelude::server_fn)), + quote::quote!(#args), + mapped_body, + trait_obj_wrapper, + None, + Some(server_fn_path.clone()), ) { Err(e) => e.to_compile_error().into(), - Ok(s) => s.to_token_stream().into(), + Ok(tokens) => quote::quote! { + #tokens + #[cfg(feature = "ssr")] + #server_fn_path::inventory::submit! { + ::dioxus_fullstack::prelude::ServerFnMiddleware { + prefix: #struct_name::PREFIX, + url: #struct_name::URL, + middleware: || vec![ + #( + std::sync::Arc::new(#middlewares), + ),* + ] + } + } + } + .to_token_stream() + .into(), + } +} + +#[derive(Debug)] +struct Middleware { + expr: syn::Expr, +} + +impl ToTokens for Middleware { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let expr = &self.expr; + tokens.extend(quote::quote! { + #expr + }); + } +} + +impl Parse for Middleware { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let arg: syn::Expr = input.parse()?; + Ok(Middleware { expr: arg }) + } +} + +struct ServerFnArgs { + struct_name: Option, + _comma: Option, + prefix: Option, + _comma2: Option, + encoding: Option, + _comma3: Option, + fn_path: Option, +} + +impl ToTokens for ServerFnArgs { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let struct_name = self.struct_name.as_ref().map(|s| quote::quote! { #s, }); + let prefix = self.prefix.as_ref().map(|p| quote::quote! { #p, }); + let encoding = self.encoding.as_ref().map(|e| quote::quote! { #e, }); + let fn_path = self.fn_path.as_ref().map(|f| quote::quote! { #f, }); + tokens.extend(quote::quote! { + #struct_name + #prefix + #encoding + #fn_path + }) + } +} + +impl Parse for ServerFnArgs { + fn parse(input: ParseStream) -> syn::Result { + let struct_name = input.parse()?; + let _comma = input.parse()?; + let prefix = input.parse()?; + let _comma2 = input.parse()?; + let encoding = input.parse()?; + let _comma3 = input.parse()?; + let fn_path = input.parse()?; + + Ok(Self { + struct_name, + _comma, + prefix, + _comma2, + encoding, + _comma3, + fn_path, + }) } } diff --git a/packages/fullstack/src/adapters/axum_adapter.rs b/packages/fullstack/src/adapters/axum_adapter.rs index 1ff136d4d..f1c5487e7 100644 --- a/packages/fullstack/src/adapters/axum_adapter.rs +++ b/packages/fullstack/src/adapters/axum_adapter.rs @@ -55,19 +55,17 @@ //! ``` use axum::{ - body::{self, Body, BoxBody, Full}, - extract::{State, WebSocketUpgrade}, + body::{self, Body, BoxBody}, + extract::State, handler::Handler, http::{Request, Response, StatusCode}, response::IntoResponse, routing::{get, post}, Router, }; -use dioxus_core::VirtualDom; -use server_fn::{Encoding, Payload, ServerFunctionRegistry}; -use std::error::Error; +use server_fn::{Encoding, ServerFunctionRegistry}; use std::sync::Arc; -use tokio::task::spawn_blocking; +use std::sync::RwLock; use crate::{ prelude::*, render::SSRState, serve_config::ServeConfig, server_context::DioxusServerContext, @@ -92,7 +90,7 @@ pub trait DioxusRouterExt { /// .register_server_fns_with_handler("", |func| { /// move |req: Request| async move { /// let (parts, body) = req.into_parts(); - /// let parts: Arc = Arc::new(parts.into()); + /// let parts: Arc = Arc::new(parts.into()); /// let server_context = DioxusServerContext::new(parts.clone()); /// server_fn_handler(server_context, func.clone(), parts, body).await /// } @@ -106,7 +104,7 @@ pub trait DioxusRouterExt { fn register_server_fns_with_handler( self, server_fn_route: &'static str, - handler: impl FnMut(server_fn::ServerFnTraitObj) -> H, + handler: impl FnMut(server_fn::ServerFnTraitObj<()>) -> H, ) -> Self where H: Handler, @@ -231,7 +229,7 @@ where fn register_server_fns_with_handler( self, server_fn_route: &'static str, - mut handler: impl FnMut(server_fn::ServerFnTraitObj) -> H, + mut handler: impl FnMut(server_fn::ServerFnTraitObj<()>) -> H, ) -> Self where H: Handler, @@ -256,11 +254,22 @@ where fn register_server_fns(self, server_fn_route: &'static str) -> Self { self.register_server_fns_with_handler(server_fn_route, |func| { - move |req: Request| async move { - let (parts, body) = req.into_parts(); - let parts: Arc = Arc::new(parts.into()); - let server_context = DioxusServerContext::new(parts.clone()); - server_fn_handler(server_context, func.clone(), parts, body).await + use crate::layer::Service; + move |req: Request| { + let mut service = crate::server_fn_service(Default::default(), func); + async move { + let (req, body) = req.into_parts(); + let req = Request::from_parts(req, body); + let res = service.run(req); + match res.await { + Ok(res) => Ok::<_, std::convert::Infallible>(res.map(|b| b.into())), + Err(_e) => { + let mut res = Response::new(Body::empty()); + *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + Ok(res) + } + } + } } }) } @@ -311,15 +320,13 @@ where cfg: impl Into>, ) -> Self { let cfg = cfg.into(); + let ssr_state = SSRState::new(&cfg); // Add server functions and render index.html self.serve_static_assets(cfg.assets_path) - .route( - "/", - get(render_handler).with_state((cfg, SSRState::default())), - ) .connect_hot_reload() .register_server_fns(server_fn_route) + .fallback(get(render_handler).with_state((cfg, ssr_state))) } fn connect_hot_reload(self) -> Self { @@ -330,7 +337,7 @@ where Router::new() .route( "/disconnect", - get(|ws: WebSocketUpgrade| async { + get(|ws: axum::extract::WebSocketUpgrade| async { ws.on_upgrade(|mut ws| async move { use axum::extract::ws::Message; let _ = ws.send(Message::Text("connected".into())).await; @@ -352,96 +359,42 @@ where } } +fn apply_request_parts_to_response( + headers: hyper::header::HeaderMap, + response: &mut axum::response::Response, +) { + let mut_headers = response.headers_mut(); + for (key, value) in headers.iter() { + mut_headers.insert(key, value.clone()); + } +} + async fn render_handler( State((cfg, ssr_state)): State<(ServeConfig

, SSRState)>, request: Request, ) -> impl IntoResponse { let (parts, _) = request.into_parts(); - let parts: Arc = Arc::new(parts.into()); - let server_context = DioxusServerContext::new(parts); - let mut vdom = - VirtualDom::new_with_props(cfg.app, cfg.props.clone()).with_root_context(server_context); - let _ = vdom.rebuild(); + let url = parts.uri.path_and_query().unwrap().to_string(); + let parts: Arc> = Arc::new(RwLock::new(parts.into())); + let server_context = DioxusServerContext::new(parts.clone()); - let rendered = ssr_state.render_vdom(&vdom, &cfg); - Full::from(rendered) -} - -/// A default handler for server functions. It will deserialize the request, call the server function, and serialize the response. -pub async fn server_fn_handler( - server_context: DioxusServerContext, - function: server_fn::ServerFnTraitObj, - parts: Arc, - body: Body, -) -> impl IntoResponse { - let body = hyper::body::to_bytes(body).await; - let Ok(body) = body else { - return report_err(body.err().unwrap()); - }; - - // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime - let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); - let query_string = parts.uri.query().unwrap_or_default().to_string(); - spawn_blocking({ - move || { - tokio::runtime::Runtime::new() - .expect("couldn't spawn runtime") - .block_on(async { - let query = &query_string.into(); - let data = match &function.encoding() { - Encoding::Url | Encoding::Cbor => &body, - Encoding::GetJSON | Encoding::GetCBOR => query, - }; - let resp = match function.call(server_context.clone(), data).await { - Ok(serialized) => { - // if this is Accept: application/json then send a serialized JSON response - let accept_header = parts - .headers - .get("Accept") - .and_then(|value| value.to_str().ok()); - let mut res = Response::builder(); - *res.headers_mut().expect("empty response should be valid") = - server_context.take_response_headers(); - if accept_header == Some("application/json") - || accept_header - == Some( - "application/\ - x-www-form-urlencoded", - ) - || accept_header == Some("application/cbor") - { - res = res.status(StatusCode::OK); - } - - let resp = match serialized { - Payload::Binary(data) => res - .header("Content-Type", "application/cbor") - .body(body::boxed(Full::from(data))), - Payload::Url(data) => res - .header( - "Content-Type", - "application/\ - x-www-form-urlencoded", - ) - .body(body::boxed(data)), - Payload::Json(data) => res - .header("Content-Type", "application/json") - .body(body::boxed(data)), - }; - - resp.unwrap() - } - Err(e) => report_err(e), - }; - - resp_tx.send(resp).unwrap(); - }) + match ssr_state.render(url, &cfg, &server_context).await { + Ok(rendered) => { + let crate::render::RenderResponse { html, freshness } = rendered; + let mut response = axum::response::Html::from(html).into_response(); + freshness.write(response.headers_mut()); + let headers = server_context.response_parts().unwrap().headers.clone(); + apply_request_parts_to_response(headers, &mut response); + response } - }); - resp_rx.await.unwrap() + Err(e) => { + log::error!("Failed to render page: {}", e); + report_err(e).into_response() + } + } } -fn report_err(e: E) -> Response { +fn report_err(e: E) -> Response { Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(body::boxed(format!("Error: {}", e))) @@ -450,7 +403,7 @@ fn report_err(e: E) -> Response { /// A handler for Dioxus web hot reload websocket. This will send the updated static parts of the RSX to the client when they change. #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] -pub async fn hot_reload_handler(ws: WebSocketUpgrade) -> impl IntoResponse { +pub async fn hot_reload_handler(ws: axum::extract::WebSocketUpgrade) -> impl IntoResponse { use axum::extract::ws::Message; use futures_util::StreamExt; diff --git a/packages/fullstack/src/adapters/mod.rs b/packages/fullstack/src/adapters/mod.rs index 5809132b8..d72a0323c 100644 --- a/packages/fullstack/src/adapters/mod.rs +++ b/packages/fullstack/src/adapters/mod.rs @@ -16,3 +16,145 @@ pub mod axum_adapter; pub mod salvo_adapter; #[cfg(feature = "warp")] pub mod warp_adapter; + +use std::sync::{Arc, RwLock}; + +use http::StatusCode; + +use server_fn::{Encoding, Payload}; +use tokio::task::spawn_blocking; + +use crate::{ + layer::{BoxedService, Service}, + prelude::{DioxusServerContext, ProvideServerContext}, +}; + +/// Create a server function handler with the given server context and server function. +pub fn server_fn_service( + context: DioxusServerContext, + function: server_fn::ServerFnTraitObj<()>, +) -> crate::layer::BoxedService { + let prefix = function.prefix().to_string(); + let url = function.url().to_string(); + if let Some(middleware) = crate::server_fn::MIDDLEWARE.get(&(&prefix, &url)) { + let mut service = BoxedService(Box::new(ServerFnHandler::new(context, function))); + for middleware in middleware { + service = middleware.layer(service); + } + service + } else { + BoxedService(Box::new(ServerFnHandler::new(context, function))) + } +} + +#[derive(Clone)] +/// A default handler for server functions. It will deserialize the request body, call the server function, and serialize the response. +pub struct ServerFnHandler { + server_context: DioxusServerContext, + function: server_fn::ServerFnTraitObj<()>, +} + +impl ServerFnHandler { + /// Create a new server function handler with the given server context and server function. + pub fn new( + server_context: impl Into, + function: server_fn::ServerFnTraitObj<()>, + ) -> Self { + let server_context = server_context.into(); + Self { + server_context, + function, + } + } +} + +impl Service for ServerFnHandler { + fn run( + &mut self, + req: http::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, server_fn::ServerFnError>, + > + Send, + >, + > { + let Self { + server_context, + function, + } = self.clone(); + Box::pin(async move { + let query = req.uri().query().unwrap_or_default().as_bytes().to_vec(); + let (parts, body) = req.into_parts(); + let body = hyper::body::to_bytes(body).await?.to_vec(); + let headers = &parts.headers; + let accept_header = headers.get("Accept").cloned(); + let parts = Arc::new(RwLock::new(parts)); + + // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + spawn_blocking({ + let function = function.clone(); + let mut server_context = server_context.clone(); + server_context.parts = parts; + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on(async move { + let data = match function.encoding() { + Encoding::Url | Encoding::Cbor => &body, + Encoding::GetJSON | Encoding::GetCBOR => &query, + }; + let server_function_future = function.call((), data); + let server_function_future = ProvideServerContext::new( + server_function_future, + server_context.clone(), + ); + let resp = server_function_future.await; + + resp_tx.send(resp).unwrap(); + }) + } + }); + let result = resp_rx.await.unwrap(); + let mut res = http::Response::builder(); + + // Set the headers from the server context + let parts = server_context.parts.read().unwrap(); + *res.headers_mut().expect("empty headers should be valid") = parts.headers.clone(); + + let serialized = result?; + // if this is Accept: application/json then send a serialized JSON response + let accept_header = accept_header.as_ref().and_then(|value| value.to_str().ok()); + if accept_header == Some("application/json") + || accept_header + == Some( + "application/\ + x-www-form-urlencoded", + ) + || accept_header == Some("application/cbor") + { + res = res.status(StatusCode::OK); + } + + Ok(match serialized { + Payload::Binary(data) => { + res = res.header("Content-Type", "application/cbor"); + res.body(data.into())? + } + Payload::Url(data) => { + res = res.header( + "Content-Type", + "application/\ + x-www-form-urlencoded", + ); + res.body(data.into())? + } + Payload::Json(data) => { + res = res.header("Content-Type", "application/json"); + res.body(data.into())? + } + }) + }) + } +} diff --git a/packages/fullstack/src/adapters/salvo_adapter.rs b/packages/fullstack/src/adapters/salvo_adapter.rs index 59b7c36ea..dc536376d 100644 --- a/packages/fullstack/src/adapters/salvo_adapter.rs +++ b/packages/fullstack/src/adapters/salvo_adapter.rs @@ -49,22 +49,31 @@ //! } //! ``` -use dioxus_core::VirtualDom; -use hyper::{http::HeaderValue, StatusCode}; +use http_body_util::{BodyExt, Limited}; +use hyper::body::Body as HyperBody; +use hyper::StatusCode; use salvo::{ async_trait, handler, + http::{ + cookie::{Cookie, CookieJar}, + ParseError, + }, serve_static::{StaticDir, StaticFile}, - Depot, FlowCtrl, Handler, Request, Response, Router, + Depot, Error as SalvoError, FlowCtrl, Handler, Request, Response, Router, }; -use server_fn::{Encoding, Payload, ServerFunctionRegistry}; +use server_fn::{Encoding, ServerFunctionRegistry}; use std::error::Error; use std::sync::Arc; -use tokio::task::spawn_blocking; +use std::sync::RwLock; use crate::{ - prelude::*, render::SSRState, serve_config::ServeConfig, server_fn::DioxusServerFnRegistry, + layer::Service, prelude::*, render::SSRState, serve_config::ServeConfig, + server_fn::DioxusServerFnRegistry, server_fn_service, }; +type HyperRequest = hyper::Request; +type HyperResponse = hyper::Response; + /// A extension trait with utilities for integrating Dioxus with your Salvo router. pub trait DioxusRouterExt { /// Registers server functions with a custom handler function. This allows you to pass custom context to your server functions by generating a [`DioxusServerContext`] from the request. @@ -76,7 +85,7 @@ pub trait DioxusRouterExt { /// use dioxus_fullstack::prelude::*; /// /// struct ServerFunctionHandler { - /// server_fn: server_fn::ServerFnTraitObj, + /// server_fn: server_fn::ServerFnTraitObj<()>, /// } /// /// #[handler] @@ -109,7 +118,7 @@ pub trait DioxusRouterExt { fn register_server_fns_with_handler( self, server_fn_route: &'static str, - handler: impl Fn(server_fn::ServerFnTraitObj) -> H, + handler: impl Fn(server_fn::ServerFnTraitObj<()>) -> H, ) -> Self where H: Handler + 'static; @@ -203,7 +212,7 @@ impl DioxusRouterExt for Router { fn register_server_fns_with_handler( self, server_fn_route: &'static str, - mut handler: impl FnMut(server_fn::ServerFnTraitObj) -> H, + mut handler: impl FnMut(server_fn::ServerFnTraitObj<()>) -> H, ) -> Self where H: Handler + 'static, @@ -282,7 +291,7 @@ impl DioxusRouterExt for Router { self.serve_static_assets(cfg.assets_path) .connect_hot_reload() .register_server_fns(server_fn_path) - .push(Router::with_path("/").get(SSRHandler { cfg })) + .push(Router::with_path("/<**any_path>").get(SSRHandler { cfg })) } fn connect_hot_reload(self) -> Self { @@ -298,13 +307,71 @@ impl DioxusRouterExt for Router { } /// Extracts the parts of a request that are needed for server functions. This will take parts of the request and replace them with empty values. -pub fn extract_parts(req: &mut Request) -> RequestParts { - RequestParts { - method: std::mem::take(req.method_mut()), - uri: std::mem::take(req.uri_mut()), - version: req.version(), - headers: std::mem::take(req.headers_mut()), - extensions: std::mem::take(req.extensions_mut()), +pub fn extract_parts(req: &mut Request) -> http::request::Parts { + let mut parts = http::request::Request::new(()).into_parts().0; + parts.method = std::mem::take(req.method_mut()); + parts.uri = std::mem::take(req.uri_mut()); + parts.version = req.version(); + parts.headers = std::mem::take(req.headers_mut()); + parts.extensions = std::mem::take(req.extensions_mut()); + + parts +} + +fn apply_request_parts_to_response( + headers: hyper::header::HeaderMap, + response: &mut salvo::prelude::Response, +) { + let mut_headers = response.headers_mut(); + for (key, value) in headers.iter() { + mut_headers.insert(key, value.clone()); + } +} + +#[inline] +async fn convert_request(req: &mut Request) -> Result { + let forward_url: hyper::Uri = TryFrom::try_from(req.uri()).map_err(SalvoError::other)?; + let mut build = hyper::Request::builder() + .method(req.method()) + .uri(&forward_url); + for (key, value) in req.headers() { + build = build.header(key, value); + } + static SECURE_MAX_SIZE: usize = 64 * 1024; + + let body = Limited::new(req.take_body(), SECURE_MAX_SIZE) + .collect() + .await + .map_err(ParseError::other)? + .to_bytes(); + build.body(body.into()).map_err(SalvoError::other) +} + +#[inline] +async fn convert_response(response: HyperResponse, res: &mut Response) { + let (parts, body) = response.into_parts(); + let http::response::Parts { + version, + headers, + status, + .. + } = parts; + res.status_code = Some(status); + res.version = version; + res.cookies = CookieJar::new(); + for cookie in headers.get_all(http::header::SET_COOKIE).iter() { + if let Some(cookie) = cookie + .to_str() + .ok() + .and_then(|s| Cookie::parse(s.to_string()).ok()) + { + res.cookies.add_original(cookie); + } + } + res.headers = headers; + res.version = version; + if let Ok(bytes) = hyper::body::to_bytes(body).await { + res.body = bytes.into() } } @@ -325,34 +392,47 @@ impl Handler for SSRHandler let renderer_pool = if let Some(renderer) = depot.obtain::() { renderer.clone() } else { - let renderer = SSRState::default(); + let renderer = SSRState::new(&self.cfg); depot.inject(renderer.clone()); renderer }; - let parts: Arc = Arc::new(extract_parts(req)); + + let route = req.uri().path().to_string(); + let parts: Arc> = Arc::new(RwLock::new(extract_parts(req))); let server_context = DioxusServerContext::new(parts); - let mut vdom = VirtualDom::new_with_props(self.cfg.app, self.cfg.props.clone()) - .with_root_context(server_context.clone()); - let _ = vdom.rebuild(); - res.write_body(renderer_pool.render_vdom(&vdom, &self.cfg)) - .unwrap(); + match renderer_pool + .render(route, &self.cfg, &server_context) + .await + { + Ok(rendered) => { + let crate::render::RenderResponse { html, freshness } = rendered; - *res.headers_mut() = server_context.take_response_headers(); + res.write_body(html).unwrap(); + + let headers = server_context.response_parts().unwrap().headers.clone(); + apply_request_parts_to_response(headers, res); + freshness.write(res.headers_mut()); + } + Err(err) => { + log::error!("Error rendering SSR: {}", err); + res.write_body("Error rendering SSR").unwrap(); + } + }; } } /// A default handler for server functions. It will deserialize the request body, call the server function, and serialize the response. pub struct ServerFnHandler { server_context: DioxusServerContext, - function: server_fn::ServerFnTraitObj, + function: server_fn::ServerFnTraitObj<()>, } impl ServerFnHandler { /// Create a new server function handler with the given server context and server function. pub fn new( server_context: impl Into, - function: server_fn::ServerFnTraitObj, + function: server_fn::ServerFnTraitObj<()>, ) -> Self { let server_context = server_context.into(); Self { @@ -365,90 +445,14 @@ impl ServerFnHandler { #[handler] impl ServerFnHandler { async fn handle(&self, req: &mut Request, _depot: &mut Depot, res: &mut Response) { - let Self { - server_context, - function, - } = self; - - let query = req - .uri() - .query() - .unwrap_or_default() - .as_bytes() - .to_vec() - .into(); - let body = hyper::body::to_bytes(req.body_mut().unwrap()).await; - let Ok(body)=body else { - handle_error(body.err().unwrap(), res); - return; - }; - let headers = req.headers(); - let accept_header = headers.get("Accept").cloned(); - - let parts = Arc::new(extract_parts(req)); - - // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime - let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); - spawn_blocking({ - let function = function.clone(); - let mut server_context = server_context.clone(); - server_context.parts = parts; - move || { - tokio::runtime::Runtime::new() - .expect("couldn't spawn runtime") - .block_on(async move { - let data = match function.encoding() { - Encoding::Url | Encoding::Cbor => &body, - Encoding::GetJSON | Encoding::GetCBOR => &query, - }; - let resp = function.call(server_context, data).await; - - resp_tx.send(resp).unwrap(); - }) - } - }); - let result = resp_rx.await.unwrap(); - - // Set the headers from the server context - *res.headers_mut() = server_context.take_response_headers(); - - match result { - Ok(serialized) => { - // if this is Accept: application/json then send a serialized JSON response - let accept_header = accept_header.as_ref().and_then(|value| value.to_str().ok()); - if accept_header == Some("application/json") - || accept_header - == Some( - "application/\ - x-www-form-urlencoded", - ) - || accept_header == Some("application/cbor") - { - res.set_status_code(StatusCode::OK); - } - - match serialized { - Payload::Binary(data) => { - res.headers_mut() - .insert("Content-Type", HeaderValue::from_static("application/cbor")); - res.write_body(data).unwrap(); - } - Payload::Url(data) => { - res.headers_mut().insert( - "Content-Type", - HeaderValue::from_static( - "application/\ - x-www-form-urlencoded", - ), - ); - res.write_body(data).unwrap(); - } - Payload::Json(data) => { - res.headers_mut() - .insert("Content-Type", HeaderValue::from_static("application/json")); - res.write_body(data).unwrap(); - } - } + match convert_request(req).await { + Ok(hyper_req) => { + let response = + server_fn_service(self.server_context.clone(), self.function.clone()) + .run(hyper_req) + .await + .unwrap(); + convert_response(response, res).await; } Err(err) => handle_error(err, res), } @@ -457,7 +461,7 @@ impl ServerFnHandler { fn handle_error(error: impl Error + Send + Sync, res: &mut Response) { let mut resp_err = Response::new(); - resp_err.set_status_code(StatusCode::INTERNAL_SERVER_ERROR); + resp_err.status_code(StatusCode::INTERNAL_SERVER_ERROR); resp_err.render(format!("Internal Server Error: {}", error)); *res = resp_err; } @@ -494,8 +498,8 @@ impl HotReloadHandler { _depot: &mut Depot, res: &mut Response, ) -> Result<(), salvo::http::StatusError> { - use salvo::ws::Message; - use salvo::ws::WebSocketUpgrade; + use salvo::websocket::Message; + use salvo::websocket::WebSocketUpgrade; let state = crate::hot_reload::spawn_hot_reload().await; @@ -542,10 +546,10 @@ impl HotReloadHandler { #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] #[handler] async fn ignore_ws(req: &mut Request, res: &mut Response) -> Result<(), salvo::http::StatusError> { - use salvo::ws::WebSocketUpgrade; + use salvo::websocket::WebSocketUpgrade; WebSocketUpgrade::new() .upgrade(req, res, |mut ws| async move { - let _ = ws.send(salvo::ws::Message::text("connected")).await; + let _ = ws.send(salvo::websocket::Message::text("connected")).await; while let Some(msg) = ws.recv().await { if msg.is_err() { return; diff --git a/packages/fullstack/src/adapters/warp_adapter.rs b/packages/fullstack/src/adapters/warp_adapter.rs index b9f23ae7f..723314ca3 100644 --- a/packages/fullstack/src/adapters/warp_adapter.rs +++ b/packages/fullstack/src/adapters/warp_adapter.rs @@ -46,16 +46,19 @@ //! //! ``` +use crate::layer::Service; use crate::{ prelude::*, render::SSRState, serve_config::ServeConfig, server_fn::DioxusServerFnRegistry, }; -use dioxus_core::VirtualDom; +use crate::server_fn_service; use server_fn::{Encoding, Payload, ServerFunctionRegistry}; use std::error::Error; use std::sync::Arc; +use std::sync::RwLock; use tokio::task::spawn_blocking; use warp::path::FullPath; +use warp::Rejection; use warp::{ filters::BoxedFilter, http::{Response, StatusCode}, @@ -71,19 +74,22 @@ use warp::{ /// /// #[tokio::main] /// async fn main() { -/// let routes = register_server_fns_with_handler("", |full_route, func| { +/// let routes = register_server_fns_with_handler(server_fn_route, |full_route, func| { /// path(full_route) -/// .and(post()) -/// .and(header::headers_cloned()) -/// .and(body::bytes()) -/// .and_then(move |headers: HeaderMap, body| { -/// let func = func.clone(); -/// async move { -/// // Add the headers to the server function context -/// server_fn_handler((headers.clone(),), func, headers, body).await -/// } -/// }) -/// }); +/// .and(warp::post().or(warp::get()).unify()) +/// .and(request_parts()) +/// .and(warp::body::bytes()) +/// .and_then(move |parts, bytes: bytes::Bytes| { +/// let mut service = server_fn_service(DioxusServerContext::default(), func.clone()); +/// async move { +/// let req = warp::hyper::Request::from_parts(parts, bytes.into()); +/// service.run(req).await.map_err(|err| { +/// log::error!("Server function error: {}", err); +/// warp::reject::reject() +/// }) +/// } +/// }) +/// }) /// warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; /// } /// ``` @@ -92,7 +98,7 @@ pub fn register_server_fns_with_handler( mut handler: H, ) -> BoxedFilter<(R,)> where - H: FnMut(String, server_fn::ServerFnTraitObj) -> F, + H: FnMut(String, server_fn::ServerFnTraitObj<()>) -> F, F: Filter + Send + Sync + 'static, F::Extract: Send, R: Reply + 'static, @@ -131,10 +137,14 @@ pub fn register_server_fns(server_fn_route: &'static str) -> BoxedFilter<(impl R .and(warp::post().or(warp::get()).unify()) .and(request_parts()) .and(warp::body::bytes()) - .and_then(move |parts, bytes| { - let func = func.clone(); + .and_then(move |parts, bytes: bytes::Bytes| { + let mut service = server_fn_service(DioxusServerContext::default(), func.clone()); async move { - server_fn_handler(DioxusServerContext::default(), func, parts, bytes).await + let req = warp::hyper::Request::from_parts(parts, bytes.into()); + service.run(req).await.map_err(|err| { + log::error!("Server function error: {}", err); + warp::reject::reject() + }) } }) }) @@ -168,9 +178,14 @@ pub fn serve_dioxus_application( ) -> impl Filter + Clone { warp::get() .and(request_parts()) - .and(with_ssr_state()) - .map(move |parts, renderer: SSRState| { - let parts = Arc::new(parts); + .and(with_ssr_state(&cfg)) + .then(move |parts: http::request::Parts, renderer: SSRState| { + let route = parts.uri.path().to_string(); + let parts = Arc::new(RwLock::new(parts)); + let cfg = cfg.clone(); + async move { + let server_context = DioxusServerContext::new(parts); - let server_context = DioxusServerContext::new(parts); + match renderer.render(route, &cfg, &server_context).await { + Ok(rendered) => { + let crate::render::RenderResponse { html, freshness } = rendered; - let mut vdom = VirtualDom::new_with_props(cfg.app, cfg.props.clone()) - .with_root_context(server_context.clone()); - let _ = vdom.rebuild(); + let mut res = Response::builder() + .header("Content-Type", "text/html") + .body(html) + .unwrap(); - let html = renderer.render_vdom(&vdom, &cfg); + let headers_mut = res.headers_mut(); + let headers = server_context.response_parts().unwrap().headers.clone(); + for (key, value) in headers.iter() { + headers_mut.insert(key, value.clone()); + } + freshness.write(headers_mut); - let mut res = Response::builder(); - - *res.headers_mut().expect("empty request should be valid") = - server_context.take_response_headers(); - - res.header("Content-Type", "text/html") - .body(Bytes::from(html)) - .unwrap() + res + } + Err(err) => { + log::error!("Failed to render ssr: {}", err); + Response::builder() + .status(500) + .body("Failed to render ssr".into()) + .unwrap() + } + } + } }) } /// An extractor for the request parts (used in [DioxusServerContext]). This will extract the method, uri, query, and headers from the request. pub fn request_parts( -) -> impl Filter + Clone { +) -> impl Filter + Clone { warp::method() .and(warp::filters::path::full()) .and( @@ -221,19 +251,23 @@ pub fn request_parts( .map_err(|err| { warp::reject::custom(FailedToReadBody(format!("Failed to build uri: {}", err))) }) - .map(|uri| RequestParts { - method, - uri, - headers, - ..Default::default() + .map(|uri| { + let mut req = http::Request::builder() + .method(method) + .uri(uri) + .body(()) + .unwrap(); + req.headers_mut().extend(headers); + req.into_parts().0 }) }) } -fn with_ssr_state() -> impl Filter + Clone -{ - let state = SSRState::default(); - warp::any().map(move || state.clone()) +fn with_ssr_state( + cfg: &ServeConfig

, +) -> impl Filter + Clone { + let renderer = SSRState::new(cfg); + warp::any().map(move || renderer.clone()) } #[derive(Debug)] @@ -246,102 +280,6 @@ struct RecieveFailed(String); impl warp::reject::Reject for RecieveFailed {} -/// A default handler for server functions. It will deserialize the request body, call the server function, and serialize the response. -pub async fn server_fn_handler( - server_context: impl Into, - function: server_fn::ServerFnTraitObj, - parts: RequestParts, - body: Bytes, -) -> Result, warp::Rejection> { - let mut server_context = server_context.into(); - - let parts = Arc::new(parts); - - server_context.parts = parts.clone(); - - // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime - let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); - spawn_blocking({ - move || { - tokio::runtime::Runtime::new() - .expect("couldn't spawn runtime") - .block_on(async move { - let query = parts - .uri - .query() - .unwrap_or_default() - .as_bytes() - .to_vec() - .into(); - let data = match function.encoding() { - Encoding::Url | Encoding::Cbor => &body, - Encoding::GetJSON | Encoding::GetCBOR => &query, - }; - let resp = match function.call(server_context.clone(), data).await { - Ok(serialized) => { - // if this is Accept: application/json then send a serialized JSON response - let accept_header = parts - .headers - .get("Accept") - .as_ref() - .and_then(|value| value.to_str().ok()); - let mut res = Response::builder(); - - *res.headers_mut().expect("empty request should be valid") = - server_context.take_response_headers(); - - if accept_header == Some("application/json") - || accept_header - == Some( - "application/\ - x-www-form-urlencoded", - ) - || accept_header == Some("application/cbor") - { - res = res.status(StatusCode::OK); - } - - let resp = match serialized { - Payload::Binary(data) => res - .header("Content-Type", "application/cbor") - .body(Bytes::from(data)), - Payload::Url(data) => res - .header( - "Content-Type", - "application/\ - x-www-form-urlencoded", - ) - .body(Bytes::from(data)), - Payload::Json(data) => res - .header("Content-Type", "application/json") - .body(Bytes::from(data)), - }; - - Box::new(resp.unwrap()) - } - Err(e) => report_err(e), - }; - - if resp_tx.send(resp).is_err() { - eprintln!("Error sending response"); - } - }) - } - }); - resp_rx.await.map_err(|err| { - warp::reject::custom(RecieveFailed(format!("Failed to recieve response {err}"))) - }) -} - -fn report_err(e: E) -> Box { - Box::new( - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(format!("Error: {}", e)) - .unwrap(), - ) as Box -} - /// Register the web RSX hot reloading endpoint. This will enable hot reloading for your application in debug mode when you call [`dioxus_hot_reload::hot_reload_init`]. /// /// # Example @@ -360,9 +298,8 @@ pub fn connect_hot_reload() -> impl Filter>>>, + pub(crate) templates: + Arc>>>, // The channel to send messages to the hot reload thread pub(crate) message_receiver: Receiver>>, } diff --git a/packages/fullstack/src/launch.rs b/packages/fullstack/src/launch.rs new file mode 100644 index 000000000..6432e69ce --- /dev/null +++ b/packages/fullstack/src/launch.rs @@ -0,0 +1,173 @@ +//! Launch helper macros for fullstack apps +#![allow(unused)] + +#[macro_export] +/// Launch a server with a router +macro_rules! launch_router { + (@router_config) => { + dioxus_fullstack::router::FullstackRouterConfig::default() + }; + + (@router_config $router_cfg:expr) => { + $router_cfg + }; + + (@$address:expr, $route:ty, $(cfg: $router_cfg:expr,)? {$($rule:ident $(: $cfg:expr)?,)*}) => { + dioxus_fullstack::launch!( + @$address, + dioxus_fullstack::router::RouteWithCfg::<$route>, + (dioxus_fullstack::launch_router!(@router_config $($router_cfg)?)), + { + $($rule $(: $cfg)?,)* + } + ) + }; +} + +#[macro_export] +/// Launch a server +macro_rules! launch { + (@web_cfg $server_cfg:ident $wcfg:expr) => { + #[cfg(feature = "web")] + let web_cfg = $wcfg; + }; + + (@web_cfg $server_cfg:ident) => { + #[cfg(feature = "web")] + let web_cfg = dioxus_web::Config::new(); + }; + + (@serve_cfg $server_cfg:ident $cfg:expr) => { + #[cfg(feature = "ssr")] + let $server_cfg = $cfg; + }; + + (@hot_reload $server_cfg:ident) => { + #[cfg(feature = "ssr")] + { + dioxus_fullstack::prelude::hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { + std::process::Command::new("cargo") + .args(&["run", "--features", "ssr"]) + .spawn() + .unwrap() + .wait() + .unwrap(); + std::process::Command::new("cargo") + .args(&["run", "--features", "web"]) + .spawn() + .unwrap() + .wait() + .unwrap(); + true + })); + } + }; + + (@hot_reload $server_cfg:ident $hot_reload_cfg:expr) => { + #[cfg(feature = "ssr")] + { + dioxus_fullstack::prelude::hot_reload_init!($hot_reload_cfg); + } + }; + + (@incremental $server_cfg:ident) => { + #[cfg(feature = "ssr")] + let $server_cfg = $server_cfg.incremental(dioxus_fullstack::prelude::IncrementalRendererConfig::default()); + }; + + (@incremental $server_cfg:ident $cfg:expr) => { + #[cfg(feature = "ssr")] + let $server_cfg = $server_cfg.incremental($cfg); + }; + + (@props_type) => { + Default::default() + }; + + (@props_type $props:expr) => { + $props + }; + + (@ $address:expr, $comp:path, $(( $props:expr ),)? {$($rule:ident $(: $cfg:expr)?,)*}) => { + #[cfg(feature = "web")] + { + #[allow(unused)] + let web_cfg = dioxus_web::Config::new(); + + $( + dioxus_fullstack::prelude::launch!(@$rule server_cfg $($cfg)?); + )* + + dioxus_web::launch_with_props( + $comp, + dioxus_fullstack::prelude::get_root_props_from_document().expect("Failed to get root props from document"), + web_cfg.hydrate(true), + ); + } + #[cfg(feature = "ssr")] + { + let server_cfg = dioxus_fullstack::prelude::ServeConfigBuilder::new($comp, dioxus_fullstack::prelude::launch!(@props_type $($props)?)); + + $( + dioxus_fullstack::prelude::launch!(@$rule server_cfg $($cfg)?); + )* + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from($address); + + dioxus_fullstack::launch::launch_server(addr, server_cfg.build()).await; + }); + } + }; +} + +/// Launch a server with the given configeration +/// This will use the routing intigration of the currently enabled intigration feature +#[cfg(feature = "ssr")] +pub async fn launch_server( + addr: std::net::SocketAddr, + cfg: crate::prelude::ServeConfig

, +) { + #[cfg(all(feature = "axum", not(feature = "warp"), not(feature = "salvo")))] + { + use crate::adapters::axum_adapter::DioxusRouterExt; + use tower::ServiceBuilder; + + axum::Server::bind(&addr) + .serve( + axum::Router::new() + .serve_dioxus_application("", cfg) + .layer( + ServiceBuilder::new() + .layer(tower_http::compression::CompressionLayer::new().gzip(true)), + ) + .into_make_service(), + ) + .await + .unwrap(); + } + #[cfg(all(feature = "warp", not(feature = "axum"), not(feature = "salvo")))] + { + use warp::Filter; + warp::serve( + crate::prelude::serve_dioxus_application("", cfg) + .with(warp::filters::compression::gzip()), + ) + .run(addr) + .await; + } + #[cfg(all(feature = "salvo", not(feature = "axum"), not(feature = "warp")))] + { + use crate::adapters::salvo_adapter::DioxusRouterExt; + use salvo::conn::Listener; + let router = salvo::Router::new().serve_dioxus_application("", cfg).hoop( + salvo::compression::Compression::new() + .enable_gzip(salvo::prelude::CompressionLevel::Default), + ); + salvo::Server::new(salvo::conn::tcp::TcpListener::new(addr).bind().await) + .serve(router) + .await; + } +} diff --git a/packages/fullstack/src/layer.rs b/packages/fullstack/src/layer.rs new file mode 100644 index 000000000..cf5633db2 --- /dev/null +++ b/packages/fullstack/src/layer.rs @@ -0,0 +1,76 @@ +use std::pin::Pin; + +use http::{Request, Response}; + +pub trait Layer: Send + Sync + 'static { + fn layer(&self, inner: BoxedService) -> BoxedService; +} + +impl Layer for L +where + L: tower_layer::Layer + Sync + Send + 'static, + L::Service: Service + Send + 'static, +{ + fn layer(&self, inner: BoxedService) -> BoxedService { + BoxedService(Box::new(self.layer(inner))) + } +} + +pub trait Service { + fn run( + &mut self, + req: http::Request, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result, server_fn::ServerFnError>, + > + Send, + >, + >; +} + +impl Service for S +where + S: tower::Service, Response = Response>, + S::Future: Send + 'static, + S::Error: Into, +{ + fn run( + &mut self, + req: http::Request, + ) -> Pin< + Box< + dyn std::future::Future< + Output = Result, server_fn::ServerFnError>, + > + Send, + >, + > { + let fut = self.call(req); + Box::pin(async move { fut.await.map_err(|err| err.into()) }) + } +} + +pub struct BoxedService(pub Box); + +impl tower::Service> for BoxedService { + type Response = http::Response; + type Error = server_fn::ServerFnError; + type Future = Pin< + Box< + dyn std::future::Future< + Output = Result, server_fn::ServerFnError>, + > + Send, + >, + >; + + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Ok(()).into() + } + + fn call(&mut self, req: Request) -> Self::Future { + self.0.run(req) + } +} diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs index 18cde9adb..4728c1846 100644 --- a/packages/fullstack/src/lib.rs +++ b/packages/fullstack/src/lib.rs @@ -3,17 +3,27 @@ #![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] #![deny(missing_docs)] -pub use adapters::*; +pub use once_cell; mod props_html; +#[cfg(feature = "router")] +pub mod router; + +#[cfg(feature = "ssr")] mod adapters; +#[cfg(feature = "ssr")] +pub use adapters::*; #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] mod hot_reload; +pub mod launch; +#[cfg(feature = "ssr")] +mod layer; #[cfg(feature = "ssr")] mod render; #[cfg(feature = "ssr")] mod serve_config; +#[cfg(feature = "ssr")] mod server_context; mod server_fn; @@ -27,16 +37,24 @@ pub mod prelude { pub use crate::adapters::warp_adapter::*; #[cfg(not(feature = "ssr"))] pub use crate::props_html::deserialize_props::get_root_props_from_document; + #[cfg(all(feature = "ssr", feature = "router"))] + pub use crate::render::pre_cache_static_routes_with_props; #[cfg(feature = "ssr")] pub use crate::render::SSRState; #[cfg(feature = "ssr")] pub use crate::serve_config::{ServeConfig, ServeConfigBuilder}; + #[cfg(all(feature = "ssr", feature = "axum"))] + pub use crate::server_context::Axum; #[cfg(feature = "ssr")] - pub use crate::server_context::RequestParts; - pub use crate::server_context::{DioxusServerContext, HasServerContext}; + pub use crate::server_context::{ + extract, server_context, DioxusServerContext, FromServerContext, ProvideServerContext, + }; pub use crate::server_fn::DioxusServerFn; #[cfg(feature = "ssr")] - pub use crate::server_fn::{ServerFnTraitObj, ServerFunction}; + pub use crate::server_fn::{ServerFnMiddleware, ServerFnTraitObj, ServerFunction}; + pub use crate::{launch, launch_router}; pub use dioxus_server_macro::*; + #[cfg(feature = "ssr")] + pub use dioxus_ssr::incremental::IncrementalRendererConfig; pub use server_fn::{self, ServerFn as _, ServerFnError}; } diff --git a/packages/fullstack/src/props_html/deserialize_props.rs b/packages/fullstack/src/props_html/deserialize_props.rs index 35e54a1e5..f5c805865 100644 --- a/packages/fullstack/src/props_html/deserialize_props.rs +++ b/packages/fullstack/src/props_html/deserialize_props.rs @@ -4,9 +4,8 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine; #[allow(unused)] -pub(crate) fn serde_from_string(string: &str) -> Option { - let decompressed = STANDARD.decode(string.as_bytes()).ok()?; - let (decompressed, _) = yazi::decompress(&decompressed, yazi::Format::Zlib).unwrap(); +pub(crate) fn serde_from_bytes(string: &[u8]) -> Option { + let decompressed = STANDARD.decode(string).ok()?; postcard::from_bytes(&decompressed).ok() } @@ -27,6 +26,6 @@ pub fn get_root_props_from_document() -> Option { .get_element_by_id("dioxus-storage")? .get_attribute("data-serialized")?; - serde_from_string(&attribute) + serde_from_bytes(attribute.as_bytes()) } } diff --git a/packages/fullstack/src/props_html/mod.rs b/packages/fullstack/src/props_html/mod.rs index 048e2aa90..143c40231 100644 --- a/packages/fullstack/src/props_html/mod.rs +++ b/packages/fullstack/src/props_html/mod.rs @@ -23,7 +23,7 @@ fn serialized_and_deserializes() { for x in 0..10usize { for y in 0..10 { - let mut as_string = String::new(); + let mut as_string: Vec = Vec::new(); let data = vec![ Data { a: x as u32, @@ -39,7 +39,7 @@ fn serialized_and_deserializes() { ]; serialize_props::serde_to_writable(&data, &mut as_string).unwrap(); - println!("{}", as_string); + println!("{:?}", as_string); println!( "original size: {}", std::mem::size_of::() * data.len() @@ -47,7 +47,7 @@ fn serialized_and_deserializes() { println!("serialized size: {}", to_allocvec(&data).unwrap().len()); println!("compressed size: {}", as_string.len()); - let decoded: Vec = deserialize_props::serde_from_string(&as_string).unwrap(); + let decoded: Vec = deserialize_props::serde_from_bytes(&as_string).unwrap(); assert_eq!(data, decoded); } } diff --git a/packages/fullstack/src/props_html/serialize_props.rs b/packages/fullstack/src/props_html/serialize_props.rs index 0012afb9c..0c6ae3c49 100644 --- a/packages/fullstack/src/props_html/serialize_props.rs +++ b/packages/fullstack/src/props_html/serialize_props.rs @@ -6,16 +6,10 @@ use base64::Engine; #[allow(unused)] pub(crate) fn serde_to_writable( value: &T, - mut write_to: impl std::fmt::Write, -) -> std::fmt::Result { + write_to: &mut impl std::io::Write, +) -> std::io::Result<()> { let serialized = postcard::to_allocvec(value).unwrap(); - let compressed = yazi::compress( - &serialized, - yazi::Format::Zlib, - yazi::CompressionLevel::BestSize, - ) - .unwrap(); - write_to.write_str(&STANDARD.encode(compressed)); + write_to.write_all(STANDARD.encode(serialized).as_bytes())?; Ok(()) } @@ -23,9 +17,10 @@ pub(crate) fn serde_to_writable( /// Encode data into a element. This is inteded to be used in the server to send data to the client. pub(crate) fn encode_in_element( data: T, - mut write_to: impl std::fmt::Write, -) -> std::fmt::Result { - write_to.write_str(r#""#) + write_to: &mut impl std::io::Write, +) -> std::io::Result<()> { + write_to + .write_all(r#""#.as_bytes()) } diff --git a/packages/fullstack/src/render.rs b/packages/fullstack/src/render.rs index b56016d5e..fbd4bd7f3 100644 --- a/packages/fullstack/src/render.rs +++ b/packages/fullstack/src/render.rs @@ -2,56 +2,152 @@ use std::sync::Arc; -use dioxus_core::VirtualDom; -use dioxus_ssr::Renderer; +use dioxus::prelude::VirtualDom; +use dioxus_ssr::{ + incremental::{IncrementalRendererConfig, RenderFreshness, WrapBody}, + Renderer, +}; +use serde::Serialize; -use crate::prelude::ServeConfig; +use crate::{prelude::*, server_context::with_server_context}; +use dioxus::prelude::*; + +enum SsrRendererPool { + Renderer(object_pool::Pool), + Incremental(object_pool::Pool), +} + +impl SsrRendererPool { + async fn render_to( + &self, + cfg: &ServeConfig

, + route: String, + component: Component

, + props: P, + to: &mut WriteBuffer, + server_context: &DioxusServerContext, + ) -> Result { + let wrapper = FullstackRenderer { cfg }; + match self { + Self::Renderer(pool) => { + let server_context = Box::new(server_context.clone()); + let mut vdom = VirtualDom::new_with_props(component, props); + + with_server_context(server_context, || { + let _ = vdom.rebuild(); + }); + + let mut renderer = pool.pull(pre_renderer); + + // SAFETY: The fullstack renderer will only write UTF-8 to the buffer. + wrapper.render_before_body(&mut **to)?; + renderer.render_to(to, &vdom)?; + wrapper.render_after_body(&mut **to)?; + + Ok(RenderFreshness::now(None)) + } + Self::Incremental(pool) => { + let mut renderer = + pool.pull(|| incremental_pre_renderer(cfg.incremental.as_ref().unwrap())); + Ok(renderer + .render( + route, + component, + props, + &mut **to, + |vdom| { + let server_context = Box::new(server_context.clone()); + with_server_context(server_context, || { + let _ = vdom.rebuild(); + }); + }, + &wrapper, + ) + .await?) + } + } + } +} /// State used in server side rendering. This utilizes a pool of [`dioxus_ssr::Renderer`]s to cache static templates between renders. #[derive(Clone)] pub struct SSRState { // We keep a pool of renderers to avoid re-creating them on every request. They are boxed to make them very cheap to move - renderers: Arc>, + renderers: Arc, } -impl Default for SSRState { - fn default() -> Self { +impl SSRState { + pub(crate) fn new(cfg: &ServeConfig

) -> Self { + if cfg.incremental.is_some() { + return Self { + renderers: Arc::new(SsrRendererPool::Incremental(object_pool::Pool::new( + 10, + || incremental_pre_renderer(cfg.incremental.as_ref().unwrap()), + ))), + }; + } + Self { - renderers: Arc::new(object_pool::Pool::new(10, pre_renderer)), + renderers: Arc::new(SsrRendererPool::Renderer(object_pool::Pool::new( + 10, + pre_renderer, + ))), + } + } + + /// Render the application to HTML. + pub fn render<'a, P: 'static + Clone + serde::Serialize + Send + Sync>( + &'a self, + route: String, + cfg: &'a ServeConfig

, + server_context: &'a DioxusServerContext, + ) -> impl std::future::Future< + Output = Result, + > + Send + + 'a { + async move { + let mut html = WriteBuffer { buffer: Vec::new() }; + let ServeConfig { app, props, .. } = cfg; + + let freshness = self + .renderers + .render_to(cfg, route, *app, props.clone(), &mut html, server_context) + .await?; + + Ok(RenderResponse { + html: String::from_utf8(html.buffer).map_err(|err| { + dioxus_ssr::incremental::IncrementalRendererError::Other(Box::new(err)) + })?, + freshness, + }) } } } -impl SSRState { - /// Render the application to HTML. - pub fn render(&self, cfg: &ServeConfig

) -> String { - let ServeConfig { app, props, .. } = cfg; +struct FullstackRenderer<'a, P: Clone + Send + Sync + 'static> { + cfg: &'a ServeConfig

, +} - let mut vdom = VirtualDom::new_with_props(*app, props.clone()); +impl<'a, P: Clone + Serialize + Send + Sync + 'static> dioxus_ssr::incremental::WrapBody + for FullstackRenderer<'a, P> +{ + fn render_before_body( + &self, + to: &mut R, + ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> { + let ServeConfig { index, .. } = &self.cfg; - let _ = vdom.rebuild(); + to.write_all(index.pre_main.as_bytes())?; - self.render_vdom(&vdom, cfg) + Ok(()) } - /// Render a VirtualDom to HTML. - pub fn render_vdom( + fn render_after_body( &self, - vdom: &VirtualDom, - cfg: &ServeConfig

, - ) -> String { - let ServeConfig { index, .. } = cfg; - - let mut renderer = self.renderers.pull(pre_renderer); - - let mut html = String::new(); - - html += &index.pre_main; - - let _ = renderer.render_to(&mut html, vdom); - + to: &mut R, + ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> { // serialize the props - let _ = crate::props_html::serialize_props::encode_in_element(&cfg.props, &mut html); + crate::props_html::serialize_props::encode_in_element(&self.cfg.props, to)?; #[cfg(all(debug_assertions, feature = "hot-reload"))] { @@ -82,19 +178,91 @@ impl SSRState { ws.onclose = reload_upon_connect; })()"#; - html += r#""#; + to.write_all(r#""#.as_bytes())?; } - html += &index.post_main; + let ServeConfig { index, .. } = &self.cfg; - html + to.write_all(index.post_main.as_bytes())?; + + Ok(()) + } +} + +/// A rendered response from the server. +pub struct RenderResponse { + pub(crate) html: String, + pub(crate) freshness: RenderFreshness, +} + +impl RenderResponse { + /// Get the rendered HTML. + pub fn html(&self) -> &str { + &self.html + } + + /// Get the freshness of the rendered HTML. + pub fn freshness(&self) -> RenderFreshness { + self.freshness } } fn pre_renderer() -> Renderer { let mut renderer = Renderer::default(); renderer.pre_render = true; + renderer.into() +} + +fn incremental_pre_renderer( + cfg: &IncrementalRendererConfig, +) -> dioxus_ssr::incremental::IncrementalRenderer { + let mut renderer = cfg.clone().build(); + renderer.renderer_mut().pre_render = true; renderer } + +#[cfg(all(feature = "ssr", feature = "router"))] +/// Pre-caches all static routes +pub async fn pre_cache_static_routes_with_props( + cfg: &crate::prelude::ServeConfig>, +) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> +where + Rt: dioxus_router::prelude::Routable + Send + Sync + Serialize, + ::Err: std::fmt::Display, +{ + let wrapper = FullstackRenderer { cfg }; + let mut renderer = incremental_pre_renderer( + cfg.incremental + .as_ref() + .expect("incremental renderer config must be set to pre-cache static routes"), + ); + + dioxus_router::incremental::pre_cache_static_routes::(&mut renderer, &wrapper).await +} + +struct WriteBuffer { + buffer: Vec, +} + +impl std::fmt::Write for WriteBuffer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.buffer.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +impl std::ops::Deref for WriteBuffer { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.buffer + } +} + +impl std::ops::DerefMut for WriteBuffer { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.buffer + } +} diff --git a/packages/fullstack/src/router.rs b/packages/fullstack/src/router.rs new file mode 100644 index 000000000..a8372e1ba --- /dev/null +++ b/packages/fullstack/src/router.rs @@ -0,0 +1,108 @@ +//! Fullstack router intigration +#![allow(non_snake_case)] +use dioxus::prelude::*; + +/// Used by the launch macro +#[doc(hidden)] +pub fn RouteWithCfg(cx: Scope>) -> Element +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + use dioxus_router::prelude::RouterConfig; + + #[cfg(feature = "ssr")] + let context = crate::prelude::server_context(); + + let cfg = *cx.props; + render! { + dioxus_router::prelude::GenericRouter:: { + config: move || { + RouterConfig::default() + .failure_external_navigation(cfg.failure_external_navigation) + .history({ + #[cfg(feature = "ssr")] + let history = dioxus_router::prelude::MemoryHistory::with_initial_path( + context + .request_parts().unwrap() + .uri + .to_string() + .parse() + .unwrap_or_else(|err| { + log::error!("Failed to parse uri: {}", err); + "/" + .parse() + .unwrap_or_else(|err| { + panic!("Failed to parse uri: {}", err); + }) + }), + ); + #[cfg(not(feature = "ssr"))] + let history = dioxus_router::prelude::WebHistory::new( + None, + cfg.scroll_restoration, + ); + history + }) + }, + } + } +} + +fn default_external_navigation_handler() -> fn(Scope) -> Element +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + dioxus_router::prelude::FailureExternalNavigation:: +} + +/// The configeration for the router +#[derive(Props, serde::Serialize, serde::Deserialize)] +pub struct FullstackRouterConfig +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + #[serde(skip)] + #[serde(default = "default_external_navigation_handler::")] + failure_external_navigation: fn(Scope) -> Element, + scroll_restoration: bool, + #[serde(skip)] + phantom: std::marker::PhantomData, +} + +impl Clone for FullstackRouterConfig +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + fn clone(&self) -> Self { + Self { + failure_external_navigation: self.failure_external_navigation, + scroll_restoration: self.scroll_restoration, + phantom: std::marker::PhantomData, + } + } +} + +impl Copy for FullstackRouterConfig +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ +} + +impl Default for FullstackRouterConfig +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self { + failure_external_navigation: dioxus_router::prelude::FailureExternalNavigation::, + scroll_restoration: true, + phantom: std::marker::PhantomData, + } + } +} diff --git a/packages/fullstack/src/serve_config.rs b/packages/fullstack/src/serve_config.rs index b7b29e9fb..75a473c89 100644 --- a/packages/fullstack/src/serve_config.rs +++ b/packages/fullstack/src/serve_config.rs @@ -1,10 +1,13 @@ +#![allow(non_snake_case)] //! Configeration for how to serve a Dioxus application +#[cfg(feature = "router")] +use crate::router::*; use std::fs::File; use std::io::Read; use std::path::PathBuf; -use dioxus_core::Component; +use dioxus::prelude::*; /// A ServeConfig is used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`]. #[derive(Clone)] @@ -14,6 +17,40 @@ pub struct ServeConfigBuilder { pub(crate) root_id: Option<&'static str>, pub(crate) index_path: Option<&'static str>, pub(crate) assets_path: Option<&'static str>, + pub(crate) incremental: + Option>, +} + +/// A template for incremental rendering that does nothing. +#[derive(Default, Clone)] +pub struct EmptyIncrementalRenderTemplate; + +impl dioxus_ssr::incremental::WrapBody for EmptyIncrementalRenderTemplate { + fn render_after_body( + &self, + _: &mut R, + ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> { + Ok(()) + } + + fn render_before_body( + &self, + _: &mut R, + ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> { + Ok(()) + } +} + +#[cfg(feature = "router")] +impl ServeConfigBuilder> +where + R: dioxus_router::prelude::Routable, + ::Err: std::fmt::Display, +{ + /// Create a new ServeConfigBuilder to serve a router on the server. + pub fn new_with_router(cfg: FullstackRouterConfig) -> Self { + Self::new(RouteWithCfg::, cfg) + } } impl ServeConfigBuilder

{ @@ -25,9 +62,16 @@ impl ServeConfigBuilder

{ root_id: None, index_path: None, assets_path: None, + incremental: None, } } + /// Enable incremental static generation + pub fn incremental(mut self, cfg: dioxus_ssr::incremental::IncrementalRendererConfig) -> Self { + self.incremental = Some(std::sync::Arc::new(cfg)); + self + } + /// Set the path of the index.html file to be served. (defaults to {assets_path}/index.html) pub fn index_path(mut self, index_path: &'static str) -> Self { self.index_path = Some(index_path); @@ -64,6 +108,7 @@ impl ServeConfigBuilder

{ props: self.props, index, assets_path, + incremental: self.incremental, } } } @@ -106,6 +151,8 @@ pub struct ServeConfig { pub(crate) props: P, pub(crate) index: IndexHtml, pub(crate) assets_path: &'static str, + pub(crate) incremental: + Option>, } impl From> for ServeConfig

{ diff --git a/packages/fullstack/src/server_context.rs b/packages/fullstack/src/server_context.rs index f225526ec..fcd0ef66b 100644 --- a/packages/fullstack/src/server_context.rs +++ b/packages/fullstack/src/server_context.rs @@ -1,63 +1,33 @@ -use dioxus_core::ScopeState; +pub use server_fn_impl::*; +use std::sync::Arc; +use std::sync::RwLock; -/// A trait for an object that contains a server context -pub trait HasServerContext { - /// Get the server context from the state - fn server_context(&self) -> DioxusServerContext; - - /// A shortcut for `self.server_context()` - fn sc(&self) -> DioxusServerContext { - self.server_context() - } -} - -impl HasServerContext for &ScopeState { - fn server_context(&self) -> DioxusServerContext { - #[cfg(feature = "ssr")] - { - self.consume_context().expect("No server context found") - } - #[cfg(not(feature = "ssr"))] - { - DioxusServerContext {} - } - } -} - -/// A shared context for server functions that contains infomation about the request and middleware state. +/// A shared context for server functions that contains information about the request and middleware state. /// This allows you to pass data between your server framework and the server functions. This can be used to pass request information or information about the state of the server. For example, you could pass authentication data though this context to your server functions. /// /// You should not construct this directly inside components. Instead use the `HasServerContext` trait to get the server context from the scope. #[derive(Clone)] pub struct DioxusServerContext { - #[cfg(feature = "ssr")] shared_context: std::sync::Arc< std::sync::RwLock>, >, - #[cfg(feature = "ssr")] - headers: std::sync::Arc>, - #[cfg(feature = "ssr")] - pub(crate) parts: std::sync::Arc, + response_parts: std::sync::Arc>, + pub(crate) parts: Arc>, } #[allow(clippy::derivable_impls)] impl Default for DioxusServerContext { fn default() -> Self { Self { - #[cfg(feature = "ssr")] shared_context: std::sync::Arc::new(std::sync::RwLock::new(anymap::Map::new())), - #[cfg(feature = "ssr")] - headers: Default::default(), - #[cfg(feature = "ssr")] - parts: Default::default(), + response_parts: std::sync::Arc::new(RwLock::new( + http::response::Response::new(()).into_parts().0, + )), + parts: std::sync::Arc::new(RwLock::new(http::request::Request::new(()).into_parts().0)), } } } -#[cfg(feature = "ssr")] -pub use server_fn_impl::*; - -#[cfg(feature = "ssr")] mod server_fn_impl { use super::*; use std::sync::LockResult; @@ -68,11 +38,13 @@ mod server_fn_impl { impl DioxusServerContext { /// Create a new server context from a request - pub fn new(parts: impl Into>) -> Self { + pub fn new(parts: impl Into>>) -> Self { Self { parts: parts.into(), shared_context: Arc::new(RwLock::new(SendSyncAnyMap::new())), - headers: Default::default(), + response_parts: std::sync::Arc::new(RwLock::new( + http::response::Response::new(()).into_parts().0, + )), } } @@ -92,69 +64,164 @@ mod server_fn_impl { .map(|_| ()) } - /// Get the headers from the server context - pub fn response_headers(&self) -> RwLockReadGuard<'_, hyper::header::HeaderMap> { - self.try_response_headers() - .expect("Failed to get headers from server context") + /// Get the response parts from the server context + pub fn response_parts(&self) -> LockResult> { + self.response_parts.read() } - /// Try to get the headers from the server context - pub fn try_response_headers( + /// Get the response parts from the server context + pub fn response_parts_mut( &self, - ) -> LockResult> { - self.headers.read() - } - - /// Get the headers mutably from the server context - pub fn response_headers_mut(&self) -> RwLockWriteGuard<'_, hyper::header::HeaderMap> { - self.try_response_headers_mut() - .expect("Failed to get headers mutably from server context") - } - - /// Try to get the headers mut from the server context - pub fn try_response_headers_mut( - &self, - ) -> LockResult> { - self.headers.write() - } - - pub(crate) fn take_response_headers(&self) -> hyper::header::HeaderMap { - let mut headers = self.headers.write().unwrap(); - std::mem::take(&mut *headers) + ) -> LockResult> { + self.response_parts.write() } /// Get the request that triggered: /// - The initial SSR render if called from a ScopeState or ServerFn /// - The server function to be called if called from a server function after the initial render - pub fn request_parts(&self) -> &RequestParts { - &self.parts + pub fn request_parts( + &self, + ) -> std::sync::LockResult> { + self.parts.read() } - } - /// Associated parts of an HTTP Request - #[derive(Debug, Default)] - pub struct RequestParts { - /// The request's method - pub method: http::Method, - /// The request's URI - pub uri: http::Uri, - /// The request's version - pub version: http::Version, - /// The request's headers - pub headers: http::HeaderMap, - /// The request's extensions - pub extensions: http::Extensions, - } + /// Get the request that triggered: + /// - The initial SSR render if called from a ScopeState or ServerFn + /// - The server function to be called if called from a server function after the initial render + pub fn request_parts_mut( + &self, + ) -> std::sync::LockResult> { + self.parts.write() + } - impl From for RequestParts { - fn from(parts: http::request::Parts) -> Self { - Self { - method: parts.method, - uri: parts.uri, - version: parts.version, - headers: parts.headers, - extensions: parts.extensions, - } + /// Extract some part from the request + pub async fn extract>( + &self, + ) -> Result { + T::from_request(self).await } } } + +std::thread_local! { + static SERVER_CONTEXT: std::cell::RefCell> = std::cell::RefCell::new(Box::new(DioxusServerContext::default() )); +} + +/// Get information about the current server request. +/// +/// This function will only provide the current server context if it is called from a server function or on the server rendering a request. +pub fn server_context() -> DioxusServerContext { + SERVER_CONTEXT.with(|ctx| *ctx.borrow().clone()) +} + +/// Extract some part from the current server request. +/// +/// This function will only provide the current server context if it is called from a server function or on the server rendering a request. +pub async fn extract, I>() -> Result { + E::from_request(&server_context()).await +} + +pub(crate) fn with_server_context( + context: Box, + f: impl FnOnce() -> O, +) -> (O, Box) { + // before polling the future, we need to set the context + let prev_context = SERVER_CONTEXT.with(|ctx| ctx.replace(context)); + // poll the future, which may call server_context() + let result = f(); + // after polling the future, we need to restore the context + (result, SERVER_CONTEXT.with(|ctx| ctx.replace(prev_context))) +} + +/// A future that provides the server context to the inner future +#[pin_project::pin_project] +pub struct ProvideServerContext { + context: Option>, + #[pin] + f: F, +} + +impl ProvideServerContext { + /// Create a new future that provides the server context to the inner future + pub fn new(f: F, context: DioxusServerContext) -> Self { + Self { + context: Some(Box::new(context)), + f, + } + } +} + +impl std::future::Future for ProvideServerContext { + type Output = F::Output; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.project(); + let context = this.context.take().unwrap(); + let (result, context) = with_server_context(context, || this.f.poll(cx)); + *this.context = Some(context); + result + } +} + +/// A trait for extracting types from the server context +#[async_trait::async_trait(?Send)] +pub trait FromServerContext: Sized { + /// The error type returned when extraction fails. This type must implement `std::error::Error`. + type Rejection: std::error::Error; + + /// Extract this type from the server context. + async fn from_request(req: &DioxusServerContext) -> Result; +} + +/// A type was not found in the server context +pub struct NotFoundInServerContext(std::marker::PhantomData); + +impl std::fmt::Debug for NotFoundInServerContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let type_name = std::any::type_name::(); + write!(f, "`{type_name}` not found in server context") + } +} + +impl std::fmt::Display for NotFoundInServerContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let type_name = std::any::type_name::(); + write!(f, "`{type_name}` not found in server context") + } +} + +impl std::error::Error for NotFoundInServerContext {} + +pub struct FromContext(pub(crate) T); + +#[async_trait::async_trait(?Send)] +impl FromServerContext for FromContext { + type Rejection = NotFoundInServerContext; + + async fn from_request(req: &DioxusServerContext) -> Result { + Ok(Self(req.clone().get::().ok_or_else(|| { + NotFoundInServerContext::(std::marker::PhantomData::) + })?)) + } +} + +#[cfg(feature = "axum")] +/// An adapter for axum extractors for the server context +pub struct Axum; + +#[cfg(feature = "axum")] +#[async_trait::async_trait(?Send)] +impl< + I: axum::extract::FromRequestParts<(), Rejection = R>, + R: axum::response::IntoResponse + std::error::Error, + > FromServerContext for I +{ + type Rejection = R; + + async fn from_request(req: &DioxusServerContext) -> Result { + Ok(I::from_request_parts(&mut *req.request_parts_mut().unwrap(), &()).await?) + } +} diff --git a/packages/fullstack/src/server_fn.rs b/packages/fullstack/src/server_fn.rs index 231285149..644a64d27 100644 --- a/packages/fullstack/src/server_fn.rs +++ b/packages/fullstack/src/server_fn.rs @@ -1,13 +1,11 @@ -use crate::server_context::DioxusServerContext; - #[cfg(any(feature = "ssr", doc))] #[derive(Clone)] /// A trait object for a function that be called on serializable arguments and returns a serializable result. -pub struct ServerFnTraitObj(server_fn::ServerFnTraitObj); +pub struct ServerFnTraitObj(server_fn::ServerFnTraitObj<()>); #[cfg(any(feature = "ssr", doc))] impl std::ops::Deref for ServerFnTraitObj { - type Target = server_fn::ServerFnTraitObj; + type Target = server_fn::ServerFnTraitObj<()>; fn deref(&self) -> &Self::Target { &self.0 @@ -33,9 +31,7 @@ impl ServerFnTraitObj { } /// Create a new `ServerFnTraitObj` from a `server_fn::ServerFnTraitObj`. - pub const fn from_generic_server_fn( - server_fn: server_fn::ServerFnTraitObj, - ) -> Self { + pub const fn from_generic_server_fn(server_fn: server_fn::ServerFnTraitObj<()>) -> Self { Self(server_fn) } } @@ -43,9 +39,42 @@ impl ServerFnTraitObj { #[cfg(any(feature = "ssr", doc))] server_fn::inventory::collect!(ServerFnTraitObj); +#[cfg(any(feature = "ssr", doc))] +/// Middleware for a server function +pub struct ServerFnMiddleware { + /// The prefix of the server function. + pub prefix: &'static str, + /// The url of the server function. + pub url: &'static str, + /// The middleware layers. + pub middleware: fn() -> Vec>, +} + +#[cfg(any(feature = "ssr", doc))] +pub(crate) static MIDDLEWARE: once_cell::sync::Lazy< + std::collections::HashMap< + (&'static str, &'static str), + Vec>, + >, +> = once_cell::sync::Lazy::new(|| { + let mut map: std::collections::HashMap< + (&'static str, &'static str), + Vec>, + > = std::collections::HashMap::new(); + for middleware in server_fn::inventory::iter:: { + map.entry((middleware.prefix, middleware.url)) + .or_default() + .extend((middleware.middleware)().iter().cloned()); + } + map +}); + +#[cfg(any(feature = "ssr", doc))] +server_fn::inventory::collect!(ServerFnMiddleware); + #[cfg(any(feature = "ssr", doc))] /// A server function that can be called on serializable arguments and returns a serializable result. -pub type ServerFunction = server_fn::SerializedFnTraitObj; +pub type ServerFunction = server_fn::SerializedFnTraitObj<()>; #[cfg(any(feature = "ssr", doc))] #[allow(clippy::type_complexity)] @@ -64,7 +93,7 @@ static REGISTERED_SERVER_FUNCTIONS: once_cell::sync::Lazy< pub struct DioxusServerFnRegistry; #[cfg(feature = "ssr")] -impl server_fn::ServerFunctionRegistry for DioxusServerFnRegistry { +impl server_fn::ServerFunctionRegistry<()> for DioxusServerFnRegistry { type Error = ServerRegistrationFnError; fn register_explicit( @@ -105,7 +134,7 @@ impl server_fn::ServerFunctionRegistry for DioxusServerFnRe } /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. - fn get(url: &str) -> Option> { + fn get(url: &str) -> Option> { REGISTERED_SERVER_FUNCTIONS .read() .ok() @@ -113,7 +142,7 @@ impl server_fn::ServerFunctionRegistry for DioxusServerFnRe } /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. - fn get_trait_obj(url: &str) -> Option> { + fn get_trait_obj(url: &str) -> Option> { Self::get(url) } @@ -155,7 +184,7 @@ pub enum ServerRegistrationFnError { /// can be queried on the server for routing purposes by calling [server_fn::ServerFunctionRegistry::get]. /// /// Technically, the trait is implemented on a type that describes the server function's arguments, not the function itself. -pub trait DioxusServerFn: server_fn::ServerFn { +pub trait DioxusServerFn: server_fn::ServerFn<()> { /// Registers the server function, allowing the client to query it by URL. #[cfg(any(feature = "ssr", doc))] fn register_explicit() -> Result<(), server_fn::ServerFnError> { @@ -163,4 +192,4 @@ pub trait DioxusServerFn: server_fn::ServerFn { } } -impl DioxusServerFn for T where T: server_fn::ServerFn {} +impl DioxusServerFn for T where T: server_fn::ServerFn<()> {} diff --git a/packages/hooks/Cargo.toml b/packages/hooks/Cargo.toml index 32b99ba5a..e4e10809c 100644 --- a/packages/hooks/Cargo.toml +++ b/packages/hooks/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.1" authors = ["Jonathan Kelley"] edition = "2018" description = "Basic useful hooks for Dioxus." -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] diff --git a/packages/hooks/src/useeffect.rs b/packages/hooks/src/useeffect.rs index f9ef70b84..aec1f9c9a 100644 --- a/packages/hooks/src/useeffect.rs +++ b/packages/hooks/src/useeffect.rs @@ -9,17 +9,33 @@ use crate::UseFutureDep; /// If a future is pending when the dependencies change, the previous future /// will be allowed to continue /// -/// - dependencies: a tuple of references to values that are PartialEq + Clone +/// - dependencies: a tuple of references to values that are `PartialEq` + `Clone` /// /// ## Examples /// -/// ```rust, ignore -/// +/// ```rust, no_run /// #[inline_props] -/// fn app(cx: Scope, name: &str) -> Element { -/// use_effect(cx, (name,), |(name,)| async move { -/// set_title(name); -/// })) +/// fn Profile(cx: Scope, id: usize) -> Element { +/// let name = use_state(cx, || None); +/// +/// // Only fetch the user data when the id changes. +/// use_effect(cx, (id,), |(id,)| { +/// to_owned![name]; +/// async move { +/// let user = fetch_user(id).await; +/// name.set(user.name); +/// } +/// }); +/// +/// let name = name.get().clone().unwrap_or("Loading...".to_string()); +/// +/// render!( +/// p { "{name}" } +/// ) +/// } +/// +/// fn app(cx: Scope) -> Element { +/// render!(Profile { id: 0 }) /// } /// ``` pub fn use_effect(cx: &ScopeState, dependencies: D, future: impl FnOnce(D::Out) -> F) diff --git a/packages/hooks/src/usefuture.rs b/packages/hooks/src/usefuture.rs index 18bf50310..4d90adbd4 100644 --- a/packages/hooks/src/usefuture.rs +++ b/packages/hooks/src/usefuture.rs @@ -1,12 +1,8 @@ #![allow(missing_docs)] use dioxus_core::{ScopeState, TaskId}; -use std::{ - any::Any, - cell::{Cell, RefCell}, - future::{Future, IntoFuture}, - rc::Rc, - sync::Arc, -}; +use std::{any::Any, cell::Cell, future::Future, rc::Rc, sync::Arc}; + +use crate::{use_state, UseState}; /// A future that resolves to a value. /// @@ -31,43 +27,30 @@ where F: Future + 'static, D: UseFutureDep, { + let val = use_state(cx, || None); + let state = cx.use_hook(move || UseFuture { update: cx.schedule_update(), needs_regen: Cell::new(true), - values: Default::default(), - task: Cell::new(None), + state: val.clone(), + task: Default::default(), dependencies: Vec::new(), - waker: Default::default(), }); - *state.waker.borrow_mut() = None; - if dependencies.clone().apply(&mut state.dependencies) || state.needs_regen.get() { - // We don't need regen anymore - state.needs_regen.set(false); + // kill the old one, if it exists + if let Some(task) = state.task.take() { + cx.remove_future(task); + } // Create the new future let fut = future(dependencies.out()); - - // Clone in our cells - let values = state.values.clone(); - let schedule_update = state.update.clone(); - let waker = state.waker.clone(); - - // Cancel the current future - if let Some(current) = state.task.take() { - cx.remove_future(current); - } + let val = val.clone(); + let task = state.task.clone(); state.task.set(Some(cx.push_future(async move { - let res = fut.await; - values.borrow_mut().push(Box::leak(Box::new(res))); - - // if there's a waker, we dont re-render the component. Instead we just progress that future - match waker.borrow().as_ref() { - Some(waker) => waker.wake_by_ref(), - None => schedule_update(), - } + val.set(Some(fut.await)); + task.take(); }))); } @@ -80,21 +63,12 @@ pub enum FutureState<'a, T> { Regenerating(&'a T), // the old value } -pub struct UseFuture { +pub struct UseFuture { update: Arc, needs_regen: Cell, - task: Cell>, + task: Rc>>, dependencies: Vec>, - waker: Rc>>, - values: Rc>>, -} - -impl Drop for UseFuture { - fn drop(&mut self) { - for value in self.values.take().into_iter() { - drop(unsafe { Box::from_raw(value) }) - } - } + state: UseState>, } pub enum UseFutureState<'a, T> { @@ -120,30 +94,16 @@ impl UseFuture { } } - // clears the value in the future slot without starting the future over - pub fn clear(&self) -> Option { - todo!() - // (self.update)(); - // self.slot.replace(None) - } - // Manually set the value in the future slot without starting the future over - pub fn set(&self, _new_value: T) { - // self.slot.set(Some(new_value)); - // self.needs_regen.set(true); - // (self.update)(); - todo!() + pub fn set(&self, new_value: T) { + self.state.set(Some(new_value)); } /// Return any value, even old values if the future has not yet resolved. /// /// If the future has never completed, the returned value will be `None`. pub fn value(&self) -> Option<&T> { - self.values - .borrow_mut() - .last() - .cloned() - .map(|x| unsafe { &*x }) + self.state.current_val.as_ref().as_ref() } /// Get the ID of the future in Dioxus' internal scheduler @@ -169,35 +129,6 @@ impl UseFuture { } } -impl<'a, T> IntoFuture for &'a UseFuture { - type Output = &'a T; - type IntoFuture = UseFutureAwait<'a, T>; - fn into_future(self) -> Self::IntoFuture { - UseFutureAwait { hook: self } - } -} - -pub struct UseFutureAwait<'a, T> { - hook: &'a UseFuture, -} - -impl<'a, T> Future for UseFutureAwait<'a, T> { - type Output = &'a T; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - match self.hook.values.borrow_mut().last().cloned() { - Some(value) => std::task::Poll::Ready(unsafe { &*value }), - None => { - self.hook.waker.replace(Some(cx.waker().clone())); - std::task::Poll::Pending - } - } - } -} - pub trait UseFutureDep: Sized + Clone { type Out; fn out(&self) -> Self::Out; @@ -343,10 +274,6 @@ mod tests { let blah = "asd"; }); - let g2 = a.await; - - let g = fut.await; - todo!() } } diff --git a/packages/hooks/src/usememo.rs b/packages/hooks/src/usememo.rs index 8cc5540e6..d3d991ccc 100644 --- a/packages/hooks/src/usememo.rs +++ b/packages/hooks/src/usememo.rs @@ -2,21 +2,27 @@ use dioxus_core::ScopeState; use crate::UseFutureDep; -/// A hook that provides a callback that executes after the hooks have been applied +/// A hook that provides a callback that executes if the dependencies change. +/// This is useful to avoid running computation-expensive calculations even when the data doesn't change. /// -/// Whenever the hooks dependencies change, the callback will be re-evaluated. -/// -/// - dependencies: a tuple of references to values that are PartialEq + Clone +/// - dependencies: a tuple of references to values that are `PartialEq` + `Clone` /// /// ## Examples /// -/// ```rust, ignore +/// ```rust, no_run /// /// #[inline_props] -/// fn app(cx: Scope, name: &str) -> Element { -/// use_memo(cx, (name,), |(name,)| { -/// expensive_computation(name); -/// })) +/// fn Calculator(cx: Scope, number: usize) -> Element { +/// let bigger_number = use_memo(cx, (number,), |(number,)| { +/// // This will only be calculated when `number` has changed. +/// number * 100 +/// }); +/// render!( +/// p { "{bigger_number}" } +/// ) +/// } +/// fn app(cx: Scope) -> Element { +/// render!(Calculator { number: 0 }) /// } /// ``` pub fn use_memo(cx: &ScopeState, dependencies: D, callback: impl FnOnce(D::Out) -> T) -> &T diff --git a/packages/hooks/src/useref.rs b/packages/hooks/src/useref.rs index 1865dadb3..cf1b0306f 100644 --- a/packages/hooks/src/useref.rs +++ b/packages/hooks/src/useref.rs @@ -16,7 +16,7 @@ use std::{ /// writes through the `write` method. Whenever `write` is called, the component /// that initialized the hook will be marked as "dirty". /// -/// ```rust, ignore +/// ```rust, no_run /// let val = use_ref(|| HashMap::::new()); /// /// // using `write` will give us a `RefMut` to the inner value, which we can call methods on @@ -26,7 +26,7 @@ use std::{ /// /// You can avoid this default behavior with `write_silent` /// -/// ```ignore +/// ```rust, no_run /// // with `write_silent`, the component will not be re-rendered /// val.write_silent().insert(2, "goodbye".to_string()); /// ``` @@ -35,7 +35,7 @@ use std::{ /// /// To read values out of the refcell, you can use the `read` method which will retrun a `Ref`. /// -/// ```rust, ignore +/// ```rust, no_run /// let map: Ref<_> = val.read(); /// /// let item = map.get(&1); @@ -43,7 +43,7 @@ use std::{ /// /// To get an &T out of the RefCell, you need to "reborrow" through the Ref: /// -/// ```rust, ignore +/// ```rust, no_run /// let read = val.read(); /// let map = &*read; /// ``` @@ -54,10 +54,10 @@ use std::{ /// Typically this will be a collection like a HashMap or a Vec. To create new /// elements from the collection, we can use `read()` directly in our rsx!. /// -/// ```rust, ignore +/// ```rust, no_run /// rsx!{ /// val.read().iter().map(|(k, v)| { -/// rsx!{ key: "{k}", value: "{v}" } +/// rsx!{ key: "{k}", "{v}" } /// }) /// } /// ``` @@ -66,9 +66,9 @@ use std::{ /// "render" inside the iterator. For some cases you might need to collect into /// a temporary Vec. /// -/// ```rust, ignore +/// ```rust, no_run /// let items = val.read().iter().map(|(k, v)| { -/// cx.render(rsx!{ key: "{k}", value: "{v}" }) +/// cx.render(rsx!{ key: "{k}", "{v}" }) /// }); /// /// // collect into a Vec @@ -80,9 +80,9 @@ use std::{ /// /// To access values from a `UseRef` in an async context, you need to detach it /// from the current scope's lifetime, making it a `'static` value. This is done -/// by simply calling `ToOnwed` or `Clone`. +/// by simply calling `to_owned` or `clone`. /// -/// ```rust, ignore +/// ```rust, no_run /// let val = use_ref(|| HashMap::::new()); /// /// cx.spawn({ @@ -95,15 +95,15 @@ use std::{ /// ``` /// /// If you're working with lots of values like UseState and UseRef, you can use the -/// `clone!` macro to make it easier to write the above code. +/// `to_owned!` macro to make it easier to write the above code. /// -/// ```rust, ignore +/// ```rust, no_run /// let val1 = use_ref(|| HashMap::::new()); /// let val2 = use_ref(|| HashMap::::new()); /// let val3 = use_ref(|| HashMap::::new()); /// /// cx.spawn({ -/// clone![val1, val2, val3]; +/// to_owned![val1, val2, val3]; /// async move { /// some_work().await; /// val.write().insert(1, "hello".to_string()); @@ -194,7 +194,7 @@ impl UseRef { /// Note: You can always "reborrow" the value through the RefCell. /// This method just does it for you automatically. /// - /// ```rust, ignore + /// ```rust, no_run /// let val = use_ref(|| HashMap::::new()); /// /// @@ -214,7 +214,7 @@ impl UseRef { /// Note: You can always "reborrow" the value through the RefCell. /// This method just does it for you automatically. /// - /// ```rust, ignore + /// ```rust, no_run /// let val = use_ref(|| HashMap::::new()); /// /// diff --git a/packages/hot-reload/Cargo.toml b/packages/hot-reload/Cargo.toml index c16dd5468..7f04aa6ef 100644 --- a/packages/hot-reload/Cargo.toml +++ b/packages/hot-reload/Cargo.toml @@ -2,22 +2,26 @@ name = "dioxus-hot-reload" version = "0.1.1" edition = "2021" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/hot_reload.html" -description = "Hot reloading utilites for Dioxus" +description = "Hot reloading utilities for Dioxus" keywords = ["dom", "ui", "gui", "react", "hot-reloading"] [dependencies] dioxus-rsx = { workspace = true } dioxus-core = { workspace = true, features = ["serialize"] } -dioxus-html = { workspace = true, features = ["hot-reload-context"] } +dioxus-html = { workspace = true } interprocess-docfix = { version = "1.2.2" } -notify = "5.0.0" -chrono = { version = "0.4.24", default-features = false, features = ["clock"] } +notify = { version = "5.0.0", optional = true } +chrono = { version = "0.4.24", default-features = false, features = ["clock"], optional = true } serde_json = "1.0.91" serde = { version = "1", features = ["derive"] } -execute = "0.2.11" -once_cell = "1.17.0" -ignore = "0.4.19" +execute = { version = "0.2.11", optional = true } +once_cell = { version = "1.17.0", optional = true } +ignore = { version = "0.4.19", optional = true } + +[features] +default = [] +file_watcher = ["ignore", "chrono", "notify", "execute", "once_cell", "ignore", "dioxus-html/hot-reload-context"] diff --git a/packages/hot-reload/src/file_watcher.rs b/packages/hot-reload/src/file_watcher.rs new file mode 100644 index 000000000..dd9b22f5f --- /dev/null +++ b/packages/hot-reload/src/file_watcher.rs @@ -0,0 +1,352 @@ +use std::{ + io::{BufRead, BufReader, Write}, + path::PathBuf, + str::FromStr, + sync::{Arc, Mutex}, +}; + +use dioxus_core::Template; +use dioxus_rsx::{ + hot_reload::{FileMap, FileMapBuildResult, UpdateResult}, + HotReloadingContext, +}; +use interprocess_docfix::local_socket::{LocalSocketListener, LocalSocketStream}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; + +pub use dioxus_html::HtmlCtx; +use serde::{Deserialize, Serialize}; + +pub struct Config { + root_path: &'static str, + listening_paths: &'static [&'static str], + excluded_paths: &'static [&'static str], + log: bool, + rebuild_with: Option bool + Send + 'static>>, + phantom: std::marker::PhantomData, +} + +impl Default for Config { + fn default() -> Self { + Self { + root_path: "", + listening_paths: &[""], + excluded_paths: &["./target"], + log: true, + rebuild_with: None, + phantom: std::marker::PhantomData, + } + } +} + +impl Config { + pub const fn new() -> Self { + Self { + root_path: "", + listening_paths: &[""], + excluded_paths: &["./target"], + log: true, + rebuild_with: None, + phantom: std::marker::PhantomData, + } + } +} + +impl Config { + /// Set the root path of the project (where the Cargo.toml file is). This is automatically set by the [`hot_reload_init`] macro. + pub fn root(self, path: &'static str) -> Self { + Self { + root_path: path, + ..self + } + } + + /// Set whether to enable logs + pub fn with_logging(self, log: bool) -> Self { + Self { log, ..self } + } + + /// Set the command to run to rebuild the project + /// + /// For example to restart the application after a change is made, you could use `cargo run` + pub fn with_rebuild_command(self, rebuild_command: &'static str) -> Self { + self.with_rebuild_callback(move || { + execute::shell(rebuild_command) + .spawn() + .expect("Failed to spawn the rebuild command"); + true + }) + } + + /// Set a callback to run to when the project needs to be rebuilt and returns if the server should shut down + /// + /// For example a CLI application could rebuild the application when a change is made + pub fn with_rebuild_callback( + self, + rebuild_callback: impl FnMut() -> bool + Send + 'static, + ) -> Self { + Self { + rebuild_with: Some(Box::new(rebuild_callback)), + ..self + } + } + + /// Set the paths to listen for changes in to trigger hot reloading. If this is a directory it will listen for changes in all files in that directory recursively. + pub fn with_paths(self, paths: &'static [&'static str]) -> Self { + Self { + listening_paths: paths, + ..self + } + } + + /// Sets paths to ignore changes on. This will override any paths set in the [`Config::with_paths`] method in the case of conflicts. + pub fn excluded_paths(self, paths: &'static [&'static str]) -> Self { + Self { + excluded_paths: paths, + ..self + } + } +} + +/// Initialize the hot reloading listener +pub fn init(cfg: Config) { + let Config { + root_path, + listening_paths, + log, + mut rebuild_with, + excluded_paths, + phantom: _, + } = cfg; + + if let Ok(crate_dir) = PathBuf::from_str(root_path) { + // try to find the gitingore file + let gitignore_file_path = crate_dir.join(".gitignore"); + let (gitignore, _) = ignore::gitignore::Gitignore::new(gitignore_file_path); + + // convert the excluded paths to absolute paths + let excluded_paths = excluded_paths + .iter() + .map(|path| crate_dir.join(PathBuf::from(path))) + .collect::>(); + + let channels = Arc::new(Mutex::new(Vec::new())); + let FileMapBuildResult { + map: file_map, + errors, + } = FileMap::::create_with_filter(crate_dir.clone(), |path| { + // skip excluded paths + excluded_paths.iter().any(|p| path.starts_with(p)) || + // respect .gitignore + gitignore + .matched_path_or_any_parents(path, path.is_dir()) + .is_ignore() + }) + .unwrap(); + for err in errors { + if log { + println!("hot reloading failed to initialize:\n{err:?}"); + } + } + let file_map = Arc::new(Mutex::new(file_map)); + + #[cfg(target_os = "macos")] + { + // On unix, if you force quit the application, it can leave the file socket open + // This will cause the local socket listener to fail to open + // We check if the file socket is already open from an old session and then delete it + let paths = ["./dioxusin", "./@dioxusin"]; + for path in paths { + let path = PathBuf::from(path); + if path.exists() { + let _ = std::fs::remove_file(path); + } + } + } + + match LocalSocketListener::bind("@dioxusin") { + Ok(local_socket_stream) => { + let aborted = Arc::new(Mutex::new(false)); + + // listen for connections + std::thread::spawn({ + let file_map = file_map.clone(); + let channels = channels.clone(); + let aborted = aborted.clone(); + let _ = local_socket_stream.set_nonblocking(true); + move || { + loop { + if let Ok(mut connection) = local_socket_stream.accept() { + // send any templates than have changed before the socket connected + let templates: Vec<_> = { + file_map + .lock() + .unwrap() + .map + .values() + .filter_map(|(_, template_slot)| *template_slot) + .collect() + }; + for template in templates { + if !send_msg( + HotReloadMsg::UpdateTemplate(template), + &mut connection, + ) { + continue; + } + } + channels.lock().unwrap().push(connection); + if log { + println!("Connected to hot reloading 🚀"); + } + } + if *aborted.lock().unwrap() { + break; + } + } + } + }); + + // watch for changes + std::thread::spawn(move || { + let mut last_update_time = chrono::Local::now().timestamp(); + + let (tx, rx) = std::sync::mpsc::channel(); + + let mut watcher = + RecommendedWatcher::new(tx, notify::Config::default()).unwrap(); + + for path in listening_paths { + let full_path = crate_dir.join(path); + if let Err(err) = watcher.watch(&full_path, RecursiveMode::Recursive) { + if log { + println!( + "hot reloading failed to start watching {full_path:?}:\n{err:?}", + ); + } + } + } + + let mut rebuild = { + let aborted = aborted.clone(); + let channels = channels.clone(); + move || { + if let Some(rebuild_callback) = &mut rebuild_with { + if log { + println!("Rebuilding the application..."); + } + let shutdown = rebuild_callback(); + + if shutdown { + *aborted.lock().unwrap() = true; + } + + for channel in &mut *channels.lock().unwrap() { + send_msg(HotReloadMsg::Shutdown, channel); + } + + return shutdown; + } else if log { + println!( + "Rebuild needed... shutting down hot reloading.\nManually rebuild the application to view futher changes." + ); + } + true + } + }; + + for evt in rx { + if chrono::Local::now().timestamp_millis() >= last_update_time { + if let Ok(evt) = evt { + let real_paths = evt + .paths + .iter() + .filter(|path| { + // skip non rust files + matches!( + path.extension().and_then(|p| p.to_str()), + Some("rs" | "toml" | "css" | "html" | "js") + ) && + // skip excluded paths + !excluded_paths.iter().any(|p| path.starts_with(p)) && + // respect .gitignore + !gitignore + .matched_path_or_any_parents(path, false) + .is_ignore() + }) + .collect::>(); + + // Give time for the change to take effect before reading the file + if !real_paths.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let mut channels = channels.lock().unwrap(); + for path in real_paths { + // if this file type cannot be hot reloaded, rebuild the application + if path.extension().and_then(|p| p.to_str()) != Some("rs") + && rebuild() + { + return; + } + // find changes to the rsx in the file + match file_map + .lock() + .unwrap() + .update_rsx(path, crate_dir.as_path()) + { + Ok(UpdateResult::UpdatedRsx(msgs)) => { + for msg in msgs { + let mut i = 0; + while i < channels.len() { + let channel = &mut channels[i]; + if send_msg( + HotReloadMsg::UpdateTemplate(msg), + channel, + ) { + i += 1; + } else { + channels.remove(i); + } + } + } + } + Ok(UpdateResult::NeedsRebuild) => { + drop(channels); + if rebuild() { + return; + } + break; + } + Err(err) => { + if log { + println!( + "hot reloading failed to update rsx:\n{err:?}" + ); + } + } + } + } + } + last_update_time = chrono::Local::now().timestamp_millis(); + } + } + }); + } + Err(error) => println!("failed to connect to hot reloading\n{error}"), + } + } +} + +fn send_msg(msg: HotReloadMsg, channel: &mut impl Write) -> bool { + if let Ok(msg) = serde_json::to_string(&msg) { + if channel.write_all(msg.as_bytes()).is_err() { + return false; + } + if channel.write_all(&[b'\n']).is_err() { + return false; + } + true + } else { + false + } +} diff --git a/packages/hot-reload/src/lib.rs b/packages/hot-reload/src/lib.rs index 48ee50a59..f70f61da9 100644 --- a/packages/hot-reload/src/lib.rs +++ b/packages/hot-reload/src/lib.rs @@ -1,21 +1,16 @@ -use std::{ - io::{BufRead, BufReader, Write}, - path::PathBuf, - str::FromStr, - sync::{Arc, Mutex}, -}; +use std::io::{BufRead, BufReader}; use dioxus_core::Template; -use dioxus_rsx::{ - hot_reload::{FileMap, FileMapBuildResult, UpdateResult}, - HotReloadingContext, -}; -use interprocess_docfix::local_socket::{LocalSocketListener, LocalSocketStream}; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; - +#[cfg(file_watcher)] pub use dioxus_html::HtmlCtx; +use interprocess_docfix::local_socket::LocalSocketStream; use serde::{Deserialize, Serialize}; +#[cfg(file_watcher)] +mod file_watcher; +#[cfg(file_watcher)] +use file_watcher::*; + /// A message the hot reloading server sends to the client #[derive(Debug, Serialize, Deserialize, Clone, Copy)] pub enum HotReloadMsg { @@ -26,341 +21,6 @@ pub enum HotReloadMsg { Shutdown, } -pub struct Config { - root_path: &'static str, - listening_paths: &'static [&'static str], - excluded_paths: &'static [&'static str], - log: bool, - rebuild_with: Option bool + Send + 'static>>, - phantom: std::marker::PhantomData, -} - -impl Default for Config { - fn default() -> Self { - Self { - root_path: "", - listening_paths: &[""], - excluded_paths: &["./target"], - log: true, - rebuild_with: None, - phantom: std::marker::PhantomData, - } - } -} - -impl Config { - pub const fn new() -> Self { - Self { - root_path: "", - listening_paths: &[""], - excluded_paths: &["./target"], - log: true, - rebuild_with: None, - phantom: std::marker::PhantomData, - } - } -} - -impl Config { - /// Set the root path of the project (where the Cargo.toml file is). This is automatically set by the [`hot_reload_init`] macro. - pub fn root(self, path: &'static str) -> Self { - Self { - root_path: path, - ..self - } - } - - /// Set whether to enable logs - pub fn with_logging(self, log: bool) -> Self { - Self { log, ..self } - } - - /// Set the command to run to rebuild the project - /// - /// For example to restart the application after a change is made, you could use `cargo run` - pub fn with_rebuild_command(self, rebuild_command: &'static str) -> Self { - self.with_rebuild_callback(move || { - execute::shell(rebuild_command) - .spawn() - .expect("Failed to spawn the rebuild command"); - true - }) - } - - /// Set a callback to run to when the project needs to be rebuilt and returns if the server should shut down - /// - /// For example a CLI application could rebuild the application when a change is made - pub fn with_rebuild_callback( - self, - rebuild_callback: impl FnMut() -> bool + Send + 'static, - ) -> Self { - Self { - rebuild_with: Some(Box::new(rebuild_callback)), - ..self - } - } - - /// Set the paths to listen for changes in to trigger hot reloading. If this is a directory it will listen for changes in all files in that directory recursively. - pub fn with_paths(self, paths: &'static [&'static str]) -> Self { - Self { - listening_paths: paths, - ..self - } - } - - /// Sets paths to ignore changes on. This will override any paths set in the [`Config::with_paths`] method in the case of conflicts. - pub fn excluded_paths(self, paths: &'static [&'static str]) -> Self { - Self { - excluded_paths: paths, - ..self - } - } -} - -/// Initialize the hot reloading listener -pub fn init(cfg: Config) { - let Config { - root_path, - listening_paths, - log, - mut rebuild_with, - excluded_paths, - phantom: _, - } = cfg; - - if let Ok(crate_dir) = PathBuf::from_str(root_path) { - // try to find the gitingore file - let gitignore_file_path = crate_dir.join(".gitignore"); - let (gitignore, _) = ignore::gitignore::Gitignore::new(gitignore_file_path); - - // convert the excluded paths to absolute paths - let excluded_paths = excluded_paths - .iter() - .map(|path| crate_dir.join(PathBuf::from(path))) - .collect::>(); - - let channels = Arc::new(Mutex::new(Vec::new())); - let FileMapBuildResult { - map: file_map, - errors, - } = FileMap::::create_with_filter(crate_dir.clone(), |path| { - // skip excluded paths - excluded_paths.iter().any(|p| path.starts_with(p)) || - // respect .gitignore - gitignore - .matched_path_or_any_parents(path, path.is_dir()) - .is_ignore() - }) - .unwrap(); - for err in errors { - if log { - println!("hot reloading failed to initialize:\n{err:?}"); - } - } - let file_map = Arc::new(Mutex::new(file_map)); - - #[cfg(target_os = "macos")] - { - // On unix, if you force quit the application, it can leave the file socket open - // This will cause the local socket listener to fail to open - // We check if the file socket is already open from an old session and then delete it - let paths = ["./dioxusin", "./@dioxusin"]; - for path in paths { - let path = PathBuf::from(path); - if path.exists() { - let _ = std::fs::remove_file(path); - } - } - } - - match LocalSocketListener::bind("@dioxusin") { - Ok(local_socket_stream) => { - let aborted = Arc::new(Mutex::new(false)); - - // listen for connections - std::thread::spawn({ - let file_map = file_map.clone(); - let channels = channels.clone(); - let aborted = aborted.clone(); - let _ = local_socket_stream.set_nonblocking(true); - move || { - loop { - if let Ok(mut connection) = local_socket_stream.accept() { - // send any templates than have changed before the socket connected - let templates: Vec<_> = { - file_map - .lock() - .unwrap() - .map - .values() - .filter_map(|(_, template_slot)| *template_slot) - .collect() - }; - for template in templates { - if !send_msg( - HotReloadMsg::UpdateTemplate(template), - &mut connection, - ) { - continue; - } - } - channels.lock().unwrap().push(connection); - if log { - println!("Connected to hot reloading 🚀"); - } - } - if *aborted.lock().unwrap() { - break; - } - } - } - }); - - // watch for changes - std::thread::spawn(move || { - let mut last_update_time = chrono::Local::now().timestamp(); - - let (tx, rx) = std::sync::mpsc::channel(); - - let mut watcher = - RecommendedWatcher::new(tx, notify::Config::default()).unwrap(); - - for path in listening_paths { - let full_path = crate_dir.join(path); - if let Err(err) = watcher.watch(&full_path, RecursiveMode::Recursive) { - if log { - println!( - "hot reloading failed to start watching {full_path:?}:\n{err:?}", - ); - } - } - } - - let mut rebuild = { - let aborted = aborted.clone(); - let channels = channels.clone(); - move || { - if let Some(rebuild_callback) = &mut rebuild_with { - if log { - println!("Rebuilding the application..."); - } - let shutdown = rebuild_callback(); - - if shutdown { - *aborted.lock().unwrap() = true; - } - - for channel in &mut *channels.lock().unwrap() { - send_msg(HotReloadMsg::Shutdown, channel); - } - - return shutdown; - } else if log { - println!( - "Rebuild needed... shutting down hot reloading.\nManually rebuild the application to view futher changes." - ); - } - true - } - }; - - for evt in rx { - if chrono::Local::now().timestamp_millis() >= last_update_time { - if let Ok(evt) = evt { - let real_paths = evt - .paths - .iter() - .filter(|path| { - // skip non rust files - matches!( - path.extension().and_then(|p| p.to_str()), - Some("rs" | "toml" | "css" | "html" | "js") - ) && - // skip excluded paths - !excluded_paths.iter().any(|p| path.starts_with(p)) && - // respect .gitignore - !gitignore - .matched_path_or_any_parents(path, false) - .is_ignore() - }) - .collect::>(); - - // Give time for the change to take effect before reading the file - if !real_paths.is_empty() { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - - let mut channels = channels.lock().unwrap(); - for path in real_paths { - // if this file type cannot be hot reloaded, rebuild the application - if path.extension().and_then(|p| p.to_str()) != Some("rs") - && rebuild() - { - return; - } - // find changes to the rsx in the file - match file_map - .lock() - .unwrap() - .update_rsx(path, crate_dir.as_path()) - { - Ok(UpdateResult::UpdatedRsx(msgs)) => { - for msg in msgs { - let mut i = 0; - while i < channels.len() { - let channel = &mut channels[i]; - if send_msg( - HotReloadMsg::UpdateTemplate(msg), - channel, - ) { - i += 1; - } else { - channels.remove(i); - } - } - } - } - Ok(UpdateResult::NeedsRebuild) => { - drop(channels); - if rebuild() { - return; - } - break; - } - Err(err) => { - if log { - println!( - "hot reloading failed to update rsx:\n{err:?}" - ); - } - } - } - } - } - last_update_time = chrono::Local::now().timestamp_millis(); - } - } - }); - } - Err(error) => println!("failed to connect to hot reloading\n{error}"), - } - } -} - -fn send_msg(msg: HotReloadMsg, channel: &mut impl Write) -> bool { - if let Ok(msg) = serde_json::to_string(&msg) { - if channel.write_all(msg.as_bytes()).is_err() { - return false; - } - if channel.write_all(&[b'\n']).is_err() { - return false; - } - true - } else { - false - } -} - /// Connect to the hot reloading listener. The callback provided will be called every time a template change is detected pub fn connect(mut f: impl FnMut(HotReloadMsg) + Send + 'static) { std::thread::spawn(move || { diff --git a/packages/html/Cargo.toml b/packages/html/Cargo.toml index a44b22a77..e72c20c97 100644 --- a/packages/html/Cargo.toml +++ b/packages/html/Cargo.toml @@ -4,14 +4,14 @@ version = "0.3.1" authors = ["Jonathan Kelley"] edition = "2018" description = "HTML Element pack for Dioxus - a concurrent renderer-agnostic Virtual DOM for interactive user experiences" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] [dependencies] dioxus-core = { workspace = true } -dioxus-rsx = { workspace = true, optional = true } +dioxus-rsx = { workspace = true, features = ["hot_reload"], optional = true } serde = { version = "1", features = ["derive"], optional = true } serde_repr = { version = "0.1", optional = true } wasm-bindgen = { workspace = true, optional = true } diff --git a/packages/html/src/elements.rs b/packages/html/src/elements.rs index e1e80057e..514aea2d1 100644 --- a/packages/html/src/elements.rs +++ b/packages/html/src/elements.rs @@ -1098,6 +1098,7 @@ builder_constructors! { autofocus: Bool DEFAULT, capture: String DEFAULT, checked: Bool DEFAULT, + directory: Bool "webkitdirectory", disabled: Bool DEFAULT, form: Id DEFAULT, formaction: Uri DEFAULT, diff --git a/packages/html/src/events/form.rs b/packages/html/src/events/form.rs index 113c765c5..1fe59a640 100644 --- a/packages/html/src/events/form.rs +++ b/packages/html/src/events/form.rs @@ -56,10 +56,9 @@ where { use serde::Deserialize; - let Ok(file_engine) = - SerializedFileEngine::deserialize(deserializer) else{ - return Ok(None); - }; + let Ok(file_engine) = SerializedFileEngine::deserialize(deserializer) else { + return Ok(None); + }; let file_engine = std::sync::Arc::new(file_engine); Ok(Some(file_engine)) diff --git a/packages/interpreter/Cargo.toml b/packages/interpreter/Cargo.toml index dd310ce41..7c6796829 100644 --- a/packages/interpreter/Cargo.toml +++ b/packages/interpreter/Cargo.toml @@ -15,7 +15,7 @@ wasm-bindgen = { workspace = true, optional = true } js-sys = { version = "0.3.56", optional = true } web-sys = { version = "0.3.56", optional = true, features = ["Element", "Node"] } sledgehammer_bindgen = { version = "0.2.1", optional = true } -sledgehammer_utils = { version = "0.1.1", optional = true } +sledgehammer_utils = { version = "0.2", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } [features] diff --git a/packages/interpreter/src/common.js b/packages/interpreter/src/common.js index c93f9eb60..1583da10e 100644 --- a/packages/interpreter/src/common.js +++ b/packages/interpreter/src/common.js @@ -25,6 +25,7 @@ const bool_attrs = { reversed: true, selected: true, truespeed: true, + webkitdirectory: true, }; export function setAttributeInner(node, field, value, ns) { diff --git a/packages/interpreter/src/sledgehammer_bindings.rs b/packages/interpreter/src/sledgehammer_bindings.rs index e661a7c16..2eb883500 100644 --- a/packages/interpreter/src/sledgehammer_bindings.rs +++ b/packages/interpreter/src/sledgehammer_bindings.rs @@ -163,6 +163,7 @@ mod js { reversed: true, selected: true, truespeed: true, + webkitdirectory: true, }; function truthy(val) { return val === "true" || val === true; diff --git a/packages/liveview/Cargo.toml b/packages/liveview/Cargo.toml index 210b72b53..b2c701877 100644 --- a/packages/liveview/Cargo.toml +++ b/packages/liveview/Cargo.toml @@ -6,7 +6,7 @@ repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/liveview.html" keywords = ["dom", "ui", "gui", "react", "liveview"] description = "Build server-side apps with Dioxus" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] thiserror = { workspace = true } diff --git a/packages/liveview/src/adapters/axum_adapter.rs b/packages/liveview/src/adapters/axum_adapter.rs index 646cde38e..dda37acc1 100644 --- a/packages/liveview/src/adapters/axum_adapter.rs +++ b/packages/liveview/src/adapters/axum_adapter.rs @@ -11,13 +11,14 @@ pub fn axum_socket(ws: WebSocket) -> impl LiveViewSocket { .sink_map_err(|_| LiveViewError::SendingFailed) } -fn transform_rx(message: Result) -> Result { +fn transform_rx(message: Result) -> Result, LiveViewError> { message .map_err(|_| LiveViewError::SendingFailed)? .into_text() + .map(|s| s.into_bytes()) .map_err(|_| LiveViewError::SendingFailed) } -async fn transform_tx(message: String) -> Result { - Ok(Message::Text(message)) +async fn transform_tx(message: Vec) -> Result { + Ok(Message::Text(String::from_utf8_lossy(&message).to_string())) } diff --git a/packages/liveview/src/adapters/salvo_adapter.rs b/packages/liveview/src/adapters/salvo_adapter.rs index 2c8912a57..138196da7 100644 --- a/packages/liveview/src/adapters/salvo_adapter.rs +++ b/packages/liveview/src/adapters/salvo_adapter.rs @@ -12,14 +12,12 @@ pub fn salvo_socket(ws: WebSocket) -> impl LiveViewSocket { .sink_map_err(|_| LiveViewError::SendingFailed) } -fn transform_rx(message: Result) -> Result { +fn transform_rx(message: Result) -> Result, LiveViewError> { let as_bytes = message.map_err(|_| LiveViewError::SendingFailed)?; - let msg = String::from_utf8(as_bytes.into_bytes()).map_err(|_| LiveViewError::SendingFailed)?; - - Ok(msg) + Ok(as_bytes.into()) } -async fn transform_tx(message: String) -> Result { - Ok(Message::text(message)) +async fn transform_tx(message: Vec) -> Result { + Ok(Message::text(String::from_utf8_lossy(&message).to_string())) } diff --git a/packages/liveview/src/adapters/warp_adapter.rs b/packages/liveview/src/adapters/warp_adapter.rs index e5c821ce3..9ee8c6fe4 100644 --- a/packages/liveview/src/adapters/warp_adapter.rs +++ b/packages/liveview/src/adapters/warp_adapter.rs @@ -11,18 +11,15 @@ pub fn warp_socket(ws: WebSocket) -> impl LiveViewSocket { .sink_map_err(|_| LiveViewError::SendingFailed) } -fn transform_rx(message: Result) -> Result { +fn transform_rx(message: Result) -> Result, LiveViewError> { // destructure the message into the buffer we got from warp let msg = message .map_err(|_| LiveViewError::SendingFailed)? .into_bytes(); - // transform it back into a string, saving us the allocation - let msg = String::from_utf8(msg).map_err(|_| LiveViewError::SendingFailed)?; - Ok(msg) } -async fn transform_tx(message: String) -> Result { - Ok(Message::text(message)) +async fn transform_tx(message: Vec) -> Result { + Ok(Message::text(String::from_utf8_lossy(&message).to_string())) } diff --git a/packages/liveview/src/pool.rs b/packages/liveview/src/pool.rs index 089d0bad4..dc7e4ea9e 100644 --- a/packages/liveview/src/pool.rs +++ b/packages/liveview/src/pool.rs @@ -87,16 +87,16 @@ impl LiveViewPool { /// } /// ``` pub trait LiveViewSocket: - SinkExt - + StreamExt> + SinkExt, Error = LiveViewError> + + StreamExt, LiveViewError>> + Send + 'static { } impl LiveViewSocket for S where - S: SinkExt - + StreamExt> + S: SinkExt, Error = LiveViewError> + + StreamExt, LiveViewError>> + Send + 'static { @@ -126,7 +126,7 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li pin_mut!(ws); // send the initial render to the client - ws.send(edits).await?; + ws.send(edits.into_bytes()).await?; // Create the a proxy for query engine let (query_tx, mut query_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -156,11 +156,11 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li evt = ws.next() => { match evt.as_ref().map(|o| o.as_deref()) { // respond with a pong every ping to keep the websocket alive - Some(Ok("__ping__")) => { - ws.send("__pong__".to_string()).await?; + Some(Ok(b"__ping__")) => { + ws.send(b"__pong__".to_vec()).await?; } Some(Ok(evt)) => { - if let Ok(message) = serde_json::from_str::(evt) { + if let Ok(message) = serde_json::from_str::(&String::from_utf8_lossy(evt)) { match message { IpcMessage::Event(evt) => { // Intercept the mounted event and insert a custom element type @@ -196,7 +196,7 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li // handle any new queries Some(query) = query_rx.recv() => { - ws.send(serde_json::to_string(&ClientUpdate::Query(query)).unwrap()).await?; + ws.send(serde_json::to_string(&ClientUpdate::Query(query)).unwrap().into_bytes()).await?; } Some(msg) = hot_reload_wait => { @@ -218,8 +218,12 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li .render_with_deadline(tokio::time::sleep(Duration::from_millis(10))) .await; - ws.send(serde_json::to_string(&ClientUpdate::Edits(edits)).unwrap()) - .await?; + ws.send( + serde_json::to_string(&ClientUpdate::Edits(edits)) + .unwrap() + .into_bytes(), + ) + .await?; } } diff --git a/packages/mobile/Cargo.toml b/packages/mobile/Cargo.toml index c5a8f8239..bbaf4168c 100644 --- a/packages/mobile/Cargo.toml +++ b/packages/mobile/Cargo.toml @@ -7,7 +7,7 @@ description = "Mobile-compatible renderer for Dioxus" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/mobile.html" keywords = ["dom", "ui", "gui", "react"] -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] dioxus-desktop = { workspace = true } diff --git a/packages/native-core-macro/Cargo.toml b/packages/native-core-macro/Cargo.toml index 82b2dc4a6..4498d674d 100644 --- a/packages/native-core-macro/Cargo.toml +++ b/packages/native-core-macro/Cargo.toml @@ -3,7 +3,7 @@ name = "dioxus-native-core-macro" version = "0.3.0" edition = "2021" description = "Build natively rendered apps with Dioxus" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react"] @@ -12,7 +12,7 @@ keywords = ["dom", "ui", "gui", "react"] proc-macro = true [dependencies] -syn = { version = "1.0.11", features = ["extra-traits", "full"] } +syn = { version = "2.0", features = ["extra-traits", "full"] } quote = "1.0" [dev-dependencies] diff --git a/packages/native-core-macro/src/lib.rs b/packages/native-core-macro/src/lib.rs index ff0e4629d..d122838bd 100644 --- a/packages/native-core-macro/src/lib.rs +++ b/packages/native-core-macro/src/lib.rs @@ -14,7 +14,7 @@ pub fn partial_derive_state(_: TokenStream, input: TokenStream) -> TokenStream { let has_create_fn = impl_block .items .iter() - .any(|item| matches!(item, syn::ImplItem::Method(method) if method.sig.ident == "create")); + .any(|item| matches!(item, syn::ImplItem::Fn(method) if method.sig.ident == "create")); let parent_dependencies = impl_block .items diff --git a/packages/native-core/Cargo.toml b/packages/native-core/Cargo.toml index 5ead1740d..44fe594f6 100644 --- a/packages/native-core/Cargo.toml +++ b/packages/native-core/Cargo.toml @@ -2,7 +2,7 @@ name = "dioxus-native-core" version = "0.2.0" edition = "2021" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" description = "Build natively rendered apps with Dioxus" diff --git a/packages/rink/Cargo.toml b/packages/rink/Cargo.toml index a51659dc7..1f5fa13b1 100644 --- a/packages/rink/Cargo.toml +++ b/packages/rink/Cargo.toml @@ -7,7 +7,7 @@ description = "TUI-based renderer for Dioxus" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react", "terminal"] -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] dioxus-html = { workspace = true } diff --git a/packages/rink/src/widgets/button.rs b/packages/rink/src/widgets/button.rs index e4d3754f1..9018a3e9a 100644 --- a/packages/rink/src/widgets/button.rs +++ b/packages/rink/src/widgets/button.rs @@ -82,7 +82,9 @@ impl Button { fn write_value(&self, rdom: &mut RealDom) { if let Some(mut text) = rdom.get_mut(self.text_id) { let node_type = text.node_type_mut(); - let NodeTypeMut::Text(mut text) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Text(mut text) = node_type else { + panic!("input must be an element") + }; *text.text_mut() = self.value.clone(); } } @@ -111,7 +113,9 @@ impl CustomElement for Button { fn create(mut root: dioxus_native_core::real_dom::NodeMut) -> Self { let node_type = root.node_type(); - let NodeType::Element(el) = &*node_type else { panic!("input must be an element") }; + let NodeType::Element(el) = &*node_type else { + panic!("input must be an element") + }; let value = el .attributes @@ -146,7 +150,9 @@ impl CustomElement for Button { AttributeMask::All => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; self.update_value_attr(&el); self.update_size_attr(&mut el); } @@ -155,7 +161,9 @@ impl CustomElement for Button { AttributeMask::Some(attrs) => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; if attrs.contains("width") || attrs.contains("height") { self.update_size_attr(&mut el); } diff --git a/packages/rink/src/widgets/checkbox.rs b/packages/rink/src/widgets/checkbox.rs index 24b3f6244..fab809ed2 100644 --- a/packages/rink/src/widgets/checkbox.rs +++ b/packages/rink/src/widgets/checkbox.rs @@ -94,14 +94,18 @@ impl CheckBox { fn write_value(&self, mut root: NodeMut) { let single_char = { let node_type = root.node_type_mut(); - let NodeTypeMut::Element( el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(el) = node_type else { + panic!("input must be an element") + }; Self::width(&el) == "1px" || Self::height(&el) == "1px" }; let rdom = root.real_dom_mut(); if let Some(mut text) = rdom.get_mut(self.text_id) { let node_type = text.node_type_mut(); - let NodeTypeMut::Text(mut text) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Text(mut text) = node_type else { + panic!("input must be an element") + }; let value = if single_char { if self.checked { "☑" @@ -156,7 +160,9 @@ impl CustomElement for CheckBox { fn create(mut root: dioxus_native_core::real_dom::NodeMut) -> Self { let node_type = root.node_type(); - let NodeType::Element(el) = &*node_type else { panic!("input must be an element") }; + let NodeType::Element(el) = &*node_type else { + panic!("input must be an element") + }; let value = el .attributes @@ -197,7 +203,9 @@ impl CustomElement for CheckBox { AttributeMask::All => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; self.update_value_attr(&el); self.update_size_attr(&mut el); self.update_checked_attr(&el); @@ -207,7 +215,9 @@ impl CustomElement for CheckBox { AttributeMask::Some(attrs) => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; if attrs.contains("width") || attrs.contains("height") { self.update_size_attr(&mut el); } diff --git a/packages/rink/src/widgets/input.rs b/packages/rink/src/widgets/input.rs index c6a3e56b9..c1fb563d7 100644 --- a/packages/rink/src/widgets/input.rs +++ b/packages/rink/src/widgets/input.rs @@ -56,7 +56,9 @@ impl CustomElement for Input { } let node_type = root.node_type(); - let NodeType::Element(el) = &*node_type else { panic!("input must be an element") }; + let NodeType::Element(el) = &*node_type else { + panic!("input must be an element") + }; let input_type = el .attributes .get(&OwnedAttributeDiscription { diff --git a/packages/rink/src/widgets/slider.rs b/packages/rink/src/widgets/slider.rs index f97c12bf6..7080b6c12 100644 --- a/packages/rink/src/widgets/slider.rs +++ b/packages/rink/src/widgets/slider.rs @@ -163,7 +163,9 @@ impl Slider { if let Some(mut div) = rdom.get_mut(self.pre_cursor_div) { let node_type = div.node_type_mut(); - let NodeTypeMut::Element(mut element) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut element) = node_type else { + panic!("input must be an element") + }; element.set_attribute( OwnedAttributeDiscription { name: "width".to_string(), @@ -175,7 +177,9 @@ impl Slider { if let Some(mut div) = rdom.get_mut(self.post_cursor_div) { let node_type = div.node_type_mut(); - let NodeTypeMut::Element(mut element) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut element) = node_type else { + panic!("input must be an element") + }; element.set_attribute( OwnedAttributeDiscription { name: "width".to_string(), @@ -259,7 +263,9 @@ impl CustomElement for Slider { fn create(mut root: dioxus_native_core::real_dom::NodeMut) -> Self { let node_type = root.node_type(); - let NodeType::Element(el) = &*node_type else { panic!("input must be an element") }; + let NodeType::Element(el) = &*node_type else { + panic!("input must be an element") + }; let value = el.attributes.get(&OwnedAttributeDiscription { name: "value".to_string(), @@ -390,7 +396,9 @@ impl CustomElement for Slider { AttributeMask::All => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; self.update_value_attr(&el); self.update_size_attr(&mut el); self.update_max_attr(&el); @@ -403,7 +411,9 @@ impl CustomElement for Slider { AttributeMask::Some(attrs) => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; if attrs.contains("width") || attrs.contains("height") { self.update_size_attr(&mut el); } diff --git a/packages/rink/src/widgets/text_like.rs b/packages/rink/src/widgets/text_like.rs index b5874c776..6521073d0 100644 --- a/packages/rink/src/widgets/text_like.rs +++ b/packages/rink/src/widgets/text_like.rs @@ -143,19 +143,25 @@ impl TextLike { if let Some(mut text) = rdom.get_mut(self.pre_cursor_text) { let node_type = text.node_type_mut(); - let NodeTypeMut::Text(mut text) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Text(mut text) = node_type else { + panic!("input must be an element") + }; *text.text_mut() = self.controller.display_text(text_before_first_cursor); } if let Some(mut text) = rdom.get_mut(self.highlighted_text) { let node_type = text.node_type_mut(); - let NodeTypeMut::Text(mut text) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Text(mut text) = node_type else { + panic!("input must be an element") + }; *text.text_mut() = self.controller.display_text(text_highlighted); } if let Some(mut text) = rdom.get_mut(self.post_cursor_text) { let node_type = text.node_type_mut(); - let NodeTypeMut::Text(mut text) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Text(mut text) = node_type else { + panic!("input must be an element") + }; *text.text_mut() = self.controller.display_text(text_after_second_cursor); } @@ -288,7 +294,9 @@ impl CustomElement for fn create(mut root: dioxus_native_core::real_dom::NodeMut) -> Self { let node_type = root.node_type(); - let NodeType::Element(el) = &*node_type else { panic!("input must be an element") }; + let NodeType::Element(el) = &*node_type else { + panic!("input must be an element") + }; let value = el .attributes @@ -370,7 +378,9 @@ impl CustomElement for AttributeMask::All => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; self.update_value_attr(&el); self.update_size_attr(&mut el); self.update_max_width_attr(&el); @@ -381,7 +391,9 @@ impl CustomElement for AttributeMask::Some(attrs) => { { let node_type = root.node_type_mut(); - let NodeTypeMut::Element(mut el) = node_type else { panic!("input must be an element") }; + let NodeTypeMut::Element(mut el) = node_type else { + panic!("input must be an element") + }; if attrs.contains("width") || attrs.contains("height") { self.update_size_attr(&mut el); } diff --git a/packages/router-macro/Cargo.toml b/packages/router-macro/Cargo.toml new file mode 100644 index 000000000..4c4c733e6 --- /dev/null +++ b/packages/router-macro/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dioxus-router-macro" +version = "0.3.0" +authors = ["Evan Almloff"] +edition = "2021" +description = "Macro for Dioxus Router" +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +documentation = "https://dioxuslabs.com" +keywords = ["dom", "ui", "gui", "react", "router"] +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["extra-traits", "full"] } +quote = "1.0" +proc-macro2 = "1.0.56" +slab = "0.4" + +[features] +default = [] diff --git a/packages/router/src/error.rs b/packages/router-macro/README.md similarity index 100% rename from packages/router/src/error.rs rename to packages/router-macro/README.md diff --git a/packages/router-macro/src/layout.rs b/packages/router-macro/src/layout.rs new file mode 100644 index 000000000..732f48201 --- /dev/null +++ b/packages/router-macro/src/layout.rs @@ -0,0 +1,62 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::Path; + +use crate::nest::{Nest, NestId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LayoutId(pub usize); + +#[derive(Debug)] +pub struct Layout { + pub comp: Path, + pub props_name: Path, + pub active_nests: Vec, +} + +impl Layout { + pub fn routable_match(&self, nests: &[Nest]) -> TokenStream { + let props_name = &self.props_name; + let comp_name = &self.comp; + let name_str = self.comp.segments.last().unwrap().ident.to_string(); + let dynamic_segments = self + .active_nests + .iter() + .flat_map(|id| nests[id.0].dynamic_segments()); + + quote! { + let comp = #props_name { #(#dynamic_segments,)* }; + let dynamic = cx.component(#comp_name, comp, #name_str); + render! { + dynamic + } + } + } +} + +impl Layout { + pub fn parse(input: syn::parse::ParseStream, active_nests: Vec) -> syn::Result { + // Then parse the component name + let _ = input.parse::(); + let comp: Path = input.parse()?; + + // Then parse the props name + let _ = input.parse::(); + let props_name = input.parse::().unwrap_or_else(|_| { + let last = format_ident!("{}Props", comp.segments.last().unwrap().ident.to_string()); + let mut segments = comp.segments.clone(); + segments.pop(); + segments.push(last.into()); + Path { + leading_colon: None, + segments, + } + }); + + Ok(Self { + comp, + props_name, + active_nests, + }) + } +} diff --git a/packages/router-macro/src/lib.rs b/packages/router-macro/src/lib.rs new file mode 100644 index 000000000..708aa6891 --- /dev/null +++ b/packages/router-macro/src/lib.rs @@ -0,0 +1,714 @@ +extern crate proc_macro; + +use layout::Layout; +use nest::{Nest, NestId}; +use proc_macro::TokenStream; +use quote::{__private::Span, format_ident, quote, ToTokens}; +use redirect::Redirect; +use route::{Route, RouteType}; +use segment::RouteSegment; +use syn::{parse::ParseStream, parse_macro_input, Ident, Token, Type}; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::{layout::LayoutId, route_tree::RouteTree}; + +mod layout; +mod nest; +mod query; +mod redirect; +mod route; +mod route_tree; +mod segment; + +/// Derives the Routable trait for an enum of routes +/// +/// Each variant must: +/// 1. Be struct-like with {}'s +/// 2. Contain all of the dynamic parameters of the current and nested routes +/// 3. Have a `#[route("route")]` attribute +/// +/// Route Segments: +/// 1. Static Segments: "/static" +/// 2. Dynamic Segments: "/:dynamic" (where dynamic has a type that is FromStr in all child Variants) +/// 3. Catch all Segments: "/:..segments" (where segments has a type that is FromSegments in all child Variants) +/// 4. Query Segments: "/?:query" (where query has a type that is FromQuery in all child Variants) +/// +/// Routes are matched: +/// 1. By there specificity this order: Query Routes ("/?:query"), Static Routes ("/route"), Dynamic Routes ("/:route"), Catch All Routes ("/:..route") +/// 2. By the order they are defined in the enum +/// +/// All features: +/// ```rust, skip +/// #[rustfmt::skip] +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// // Define routes with the route macro. If the name of the component is not the same as the variant, you can specify it as the second parameter and the props type as the third +/// #[route("/", IndexComponent, ComponentProps)] +/// Index {}, +/// // Nests with parameters have types taken from child routes +/// // Everything inside the nest has the added parameter `user_id: usize` +/// #[nest("/user/:user_id")] +/// // All children of layouts will be rendered inside the Outlet in the layout component +/// // Creates a Layout UserFrame that has the parameter `user_id: usize` +/// #[layout(UserFrame)] +/// // If there is a component with the name Route1 and props with the name Route1Props, you do not need to pass in the component and type +/// #[route("/:dynamic?:query")] +/// Route1 { +/// // The type is taken from the first instance of the dynamic parameter +/// user_id: usize, +/// dynamic: usize, +/// query: String, +/// extra: String, +/// }, +/// #[route("/hello_world")] +/// // You can opt out of the layout by using the `!` prefix +/// #[layout(!UserFrame)] +/// Route2 { user_id: usize }, +/// // End layouts with #[end_layout] +/// #[end_layout] +/// // End nests with #[end_nest] +/// #[end_nest] +/// // Redirects take a path and a function that takes the parameters from the path and returns a new route +/// #[redirect("/:id/user", |id: usize| Route::Route3 { dynamic: id.to_string()})] +/// #[route("/:dynamic")] +/// Route3 { dynamic: String }, +/// #[child] +/// NestedRoute(NestedRoute), +/// } +/// ``` +/// +/// # `#[route("path", component, props)]` +/// +/// The `#[route]` attribute is used to define a route. It takes up to 3 parameters: +/// - `path`: The path to the enum variant (relative to the parent nest) +/// - (optional) `component`: The component to render when the route is matched. If not specified, the name of the variant is used +/// - (optional) `props`: The props type for the component. If not specified, the name of the variant with `Props` appended is used +/// +/// Routes are the most basic attribute. They allow you to define a route and the component to render when the route is matched. The component must take all dynamic parameters of the route and all parent nests. +/// The next variant will be tied to the component. If you link to that variant, the component will be rendered. +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// // Define routes that renders the IndexComponent that takes the IndexProps +/// // The Index component will be rendered when the route is matched (e.g. when the user navigates to /) +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// } +/// ``` +/// +/// # `#[redirect("path", function)]` +/// +/// The `#[redirect]` attribute is used to define a redirect. It takes 2 parameters: +/// - `path`: The path to the enum variant (relative to the parent nest) +/// - `function`: A function that takes the parameters from the path and returns a new route +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// // Redirects the /:id route to the Index route +/// #[redirect("/:id", |_: usize| Route::Index {})] +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// } +/// ``` +/// +/// Redirects allow you to redirect a route to another route. The function must take all dynamic parameters of the route and all parent nests. +/// +/// # `#[nest("path")]` +/// +/// The `#[nest]` attribute is used to define a nest. It takes 1 parameter: +/// - `path`: The path to the nest (relative to the parent nest) +/// +/// Nests effect all nests, routes and redirects defined until the next `#[end_nest]` attribute. All children of nests are relative to the nest route and must include all dynamic parameters of the nest. +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// // Nests all child routes in the /blog route +/// #[nest("/blog")] +/// // This is at /blog/:id +/// #[redirect("/:id", |_: usize| Route::Index {})] +/// // This is at /blog +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// } +/// ``` +/// +/// # `#[end_nest]` +/// +/// The `#[end_nest]` attribute is used to end a nest. It takes no parameters. +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// #[nest("/blog")] +/// // This is at /blog/:id +/// #[redirect("/:id", |_: usize| Route::Index {})] +/// // This is at /blog +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// // Ends the nest +/// #[end_nest] +/// // This is at / +/// #[route("/")] +/// Home {}, +/// } +/// ``` +/// +/// # `#[layout(component)]` +/// +/// The `#[layout]` attribute is used to define a layout. It takes 2 parameters: +/// - `component`: The component to render when the route is matched. If not specified, the name of the variant is used +/// - (optional) `props`: The props type for the component. If not specified, the name of the variant with `Props` appended is used +/// +/// The layout component allows you to wrap all children of the layout in a component. The child routes are rendered in the Outlet of the layout component. The layout component must take all dynamic parameters of the nests it is nested in. +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// #[layout(BlogFrame)] +/// #[redirect("/:id", |_: usize| Route::Index {})] +/// // Index will be rendered in the Outlet of the BlogFrame component +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// } +/// ``` +/// +/// # `#[end_layout]` +/// +/// The `#[end_layout]` attribute is used to end a layout. It takes no parameters. +/// +/// ```rust, skip +/// #[derive(Clone, Debug, PartialEq, Routable)] +/// enum Route { +/// #[layout(BlogFrame)] +/// #[redirect("/:id", |_: usize| Route::Index {})] +/// // Index will be rendered in the Outlet of the BlogFrame component +/// #[route("/", Index, IndexProps)] +/// Index {}, +/// // Ends the layout +/// #[end_layout] +/// // This will be rendered standalone +/// #[route("/")] +/// Home {}, +/// } +/// ``` +#[proc_macro_derive( + Routable, + attributes(route, nest, end_nest, layout, end_layout, redirect, child) +)] +pub fn routable(input: TokenStream) -> TokenStream { + let routes_enum = parse_macro_input!(input as syn::ItemEnum); + + let route_enum = match RouteEnum::parse(routes_enum) { + Ok(route_enum) => route_enum, + Err(err) => return err.to_compile_error().into(), + }; + + let error_type = route_enum.error_type(); + let parse_impl = route_enum.parse_impl(); + let display_impl = route_enum.impl_display(); + let routable_impl = route_enum.routable_impl(); + let name = &route_enum.name; + let vis = &route_enum.vis; + + quote! { + #vis fn Outlet(cx: dioxus::prelude::Scope) -> dioxus::prelude::Element { + dioxus_router::prelude::GenericOutlet::<#name>(cx) + } + + #vis fn Router(cx: dioxus::prelude::Scope>) -> dioxus::prelude::Element { + dioxus_router::prelude::GenericRouter(cx) + } + + #vis fn Link<'a>(cx: dioxus::prelude::Scope<'a, dioxus_router::prelude::GenericLinkProps<'a, #name>>) -> dioxus::prelude::Element<'a> { + dioxus_router::prelude::GenericLink(cx) + } + + #vis fn GoBackButton<'a>(cx: dioxus::prelude::Scope<'a, dioxus_router::prelude::GenericHistoryButtonProps<'a>>) -> dioxus::prelude::Element<'a> { + dioxus_router::prelude::GenericGoBackButton::<#name>(cx) + } + + #vis fn GoForwardButton<'a>(cx: dioxus::prelude::Scope<'a, dioxus_router::prelude::GenericHistoryButtonProps<'a>>) -> dioxus::prelude::Element<'a> { + dioxus_router::prelude::GenericGoForwardButton::<#name>(cx) + } + + #vis fn use_route(cx: &dioxus::prelude::ScopeState) -> Option<#name> { + dioxus_router::prelude::use_generic_route(cx) + } + + #vis fn use_navigator(cx: &dioxus::prelude::ScopeState) -> &dioxus_router::prelude::GenericNavigator<#name> { + dioxus_router::prelude::use_generic_navigator(cx) + } + + #error_type + + #display_impl + + #routable_impl + + #parse_impl + } + .into() +} + +struct RouteEnum { + vis: syn::Visibility, + name: Ident, + redirects: Vec, + routes: Vec, + nests: Vec, + layouts: Vec, + site_map: Vec, +} + +impl RouteEnum { + fn parse(data: syn::ItemEnum) -> syn::Result { + let name = &data.ident; + let vis = &data.vis; + + let mut site_map = Vec::new(); + let mut site_map_stack: Vec> = Vec::new(); + + let mut routes = Vec::new(); + + let mut redirects = Vec::new(); + + let mut layouts: Vec = Vec::new(); + let mut layout_stack = Vec::new(); + + let mut nests = Vec::new(); + let mut nest_stack = Vec::new(); + + for variant in &data.variants { + let mut excluded = Vec::new(); + // Apply the any nesting attributes in order + for attr in &variant.attrs { + if attr.path().is_ident("nest") { + let mut children_routes = Vec::new(); + { + // add all of the variants of the enum to the children_routes until we hit an end_nest + let mut level = 0; + 'o: for variant in &data.variants { + children_routes.push(variant.fields.clone()); + for attr in &variant.attrs { + if attr.path().is_ident("nest") { + level += 1; + } else if attr.path().is_ident("end_nest") { + level -= 1; + if level < 0 { + break 'o; + } + } + } + } + } + + let nest_index = nests.len(); + + let parser = |input: ParseStream| { + Nest::parse( + input, + children_routes + .iter() + .filter_map(|f: &syn::Fields| match f { + syn::Fields::Named(fields) => Some(fields.clone()), + _ => None, + }) + .collect(), + nest_index, + ) + }; + let nest = attr.parse_args_with(parser)?; + + // add the current segment to the site map stack + let segments: Vec<_> = nest + .segments + .iter() + .map(|seg| { + let segment_type = seg.into(); + SiteMapSegment { + segment_type, + children: Vec::new(), + } + }) + .collect(); + if !segments.is_empty() { + site_map_stack.push(segments); + } + + nests.push(nest); + nest_stack.push(NestId(nest_index)); + } else if attr.path().is_ident("end_nest") { + nest_stack.pop(); + // pop the current nest segment off the stack and add it to the parent or the site map + if let Some(segment) = site_map_stack.pop() { + let children = site_map_stack + .last_mut() + .map(|seg| &mut seg.last_mut().unwrap().children) + .unwrap_or(&mut site_map); + + // Turn the list of segments in the segments stack into a tree + let mut iter = segment.into_iter().rev(); + let mut current = iter.next().unwrap(); + for mut segment in iter { + segment.children.push(current); + current = segment; + } + + children.push(current); + } + } else if attr.path().is_ident("layout") { + let parser = |input: ParseStream| { + let bang: Option = input.parse().ok(); + let exclude = bang.is_some(); + Ok((exclude, Layout::parse(input, nest_stack.clone())?)) + }; + let (exclude, layout): (bool, Layout) = attr.parse_args_with(parser)?; + + if exclude { + let Some(layout_index) = layouts.iter().position(|l| l.comp == layout.comp) + else { + return Err(syn::Error::new( + Span::call_site(), + "Attempted to exclude a layout that does not exist", + )); + }; + excluded.push(LayoutId(layout_index)); + } else { + let layout_index = layouts.len(); + layouts.push(layout); + layout_stack.push(LayoutId(layout_index)); + } + } else if attr.path().is_ident("end_layout") { + layout_stack.pop(); + } else if attr.path().is_ident("redirect") { + let parser = |input: ParseStream| { + Redirect::parse(input, nest_stack.clone(), redirects.len()) + }; + let redirect = attr.parse_args_with(parser)?; + redirects.push(redirect); + } + } + + let active_nests = nest_stack.clone(); + let mut active_layouts = layout_stack.clone(); + active_layouts.retain(|&id| !excluded.contains(&id)); + + let route = Route::parse(active_nests, active_layouts, variant.clone())?; + + // add the route to the site map + let mut segment = SiteMapSegment::new(&route.segments); + if let RouteType::Child(child) = &route.ty { + let new_segment = SiteMapSegment { + segment_type: SegmentType::Child(child.ty.clone()), + children: Vec::new(), + }; + match &mut segment { + Some(segment) => { + fn set_last_child_to( + segment: &mut SiteMapSegment, + new_segment: SiteMapSegment, + ) { + if let Some(last) = segment.children.last_mut() { + set_last_child_to(last, new_segment); + } else { + segment.children = vec![new_segment]; + } + } + set_last_child_to(segment, new_segment); + } + None => { + segment = Some(new_segment); + } + } + } + + if let Some(segment) = segment { + let parent = site_map_stack.last_mut(); + let children = match parent { + Some(parent) => &mut parent.last_mut().unwrap().children, + None => &mut site_map, + }; + children.push(segment); + } + + routes.push(route); + } + + // pop any remaining site map segments + while let Some(segment) = site_map_stack.pop() { + let children = site_map_stack + .last_mut() + .map(|seg| &mut seg.last_mut().unwrap().children) + .unwrap_or(&mut site_map); + + // Turn the list of segments in the segments stack into a tree + let mut iter = segment.into_iter().rev(); + let mut current = iter.next().unwrap(); + for mut segment in iter { + segment.children.push(current); + current = segment; + } + + children.push(current); + } + + let myself = Self { + vis: vis.clone(), + name: name.clone(), + routes, + redirects, + nests, + layouts, + site_map, + }; + + Ok(myself) + } + + fn impl_display(&self) -> TokenStream2 { + let mut display_match = Vec::new(); + + for route in &self.routes { + display_match.push(route.display_match(&self.nests)); + } + + let name = &self.name; + + quote! { + impl std::fmt::Display for #name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + #[allow(unused)] + match self { + #(#display_match)* + } + Ok(()) + } + } + } + } + + fn parse_impl(&self) -> TokenStream2 { + let tree = RouteTree::new(&self.routes, &self.nests, &self.redirects); + let name = &self.name; + + let error_name = format_ident!("{}MatchError", self.name); + let tokens = tree.roots.iter().map(|&id| { + let route = tree.get(id).unwrap(); + route.to_tokens(&self.nests, &tree, self.name.clone(), error_name.clone()) + }); + + quote! { + impl<'a> core::convert::TryFrom<&'a str> for #name { + type Error = ::Err; + + fn try_from(s: &'a str) -> Result { + s.parse() + } + } + + impl std::str::FromStr for #name { + type Err = dioxus_router::routable::RouteParseError<#error_name>; + + fn from_str(s: &str) -> Result { + let route = s; + let (route, _hash) = route.split_once('#').unwrap_or((route, "")); + let (route, query) = route.split_once('?').unwrap_or((route, "")); + let mut segments = route.split('/'); + // skip the first empty segment + if s.starts_with('/') { + segments.next(); + } + let mut errors = Vec::new(); + + #(#tokens)* + + Err(dioxus_router::routable::RouteParseError { + attempted_routes: errors, + }) + } + } + } + } + + fn error_name(&self) -> Ident { + Ident::new(&(self.name.to_string() + "MatchError"), Span::call_site()) + } + + fn error_type(&self) -> TokenStream2 { + let match_error_name = self.error_name(); + + let mut type_defs = Vec::new(); + let mut error_variants = Vec::new(); + let mut display_match = Vec::new(); + + for route in &self.routes { + let route_name = &route.route_name; + + let error_name = route.error_ident(); + let route_str = &route.route; + + error_variants.push(quote! { #route_name(#error_name) }); + display_match.push(quote! { Self::#route_name(err) => write!(f, "Route '{}' ('{}') did not match:\n{}", stringify!(#route_name), #route_str, err)? }); + type_defs.push(route.error_type()); + } + + for nest in &self.nests { + let error_variant = nest.error_variant(); + let error_name = nest.error_ident(); + let route_str = &nest.route; + + error_variants.push(quote! { #error_variant(#error_name) }); + display_match.push(quote! { Self::#error_variant(err) => write!(f, "Nest '{}' ('{}') did not match:\n{}", stringify!(#error_name), #route_str, err)? }); + type_defs.push(nest.error_type()); + } + + for redirect in &self.redirects { + let error_variant = redirect.error_variant(); + let error_name = redirect.error_ident(); + let route_str = &redirect.route; + + error_variants.push(quote! { #error_variant(#error_name) }); + display_match.push(quote! { Self::#error_variant(err) => write!(f, "Redirect '{}' ('{}') did not match:\n{}", stringify!(#error_name), #route_str, err)? }); + type_defs.push(redirect.error_type()); + } + + quote! { + #(#type_defs)* + + #[allow(non_camel_case_types)] + #[derive(Debug, PartialEq)] + pub enum #match_error_name { + #(#error_variants),* + } + + impl std::fmt::Display for #match_error_name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #(#display_match),* + } + Ok(()) + } + } + } + } + + fn routable_impl(&self) -> TokenStream2 { + let name = &self.name; + let site_map = &self.site_map; + + let mut matches = Vec::new(); + + // Collect all routes matches + for route in &self.routes { + matches.push(route.routable_match(&self.layouts, &self.nests)); + } + + quote! { + impl dioxus_router::routable::Routable for #name where Self: Clone { + const SITE_MAP: &'static [dioxus_router::routable::SiteMapSegment] = &[ + #(#site_map,)* + ]; + + fn render<'a>(&self, cx: &'a dioxus::prelude::ScopeState, level: usize) -> dioxus::prelude::Element<'a> { + let myself = self.clone(); + match (level, myself) { + #(#matches)* + _ => None + } + } + } + } + } +} + +struct SiteMapSegment { + pub segment_type: SegmentType, + pub children: Vec, +} + +impl SiteMapSegment { + fn new(segments: &[RouteSegment]) -> Option { + let mut current = None; + // walk backwards through the new segments, adding children as we go + for segment in segments.iter().rev() { + let segment_type = segment.into(); + let mut segment = SiteMapSegment { + segment_type, + children: Vec::new(), + }; + // if we have a current segment, add it as a child + if let Some(current) = current.take() { + segment.children.push(current) + } + current = Some(segment); + } + current + } +} + +impl ToTokens for SiteMapSegment { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let segment_type = &self.segment_type; + let children = if let SegmentType::Child(ty) = &self.segment_type { + quote! { #ty::SITE_MAP } + } else { + let children = self + .children + .iter() + .map(|child| child.to_token_stream()) + .collect::>(); + quote! { + &[ + #(#children,)* + ] + } + }; + + tokens.extend(quote! { + dioxus_router::routable::SiteMapSegment { + segment_type: #segment_type, + children: #children, + } + }); + } +} + +enum SegmentType { + Static(String), + Dynamic(String), + CatchAll(String), + Child(Type), +} + +impl ToTokens for SegmentType { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + SegmentType::Static(s) => { + tokens.extend(quote! { dioxus_router::routable::SegmentType::Static(#s) }) + } + SegmentType::Dynamic(s) => { + tokens.extend(quote! { dioxus_router::routable::SegmentType::Dynamic(#s) }) + } + SegmentType::CatchAll(s) => { + tokens.extend(quote! { dioxus_router::routable::SegmentType::CatchAll(#s) }) + } + SegmentType::Child(_) => { + tokens.extend(quote! { dioxus_router::routable::SegmentType::Child }) + } + } + } +} + +impl<'a> From<&'a RouteSegment> for SegmentType { + fn from(value: &'a RouteSegment) -> Self { + match value { + segment::RouteSegment::Static(s) => SegmentType::Static(s.to_string()), + segment::RouteSegment::Dynamic(s, _) => SegmentType::Dynamic(s.to_string()), + segment::RouteSegment::CatchAll(s, _) => SegmentType::CatchAll(s.to_string()), + } + } +} diff --git a/packages/router-macro/src/nest.rs b/packages/router-macro/src/nest.rs new file mode 100644 index 000000000..7b315c1d2 --- /dev/null +++ b/packages/router-macro/src/nest.rs @@ -0,0 +1,87 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, LitStr}; + +use crate::segment::{create_error_type, parse_route_segments, RouteSegment}; + +#[derive(Debug, Clone, Copy)] +pub struct NestId(pub usize); + +#[derive(Debug, Clone)] +pub struct Nest { + pub route: String, + pub segments: Vec, + index: usize, +} + +impl Nest { + pub fn parse( + input: syn::parse::ParseStream, + children_routes: Vec, + index: usize, + ) -> syn::Result { + // Parse the route + let route: LitStr = input.parse()?; + + let route_segments = parse_route_segments( + route.span(), + children_routes + .iter() + .flat_map(|f| f.named.iter()) + .map(|f| (f.ident.as_ref().unwrap(), &f.ty)), + &route.value(), + )? + .0; + for seg in &route_segments { + if let RouteSegment::CatchAll(name, _) = seg { + return Err(syn::Error::new_spanned( + name, + format!( + "Catch-all segments are not allowed in nested routes: {}", + route.value() + ), + )); + } + } + + Ok(Self { + route: route.value(), + segments: route_segments, + index, + }) + } +} + +impl Nest { + pub fn dynamic_segments(&self) -> impl Iterator + '_ { + self.dynamic_segments_names().map(|i| quote! {#i}) + } + + pub fn dynamic_segments_names(&self) -> impl Iterator + '_ { + self.segments.iter().filter_map(|seg| seg.name()) + } + + pub fn write(&self) -> TokenStream { + let write_segments = self.segments.iter().map(|s| s.write_segment()); + + quote! { + { + #(#write_segments)* + } + } + } + + pub fn error_ident(&self) -> Ident { + format_ident!("Nest{}ParseError", self.index) + } + + pub fn error_variant(&self) -> Ident { + format_ident!("Nest{}", self.index) + } + + pub fn error_type(&self) -> TokenStream { + let error_name = self.error_ident(); + + create_error_type(error_name, &self.segments, None) + } +} diff --git a/packages/router-macro/src/query.rs b/packages/router-macro/src/query.rs new file mode 100644 index 000000000..8b9db9e51 --- /dev/null +++ b/packages/router-macro/src/query.rs @@ -0,0 +1,27 @@ +use quote::quote; +use syn::{Ident, Type}; + +use proc_macro2::TokenStream as TokenStream2; + +#[derive(Debug)] +pub struct QuerySegment { + pub ident: Ident, + pub ty: Type, +} + +impl QuerySegment { + pub fn parse(&self) -> TokenStream2 { + let ident = &self.ident; + let ty = &self.ty; + quote! { + let #ident = <#ty as dioxus_router::routable::FromQuery>::from_query(query); + } + } + + pub fn write(&self) -> TokenStream2 { + let ident = &self.ident; + quote! { + write!(f, "?{}", #ident)?; + } + } +} diff --git a/packages/router-macro/src/redirect.rs b/packages/router-macro/src/redirect.rs new file mode 100644 index 000000000..c1efb8b4d --- /dev/null +++ b/packages/router-macro/src/redirect.rs @@ -0,0 +1,91 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::LitStr; + +use crate::{ + nest::NestId, + query::QuerySegment, + segment::{create_error_type, parse_route_segments, RouteSegment}, +}; + +#[derive(Debug)] +pub(crate) struct Redirect { + pub route: LitStr, + pub nests: Vec, + pub segments: Vec, + pub query: Option, + pub function: syn::ExprClosure, + pub index: usize, +} + +impl Redirect { + pub fn error_ident(&self) -> Ident { + format_ident!("Redirect{}ParseError", self.index) + } + + pub fn error_variant(&self) -> Ident { + format_ident!("Redirect{}", self.index) + } + + pub fn error_type(&self) -> TokenStream { + let error_name = self.error_ident(); + + create_error_type(error_name, &self.segments, None) + } + + pub fn parse_query(&self) -> TokenStream { + match &self.query { + Some(query) => query.parse(), + None => quote! {}, + } + } + + pub fn parse( + input: syn::parse::ParseStream, + active_nests: Vec, + index: usize, + ) -> syn::Result { + let path = input.parse::()?; + + let _ = input.parse::(); + let function = input.parse::()?; + + let mut closure_arguments = Vec::new(); + for arg in function.inputs.iter() { + match arg { + syn::Pat::Type(pat) => match &*pat.pat { + syn::Pat::Ident(ident) => { + closure_arguments.push((ident.ident.clone(), (*pat.ty).clone())); + } + _ => { + return Err(syn::Error::new_spanned( + arg, + "Expected closure argument to be a typed pattern", + )) + } + }, + _ => { + return Err(syn::Error::new_spanned( + arg, + "Expected closure argument to be a typed pattern", + )) + } + } + } + + let (segments, query) = parse_route_segments( + path.span(), + closure_arguments.iter().map(|(name, ty)| (name, ty)), + &path.value(), + )?; + + Ok(Redirect { + route: path, + nests: active_nests, + segments, + query, + function, + index, + }) + } +} diff --git a/packages/router-macro/src/route.rs b/packages/router-macro/src/route.rs new file mode 100644 index 000000000..af6d7136f --- /dev/null +++ b/packages/router-macro/src/route.rs @@ -0,0 +1,368 @@ +use quote::{format_ident, quote}; +use syn::parse::Parse; +use syn::parse::ParseStream; +use syn::parse_quote; +use syn::Field; +use syn::Path; +use syn::Type; +use syn::{Ident, LitStr}; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::layout::Layout; +use crate::layout::LayoutId; +use crate::nest::Nest; +use crate::nest::NestId; +use crate::query::QuerySegment; +use crate::segment::create_error_type; +use crate::segment::parse_route_segments; +use crate::segment::RouteSegment; + +struct RouteArgs { + route: LitStr, + comp_name: Option, + props_name: Option, +} + +impl Parse for RouteArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let route = input.parse::()?; + + Ok(RouteArgs { + route, + comp_name: { + let _ = input.parse::(); + input.parse().ok() + }, + props_name: { + let _ = input.parse::(); + input.parse().ok() + }, + }) + } +} + +struct ChildArgs { + route: LitStr, +} + +impl Parse for ChildArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let route = input.parse::()?; + + Ok(ChildArgs { route }) + } +} + +#[derive(Debug)] +pub(crate) struct Route { + pub route_name: Ident, + pub ty: RouteType, + pub route: String, + pub segments: Vec, + pub query: Option, + pub nests: Vec, + pub layouts: Vec, + fields: Vec<(Ident, Type)>, +} + +impl Route { + pub fn parse( + nests: Vec, + layouts: Vec, + variant: syn::Variant, + ) -> syn::Result { + let route_attr = variant + .attrs + .iter() + .find(|attr| attr.path().is_ident("route")); + let route; + let ty; + let route_name = variant.ident.clone(); + match route_attr { + Some(attr) => { + let args = attr.parse_args::()?; + let comp_name = args.comp_name.unwrap_or_else(|| parse_quote!(#route_name)); + let props_name = args.props_name.unwrap_or_else(|| { + let last = format_ident!( + "{}Props", + comp_name.segments.last().unwrap().ident.to_string() + ); + let mut segments = comp_name.segments.clone(); + segments.pop(); + segments.push(last.into()); + Path { + leading_colon: None, + segments, + } + }); + ty = RouteType::Leaf { + component: comp_name, + props: props_name, + }; + route = args.route.value(); + } + None => { + if let Some(route_attr) = variant + .attrs + .iter() + .find(|attr| attr.path().is_ident("child")) + { + let args = route_attr.parse_args::()?; + route = args.route.value(); + match &variant.fields { + syn::Fields::Named(fields) => { + // find either a field with #[child] or a field named "child" + let child_field = fields.named.iter().find(|f| { + f.attrs + .iter() + .any(|attr| attr.path().is_ident("child")) + || *f.ident.as_ref().unwrap() == "child" + }); + match child_field{ + Some(child) => { + ty = RouteType::Child(child.clone()); + } + None => { + return Err(syn::Error::new_spanned( + variant.clone(), + "Routable variants with a #[child(..)] attribute must have a field named \"child\" or a field with a #[child] attribute", + )); + } + } + } + _ => { + return Err(syn::Error::new_spanned( + variant.clone(), + "Routable variants with a #[child(..)] attribute must have named fields", + )) + } + } + } else { + return Err(syn::Error::new_spanned( + variant.clone(), + "Routable variants must either have a #[route(..)] attribute or a #[child(..)] attribute", + )); + } + } + }; + + let fields = match &variant.fields { + syn::Fields::Named(fields) => fields + .named + .iter() + .filter_map(|f| { + if let RouteType::Child(child) = &ty { + if f.ident == child.ident { + return None; + } + } + Some((f.ident.clone().unwrap(), f.ty.clone())) + }) + .collect(), + _ => Vec::new(), + }; + + let (route_segments, query) = { + parse_route_segments( + variant.ident.span(), + fields.iter().map(|f| (&f.0, &f.1)), + &route, + )? + }; + + Ok(Self { + ty, + route_name, + segments: route_segments, + route, + query, + nests, + layouts, + fields, + }) + } + + pub fn display_match(&self, nests: &[Nest]) -> TokenStream2 { + let name = &self.route_name; + let dynamic_segments = self.dynamic_segments(); + let write_query = self.query.as_ref().map(|q| q.write()); + + match &self.ty { + RouteType::Child(field) => { + let write_nests = self.nests.iter().map(|id| nests[id.0].write()); + let write_segments = self.segments.iter().map(|s| s.write_segment()); + let child = field.ident.as_ref().unwrap(); + quote! { + Self::#name { #(#dynamic_segments,)* #child } => { + use std::fmt::Display; + use std::fmt::Write; + let mut route = String::new(); + { + let f = &mut route; + #(#write_nests)* + #(#write_segments)* + } + if route.ends_with('/') { + route.pop(); + } + f.write_str(&route)?; + #child.fmt(f)?; + } + } + } + RouteType::Leaf { .. } => { + let write_nests = self.nests.iter().map(|id| nests[id.0].write()); + let write_segments = self.segments.iter().map(|s| s.write_segment()); + quote! { + Self::#name { #(#dynamic_segments,)* } => { + #(#write_nests)* + #(#write_segments)* + #write_query + } + } + } + } + } + + pub fn routable_match(&self, layouts: &[Layout], nests: &[Nest]) -> TokenStream2 { + let name = &self.route_name; + let name_str = name.to_string(); + + let mut tokens = TokenStream2::new(); + + // First match all layouts + for (idx, layout_id) in self.layouts.iter().copied().enumerate() { + let render_layout = layouts[layout_id.0].routable_match(nests); + let dynamic_segments = self.dynamic_segments(); + let mut field_name = None; + if let RouteType::Child(field) = &self.ty { + field_name = field.ident.as_ref(); + } + let field_name = field_name.map(|f| quote!(#f,)); + // This is a layout + tokens.extend(quote! { + #[allow(unused)] + (#idx, Self::#name { #(#dynamic_segments,)* #field_name .. }) => { + #render_layout + } + }); + } + + // Then match the route + let last_index = self.layouts.len(); + tokens.extend(match &self.ty { + RouteType::Child(field) => { + let field_name = field.ident.as_ref().unwrap(); + quote! { + #[allow(unused)] + (#last_index.., Self::#name { #field_name, .. }) => { + #field_name.render(cx, level - #last_index) + } + } + } + RouteType::Leaf { component, props } => { + let dynamic_segments = self.dynamic_segments(); + let dynamic_segments_from_route = self.dynamic_segments(); + quote! { + #[allow(unused)] + (#last_index, Self::#name { #(#dynamic_segments,)* }) => { + let comp = #props { #(#dynamic_segments_from_route,)* }; + let dynamic = cx.component(#component, comp, #name_str); + render! { + dynamic + } + } + } + } + }); + + tokens + } + + fn dynamic_segments(&self) -> impl Iterator + '_ { + self.fields.iter().map(|(name, _)| { + quote! {#name} + }) + } + + pub fn construct(&self, nests: &[Nest], enum_name: Ident) -> TokenStream2 { + let segments = self.fields.iter().map(|(name, _)| { + let mut from_route = false; + + for id in &self.nests { + let nest = &nests[id.0]; + if nest.dynamic_segments_names().any(|i| &i == name) { + from_route = true + } + } + for segment in &self.segments { + if let RouteSegment::Dynamic(other, ..) = segment { + if other == name { + from_route = true + } + } + } + if let Some(query) = &self.query { + if &query.ident == name { + from_route = true + } + } + + if from_route { + quote! {#name} + } else { + quote! {#name: Default::default()} + } + }); + match &self.ty { + RouteType::Child(field) => { + let name = &self.route_name; + let child_name = field.ident.as_ref().unwrap(); + + quote! { + #enum_name::#name { + #child_name, + #(#segments,)* + } + } + } + RouteType::Leaf { .. } => { + let name = &self.route_name; + + quote! { + #enum_name::#name { + #(#segments,)* + } + } + } + } + } + + pub fn error_ident(&self) -> Ident { + format_ident!("{}ParseError", self.route_name) + } + + pub fn error_type(&self) -> TokenStream2 { + let error_name = self.error_ident(); + let child_type = match &self.ty { + RouteType::Child(field) => Some(&field.ty), + RouteType::Leaf { .. } => None, + }; + + create_error_type(error_name, &self.segments, child_type) + } + + pub fn parse_query(&self) -> TokenStream2 { + match &self.query { + Some(query) => query.parse(), + None => quote! {}, + } + } +} + +#[derive(Debug)] +pub(crate) enum RouteType { + Child(Field), + Leaf { component: Path, props: Path }, +} diff --git a/packages/router-macro/src/route_tree.rs b/packages/router-macro/src/route_tree.rs new file mode 100644 index 000000000..171b6a042 --- /dev/null +++ b/packages/router-macro/src/route_tree.rs @@ -0,0 +1,599 @@ +use proc_macro2::TokenStream; +use quote::quote; +use slab::Slab; +use syn::Ident; + +use crate::{ + nest::{Nest, NestId}, + redirect::Redirect, + route::{Route, RouteType}, + segment::{static_segment_idx, RouteSegment}, +}; + +#[derive(Debug, Clone, Default)] +pub(crate) struct RouteTree<'a> { + pub roots: Vec, + entries: Slab>, +} + +impl<'a> RouteTree<'a> { + pub fn get(&self, index: usize) -> Option<&RouteTreeSegmentData<'a>> { + self.entries.get(index) + } + + pub fn get_mut(&mut self, element: usize) -> Option<&mut RouteTreeSegmentData<'a>> { + self.entries.get_mut(element) + } + + fn sort_children(&mut self) { + let mut old_roots = self.roots.clone(); + self.sort_ids(&mut old_roots); + self.roots = old_roots; + + for id in self.roots.clone() { + self.sort_children_of_id(id); + } + } + + fn sort_ids(&self, ids: &mut [usize]) { + ids.sort_by_key(|&seg| { + let seg = self.get(seg).unwrap(); + match seg { + RouteTreeSegmentData::Static { .. } => 0, + RouteTreeSegmentData::Nest { .. } => 1, + RouteTreeSegmentData::Route(route) => { + // Routes that end in a catch all segment should be checked last + match route.segments.last() { + Some(RouteSegment::CatchAll(..)) => 2, + _ => 1, + } + } + RouteTreeSegmentData::Redirect(redirect) => { + // Routes that end in a catch all segment should be checked last + match redirect.segments.last() { + Some(RouteSegment::CatchAll(..)) => 2, + _ => 1, + } + } + } + }); + } + + fn sort_children_of_id(&mut self, id: usize) { + // Sort segments so that all static routes are checked before dynamic routes + let mut children = self.children(id); + + self.sort_ids(&mut children); + + if let Some(old) = self.try_children_mut(id) { + old.clone_from(&children) + } + + for id in children { + self.sort_children_of_id(id); + } + } + + fn children(&self, element: usize) -> Vec { + let element = self.entries.get(element).unwrap(); + match element { + RouteTreeSegmentData::Static { children, .. } => children.clone(), + RouteTreeSegmentData::Nest { children, .. } => children.clone(), + _ => Vec::new(), + } + } + + fn try_children_mut(&mut self, element: usize) -> Option<&mut Vec> { + let element = self.entries.get_mut(element).unwrap(); + match element { + RouteTreeSegmentData::Static { children, .. } => Some(children), + RouteTreeSegmentData::Nest { children, .. } => Some(children), + _ => None, + } + } + + fn children_mut(&mut self, element: usize) -> &mut Vec { + self.try_children_mut(element) + .expect("Cannot get children of non static or nest segment") + } + + pub(crate) fn new(routes: &'a [Route], nests: &'a [Nest], redirects: &'a [Redirect]) -> Self { + let routes = routes + .iter() + .map(|route| PathIter::new_route(route, nests)) + .chain( + redirects + .iter() + .map(|redirect| PathIter::new_redirect(redirect, nests)), + ) + .collect::>(); + + let mut myself = Self::default(); + myself.roots = myself.construct(routes); + myself.sort_children(); + + myself + } + + pub fn construct(&mut self, routes: Vec>) -> Vec { + let mut segments = Vec::new(); + + // Add all routes to the tree + for mut route in routes { + let mut current_route: Option = None; + + // First add all nests + while let Some(nest) = route.next_nest() { + let segments_iter = nest.segments.iter(); + + // Add all static segments of the nest + 'o: for (index, segment) in segments_iter.enumerate() { + match segment { + RouteSegment::Static(segment) => { + // Check if the segment already exists + { + // Either look for the segment in the current route or in the static segments + let segments = current_route + .map(|id| self.children(id)) + .unwrap_or_else(|| segments.clone()); + + for &seg_id in segments.iter() { + let seg = self.get(seg_id).unwrap(); + if let RouteTreeSegmentData::Static { segment: s, .. } = seg { + if *s == segment { + // If it does, just update the current route + current_route = Some(seg_id); + continue 'o; + } + } + } + } + + let static_segment = RouteTreeSegmentData::Static { + segment, + children: Vec::new(), + error_variant: StaticErrorVariant { + varient_parse_error: nest.error_ident(), + enum_varient: nest.error_variant(), + }, + index, + }; + + // If it doesn't, add the segment to the current route + let static_segment = self.entries.insert(static_segment); + + let current_children = current_route + .map(|id| self.children_mut(id)) + .unwrap_or_else(|| &mut segments); + current_children.push(static_segment); + + // Update the current route + current_route = Some(static_segment); + } + // If there is a dynamic segment, stop adding static segments + RouteSegment::Dynamic(..) => break, + RouteSegment::CatchAll(..) => { + todo!("Catch all segments are not allowed in nests") + } + } + } + + // Add the nest to the current route + let nest = RouteTreeSegmentData::Nest { + nest, + children: Vec::new(), + }; + + let nest = self.entries.insert(nest); + let segments = match current_route.and_then(|id| self.get_mut(id)) { + Some(RouteTreeSegmentData::Static { children, .. }) => children, + Some(RouteTreeSegmentData::Nest { children, .. }) => children, + Some(r) => { + unreachable!("{current_route:?}\n{r:?} is not a static or nest segment",) + } + None => &mut segments, + }; + segments.push(nest); + + // Update the current route + current_route = Some(nest); + } + + match route.next_static_segment() { + // If there is a static segment, check if it already exists in the tree + Some((i, segment)) => { + let current_children = current_route + .map(|id| self.children(id)) + .unwrap_or_else(|| segments.clone()); + let found = current_children.iter().find_map(|&id| { + let seg = self.get(id).unwrap(); + match seg { + RouteTreeSegmentData::Static { segment: s, .. } => { + (s == &segment).then_some(id) + } + _ => None, + } + }); + + match found { + Some(id) => { + // If it exists, add the route to the children of the segment + let new_children = self.construct(vec![route]); + self.children_mut(id).extend(new_children.into_iter()); + } + None => { + // If it doesn't exist, add the route as a new segment + let data = RouteTreeSegmentData::Static { + segment, + error_variant: route.error_variant(), + children: self.construct(vec![route]), + index: i, + }; + let id = self.entries.insert(data); + let current_children_mut = current_route + .map(|id| self.children_mut(id)) + .unwrap_or_else(|| &mut segments); + current_children_mut.push(id); + } + } + } + // If there is no static segment, add the route to the current_route + None => { + let id = self.entries.insert(route.final_segment); + let current_children_mut = current_route + .map(|id| self.children_mut(id)) + .unwrap_or_else(|| &mut segments); + current_children_mut.push(id); + } + } + } + + segments + } +} + +#[derive(Debug, Clone)] +pub struct StaticErrorVariant { + varient_parse_error: Ident, + enum_varient: Ident, +} + +// First deduplicate the routes by the static part of the route +#[derive(Debug, Clone)] +pub(crate) enum RouteTreeSegmentData<'a> { + Static { + segment: &'a str, + error_variant: StaticErrorVariant, + index: usize, + children: Vec, + }, + Nest { + nest: &'a Nest, + children: Vec, + }, + Route(&'a Route), + Redirect(&'a Redirect), +} + +impl<'a> RouteTreeSegmentData<'a> { + pub fn to_tokens( + &self, + nests: &[Nest], + tree: &RouteTree, + enum_name: syn::Ident, + error_enum_name: syn::Ident, + ) -> TokenStream { + match self { + RouteTreeSegmentData::Static { + segment, + children, + index, + error_variant: + StaticErrorVariant { + varient_parse_error, + enum_varient, + }, + } => { + let children = children.iter().map(|child| { + let child = tree.get(*child).unwrap(); + child.to_tokens(nests, tree, enum_name.clone(), error_enum_name.clone()) + }); + + if segment.is_empty() { + return quote! { + { + #(#children)* + } + }; + } + + let error_ident = static_segment_idx(*index); + + quote! { + { + let mut segments = segments.clone(); + if let Some(segment) = segments.next() { + if #segment == segment { + #(#children)* + } + else { + errors.push(#error_enum_name::#enum_varient(#varient_parse_error::#error_ident(segment.to_string()))) + } + } + } + } + } + RouteTreeSegmentData::Route(route) => { + // At this point, we have matched all static segments, so we can just check if the remaining segments match the route + let varient_parse_error = route.error_ident(); + let enum_varient = &route.route_name; + + let route_segments = route + .segments + .iter() + .enumerate() + .skip_while(|(_, seg)| matches!(seg, RouteSegment::Static(_))); + + let construct_variant = route.construct(nests, enum_name); + let parse_query = route.parse_query(); + + let insure_not_trailing = match route.ty { + RouteType::Leaf { .. } => route + .segments + .last() + .map(|seg| !matches!(seg, RouteSegment::CatchAll(_, _))) + .unwrap_or(true), + RouteType::Child(_) => false, + }; + + let print_route_segment = print_route_segment( + route_segments.peekable(), + return_constructed( + insure_not_trailing, + construct_variant, + &error_enum_name, + enum_varient, + &varient_parse_error, + parse_query, + ), + &error_enum_name, + enum_varient, + &varient_parse_error, + ); + + match &route.ty { + RouteType::Child(child) => { + let ty = &child.ty; + let child_name = &child.ident; + + quote! { + let mut trailing = String::new(); + for seg in segments.clone() { + trailing += seg; + trailing += "/"; + } + trailing.pop(); + match #ty::from_str(&trailing).map_err(|err| #error_enum_name::#enum_varient(#varient_parse_error::ChildRoute(err))) { + Ok(#child_name) => { + #print_route_segment + } + Err(err) => { + errors.push(err); + } + } + } + } + RouteType::Leaf { .. } => print_route_segment, + } + } + Self::Nest { nest, children } => { + // At this point, we have matched all static segments, so we can just check if the remaining segments match the route + let varient_parse_error: Ident = nest.error_ident(); + let enum_varient = nest.error_variant(); + + let route_segments = nest + .segments + .iter() + .enumerate() + .skip_while(|(_, seg)| matches!(seg, RouteSegment::Static(_))); + + let parse_children = children + .iter() + .map(|child| { + let child = tree.get(*child).unwrap(); + child.to_tokens(nests, tree, enum_name.clone(), error_enum_name.clone()) + }) + .collect(); + + print_route_segment( + route_segments.peekable(), + parse_children, + &error_enum_name, + &enum_varient, + &varient_parse_error, + ) + } + Self::Redirect(redirect) => { + // At this point, we have matched all static segments, so we can just check if the remaining segments match the route + let varient_parse_error = redirect.error_ident(); + let enum_varient = &redirect.error_variant(); + + let route_segments = redirect + .segments + .iter() + .enumerate() + .skip_while(|(_, seg)| matches!(seg, RouteSegment::Static(_))); + + let parse_query = redirect.parse_query(); + + let insure_not_trailing = redirect + .segments + .last() + .map(|seg| !matches!(seg, RouteSegment::CatchAll(_, _))) + .unwrap_or(true); + + let redirect_function = &redirect.function; + let args = redirect_function.inputs.iter().map(|pat| match pat { + syn::Pat::Type(ident) => { + let name = &ident.pat; + quote! {#name} + } + _ => panic!("Expected closure argument to be a typed pattern"), + }); + let return_redirect = quote! { + (#redirect_function)(#(#args,)*) + }; + + print_route_segment( + route_segments.peekable(), + return_constructed( + insure_not_trailing, + return_redirect, + &error_enum_name, + enum_varient, + &varient_parse_error, + parse_query, + ), + &error_enum_name, + enum_varient, + &varient_parse_error, + ) + } + } + } +} + +fn print_route_segment<'a, I: Iterator>( + mut s: std::iter::Peekable, + sucess_tokens: TokenStream, + error_enum_name: &Ident, + enum_varient: &Ident, + varient_parse_error: &Ident, +) -> TokenStream { + if let Some((i, route)) = s.next() { + let children = print_route_segment( + s, + sucess_tokens, + error_enum_name, + enum_varient, + varient_parse_error, + ); + + route.try_parse( + i, + error_enum_name, + enum_varient, + varient_parse_error, + children, + ) + } else { + quote! { + #sucess_tokens + } + } +} + +fn return_constructed( + insure_not_trailing: bool, + construct_variant: TokenStream, + error_enum_name: &Ident, + enum_varient: &Ident, + varient_parse_error: &Ident, + parse_query: TokenStream, +) -> TokenStream { + if insure_not_trailing { + quote! { + let remaining_segments = segments.clone(); + let mut segments_clone = segments.clone(); + let next_segment = segments_clone.next(); + let segment_after_next = segments_clone.next(); + match (next_segment, segment_after_next) { + // This is the last segment, return the parsed route + (None, _) | (Some(""), None) => { + #parse_query + return Ok(#construct_variant); + } + _ => { + let mut trailing = String::new(); + for seg in remaining_segments { + trailing += seg; + trailing += "/"; + } + trailing.pop(); + errors.push(#error_enum_name::#enum_varient(#varient_parse_error::ExtraSegments(trailing))) + } + } + } + } else { + quote! { + #parse_query + return Ok(#construct_variant); + } + } +} + +pub struct PathIter<'a> { + final_segment: RouteTreeSegmentData<'a>, + active_nests: &'a [NestId], + all_nests: &'a [Nest], + segments: &'a [RouteSegment], + error_ident: Ident, + error_variant: Ident, + nest_index: usize, + static_segment_index: usize, +} + +impl<'a> PathIter<'a> { + fn new_route(route: &'a Route, nests: &'a [Nest]) -> Self { + Self { + final_segment: RouteTreeSegmentData::Route(route), + active_nests: &*route.nests, + segments: &*route.segments, + error_ident: route.error_ident(), + error_variant: route.route_name.clone(), + all_nests: nests, + nest_index: 0, + static_segment_index: 0, + } + } + + fn new_redirect(redirect: &'a Redirect, nests: &'a [Nest]) -> Self { + Self { + final_segment: RouteTreeSegmentData::Redirect(redirect), + active_nests: &*redirect.nests, + segments: &*redirect.segments, + error_ident: redirect.error_ident(), + error_variant: redirect.error_variant(), + all_nests: nests, + nest_index: 0, + static_segment_index: 0, + } + } + + fn next_nest(&mut self) -> Option<&'a Nest> { + let idx = self.nest_index; + let nest_index = self.active_nests.get(idx)?; + let nest = &self.all_nests[nest_index.0]; + self.nest_index += 1; + Some(nest) + } + + fn next_static_segment(&mut self) -> Option<(usize, &'a str)> { + let idx = self.static_segment_index; + let segment = self.segments.get(idx)?; + match segment { + RouteSegment::Static(segment) => { + self.static_segment_index += 1; + Some((idx, segment)) + } + _ => None, + } + } + + fn error_variant(&self) -> StaticErrorVariant { + StaticErrorVariant { + varient_parse_error: self.error_ident.clone(), + enum_varient: self.error_variant.clone(), + } + } +} diff --git a/packages/router-macro/src/segment.rs b/packages/router-macro/src/segment.rs new file mode 100644 index 000000000..62dea59a7 --- /dev/null +++ b/packages/router-macro/src/segment.rs @@ -0,0 +1,295 @@ +use quote::{format_ident, quote}; +use syn::{Ident, Type}; + +use proc_macro2::{Span, TokenStream as TokenStream2}; + +use crate::query::QuerySegment; + +#[derive(Debug, Clone)] +pub enum RouteSegment { + Static(String), + Dynamic(Ident, Type), + CatchAll(Ident, Type), +} + +impl RouteSegment { + pub fn name(&self) -> Option { + match self { + Self::Static(_) => None, + Self::Dynamic(ident, _) => Some(ident.clone()), + Self::CatchAll(ident, _) => Some(ident.clone()), + } + } + + pub fn write_segment(&self) -> TokenStream2 { + match self { + Self::Static(segment) => quote! { write!(f, "/{}", #segment)?; }, + Self::Dynamic(ident, _) => quote! { write!(f, "/{}", #ident)?; }, + Self::CatchAll(ident, _) => quote! { #ident.display_route_segements(f)?; }, + } + } + + pub fn error_name(&self, idx: usize) -> Ident { + match self { + Self::Static(_) => static_segment_idx(idx), + Self::Dynamic(ident, _) => format_ident!("{}ParseError", ident), + Self::CatchAll(ident, _) => format_ident!("{}ParseError", ident), + } + } + + pub fn missing_error_name(&self) -> Option { + match self { + Self::Dynamic(ident, _) => Some(format_ident!("{}MissingError", ident)), + _ => None, + } + } + + pub fn try_parse( + &self, + idx: usize, + error_enum_name: &Ident, + error_enum_varient: &Ident, + inner_parse_enum: &Ident, + parse_children: TokenStream2, + ) -> TokenStream2 { + let error_name = self.error_name(idx); + match self { + Self::Static(segment) => { + quote! { + { + let mut segments = segments.clone(); + let segment = segments.next(); + let parsed = if let Some(#segment) = segment { + Ok(()) + } else { + Err(#error_enum_name::#error_enum_varient(#inner_parse_enum::#error_name(segment.map(|s|s.to_string()).unwrap_or_default()))) + }; + match parsed { + Ok(_) => { + #parse_children + } + Err(err) => { + errors.push(err); + } + } + } + } + } + Self::Dynamic(name, ty) => { + let missing_error_name = self.missing_error_name().unwrap(); + quote! { + { + let mut segments = segments.clone(); + let parsed = if let Some(segment) = segments.next() { + <#ty as dioxus_router::routable::FromRouteSegment>::from_route_segment(segment).map_err(|err| #error_enum_name::#error_enum_varient(#inner_parse_enum::#error_name(err))) + } else { + Err(#error_enum_name::#error_enum_varient(#inner_parse_enum::#missing_error_name)) + }; + match parsed { + Ok(#name) => { + #parse_children + } + Err(err) => { + errors.push(err); + } + } + } + } + } + Self::CatchAll(name, ty) => { + quote! { + { + let parsed = { + let mut segments = segments.clone(); + let segments: Vec<_> = segments.collect(); + <#ty as dioxus_router::routable::FromRouteSegments>::from_route_segments(&segments).map_err(|err| #error_enum_name::#error_enum_varient(#inner_parse_enum::#error_name(err))) + }; + match parsed { + Ok(#name) => { + #parse_children + } + Err(err) => { + errors.push(err); + } + } + } + } + } + } + } +} + +pub fn static_segment_idx(idx: usize) -> Ident { + format_ident!("StaticSegment{}ParseError", idx) +} + +pub fn parse_route_segments<'a>( + route_span: Span, + mut fields: impl Iterator, + route: &str, +) -> syn::Result<(Vec, Option)> { + let mut route_segments = Vec::new(); + + let (route_string, query) = match route.rsplit_once('?') { + Some((route, query)) => (route, Some(query)), + None => (route, None), + }; + let mut iterator = route_string.split('/'); + + // skip the first empty segment + let first = iterator.next(); + if first != Some("") { + return Err(syn::Error::new( + route_span, + format!( + "Routes should start with /. Error found in the route '{}'", + route + ), + )); + } + + while let Some(segment) = iterator.next() { + if let Some(segment) = segment.strip_prefix(':') { + let spread = segment.starts_with(".."); + + let ident = if spread { + segment[2..].to_string() + } else { + segment.to_string() + }; + + let field = fields.find(|(name, _)| **name == ident); + + let ty = if let Some(field) = field { + field.1.clone() + } else { + return Err(syn::Error::new( + route_span, + format!("Could not find a field with the name '{}'", ident,), + )); + }; + if spread { + route_segments.push(RouteSegment::CatchAll( + Ident::new(&ident, Span::call_site()), + ty, + )); + + if iterator.next().is_some() { + return Err(syn::Error::new( + route_span, + "Catch-all route segments must be the last segment in a route. The route segments after the catch-all segment will never be matched.", + )); + } else { + break; + } + } else { + route_segments.push(RouteSegment::Dynamic( + Ident::new(&ident, Span::call_site()), + ty, + )); + } + } else { + route_segments.push(RouteSegment::Static(segment.to_string())); + } + } + + // check if the route has a query string + let parsed_query = match query { + Some(query) => { + if let Some(query) = query.strip_prefix(':') { + let query_ident = Ident::new(query, Span::call_site()); + let field = fields.find(|(name, _)| *name == &query_ident); + + let ty = if let Some((_, ty)) = field { + ty.clone() + } else { + return Err(syn::Error::new( + route_span, + format!("Could not find a field with the name '{}'", query_ident), + )); + }; + + Some(QuerySegment { + ident: query_ident, + ty, + }) + } else { + None + } + } + None => None, + }; + + Ok((route_segments, parsed_query)) +} + +pub(crate) fn create_error_type( + error_name: Ident, + segments: &[RouteSegment], + child_type: Option<&Type>, +) -> TokenStream2 { + let mut error_variants = Vec::new(); + let mut display_match = Vec::new(); + + for (i, segment) in segments.iter().enumerate() { + let error_name = segment.error_name(i); + match segment { + RouteSegment::Static(index) => { + error_variants.push(quote! { #error_name(String) }); + display_match.push(quote! { Self::#error_name(found) => write!(f, "Static segment '{}' did not match instead found '{}'", #index, found)? }); + } + RouteSegment::Dynamic(ident, ty) => { + let missing_error = segment.missing_error_name().unwrap(); + error_variants.push( + quote! { #error_name(<#ty as dioxus_router::routable::FromRouteSegment>::Err) }, + ); + display_match.push(quote! { Self::#error_name(err) => write!(f, "Dynamic segment '({}:{})' did not match: {}", stringify!(#ident), stringify!(#ty), err)? }); + error_variants.push(quote! { #missing_error }); + display_match.push(quote! { Self::#missing_error => write!(f, "Dynamic segment '({}:{})' was missing", stringify!(#ident), stringify!(#ty))? }); + } + RouteSegment::CatchAll(ident, ty) => { + error_variants.push(quote! { #error_name(<#ty as dioxus_router::routable::FromRouteSegments>::Err) }); + display_match.push(quote! { Self::#error_name(err) => write!(f, "Catch-all segment '({}:{})' did not match: {}", stringify!(#ident), stringify!(#ty), err)? }); + } + } + } + + let child_type_variant = child_type + .map(|child_type| { + quote! { ChildRoute(<#child_type as std::str::FromStr>::Err) } + }) + .into_iter(); + + let child_type_error = child_type + .map(|_| { + quote! { + Self::ChildRoute(error) => { + write!(f, "{}", error)? + } + } + }) + .into_iter(); + + quote! { + #[allow(non_camel_case_types)] + #[derive(Debug, PartialEq)] + pub enum #error_name { + ExtraSegments(String), + #(#child_type_variant,)* + #(#error_variants,)* + } + + impl std::fmt::Display for #error_name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ExtraSegments(segments) => { + write!(f, "Found additional trailing segments: {}", segments)? + }, + #(#child_type_error,)* + #(#display_match,)* + } + Ok(()) + } + } + } +} diff --git a/packages/router/.gitignore b/packages/router/.gitignore index 849ddff3b..8cf5c8dd1 100644 --- a/packages/router/.gitignore +++ b/packages/router/.gitignore @@ -1 +1,2 @@ dist/ +static \ No newline at end of file diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 2a0afbc20..a23e87d2c 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -3,57 +3,58 @@ name = "dioxus-router" version = "0.3.0" edition = "2018" description = "Cross-platform router for Dioxus apps" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react", "wasm"] [dependencies] -dioxus = { workspace = true} -futures-channel = { workspace = true } -url = { version = "2.2.2", default-features = false } - -# for wasm -web-sys = { version = "0.3", features = [ - "Attr", - "Document", - "History", - "HtmlBaseElement", - "Event", - "NamedNodeMap", - "Url", - "UrlSearchParams", - "Window", - "Location", -], optional = true } -wasm-bindgen = { workspace = true, optional = true } -js-sys = { version = "0.3", optional = true } -gloo-events = { version = "0.1.1", optional = true } +anyhow = "1.0.66" +dioxus = { workspace = true } +dioxus-router-macro = { workspace = true } +gloo = { version = "0.8.0", optional = true } log = { workspace = true } thiserror = { workspace = true } futures-util = { workspace = true } -serde = { version = "1", optional = true } serde_urlencoded = { version = "0.7.1", optional = true } +serde = { version = "1", features = ["derive"], optional = true } +thiserror = "1.0.37" +url = "2.3.1" +wasm-bindgen = { workspace = true, optional = true } +web-sys = { version = "0.3.60", optional = true, features = ["ScrollRestoration"] } +js-sys = { version = "0.3.63", optional = true } +gloo-utils = { version = "0.1.6", optional = true } +dioxus-ssr = { workspace = true, optional = true } +tokio = { workspace = true, features = ["full"], optional = true } [features] -default = ["query"] -web = ["web-sys", "gloo-events", "js-sys", "wasm-bindgen"] -query = ["serde", "serde_urlencoded"] +default = ["web"] +ssr = ["dioxus-ssr", "tokio"] wasm_test = [] +serde = ["dep:serde", "gloo-utils/serde"] +web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] [dev-dependencies] +dioxus = { path = "../dioxus" } +dioxus-ssr = { path = "../ssr" } +criterion = { verison = "0.5", features = ["async_tokio", "html_reports"] } + +[[example]] +name = "static_generation" +required-features = ["ssr"] + +[[bench]] +name = "incremental" +required-features = ["ssr"] +harness = false + +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] +dioxus-desktop = { path = "../desktop" } + +[target.'cfg(target_family = "wasm")'.dev-dependencies] console_error_panic_hook = "0.1.7" -wasm-logger = "0.2.0" -wasm-bindgen-test = "0.3" -gloo-utils = "0.1.2" -dioxus-web = { workspace = true } -# dioxus-desktop = { workspace = true, optional = true } - -# not wasm - -[target.wasm32-unknown-unknown.dev-dependencies] dioxus-router = { workspace = true, features = ["web"] } - -[dev-dependencies.web-sys] -version = "0.3" -features = ["Document"] +dioxus-web = { workspace = true } +gloo = "0.8.0" +wasm-bindgen-test = "0.3.33" +wasm-logger = "0.2.0" diff --git a/packages/router/Makefile.toml b/packages/router/Makefile.toml deleted file mode 100644 index 42b7f5c9a..000000000 --- a/packages/router/Makefile.toml +++ /dev/null @@ -1,10 +0,0 @@ -[tasks.test-with-browser] -extend = "core::wasm-pack-base" -command = "wasm-pack" -args = [ - "test", - "@@split(DIOXUS_TEST_FLAGS, )", - "--", - "--features", - "${DIOXUS_TEST_FEATURES}", -] \ No newline at end of file diff --git a/packages/router/README.md b/packages/router/README.md index c7be4496d..13437bf56 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -7,35 +7,93 @@ [crates-badge]: https://img.shields.io/crates/v/dioxus-router.svg [crates-url]: https://crates.io/crates/dioxus-router - [mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg [mit-url]: https://github.com/dioxuslabs/dioxus/blob/master/LICENSE - [actions-badge]: https://github.com/dioxuslabs/dioxus/actions/workflows/main.yml/badge.svg [actions-url]: https://github.com/dioxuslabs/dioxus/actions?query=workflow%3ACI+branch%3Amaster - [discord-badge]: https://img.shields.io/discord/899851952891002890.svg?logo=discord&style=flat-square [discord-url]: https://discord.gg/XgGxMSkvUM [Website](https://dioxuslabs.com) | -[Guides](https://dioxuslabs.com/docs/0.3/guide/en/) | +[Guides](https://dioxuslabs.com/docs/0.3/router/) | [API Docs](https://docs.rs/dioxus-router/latest/dioxus_router) | [Chat](https://discord.gg/XgGxMSkvUM) - ## Overview -Dioxus Router is a first-party Router for all your Dioxus Apps. It provides a React-Router-style interface using somewhat loose typing rules. +Dioxus Router is a first-party Router for all your Dioxus Apps. It provides an +interface similar to React Router, but takes advantage of types for more +expressiveness. -```rust, ignore -fn app() { - cx.render(rsx! { - Router { - Route { to: "/", Component {} }, - Route { to: "/blog", Blog {} }, - Route { to: "/blog/:id", BlogPost {} }, +```rust, no_run +#![allow(non_snake_case)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; +use std::str::FromStr; + +#[rustfmt::skip] +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[nest("/blog")] + #[layout(Blog)] + #[route("/")] + BlogList {}, + + #[route("/:blog_id")] + BlogPost { blog_id: usize }, + #[end_layout] + #[end_nest] + #[route("/")] + Index {}, +} + + +fn App(cx: Scope) -> Element { + render! { + Router { } + } +} + +#[inline_props] +fn Index(cx: Scope) -> Element { + render! { + h1 { "Index" } + Link { + target: Route::BlogList {}, + "Go to the blog" } - }) + } +} + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + h1 { "Blog" } + Outlet { } + } +} + +#[inline_props] +fn BlogList(cx: Scope) -> Element { + render! { + h2 { "List of blog posts" } + Link { + target: Route::BlogPost { blog_id: 0 }, + "Blog post 1" + } + Link { + target: Route::BlogPost { blog_id: 1 }, + "Blog post 2" + } + } +} + +#[inline_props] +fn BlogPost(cx: Scope, blog_id: usize) -> Element { + render! { + h2 { "Blog Post" } + } } ``` @@ -47,6 +105,7 @@ You need to enable the right features for the platform you're targeting since th - Join the discord and ask questions! ## License + This project is licensed under the [MIT license]. [mit license]: https://github.com/DioxusLabs/dioxus/blob/master/LICENSE-MIT @@ -54,4 +113,3 @@ This project is licensed under the [MIT license]. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Dioxus by you shall be licensed as MIT without any additional terms or conditions. - diff --git a/packages/router/benches/incremental.rs b/packages/router/benches/incremental.rs new file mode 100644 index 000000000..0694e3636 --- /dev/null +++ b/packages/router/benches/incremental.rs @@ -0,0 +1,200 @@ +#![allow(unused, non_snake_case)] + +use std::time::Duration; + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +use dioxus_ssr::Renderer; + +pub fn criterion_benchmark(c: &mut Criterion) { + c.bench_function("build 1000 routes", |b| { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .invalidate_after(Duration::from_secs(10)) + .build(); + + b.iter(|| { + tokio::runtime::Runtime::new().unwrap().block_on(async { + for id in 0..1000 { + render_route( + &mut renderer, + Route::Post { id }, + &mut tokio::io::sink(), + |_| {}, + ) + .await + .unwrap(); + } + }) + }) + }); + c.bench_function("build 1000 routes no memory cache", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()) + .iter(|| async { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .memory_cache_limit(0) + .invalidate_after(Duration::from_secs(10)) + .build(); + for id in 0..1000 { + render_route( + &mut renderer, + Route::Post { id }, + &mut tokio::io::sink(), + |_| {}, + ) + .await + .unwrap(); + } + }) + }); + c.bench_function("build 1000 routes no cache", |b| { + let mut renderer = Renderer::default(); + + b.iter(|| { + for id in 0..1000 { + let mut vdom = VirtualDom::new_with_props( + RenderPath, + RenderPathProps::builder().path(Route::Post { id }).build(), + ); + + vdom.rebuild(); + + struct Ignore; + + impl std::fmt::Write for Ignore { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + Ok(()) + } + } + + renderer.render_to(&mut Ignore, &vdom).unwrap(); + } + }) + }); + c.bench_function("cache static", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()) + .iter(|| async { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .build(); + + pre_cache_static_routes::(&mut renderer) + .await + .unwrap(); + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + div { + "Blog" + } + } +} + +#[inline_props] +fn Post(cx: Scope, id: usize) -> Element { + render! { + for _ in 0..*id { + div { + "PostId: {id}" + } + } + } +} + +#[inline_props] +fn PostHome(cx: Scope) -> Element { + render! { + div { + "Post" + } + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + div { + "Home" + } + } +} + +#[rustfmt::skip] +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[nest("/blog")] + #[route("/")] + Blog {}, + #[route("/post/index")] + PostHome {}, + #[route("/post/:id")] + Post { + id: usize, + }, + #[end_nest] + #[route("/")] + Home {}, +} + +#[inline_props] +fn RenderPath(cx: Scope, path: Route) -> Element { + let path = path.clone(); + render! { + Router { + config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + } + } +} diff --git a/packages/router/examples/simple_routes.rs b/packages/router/examples/simple_routes.rs index 9b94c7a9d..3036bdb9b 100644 --- a/packages/router/examples/simple_routes.rs +++ b/packages/router/examples/simple_routes.rs @@ -1,52 +1,152 @@ #![allow(non_snake_case)] use dioxus::prelude::*; -use dioxus_router::*; +use dioxus_router::prelude::*; +use std::str::FromStr; fn main() { - dioxus_web::launch(app); + #[cfg(not(target_arch = "wasm32"))] + dioxus_desktop::launch(root); + + #[cfg(target_arch = "wasm32")] + dioxus_web::launch(root); } -fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - h1 { "Your app here" } - ul { - Link { to: "/", li { "home" } } - Link { to: "/blog", li { "blog" } } - Link { to: "/blog/tim", li { "tims' blog" } } - Link { to: "/blog/bill", li { "bills' blog" } } - Link { to: "/blog/james", li { "james amazing' blog" } } - Link { to: "/apples", li { "go to apples" } } - } - Route { to: "/", Home {} } - Route { to: "/blog/", BlogList {} } - Route { to: "/blog/:id/", BlogPost {} } - Route { to: "/oranges", "Oranges are not apples!" } - Redirect { from: "/apples", to: "/oranges" } +fn root(cx: Scope) -> Element { + render! { + Router {} + } +} + +#[inline_props] +fn UserFrame(cx: Scope, user_id: usize) -> Element { + render! { + pre { + "UserFrame{{\n\tuser_id:{user_id}\n}}" } - }) -} - -fn Home(cx: Scope) -> Element { - cx.render(rsx! { h1 { "Home" } }) -} - -fn BlogList(cx: Scope) -> Element { - cx.render(rsx! { div { "Blog List" } }) -} - -fn BlogPost(cx: Scope) -> Element { - let Some(id) = use_route(cx).segment("id") else { - return cx.render(rsx! { div { "No blog post id" } }) - }; - - log::trace!("rendering blog post {}", id); - - cx.render(rsx! { div { - h3 { "blog post: {id:?}" } - Link { to: "/blog/", "back to blog list" } + background_color: "rgba(0,0,0,50%)", + "children:" + Outlet {} } - }) + } +} + +#[inline_props] +fn Route1(cx: Scope, user_id: usize, dynamic: usize, query: String, extra: String) -> Element { + render! { + pre { + "Route1{{\n\tuser_id:{user_id},\n\tdynamic:{dynamic},\n\tquery:{query},\n\textra:{extra}\n}}" + } + Link { + target: Route::Route1 { user_id: *user_id, dynamic: *dynamic, query: String::new(), extra: extra.clone() + "." }, + "Route1 with extra+\".\"" + } + p { "Footer" } + Link { + target: Route::Route3 { dynamic: String::new() }, + "Home" + } + } +} + +#[inline_props] +fn Route2(cx: Scope, user_id: usize) -> Element { + render! { + pre { + "Route2{{\n\tuser_id:{user_id}\n}}" + } + (0..*user_id).map(|i| rsx!{ p { "{i}" } }), + p { "Footer" } + Link { + target: Route::Route3 { dynamic: String::new() }, + "Home" + } + } +} + +#[inline_props] +fn Route3(cx: Scope, dynamic: String) -> Element { + let navigator = use_navigator(cx); + let current_route = use_route(cx)?; + let current_route_str = use_ref(cx, String::new); + let parsed = Route::from_str(¤t_route_str.read()); + + let site_map = Route::SITE_MAP + .iter() + .flat_map(|seg| seg.flatten().into_iter()) + .collect::>(); + + render! { + input { + oninput: move |evt| { + *current_route_str.write() = evt.value.clone(); + }, + value: "{current_route_str.read()}" + } + "dynamic: {dynamic}" + Link { + target: Route::Route2 { user_id: 8888 }, + "hello world link" + } + button { + onclick: move |_| { navigator.push(NavigationTarget::External("https://www.google.com".to_string())); }, + "google link" + } + p { "Site Map" } + pre { "{site_map:#?}" } + p { "Dynamic link" } + match parsed { + Ok(route) => { + if route != current_route { + render! { + Link { + target: route.clone(), + "{route}" + } + } + } + else { + None + } + } + Err(err) => { + render! { + pre { + color: "red", + "Invalid route:\n{err}" + } + } + } + } + } +} + +#[rustfmt::skip] +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[nest("/test")] + // Nests with parameters have types taken from child routes + #[nest("/user/:user_id")] + // Everything inside the nest has the added parameter `user_id: usize` + // UserFrame is a layout component that will receive the `user_id: usize` parameter + #[layout(UserFrame)] + #[route("/:dynamic?:query")] + Route1 { + // The type is taken from the first instance of the dynamic parameter + user_id: usize, + dynamic: usize, + query: String, + extra: String, + }, + #[route("/hello_world")] + // You can opt out of the layout by using the `!` prefix + #[layout(!UserFrame)] + Route2 { user_id: usize }, + #[end_layout] + #[end_nest] + #[end_nest] + #[redirect("/:id/user", |id: usize| Route::Route3 { dynamic: id.to_string()})] + #[route("/:dynamic")] + Route3 { dynamic: String }, } diff --git a/packages/router/examples/static_generation.rs b/packages/router/examples/static_generation.rs new file mode 100644 index 000000000..ca05ae774 --- /dev/null +++ b/packages/router/examples/static_generation.rs @@ -0,0 +1,103 @@ +#![allow(non_snake_case)] + +use std::time::Duration; + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +use dioxus_ssr::incremental::{DefaultRenderer, IncrementalRendererConfig}; + +#[tokio::main] +async fn main() { + let mut renderer = IncrementalRendererConfig::new(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .invalidate_after(Duration::from_secs(10)) + .build(); + + println!( + "SITE MAP:\n{}", + Route::SITE_MAP + .iter() + .flat_map(|route| route.flatten().into_iter()) + .map(|route| { + route + .iter() + .map(|segment| segment.to_string()) + .collect::>() + .join("") + }) + .collect::>() + .join("\n") + ); + + pre_cache_static_routes::(&mut renderer) + .await + .unwrap(); +} + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + div { + "Blog" + } + } +} + +#[inline_props] +fn Post(cx: Scope, id: usize) -> Element { + render! { + div { + "PostId: {id}" + } + } +} + +#[inline_props] +fn PostHome(cx: Scope) -> Element { + render! { + div { + "Post" + } + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + div { + "Home" + } + } +} + +#[rustfmt::skip] +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[nest("/blog")] + #[route("/")] + Blog {}, + #[route("/post/index")] + PostHome {}, + #[route("/post/:id")] + Post { + id: usize, + }, + #[end_nest] + #[route("/")] + Home {}, +} diff --git a/packages/router/src/cfg.rs b/packages/router/src/cfg.rs deleted file mode 100644 index 78ecd7b0c..000000000 --- a/packages/router/src/cfg.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Default)] -pub struct RouterCfg { - pub base_url: Option, - pub active_class: Option, - pub initial_url: Option, -} diff --git a/packages/router/src/components/default_errors.rs b/packages/router/src/components/default_errors.rs new file mode 100644 index 000000000..16a680803 --- /dev/null +++ b/packages/router/src/components/default_errors.rs @@ -0,0 +1,22 @@ +use crate::{hooks::use_generic_router, routable::Routable}; +use dioxus::prelude::*; + +/// The default component to render when an external navigation fails. +#[allow(non_snake_case)] +pub fn FailureExternalNavigation(cx: Scope) -> Element { + let router = use_generic_router::(cx); + + render! { + h1 { "External Navigation Failure!" } + p { + "The application tried to programmatically navigate to an external page. This " + "operation has failed. Click the link below to complete the navigation manually." + } + a { + onclick: move |_| { + router.clear_error() + }, + "Click here to go back" + } + } +} diff --git a/packages/router/src/components/history_buttons.rs b/packages/router/src/components/history_buttons.rs new file mode 100644 index 000000000..a09bc66b6 --- /dev/null +++ b/packages/router/src/components/history_buttons.rs @@ -0,0 +1,157 @@ +use dioxus::prelude::*; +use log::error; + +use crate::{prelude::*, utils::use_router_internal::use_router_internal}; + +/// The properties for a [`GenericGoBackButton`] or a [`GenericGoForwardButton`]. +#[derive(Debug, Props)] +pub struct GenericHistoryButtonProps<'a> { + /// The children to render within the generated HTML button tag. + pub children: Element<'a>, +} + +/// A button to go back through the navigation history. Similar to a browsers back button. +/// +/// Only works as descendant of a [`GenericRouter`] component, otherwise it will be inactive. +/// +/// The button will disable itself if it is known that no prior history is available. +/// +/// # Panic +/// - When the [`GenericGoBackButton`] is not nested within a [`GenericRouter`] component +/// hook, but only in debug builds. +/// +/// # Example +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::prelude::*; +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// } +/// +/// fn App(cx: Scope) -> Element { +/// render! { +/// Router {} +/// } +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// render! { +/// GoBackButton { +/// "go back" +/// } +/// } +/// } +/// # +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// # assert_eq!( +/// # dioxus_ssr::render(&vdom), +/// # r#""# +/// # ); +/// ``` +#[allow(non_snake_case)] +pub fn GenericGoBackButton<'a, R: Routable>( + cx: Scope<'a, GenericHistoryButtonProps<'a>>, +) -> Element { + let GenericHistoryButtonProps { children } = cx.props; + + // hook up to router + let router = match use_router_internal::(cx) { + Some(r) => r, + #[allow(unreachable_code)] + None => { + let msg = "`GoBackButton` must have access to a parent router"; + error!("{msg}, will be inactive"); + #[cfg(debug_assertions)] + panic!("{}", msg); + return None; + } + }; + + let disabled = !router.can_go_back(); + + render! { + button { + disabled: "{disabled}", + prevent_default: "onclick", + onclick: move |_| router.go_back(), + children + } + } +} + +/// A button to go forward through the navigation history. Similar to a browsers forward button. +/// +/// Only works as descendant of a [`GenericRouter`] component, otherwise it will be inactive. +/// +/// The button will disable itself if it is known that no later history is available. +/// +/// # Panic +/// - When the [`GenericGoForwardButton`] is not nested within a [`GenericRouter`] component +/// hook, but only in debug builds. +/// +/// # Example +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::prelude::*; +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// } +/// +/// fn App(cx: Scope) -> Element { +/// render! { +/// Router {} +/// } +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// render! { +/// GoForwardButton { +/// "go forward" +/// } +/// } +/// } +/// # +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// # assert_eq!( +/// # dioxus_ssr::render(&vdom), +/// # r#""# +/// # ); +/// ``` +#[allow(non_snake_case)] +pub fn GenericGoForwardButton<'a, R: Routable>( + cx: Scope<'a, GenericHistoryButtonProps<'a>>, +) -> Element { + let GenericHistoryButtonProps { children } = cx.props; + + // hook up to router + let router = match use_router_internal::(cx) { + Some(r) => r, + #[allow(unreachable_code)] + None => { + let msg = "`GoForwardButton` must have access to a parent router"; + error!("{msg}, will be inactive"); + #[cfg(debug_assertions)] + panic!("{}", msg); + return None; + } + }; + + let disabled = !router.can_go_back(); + + render! { + button { + disabled: "{disabled}", + prevent_default: "onclick", + onclick: move |_| router.go_forward(), + children + } + } +} diff --git a/packages/router/src/components/link.rs b/packages/router/src/components/link.rs index 3ca0303c7..4e71dacb6 100644 --- a/packages/router/src/components/link.rs +++ b/packages/router/src/components/link.rs @@ -1,152 +1,192 @@ -use crate::{use_route, RouterContext}; +use std::fmt::Debug; + use dioxus::prelude::*; +use log::error; -/// Props for the [`Link`](struct.Link.html) component. +use crate::navigation::NavigationTarget; +use crate::prelude::*; +use crate::utils::use_router_internal::use_router_internal; + +/// The properties for a [`GenericLink`]. #[derive(Props)] -pub struct LinkProps<'a> { - /// The route to link to. This can be a relative path, or a full URL. - /// - /// ```rust, ignore - /// // Absolute path - /// Link { to: "/home", "Go Home" } - /// - /// // Relative path - /// Link { to: "../", "Go Up" } - /// ``` - pub to: &'a str, - - /// Set the class of the inner link ['a'](https://www.w3schools.com/tags/tag_a.asp) element. - /// - /// This can be useful when styling the inner link element. - #[props(default, strip_option)] - pub class: Option<&'a str>, - - /// Set the class added to the inner link when the current route is the same as the "to" route. - /// - /// To set all of the active classes inside a Router at the same time use the `active_class` - /// prop on the Router component. If both the Router prop as well as this prop are provided then - /// this one has precedence. By default set to `"active"`. - #[props(default, strip_option)] +pub struct GenericLinkProps<'a, R: Routable> { + /// A class to apply to the generate HTML anchor tag if the `target` route is active. pub active_class: Option<&'a str>, - - /// Set the ID of the inner link ['a'](https://www.w3schools.com/tags/tag_a.asp) element. - /// - /// This can be useful when styling the inner link element. - #[props(default, strip_option)] - pub id: Option<&'a str>, - - /// Set the title of the window after the link is clicked.. - #[props(default, strip_option)] - pub title: Option<&'a str>, - - /// Autodetect if a link is external or not. - /// - /// This is automatically set to `true` and will use http/https detection - #[props(default = true)] - pub autodetect: bool, - - /// Is this link an external link? - #[props(default = false)] - pub external: bool, - - /// New tab? - #[props(default = false)] - pub new_tab: bool, - - /// Pass children into the `` element + /// The children to render within the generated HTML anchor tag. pub children: Element<'a>, - + /// The class attribute for the generated HTML anchor tag. + /// + /// If `active_class` is [`Some`] and the `target` route is active, `active_class` will be + /// appended at the end of `class`. + pub class: Option<&'a str>, + /// The id attribute for the generated HTML anchor tag. + pub id: Option<&'a str>, + /// When [`true`], the `target` route will be opened in a new tab. + /// + /// This does not change whether the [`GenericLink`] is active or not. + #[props(default)] + pub new_tab: bool, /// The onclick event handler. pub onclick: Option>, + #[props(default)] + /// Whether the default behavior should be executed if an `onclick` handler is provided. + /// + /// 1. When `onclick` is [`None`] (default if not specified), `onclick_only` has no effect. + /// 2. If `onclick_only` is [`false`] (default if not specified), the provided `onclick` handler + /// will be executed after the links regular functionality. + /// 3. If `onclick_only` is [`true`], only the provided `onclick` handler will be executed. + pub onclick_only: bool, + /// The rel attribute for the generated HTML anchor tag. + /// + /// For external `target`s, this defaults to `noopener noreferrer`. + pub rel: Option<&'a str>, + /// The navigation target. Roughly equivalent to the href attribute of an HTML anchor tag. + #[props(into)] + pub target: NavigationTarget, } -/// A component that renders a link to a route. -/// -/// `Link` components are just [``](https://www.w3schools.com/tags/tag_a.asp) elements -/// that link to different pages *within* your single-page app. -/// -/// If you need to link to a resource outside of your app, then just use a regular -/// `` element directly. -/// -/// # Examples -/// -/// ```rust, ignore -/// fn Header(cx: Scope) -> Element { -/// cx.render(rsx!{ -/// Link { to: "/home", "Go Home" } -/// }) -/// } -/// ``` -pub fn Link<'a>(cx: Scope<'a, LinkProps<'a>>) -> Element { - let svc = use_context::(cx); +impl Debug for GenericLinkProps<'_, R> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LinkProps") + .field("active_class", &self.active_class) + .field("children", &self.children) + .field("class", &self.class) + .field("id", &self.id) + .field("new_tab", &self.new_tab) + .field("onclick", &self.onclick.as_ref().map(|_| "onclick is set")) + .field("onclick_only", &self.onclick_only) + .field("rel", &self.rel) + .field("target", &self.target.to_string()) + .finish() + } +} - let LinkProps { - to, +/// A link to navigate to another route. +/// +/// Only works as descendant of a [`GenericRouter`] component, otherwise it will be inactive. +/// +/// Unlike a regular HTML anchor, a [`GenericLink`] allows the router to handle the navigation and doesn't +/// cause the browser to load a new page. +/// +/// However, in the background a [`GenericLink`] still generates an anchor, which you can use for styling +/// as normal. +/// +/// # External targets +/// When the [`GenericLink`]s target is an [`NavigationTarget::External`] target, that is used as the `href` directly. This +/// means that a [`GenericLink`] can always navigate to an [`NavigationTarget::External`] target, even if the [`HistoryProvider`] does not support it. +/// +/// # Panic +/// - When the [`GenericLink`] is not nested within a [`GenericRouter`], but +/// only in debug builds. +/// +/// # Example +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::prelude::*; +/// +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// } +/// +/// fn App(cx: Scope) -> Element { +/// render! { +/// Router {} +/// } +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// render! { +/// render! { +/// Link { +/// active_class: "active", +/// class: "link_class", +/// id: "link_id", +/// new_tab: true, +/// rel: "link_rel", +/// target: Route::Index {}, +/// +/// "A fully configured link" +/// } +/// } +/// } +/// } +/// # +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// # assert_eq!( +/// # dioxus_ssr::render(&vdom), +/// # r#"A fully configured link"# +/// # ); +/// ``` +#[allow(non_snake_case)] +pub fn GenericLink<'a, R: Routable + Clone>(cx: Scope<'a, GenericLinkProps<'a, R>>) -> Element { + let GenericLinkProps { + active_class, + children, class, id, - title, - autodetect, - external, new_tab, - children, - active_class, - .. + onclick, + onclick_only, + rel, + target, } = cx.props; - let is_http = to.starts_with("http") || to.starts_with("https"); - let outerlink = (*autodetect && is_http) || *external; - let prevent_default = if outerlink { "" } else { "onclick" }; - - let active_class_name = match active_class { - Some(c) => (*c).into(), + // hook up to router + let router = match use_router_internal::(cx) { + Some(r) => r, + #[allow(unreachable_code)] None => { - let active_from_router = match svc { - Some(service) => service.cfg.active_class.clone(), - None => None, - }; - active_from_router.unwrap_or_else(|| "active".into()) + let msg = "`Link` must have access to a parent router"; + error!("{msg}, will be inactive"); + #[cfg(debug_assertions)] + panic!("{}", msg); + return None; } }; - let route = use_route(cx); - let url = route.url(); - let path = url.path(); - let active = path == cx.props.to; - let active_class = if active { active_class_name } else { "".into() }; + let current_route = router.current(); + let current_url = current_route.to_string(); + let href = target.to_string(); + let ac = active_class + .and_then(|active_class| (href == current_url).then(|| format!(" {active_class}"))) + .unwrap_or_default(); - cx.render(rsx! { + let id = id.unwrap_or_default(); + let class = format!("{}{ac}", class.unwrap_or_default()); + let tag_target = new_tab.then_some("_blank").unwrap_or_default(); + + let is_external = matches!(target, NavigationTarget::External(_)); + let is_router_nav = !is_external && !new_tab; + let prevent_default = is_router_nav.then_some("onclick").unwrap_or_default(); + let rel = rel + .or_else(|| is_external.then_some("noopener noreferrer")) + .unwrap_or_default(); + + let do_default = onclick.is_none() || !onclick_only; + let action = move |event| { + if do_default && is_router_nav { + router.push(target.clone()); + } + + if let Some(handler) = onclick { + handler.call(event); + } + }; + + render! { a { - href: "{to}", - class: format_args!("{} {}", class.unwrap_or(""), active_class), - id: format_args!("{}", id.unwrap_or("")), - title: format_args!("{}", title.unwrap_or("")), + onclick: action, + href: "{href}", prevent_default: "{prevent_default}", - target: format_args!("{}", if * new_tab { "_blank" } else { "" }), - onclick: move |evt| { - log::trace!("Clicked link to {}", to); - - if !outerlink { - if let Some(service) = svc { - log::trace!("Pushing route to {}", to); - service.push_route(to, cx.props.title.map(|f| f.to_string()), None); - - #[cfg(feature = "web")] - { - web_sys::window().unwrap().scroll_to_with_x_and_y(0.0, 0.0); - } - } else { - log::error!( - "Attempted to create a Link to {} outside of a Router context", cx.props - .to, - ); - } - } - - if let Some(onclick) = cx.props.onclick.as_ref() { - onclick.call(evt); - } - }, + class: "{class}", + id: "{id}", + rel: "{rel}", + target: "{tag_target}", children } - }) + } } diff --git a/packages/router/src/components/outlet.rs b/packages/router/src/components/outlet.rs new file mode 100644 index 000000000..da312402e --- /dev/null +++ b/packages/router/src/components/outlet.rs @@ -0,0 +1,72 @@ +use crate::prelude::{outlet::OutletContext, *}; +use dioxus::prelude::*; + +/// An outlet for the current content. +/// +/// Only works as descendant of a [`GenericRouter`] component, otherwise it will be inactive. +/// +/// The [`GenericOutlet`] is aware of how many [`GenericOutlet`]s it is nested within. It will render the content +/// of the active route that is __exactly as deep__. +/// +/// # Panic +/// - When the [`GenericOutlet`] is not nested a [`GenericRouter`] component, +/// but only in debug builds. +/// +/// # Example +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::prelude::*; +/// #[derive(Clone, Routable)] +/// #[rustfmt::skip] +/// enum Route { +/// #[nest("/wrap")] +/// #[layout(Wrapper)] // Every layout component must have one Outlet +/// #[route("/")] +/// Child {}, +/// #[end_layout] +/// #[end_nest] +/// #[route("/")] +/// Index {}, +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// render! { +/// div { +/// "Index" +/// } +/// } +/// } +/// +/// #[inline_props] +/// fn Wrapper(cx: Scope) -> Element { +/// render! { +/// h1 { "App" } +/// Outlet {} // The content of child routes will be rendered here +/// } +/// } +/// +/// #[inline_props] +/// fn Child(cx: Scope) -> Element { +/// render! { +/// p { +/// "Child" +/// } +/// } +/// } +/// +/// # fn App(cx: Scope) -> Element { +/// # render! { +/// # Router { +/// # config: || RouterConfig::default().history(MemoryHistory::with_initial_path(Route::Child {})) +/// # } +/// # } +/// # } +/// # +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// # assert_eq!(dioxus_ssr::render(&vdom), "

App

Child

"); +/// ``` +pub fn GenericOutlet(cx: Scope) -> Element { + OutletContext::::render(cx) +} diff --git a/packages/router/src/components/redirect.rs b/packages/router/src/components/redirect.rs deleted file mode 100644 index 3bbe50b7e..000000000 --- a/packages/router/src/components/redirect.rs +++ /dev/null @@ -1,51 +0,0 @@ -use dioxus::prelude::*; - -use crate::use_router; - -/// The props for the [`Router`](fn.Router.html) component. -#[derive(Props)] -pub struct RedirectProps<'a> { - /// The route to link to. This can be a relative path, or a full URL. - /// - /// ```rust, ignore - /// // Absolute path - /// Redirect { from: "", to: "/home" } - /// - /// // Relative path - /// Redirect { from: "", to: "../" } - /// ``` - pub to: &'a str, - - /// The route to link from. This can be a relative path, or a full URL. - /// - /// ```rust, ignore - /// // Absolute path - /// Redirect { from: "", to: "/home" } - /// - /// // Relative path - /// Redirect { from: "", to: "../" } - /// ``` - pub from: Option<&'a str>, -} - -/// If this component is rendered, it will redirect the user to the given route. -/// -/// It will replace the current route rather than pushing the current one to the stack. -pub fn Redirect<'a>(cx: Scope<'a, RedirectProps<'a>>) -> Element { - let router = use_router(cx); - - let immediate_redirect = cx.use_hook(|| { - if let Some(from) = cx.props.from { - router.register_total_route(from.to_string(), cx.scope_id()); - false - } else { - true - } - }); - - if *immediate_redirect || router.should_render(cx.scope_id()) { - router.replace_route(cx.props.to, None, None); - } - - cx.render(rsx!(())) -} diff --git a/packages/router/src/components/route.rs b/packages/router/src/components/route.rs deleted file mode 100644 index 7bfc48993..000000000 --- a/packages/router/src/components/route.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{RouteContext, RouterContext}; -use dioxus::prelude::*; - -/// Props for the [`Route`](struct.Route.html) component. -#[derive(Props)] -pub struct RouteProps<'a> { - /// The path to match. - pub to: &'a str, - - /// The component to render when the path matches. - pub children: Element<'a>, -} - -/// A component that conditionally renders children based on the current location. -/// -/// # Example -/// -///```rust, ignore -/// rsx!( -/// Router { -/// Route { to: "/home", Home {} } -/// Route { to: "/about", About {} } -/// Route { to: "/Blog", Blog {} } -/// } -/// ) -/// ``` -pub fn Route<'a>(cx: Scope<'a, RouteProps<'a>>) -> Element { - let router_root = use_context::(cx).unwrap(); - let root_context = use_context::(cx); - - cx.use_hook(|| { - // create a bigger, better, longer route if one above us exists - let total_route = match root_context { - Some(ctx) => ctx.total_route.clone(), - None => cx.props.to.to_string(), - }; - - // provide our route context - let route_context = cx.provide_context(RouteContext { - declared_route: cx.props.to.to_string(), - total_route, - }); - - // submit our rout - router_root.register_total_route(route_context.total_route, cx.scope_id()); - }); - - log::trace!("Checking Route: {:?}", cx.props.to); - - if router_root.should_render(cx.scope_id()) { - log::trace!("Route should render: {:?}", cx.scope_id()); - cx.render(rsx!(&cx.props.children)) - } else { - log::trace!("Route should *not* render: {:?}", cx.scope_id()); - cx.render(rsx!(())) - } -} diff --git a/packages/router/src/components/router.rs b/packages/router/src/components/router.rs index 8d5365c35..7c82cf19b 100644 --- a/packages/router/src/components/router.rs +++ b/packages/router/src/components/router.rs @@ -1,61 +1,169 @@ -use crate::{cfg::RouterCfg, RouterContext, RouterService}; use dioxus::prelude::*; +use std::{cell::RefCell, str::FromStr}; -/// The props for the [`Router`](fn.Router.html) component. -#[derive(Props)] -pub struct RouterProps<'a> { - /// The routes and elements that should be rendered when the path matches. - /// - /// If elements are not contained within Routes, the will be rendered - /// regardless of the path. - pub children: Element<'a>, +use crate::{ + prelude::{GenericOutlet, GenericRouterContext}, + routable::Routable, + router_cfg::RouterConfig, +}; - /// The URL to point at - /// - /// This will be used to trim any latent segments from the URL when your app is - /// not deployed to the root of the domain. - pub base_url: Option<&'a str>, - - /// Hook into the router when the route is changed. - /// - /// This lets you easily implement redirects - #[props(default)] - pub onchange: EventHandler<'a, RouterContext>, - - /// Set the active class of all Link components contained in this router. - /// - /// This is useful if you don't want to repeat the same `active_class` prop value in every Link. - /// By default set to `"active"`. - pub active_class: Option<&'a str>, - - /// Set the initial url. - // This is Option> because we want to be able to either omit the prop or pass in Option - #[props(into)] - pub initial_url: Option>, +/// The config for [`GenericRouter`]. +pub struct RouterConfigFactory { + #[allow(clippy::type_complexity)] + config: RefCell RouterConfig>>>, } -/// A component that conditionally renders children based on the current location of the app. -/// -/// Uses BrowserRouter in the browser and HashRouter everywhere else. -/// -/// Will fallback to HashRouter is BrowserRouter is not available, or through configuration. -#[allow(non_snake_case)] -pub fn Router<'a>(cx: Scope<'a, RouterProps<'a>>) -> Element { - let svc = cx.use_hook(|| { - cx.provide_context(RouterService::new( - cx, - RouterCfg { - base_url: cx.props.base_url.map(|s| s.to_string()), - active_class: cx.props.active_class.map(|s| s.to_string()), - initial_url: cx.props.initial_url.clone().flatten(), - }, - )) +#[cfg(feature = "serde")] +impl Default for RouterConfigFactory +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + fn default() -> Self { + Self::from(RouterConfig::default) + } +} + +#[cfg(not(feature = "serde"))] +impl Default for RouterConfigFactory +where + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self::from(RouterConfig::default) + } +} + +impl RouterConfig + 'static> From for RouterConfigFactory { + fn from(value: F) -> Self { + Self { + config: RefCell::new(Some(Box::new(value))), + } + } +} + +#[cfg(feature = "serde")] +/// The props for [`GenericRouter`]. +#[derive(Props)] +pub struct GenericRouterProps +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + #[props(default, into)] + config: RouterConfigFactory, +} + +#[cfg(not(feature = "serde"))] +/// The props for [`GenericRouter`]. +#[derive(Props)] +pub struct GenericRouterProps +where + ::Err: std::fmt::Display, +{ + #[props(default, into)] + config: RouterConfigFactory, +} + +#[cfg(not(feature = "serde"))] +impl Default for GenericRouterProps +where + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self { + config: RouterConfigFactory::default(), + } + } +} + +#[cfg(feature = "serde")] +impl Default for GenericRouterProps +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + fn default() -> Self { + Self { + config: RouterConfigFactory::default(), + } + } +} + +#[cfg(not(feature = "serde"))] +impl PartialEq for GenericRouterProps +where + ::Err: std::fmt::Display, +{ + fn eq(&self, _: &Self) -> bool { + // prevent the router from re-rendering when the initial url or config changes + true + } +} + +#[cfg(feature = "serde")] +impl PartialEq for GenericRouterProps +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + fn eq(&self, _: &Self) -> bool { + // prevent the router from re-rendering when the initial url or config changes + true + } +} + +#[cfg(not(feature = "serde"))] +/// A component that renders the current route. +pub fn GenericRouter(cx: Scope>) -> Element +where + ::Err: std::fmt::Display, +{ + use crate::prelude::outlet::OutletContext; + + use_context_provider(cx, || { + GenericRouterContext::new( + (cx.props + .config + .config + .take() + .expect("use_context_provider ran twice"))(), + cx.schedule_update_any(), + ) + }); + use_context_provider(cx, || OutletContext:: { + current_level: 0, + _marker: std::marker::PhantomData, }); - // next time we run the rout_found will be filled - if svc.route_found.get().is_none() { - cx.props.onchange.call(svc.clone()); + render! { + GenericOutlet:: {} + } +} + +#[cfg(feature = "serde")] +/// A component that renders the current route. +pub fn GenericRouter(cx: Scope>) -> Element +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + use_context_provider(cx, || { + GenericRouterContext::new( + (cx.props + .config + .config + .take() + .expect("use_context_provider ran twice"))(), + cx.schedule_update_any(), + ) + }); + use_context_provider(cx, || OutletContext:: { + current_level: 0, + _marker: std::marker::PhantomData, + }); + + render! { + GenericOutlet:: {} } - - cx.render(rsx!(&cx.props.children)) } diff --git a/packages/router/src/contexts/navigator.rs b/packages/router/src/contexts/navigator.rs new file mode 100644 index 000000000..57538dc58 --- /dev/null +++ b/packages/router/src/contexts/navigator.rs @@ -0,0 +1,53 @@ +use crate::prelude::{ExternalNavigationFailure, GenericRouterContext, NavigationTarget, Routable}; + +/// A view into the navigation state of a router. +#[derive(Clone)] +pub struct GenericNavigator(pub(crate) GenericRouterContext); + +impl GenericNavigator { + /// Check whether there is a previous page to navigate back to. + #[must_use] + pub fn can_go_back(&self) -> bool { + self.0.can_go_back() + } + + /// Check whether there is a future page to navigate forward to. + #[must_use] + pub fn can_go_forward(&self) -> bool { + self.0.can_go_forward() + } + + /// Go back to the previous location. + /// + /// Will fail silently if there is no previous location to go to. + pub fn go_back(&self) { + self.0.go_back(); + } + + /// Go back to the next location. + /// + /// Will fail silently if there is no next location to go to. + pub fn go_forward(&self) { + self.0.go_forward(); + } + + /// Push a new location. + /// + /// The previous location will be available to go back to. + pub fn push( + &self, + target: impl Into>, + ) -> Option { + self.0.push(target) + } + + /// Replace the current location. + /// + /// The previous location will **not** be available to go back to. + pub fn replace( + &self, + target: impl Into>, + ) -> Option { + self.0.replace(target) + } +} diff --git a/packages/router/src/contexts/outlet.rs b/packages/router/src/contexts/outlet.rs new file mode 100644 index 000000000..6284e25f8 --- /dev/null +++ b/packages/router/src/contexts/outlet.rs @@ -0,0 +1,56 @@ +use dioxus::prelude::*; + +use crate::{routable::Routable, utils::use_router_internal::use_router_internal}; + +pub(crate) struct OutletContext { + pub current_level: usize, + pub _marker: std::marker::PhantomData, +} + +impl Clone for OutletContext { + fn clone(&self) -> Self { + OutletContext { + current_level: self.current_level, + _marker: std::marker::PhantomData, + } + } +} + +pub(crate) fn use_outlet_context(cx: &ScopeState) -> &OutletContext { + let outlet_context = cx.use_hook(|| { + cx.consume_context().unwrap_or(OutletContext:: { + current_level: 1, + _marker: std::marker::PhantomData, + }) + }); + outlet_context +} + +impl OutletContext { + pub(crate) fn render(cx: Scope) -> Element<'_> + where + R: Routable + Clone, + { + let router = use_router_internal::(cx) + .as_ref() + .expect("Outlet must be inside of a router"); + let outlet: &OutletContext = use_outlet_context(cx); + let current_level = outlet.current_level; + cx.provide_context({ + OutletContext:: { + current_level: current_level + 1, + _marker: std::marker::PhantomData, + } + }); + + if let Some(error) = router.render_error(cx) { + if current_level == 0 { + return Some(error); + } else { + return None; + } + } + + router.current().render(cx, current_level) + } +} diff --git a/packages/router/src/contexts/router.rs b/packages/router/src/contexts/router.rs new file mode 100644 index 000000000..a64b64b4c --- /dev/null +++ b/packages/router/src/contexts/router.rs @@ -0,0 +1,256 @@ +use std::{ + collections::HashSet, + sync::{Arc, RwLock, RwLockWriteGuard}, +}; + +use dioxus::prelude::*; + +use crate::{ + history::HistoryProvider, navigation::NavigationTarget, routable::Routable, + router_cfg::RouterConfig, +}; + +/// An error that can occur when navigating. +#[derive(Debug, Clone)] +pub struct ExternalNavigationFailure(String); + +/// A function the router will call after every routing update. +pub(crate) type RoutingCallback = + Arc) -> Option>>; + +struct MutableRouterState +where + R: Routable, +{ + /// The current prefix. + prefix: Option, + + history: Box>, + + unresolved_error: Option, +} + +/// A collection of router data that manages all routing functionality. +pub struct GenericRouterContext +where + R: Routable, +{ + state: Arc>>, + + subscribers: Arc>>, + subscriber_update: Arc, + routing_callback: Option>, + + failure_external_navigation: fn(Scope) -> Element, +} + +impl Clone for GenericRouterContext { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + subscribers: self.subscribers.clone(), + subscriber_update: self.subscriber_update.clone(), + routing_callback: self.routing_callback.clone(), + failure_external_navigation: self.failure_external_navigation, + } + } +} + +impl GenericRouterContext +where + R: Routable, +{ + pub(crate) fn new( + mut cfg: RouterConfig, + mark_dirty: Arc, + ) -> Self + where + R: Clone, + ::Err: std::fmt::Display, + { + let state = Arc::new(RwLock::new(MutableRouterState { + prefix: Default::default(), + history: cfg.take_history(), + unresolved_error: None, + })); + + let subscriber_update = mark_dirty.clone(); + let subscribers = Arc::new(RwLock::new(HashSet::new())); + + let myself = Self { + state, + subscribers: subscribers.clone(), + subscriber_update, + + routing_callback: cfg.on_update, + + failure_external_navigation: cfg.failure_external_navigation, + }; + + // set the updater + { + let mut state = myself.state.write().unwrap(); + state.history.updater(Arc::new(move || { + for &id in subscribers.read().unwrap().iter() { + (mark_dirty)(id); + } + })); + } + + myself + } + + /// Check whether there is a previous page to navigate back to. + #[must_use] + pub fn can_go_back(&self) -> bool { + self.state.read().unwrap().history.can_go_back() + } + + /// Check whether there is a future page to navigate forward to. + #[must_use] + pub fn can_go_forward(&self) -> bool { + self.state.read().unwrap().history.can_go_forward() + } + + /// Go back to the previous location. + /// + /// Will fail silently if there is no previous location to go to. + pub fn go_back(&self) { + { + self.state.write().unwrap().history.go_back(); + } + + self.change_route(); + } + + /// Go back to the next location. + /// + /// Will fail silently if there is no next location to go to. + pub fn go_forward(&self) { + { + self.state.write().unwrap().history.go_forward(); + } + + self.change_route(); + } + + /// Push a new location. + /// + /// The previous location will be available to go back to. + pub fn push( + &self, + target: impl Into>, + ) -> Option { + let target = target.into(); + match target { + NavigationTarget::Internal(p) => { + let mut state = self.state_mut(); + state.history.push(p) + } + NavigationTarget::External(e) => return self.external(e), + } + + self.change_route() + } + + /// Replace the current location. + /// + /// The previous location will **not** be available to go back to. + pub fn replace( + &self, + target: impl Into>, + ) -> Option { + let target = target.into(); + + { + let mut state = self.state_mut(); + match target { + NavigationTarget::Internal(p) => state.history.replace(p), + NavigationTarget::External(e) => return self.external(e), + } + } + + self.change_route() + } + + /// The route that is currently active. + pub fn current(&self) -> R + where + R: Clone, + { + self.state.read().unwrap().history.current_route() + } + + /// The prefix that is currently active. + pub fn prefix(&self) -> Option { + self.state.read().unwrap().prefix.clone() + } + + fn external(&self, external: String) -> Option { + let mut state = self.state_mut(); + match state.history.external(external.clone()) { + true => None, + false => { + let failure = ExternalNavigationFailure(external); + state.unresolved_error = Some(failure.clone()); + + self.update_subscribers(); + + Some(failure) + } + } + } + + fn state_mut(&self) -> RwLockWriteGuard> { + self.state.write().unwrap() + } + + /// Manually subscribe to the current route + pub fn subscribe(&self, id: ScopeId) { + self.subscribers.write().unwrap().insert(id); + } + + /// Manually unsubscribe from the current route + pub fn unsubscribe(&self, id: ScopeId) { + self.subscribers.write().unwrap().remove(&id); + } + + fn update_subscribers(&self) { + for &id in self.subscribers.read().unwrap().iter() { + (self.subscriber_update)(id); + } + } + + /// Clear any unresolved errors + pub fn clear_error(&self) { + self.state.write().unwrap().unresolved_error = None; + + self.update_subscribers(); + } + + pub(crate) fn render_error<'a>(&self, cx: Scope<'a>) -> Element<'a> { + self.state + .read() + .unwrap() + .unresolved_error + .as_ref() + .and_then(|_| (self.failure_external_navigation)(cx)) + } + + fn change_route(&self) -> Option { + if let Some(callback) = &self.routing_callback { + let myself = self.clone(); + if let Some(new) = callback(myself) { + let mut state = self.state_mut(); + match new { + NavigationTarget::Internal(p) => state.history.replace(p), + NavigationTarget::External(e) => return self.external(e), + } + } + } + + self.update_subscribers(); + + None + } +} diff --git a/packages/router/src/history/memory.rs b/packages/router/src/history/memory.rs new file mode 100644 index 000000000..07c1f4379 --- /dev/null +++ b/packages/router/src/history/memory.rs @@ -0,0 +1,99 @@ +use std::str::FromStr; + +use crate::routable::Routable; + +use super::HistoryProvider; + +/// A [`HistoryProvider`] that stores all navigation information in memory. +pub struct MemoryHistory { + current: R, + history: Vec, + future: Vec, +} + +impl MemoryHistory +where + ::Err: std::fmt::Display, +{ + /// Create a [`MemoryHistory`] starting at `path`. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// + /// let mut history = MemoryHistory::::with_initial_path(Route::Index {}); + /// assert_eq!(history.current_route(), Route::Index {}); + /// assert_eq!(history.can_go_back(), false); + /// ``` + pub fn with_initial_path(path: R) -> Self { + Self { + current: path, + history: Vec::new(), + future: Vec::new(), + } + } +} + +impl Default for MemoryHistory +where + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self { + current: "/".parse().unwrap_or_else(|err| { + panic!("index route does not exist:\n{}\n use MemoryHistory::with_initial_path to set a custom path", err) + }), + history: Vec::new(), + future: Vec::new(), + } + } +} + +impl HistoryProvider for MemoryHistory { + fn current_route(&self) -> R { + self.current.clone() + } + + fn can_go_back(&self) -> bool { + !self.history.is_empty() + } + + fn go_back(&mut self) { + if let Some(last) = self.history.pop() { + let old = std::mem::replace(&mut self.current, last); + self.future.push(old); + } + } + + fn can_go_forward(&self) -> bool { + !self.future.is_empty() + } + + fn go_forward(&mut self) { + if let Some(next) = self.future.pop() { + let old = std::mem::replace(&mut self.current, next); + self.history.push(old); + } + } + + fn push(&mut self, new: R) { + let old = std::mem::replace(&mut self.current, new); + self.history.push(old); + self.future.clear(); + } + + fn replace(&mut self, path: R) { + self.current = path; + } +} diff --git a/packages/router/src/history/mod.rs b/packages/router/src/history/mod.rs new file mode 100644 index 000000000..21c7b9e92 --- /dev/null +++ b/packages/router/src/history/mod.rs @@ -0,0 +1,279 @@ +//! History Integration +//! +//! dioxus-router-core relies on [`HistoryProvider`]s to store the current Route, and possibly a +//! history (i.e. a browsers back button) and future (i.e. a browsers forward button). +//! +//! To integrate dioxus-router with a any type of history, all you have to do is implement the +//! [`HistoryProvider`] trait. +//! +//! dioxus-router contains two built in history providers: +//! 1) [`MemoryHistory`] for desktop/mobile/ssr platforms +//! 2) [`WebHistory`] for web platforms + +use std::sync::Arc; + +mod memory; +pub use memory::*; + +#[cfg(feature = "web")] +mod web; +#[cfg(feature = "web")] +pub use web::*; +#[cfg(feature = "web")] +pub(crate) mod web_history; + +// #[cfg(feature = "web")] +// mod web_hash; +// #[cfg(feature = "web")] +// pub use web_hash::*; + +use crate::routable::Routable; + +#[cfg(feature = "web")] +pub(crate) mod web_scroll; + +/// An integration with some kind of navigation history. +/// +/// Depending on your use case, your implementation may deviate from the described procedure. This +/// is fine, as long as both `current_route` and `current_query` match the described format. +/// +/// However, you should document all deviations. Also, make sure the navigation is user-friendly. +/// The described behaviors are designed to mimic a web browser, which most users should already +/// know. Deviations might confuse them. +pub trait HistoryProvider { + /// Get the path of the current URL. + /// + /// **Must start** with `/`. **Must _not_ contain** the prefix. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.current_route().to_string(), "/"); + /// + /// history.push(Route::OtherPage {}); + /// assert_eq!(history.current_route().to_string(), "/some-other-page"); + /// ``` + #[must_use] + fn current_route(&self) -> R; + + /// Get the current path prefix of the URL. + /// + /// Not all [`HistoryProvider`]s need a prefix feature. It is meant for environments where a + /// dioxus-router-core-routed application is not running on `/`. The [`HistoryProvider`] is responsible + /// for removing the prefix from the dioxus-router-core-internal path, and also for adding it back in + /// during navigation. This functions value is only used for creating `href`s (e.g. for SSR or + /// display (but not navigation) in a web app). + fn current_prefix(&self) -> Option { + None + } + + /// Check whether there is a previous page to navigate back to. + /// + /// If a [`HistoryProvider`] cannot know this, it should return [`true`]. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.can_go_back(), false); + /// + /// history.push(Route::Index {}); + /// assert_eq!(history.can_go_back(), true); + /// ``` + #[must_use] + fn can_go_back(&self) -> bool { + true + } + + /// Go back to a previous page. + /// + /// If a [`HistoryProvider`] cannot go to a previous page, it should do nothing. This method + /// might be called, even if `can_go_back` returns [`false`]. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.current_route().to_string(), "/"); + /// + /// history.go_back(); + /// assert_eq!(history.current_route().to_string(), "/"); + /// + /// history.push(Route::OtherPage {}); + /// assert_eq!(history.current_route().to_string(), "/some-other-page"); + /// + /// history.go_back(); + /// assert_eq!(history.current_route().to_string(), "/"); + /// ``` + fn go_back(&mut self); + + /// Check whether there is a future page to navigate forward to. + /// + /// If a [`HistoryProvider`] cannot know this, it should return [`true`]. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.can_go_forward(), false); + /// + /// history.push(Route::OtherPage {}); + /// assert_eq!(history.can_go_forward(), false); + /// + /// history.go_back(); + /// assert_eq!(history.can_go_forward(), true); + /// ``` + #[must_use] + fn can_go_forward(&self) -> bool { + true + } + + /// Go forward to a future page. + /// + /// If a [`HistoryProvider`] cannot go to a previous page, it should do nothing. This method + /// might be called, even if `can_go_forward` returns [`false`]. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// history.push(Route::OtherPage {}); + /// assert_eq!(history.current_route(), Route::OtherPage {}); + /// + /// history.go_back(); + /// assert_eq!(history.current_route(), Route::Index {}); + /// + /// history.go_forward(); + /// assert_eq!(history.current_route(), Route::OtherPage {}); + /// ``` + fn go_forward(&mut self); + + /// Go to another page. + /// + /// This should do three things: + /// 1. Merge the current URL with the `path` parameter (which may also include a query part). + /// 2. Remove the previous URL to the navigation history. + /// 3. Clear the navigation future. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.current_route(), Route::Index {}); + /// + /// history.push(Route::OtherPage {}); + /// assert_eq!(history.current_route(), Route::OtherPage {}); + /// assert!(history.can_go_back()); + /// ``` + fn push(&mut self, route: R); + + /// Replace the current page with another one. + /// + /// This should merge the current URL with the `path` parameter (which may also include a query + /// part). In contrast to the `push` function, the navigation history and future should stay + /// untouched. + /// + /// ```rust + /// # use dioxus_router::prelude::*; + /// # use dioxus::prelude::*; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { todo!() } + /// # #[inline_props] + /// # fn OtherPage(cx: Scope) -> Element { todo!() } + /// #[derive(Clone, Routable, Debug, PartialEq)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// #[route("/some-other-page")] + /// OtherPage {}, + /// } + /// let mut history = MemoryHistory::::default(); + /// assert_eq!(history.current_route(), Route::Index {}); + /// + /// history.replace(Route::OtherPage {}); + /// assert_eq!(history.current_route(), Route::OtherPage {}); + /// assert!(!history.can_go_back()); + /// ``` + fn replace(&mut self, path: R); + + /// Navigate to an external URL. + /// + /// This should navigate to an external URL, which isn't controlled by the router. If a + /// [`HistoryProvider`] cannot do that, it should return [`false`], otherwise [`true`]. + /// + /// Returning [`false`] will cause the router to handle the external navigation failure. + #[allow(unused_variables)] + fn external(&mut self, url: String) -> bool { + false + } + + /// Provide the [`HistoryProvider`] with an update callback. + /// + /// Some [`HistoryProvider`]s may receive URL updates from outside the router. When such + /// updates are received, they should call `callback`, which will cause the router to update. + #[allow(unused_variables)] + fn updater(&mut self, callback: Arc) {} +} diff --git a/packages/router/src/history/web.rs b/packages/router/src/history/web.rs new file mode 100644 index 000000000..f7bd59dfa --- /dev/null +++ b/packages/router/src/history/web.rs @@ -0,0 +1,401 @@ +use std::sync::{Arc, Mutex}; + +use gloo::{console::error, events::EventListener, render::AnimationFrame}; +use wasm_bindgen::JsValue; +use web_sys::{window, History, ScrollRestoration, Window}; + +use crate::routable::Routable; + +use super::{ + web_history::{get_current, push_state_and_url, replace_state_with_url}, + web_scroll::ScrollPosition, + HistoryProvider, +}; + +#[cfg(not(feature = "serde"))] +#[allow(clippy::extra_unused_type_parameters)] +fn update_scroll(window: &Window, history: &History) { + let scroll = ScrollPosition::of_window(window); + if let Err(err) = replace_state_with_url(history, &[scroll.x, scroll.y], None) { + error!(err); + } +} + +#[cfg(feature = "serde")] +fn update_scroll( + window: &Window, + history: &History, +) { + if let Some(WebHistoryState { state, .. }) = get_current::>(history) { + let scroll = ScrollPosition::of_window(window); + let state = WebHistoryState { state, scroll }; + if let Err(err) = replace_state_with_url(history, &state, None) { + error!(err); + } + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize, serde::Serialize)] +struct WebHistoryState { + state: R, + scroll: ScrollPosition, +} + +/// A [`HistoryProvider`] that integrates with a browser via the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API). +/// +/// # Prefix +/// This [`HistoryProvider`] supports a prefix, which can be used for web apps that aren't located +/// at the root of their domain. +/// +/// Application developers are responsible for ensuring that right after the prefix comes a `/`. If +/// that is not the case, this [`HistoryProvider`] will replace the first character after the prefix +/// with one. +/// +/// Application developers are responsible for not rendering the router if the prefix is not present +/// in the URL. Otherwise, if a router navigation is triggered, the prefix will be added. +pub struct WebHistory { + do_scroll_restoration: bool, + history: History, + listener_navigation: Option, + #[allow(dead_code)] + listener_scroll: Option, + listener_animation_frame: Arc>>, + prefix: Option, + window: Window, + phantom: std::marker::PhantomData, +} + +#[cfg(not(feature = "serde"))] +impl Default for WebHistory +where + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self::new(None, true) + } +} + +#[cfg(feature = "serde")] +impl Default for WebHistory +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + fn default() -> Self { + Self::new(None, true) + } +} + +impl WebHistory { + #[cfg(not(feature = "serde"))] + /// Create a new [`WebHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`WebHistory`] will take control of the history + /// state. It'll also set the browsers scroll restoration to `manual`. + pub fn new(prefix: Option, do_scroll_restoration: bool) -> Self + where + ::Err: std::fmt::Display, + { + let w = window().expect("access to `window`"); + let h = w.history().expect("`window` has access to `history`"); + let document = w.document().expect("`window` has access to `document`"); + + let myself = Self::new_inner( + prefix, + do_scroll_restoration, + EventListener::new(&document, "scroll", { + let mut last_updated = 0.0; + move |evt| { + // the time stamp in milliseconds + let time_stamp = evt.time_stamp(); + // throttle the scroll event to 100ms + if (time_stamp - last_updated) < 100.0 { + return; + } + update_scroll::(&w, &h); + last_updated = time_stamp; + } + }), + ); + + let current_route = myself.current_route(); + let current_url = current_route.to_string(); + let state = myself.create_state(current_route); + let _ = replace_state_with_url(&myself.history, &state, Some(¤t_url)); + + myself + } + + #[cfg(feature = "serde")] + /// Create a new [`WebHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`WebHistory`] will take control of the history + /// state. It'll also set the browsers scroll restoration to `manual`. + pub fn new(prefix: Option, do_scroll_restoration: bool) -> Self + where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, + { + let w = window().expect("access to `window`"); + let h = w.history().expect("`window` has access to `history`"); + let document = w.document().expect("`window` has access to `document`"); + + let myself = Self::new_inner( + prefix, + do_scroll_restoration, + EventListener::new(&document, "scroll", { + let mut last_updated = 0.0; + move |evt| { + // the time stamp in milliseconds + let time_stamp = evt.time_stamp(); + // throttle the scroll event to 100ms + if (time_stamp - last_updated) < 100.0 { + return; + } + update_scroll::(&w, &h); + last_updated = time_stamp; + } + }), + ); + + let current_route = myself.current_route(); + log::trace!("initial route: {:?}", current_route); + let current_url = current_route.to_string(); + let state = myself.create_state(current_route); + let _ = replace_state_with_url(&myself.history, &state, Some(¤t_url)); + + myself + } + + fn new_inner( + prefix: Option, + do_scroll_restoration: bool, + event_listener: EventListener, + ) -> Self + where + ::Err: std::fmt::Display, + { + let window = window().expect("access to `window`"); + let history = window.history().expect("`window` has access to `history`"); + + let listener_scroll = match do_scroll_restoration { + true => { + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + Some(event_listener) + } + false => None, + }; + + Self { + do_scroll_restoration, + history, + listener_navigation: None, + listener_scroll, + listener_animation_frame: Default::default(), + prefix, + window, + phantom: Default::default(), + } + } + + fn scroll_pos(&self) -> ScrollPosition { + self.do_scroll_restoration + .then(|| ScrollPosition::of_window(&self.window)) + .unwrap_or_default() + } + + #[cfg(not(feature = "serde"))] + fn create_state(&self, _state: R) -> [f64; 2] { + let scroll = self.scroll_pos(); + [scroll.x, scroll.y] + } + + #[cfg(feature = "serde")] + fn create_state(&self, state: R) -> WebHistoryState { + let scroll = self.scroll_pos(); + WebHistoryState { state, scroll } + } +} + +impl WebHistory +where + ::Err: std::fmt::Display, +{ + fn route_from_location(&self) -> R { + R::from_str( + &self + .window + .location() + .pathname() + .unwrap_or_else(|_| String::from("/")), + ) + .unwrap_or_else(|err| panic!("{}", err)) + } + + fn full_path(&self, state: &R) -> String { + match &self.prefix { + None => format!("{state}"), + Some(prefix) => format!("{prefix}{state}"), + } + } + + fn handle_nav(&self, result: Result<(), JsValue>) { + match result { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to change state: ", e), + } + } + + fn navigate_external(&mut self, url: String) -> bool { + match self.window.location().set_href(&url) { + Ok(_) => true, + Err(e) => { + error!("failed to navigate to external url (", url, "): ", e); + false + } + } + } +} + +#[cfg(feature = "serde")] +impl HistoryProvider + for WebHistory +where + ::Err: std::fmt::Display, +{ + fn current_route(&self) -> R { + match get_current::>(&self.history) { + // Try to get the route from the history state + Some(route) => route.state, + // If that fails, get the route from the current URL + None => self.route_from_location(), + } + } + + fn current_prefix(&self) -> Option { + self.prefix.clone() + } + + fn go_back(&mut self) { + if let Err(e) = self.history.back() { + error!("failed to go back: ", e) + } + } + + fn go_forward(&mut self) { + if let Err(e) = self.history.forward() { + error!("failed to go forward: ", e) + } + } + + fn push(&mut self, state: R) { + let path = self.full_path(&state); + + let state = self.create_state(state); + + self.handle_nav(push_state_and_url(&self.history, &state, path)); + } + + fn replace(&mut self, state: R) { + let path = match &self.prefix { + None => format!("{state}"), + Some(prefix) => format!("{prefix}{state}"), + }; + + let state = self.create_state(state); + + self.handle_nav(replace_state_with_url(&self.history, &state, Some(&path))); + } + + fn external(&mut self, url: String) -> bool { + self.navigate_external(url) + } + + fn updater(&mut self, callback: std::sync::Arc) { + let w = self.window.clone(); + let h = self.history.clone(); + let s = self.listener_animation_frame.clone(); + let d = self.do_scroll_restoration; + + self.listener_navigation = Some(EventListener::new(&self.window, "popstate", move |_| { + (*callback)(); + if d { + let mut s = s.lock().expect("unpoisoned scroll mutex"); + if let Some(current_state) = get_current::>(&h) { + *s = Some(current_state.scroll.scroll_to(w.clone())); + } + } + })); + } +} + +#[cfg(not(feature = "serde"))] +impl HistoryProvider for WebHistory +where + ::Err: std::fmt::Display, +{ + fn current_route(&self) -> R { + self.route_from_location() + } + + fn current_prefix(&self) -> Option { + self.prefix.clone() + } + + fn go_back(&mut self) { + if let Err(e) = self.history.back() { + error!("failed to go back: ", e) + } + } + + fn go_forward(&mut self) { + if let Err(e) = self.history.forward() { + error!("failed to go forward: ", e) + } + } + + fn push(&mut self, state: R) { + let path = self.full_path(&state); + + let state: [f64; 2] = self.create_state(state); + self.handle_nav(push_state_and_url(&self.history, &state, path)); + } + + fn replace(&mut self, state: R) { + let path = match &self.prefix { + None => format!("{state}"), + Some(prefix) => format!("{prefix}{state}"), + }; + + let state = self.create_state(state); + self.handle_nav(replace_state_with_url(&self.history, &state, Some(&path))); + } + + fn external(&mut self, url: String) -> bool { + self.navigate_external(url) + } + + fn updater(&mut self, callback: std::sync::Arc) { + let w = self.window.clone(); + let h = self.history.clone(); + let s = self.listener_animation_frame.clone(); + let d = self.do_scroll_restoration; + + self.listener_navigation = Some(EventListener::new(&self.window, "popstate", move |_| { + (*callback)(); + if d { + let mut s = s.lock().expect("unpoisoned scroll mutex"); + if let Some([x, y]) = get_current(&h) { + *s = Some(ScrollPosition { x, y }.scroll_to(w.clone())); + } + } + })); + } +} diff --git a/packages/router/src/history/web_hash.rs b/packages/router/src/history/web_hash.rs new file mode 100644 index 000000000..c910b1c97 --- /dev/null +++ b/packages/router/src/history/web_hash.rs @@ -0,0 +1,211 @@ +use std::sync::{Arc, Mutex}; + +use gloo::{events::EventListener, render::AnimationFrame, utils::window}; +use log::error; +use serde::{de::DeserializeOwned, Serialize}; +use url::Url; +use web_sys::{History, ScrollRestoration, Window}; + +use crate::routable::Routable; + +use super::HistoryProvider; + +const INITIAL_URL: &str = "dioxus-router-core://initial_url.invalid/"; + +/// A [`HistoryProvider`] that integrates with a browser via the [History API]. It uses the URLs +/// hash instead of its path. +/// +/// Early web applications used the hash to store the current path because there was no other way +/// for them to interact with the history without triggering a browser navigation, as the +/// [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) did not yet exist. While this implementation could have been written that way, it +/// was not, because no browser supports WebAssembly without the [History API]. +pub struct WebHashHistory { + do_scroll_restoration: bool, + history: History, + listener_navigation: Option, + #[allow(dead_code)] + listener_scroll: Option, + listener_animation_frame: Arc>>, + window: Window, + phantom: std::marker::PhantomData, +} + +impl WebHashHistory { + /// Create a new [`WebHashHistory`]. + /// + /// If `do_scroll_restoration` is [`true`], [`WebHashHistory`] will take control of the history + /// state. It'll also set the browsers scroll restoration to `manual`. + pub fn new(do_scroll_restoration: bool) -> Self { + let window = window(); + let history = window.history().expect("`window` has access to `history`"); + + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + + let listener_scroll = match do_scroll_restoration { + true => { + history + .set_scroll_restoration(ScrollRestoration::Manual) + .expect("`history` can set scroll restoration"); + let w = window.clone(); + let h = history.clone(); + let document = w.document().expect("`window` has access to `document`"); + + Some(EventListener::new(&document, "scroll", move |_| { + update_history(&w, &h); + })) + } + false => None, + }; + + Self { + do_scroll_restoration, + history, + listener_navigation: None, + listener_scroll, + listener_animation_frame: Default::default(), + window, + phantom: Default::default(), + } + } +} + +impl WebHashHistory { + fn join_url_to_hash(&self, path: R) -> Option { + let url = match self.url() { + Some(c) => match c.join(&path) { + Ok(new) => new, + Err(e) => { + error!("failed to join location with target: {e}"); + return None; + } + }, + None => { + error!("current location unknown"); + return None; + } + }; + + Some(format!( + "#{path}{query}", + path = url.path(), + query = url.query().map(|q| format!("?{q}")).unwrap_or_default() + )) + } + + fn url(&self) -> Option { + let mut path = self.window.location().hash().ok()?; + + if path.starts_with('#') { + path.remove(0); + } + + if path.starts_with('/') { + path.remove(0); + } + + match Url::parse(&format!("{INITIAL_URL}/{path}")) { + Ok(url) => Some(url), + Err(e) => { + error!("failed to parse hash path: {e}"); + None + } + } + } +} + +impl HistoryProvider for WebHashHistory { + fn current_route(&self) -> R { + self.url() + .map(|url| url.path().to_string()) + .unwrap_or(String::from("/")) + } + + fn current_prefix(&self) -> Option { + Some(String::from("#")) + } + + fn go_back(&mut self) { + if let Err(e) = self.history.back() { + error!("failed to go back: {e:?}") + } + } + + fn go_forward(&mut self) { + if let Err(e) = self.history.forward() { + error!("failed to go forward: {e:?}") + } + } + + fn push(&mut self, path: R) { + let hash = match self.join_url_to_hash(path) { + Some(hash) => hash, + None => return, + }; + + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.push_state_with_url(&state, "", Some(&hash)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to push state: {e:?}"), + } + } + + fn replace(&mut self, path: R) { + let hash = match self.join_url_to_hash(path) { + Some(hash) => hash, + None => return, + }; + + let state = match self.do_scroll_restoration { + true => top_left(), + false => self.history.state().unwrap_or_default(), + }; + + let nav = self.history.replace_state_with_url(&state, "", Some(&hash)); + + match nav { + Ok(_) => { + if self.do_scroll_restoration { + self.window.scroll_to_with_x_and_y(0.0, 0.0) + } + } + Err(e) => error!("failed to replace state: {e:?}"), + } + } + + fn external(&mut self, url: String) -> bool { + match self.window.location().set_href(&url) { + Ok(_) => true, + Err(e) => { + error!("failed to navigate to external url (`{url}): {e:?}"); + false + } + } + } + + fn updater(&mut self, callback: std::sync::Arc) { + let w = self.window.clone(); + let h = self.history.clone(); + let s = self.listener_animation_frame.clone(); + let d = self.do_scroll_restoration; + + self.listener_navigation = Some(EventListener::new(&self.window, "popstate", move |_| { + (*callback)(); + if d { + let mut s = s.lock().expect("unpoisoned scroll mutex"); + *s = Some(update_scroll(&w, &h)); + } + })); + } +} diff --git a/packages/router/src/history/web_history.rs b/packages/router/src/history/web_history.rs new file mode 100644 index 000000000..25a7063b4 --- /dev/null +++ b/packages/router/src/history/web_history.rs @@ -0,0 +1,84 @@ +use gloo::console::error; +#[cfg(feature = "serde")] +use gloo_utils::format::JsValueSerdeExt; +use wasm_bindgen::JsValue; +use web_sys::History; + +#[cfg(not(feature = "serde"))] +pub(crate) fn replace_state_with_url( + history: &History, + value: &[f64; 2], + url: Option<&str>, +) -> Result<(), JsValue> { + let position = js_sys::Array::new(); + position.push(&JsValue::from(value[0])); + position.push(&JsValue::from(value[1])); + + history.replace_state_with_url(&position, "", url) +} + +#[cfg(feature = "serde")] +pub(crate) fn replace_state_with_url( + history: &History, + value: &V, + url: Option<&str>, +) -> Result<(), JsValue> { + let position = JsValue::from_serde(value).unwrap(); + + history.replace_state_with_url(&position, "", url) +} + +#[cfg(not(feature = "serde"))] +pub(crate) fn push_state_and_url( + history: &History, + value: &[f64; 2], + url: String, +) -> Result<(), JsValue> { + let position = js_sys::Array::new(); + position.push(&JsValue::from(value[0])); + position.push(&JsValue::from(value[1])); + + history.push_state_with_url(&position, "", Some(&url)) +} + +#[cfg(feature = "serde")] +pub(crate) fn push_state_and_url( + history: &History, + value: &V, + url: String, +) -> Result<(), JsValue> { + let position = JsValue::from_serde(value).unwrap(); + + history.push_state_with_url(&position, "", Some(&url)) +} + +#[cfg(feature = "serde")] +pub(crate) fn get_current(history: &History) -> Option { + let state = history.state(); + if let Err(err) = &state { + error!(err); + } + state.ok().and_then(|state| { + let deserialized = state.into_serde(); + if let Err(err) = &deserialized { + error!(format!("{}", err)); + } + deserialized.ok() + }) +} + +#[cfg(not(feature = "serde"))] +pub(crate) fn get_current(history: &History) -> Option<[f64; 2]> { + use wasm_bindgen::JsCast; + + let state = history.state(); + if let Err(err) = &state { + error!(err); + } + state.ok().and_then(|state| { + let state = state.dyn_into::().ok()?; + let x = state.get(0).as_f64()?; + let y = state.get(1).as_f64()?; + Some([x, y]) + }) +} diff --git a/packages/router/src/history/web_scroll.rs b/packages/router/src/history/web_scroll.rs new file mode 100644 index 000000000..4e5d8318f --- /dev/null +++ b/packages/router/src/history/web_scroll.rs @@ -0,0 +1,23 @@ +use gloo::render::{request_animation_frame, AnimationFrame}; +use web_sys::Window; + +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub(crate) struct ScrollPosition { + pub x: f64, + pub y: f64, +} + +impl ScrollPosition { + pub(crate) fn of_window(window: &Window) -> Self { + Self { + x: window.scroll_x().unwrap_or_default(), + y: window.scroll_y().unwrap_or_default(), + } + } + + pub(crate) fn scroll_to(&self, window: Window) -> AnimationFrame { + let Self { x, y } = *self; + request_animation_frame(move |_| window.scroll_to_with_x_and_y(x, y)) + } +} diff --git a/packages/router/src/hooks/use_navigator.rs b/packages/router/src/hooks/use_navigator.rs new file mode 100644 index 000000000..5cd0a0e83 --- /dev/null +++ b/packages/router/src/hooks/use_navigator.rs @@ -0,0 +1,61 @@ +use dioxus::prelude::ScopeState; + +use crate::{ + prelude::{GenericNavigator, GenericRouterContext}, + routable::Routable, +}; + +/// A hook that provides access to the navigator to change the router history. Unlike [`use_router`], this hook will not cause a rerender when the current route changes +/// +/// > The Routable macro will define a version of this hook with an explicit type. +/// +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::prelude::*; +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// #[route("/:id")] +/// Dynamic { id: usize }, +/// } +/// +/// fn App(cx: Scope) -> Element { +/// render! { +/// Router {} +/// } +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// let navigator = use_navigator(&cx); +/// +/// render! { +/// button { +/// onclick: move |_| { navigator.push(Route::Dynamic { id: 1234 }); }, +/// "Go to /1234" +/// } +/// } +/// } +/// +/// #[inline_props] +/// fn Dynamic(cx: Scope, id: usize) -> Element { +/// render! { +/// p { +/// "Current ID: {id}" +/// } +/// } +/// } +/// +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// ``` +pub fn use_generic_navigator(cx: &ScopeState) -> &GenericNavigator { + &*cx.use_hook(|| { + let router = cx + .consume_context::>() + .expect("Must be called in a descendant of a Router component"); + + GenericNavigator(router) + }) +} diff --git a/packages/router/src/hooks/use_route.rs b/packages/router/src/hooks/use_route.rs index e0b08e22f..a0ba34ce8 100644 --- a/packages/router/src/hooks/use_route.rs +++ b/packages/router/src/hooks/use_route.rs @@ -1,143 +1,58 @@ -use crate::{ParsedRoute, RouteContext, RouterContext}; -use dioxus::core::{ScopeId, ScopeState}; -use std::{borrow::Cow, str::FromStr, sync::Arc}; -use url::Url; +use dioxus::prelude::ScopeState; -/// This hook provides access to information about the current location in the -/// context of a [`Router`]. If this function is called outside of a `Router` -/// component it will panic. -pub fn use_route(cx: &ScopeState) -> &UseRoute { - let router = cx - .consume_context::() - .expect("Cannot call use_route outside the scope of a Router component"); +use crate::prelude::*; +use crate::utils::use_router_internal::use_router_internal; - let handle = cx.use_hook(|| { - let route_context = cx.consume_context::(); - - router.subscribe_onchange(cx.scope_id()); - - UseRouteListener { - state: UseRoute { - route_context, - route: router.current_location(), - }, - router, - scope: cx.scope_id(), - } - }); - - handle.state.route = handle.router.current_location(); - - &handle.state -} - -/// A handle to the current location of the router. -pub struct UseRoute { - pub(crate) route: Arc, - - /// If `use_route` is used inside a `Route` component this has some context otherwise `None`. - pub(crate) route_context: Option, -} - -impl UseRoute { - /// Get the underlying [`Url`] of the current location. - pub fn url(&self) -> &Url { - &self.route.url - } - - /// Get the first query parameter given the parameter name. - /// - /// If you need to get more than one parameter, use [`query_pairs`] on the [`Url`] instead. - #[cfg(feature = "query")] - pub fn query(&self) -> Option { - let query = self.url().query()?; - serde_urlencoded::from_str(query).ok() - } - - /// Get the first query parameter given the parameter name. - /// - /// If you need to get more than one parameter, use [`query_pairs`] on the [`Url`] instead. - pub fn query_param(&self, param: &str) -> Option> { - self.route - .url - .query_pairs() - .find(|(k, _)| k == param) - .map(|(_, v)| v) - } - - /// Returns the nth segment in the path. Paths that end with a slash have - /// the slash removed before determining the segments. If the path has - /// fewer segments than `n` then this method returns `None`. - pub fn nth_segment(&self, n: usize) -> Option<&str> { - self.route.url.path_segments()?.nth(n) - } - - /// Returns the last segment in the path. Paths that end with a slash have - /// the slash removed before determining the segments. The root path, `/`, - /// will return an empty string. - pub fn last_segment(&self) -> Option<&str> { - self.route.url.path_segments()?.last() - } - - /// Get the named parameter from the path, as defined in your router. The - /// value will be parsed into the type specified by `T` by calling - /// `value.parse::()`. This method returns `None` if the named - /// parameter does not exist in the current path. - pub fn segment(&self, name: &str) -> Option<&str> { - let total_route = match self.route_context { - None => self.route.url.path(), - Some(ref ctx) => &ctx.total_route, - }; - - let index = total_route - .trim_start_matches('/') - .split('/') - .position(|segment| segment.starts_with(':') && &segment[1..] == name)?; - - self.route.url.path_segments()?.nth(index) - } - - /// Get the named parameter from the path, as defined in your router. The - /// value will be parsed into the type specified by `T` by calling - /// `value.parse::()`. This method returns `None` if the named - /// parameter does not exist in the current path. - pub fn parse_segment(&self, name: &str) -> Option> - where - T: FromStr, - { - self.segment(name).map(|value| value.parse::()) - } - - /// Get the named parameter from the path, as defined in your router. The - /// value will be parsed into the type specified by `T` by calling - /// `value.parse::()`. This method returns `None` if the named - /// parameter does not exist in the current path. - pub fn parse_segment_or_404(&self, name: &str) -> Option - where - T: FromStr, - { - match self.parse_segment(name) { - Some(Ok(val)) => Some(val), - _ => { - // todo: throw a 404 - None - } +/// A hook that provides access to information about the current routing location. +/// +/// > The Routable macro will define a version of this hook with an explicit type. +/// +/// # Return values +/// - None, when not called inside a [`GenericRouter`] component. +/// - Otherwise the current route. +/// +/// # Panic +/// - When the calling component is not nested within a [`GenericRouter`] component durring a debug build. +/// +/// # Example +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_router::{prelude::*}; +/// +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// } +/// +/// fn App(cx: Scope) -> Element { +/// render! { +/// h1 { "App" } +/// Router {} +/// } +/// } +/// +/// #[inline_props] +/// fn Index(cx: Scope) -> Element { +/// let path = use_route(&cx).unwrap(); +/// render! { +/// h2 { "Current Path" } +/// p { "{path}" } +/// } +/// } +/// # +/// # let mut vdom = VirtualDom::new(App); +/// # let _ = vdom.rebuild(); +/// # assert_eq!(dioxus_ssr::render(&vdom), "

App

Current Path

/

") +/// ``` +pub fn use_generic_route(cx: &ScopeState) -> Option { + match use_router_internal(cx) { + Some(r) => Some(r.current()), + None => { + #[cfg(debug_assertions)] + panic!("`use_route` must have access to a parent router"); + #[allow(unreachable_code)] + None } } } - -// The entire purpose of this struct is to unubscribe this component when it is unmounted. -// The UseRoute can be cloned into async contexts, so we can't rely on its drop to unubscribe. -// Instead, we hide the drop implementation on this private type exclusive to the hook, -// and reveal our cached version of UseRoute to the component. -struct UseRouteListener { - state: UseRoute, - router: RouterContext, - scope: ScopeId, -} - -impl Drop for UseRouteListener { - fn drop(&mut self) { - self.router.unsubscribe_onchange(self.scope) - } -} diff --git a/packages/router/src/hooks/use_router.rs b/packages/router/src/hooks/use_router.rs index bc4402ecb..b9485577a 100644 --- a/packages/router/src/hooks/use_router.rs +++ b/packages/router/src/hooks/use_router.rs @@ -1,8 +1,13 @@ -use crate::RouterContext; -use dioxus::{core::ScopeState, prelude::use_context}; +use dioxus::prelude::ScopeState; -/// This hook provides access to the `RouterService` for the app. -pub fn use_router(cx: &ScopeState) -> &RouterContext { - use_context::(cx) - .expect("Cannot call use_route outside the scope of a Router component") +use crate::{ + prelude::GenericRouterContext, routable::Routable, + utils::use_router_internal::use_router_internal, +}; + +/// A hook that provides access to information about the router. +pub fn use_generic_router(cx: &ScopeState) -> &GenericRouterContext { + use_router_internal(cx) + .as_ref() + .expect("use_route must have access to a router") } diff --git a/packages/router/src/incremental.rs b/packages/router/src/incremental.rs new file mode 100644 index 000000000..f465f45f6 --- /dev/null +++ b/packages/router/src/incremental.rs @@ -0,0 +1,104 @@ +//! Exentsions to the incremental renderer to support pre-caching static routes. +use std::str::FromStr; + +use dioxus::prelude::*; +use dioxus_ssr::incremental::{ + IncrementalRenderer, IncrementalRendererError, RenderFreshness, WrapBody, +}; + +use crate::prelude::*; + +/// Pre-cache all static routes. +pub async fn pre_cache_static_routes( + renderer: &mut IncrementalRenderer, + wrapper: &R, +) -> Result<(), IncrementalRendererError> +where + Rt: Routable, + ::Err: std::fmt::Display, +{ + for route in Rt::SITE_MAP + .iter() + .flat_map(|seg| seg.flatten().into_iter()) + { + // check if this is a static segment + let mut is_static = true; + let mut full_path = String::new(); + for segment in &route { + match segment { + SegmentType::Child => {} + SegmentType::Static(s) => { + full_path += "/"; + full_path += s; + } + _ => { + // skip routes with any dynamic segments + is_static = false; + break; + } + } + } + + if is_static { + match Rt::from_str(&full_path) { + Ok(route) => { + render_route( + renderer, + route, + &mut tokio::io::sink(), + |vdom| { + let _ = vdom.rebuild(); + }, + wrapper, + ) + .await?; + } + Err(e) => { + log::info!("@ route: {}", full_path); + log::error!("Error pre-caching static route: {}", e); + } + } + } + } + + Ok(()) +} + +/// Render a route to a writer. +pub async fn render_route( + renderer: &mut IncrementalRenderer, + route: Rt, + writer: &mut W, + modify_vdom: F, + wrapper: &R, +) -> Result +where + Rt: Routable, + ::Err: std::fmt::Display, + W: tokio::io::AsyncWrite + Unpin + Send, +{ + #[inline_props] + fn RenderPath(cx: Scope, path: R) -> Element + where + R: Routable, + ::Err: std::fmt::Display, + { + let path = path.clone(); + render! { + GenericRouter:: { + config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + } + } + } + + renderer + .render( + route.to_string(), + RenderPath, + RenderPathProps { path: route }, + writer, + modify_vdom, + wrapper, + ) + .await +} diff --git a/packages/router/src/lib.rs b/packages/router/src/lib.rs index 7a931859f..1310f64bd 100644 --- a/packages/router/src/lib.rs +++ b/packages/router/src/lib.rs @@ -1,41 +1,84 @@ #![doc = include_str!("../README.md")] -#![warn(missing_docs)] +// cannot use forbid, because props derive macro generates #[allow(missing_docs)] +#![deny(missing_docs)] +#![allow(non_snake_case)] -mod hooks { - mod use_route; - mod use_router; - pub use use_route::*; - pub use use_router::*; -} -pub use hooks::*; +pub mod navigation; +pub mod routable; -mod components { - #![allow(non_snake_case)] +#[cfg(feature = "ssr")] +pub mod incremental; + +/// Components interacting with the router. +pub mod components { + mod default_errors; + pub use default_errors::*; + + mod history_buttons; + pub use history_buttons::*; mod link; - mod redirect; - mod route; - mod router; - pub use link::*; - pub use redirect::*; - pub use route::*; + + mod outlet; + pub use outlet::*; + + mod router; pub use router::*; } -pub use components::*; -mod cfg; -mod routecontext; -mod service; - -pub use routecontext::*; -pub use service::*; - -/// An error specific to the Router -#[derive(Debug)] -pub enum Error { - /// The route was not found while trying to navigate to it. - /// - /// This will force the router to redirect to the 404 page. - NotFound, +mod contexts { + pub(crate) mod navigator; + pub(crate) mod outlet; + pub(crate) mod router; + pub use navigator::*; + pub(crate) use router::*; +} + +mod router_cfg; + +mod history; + +/// Hooks for interacting with the router in components. +pub mod hooks { + mod use_router; + pub(crate) use use_router::*; + + mod use_route; + pub use use_route::*; + + mod use_navigator; + pub use use_navigator::*; +} + +/// A collection of useful items most applications might need. +pub mod prelude { + pub use crate::components::*; + pub use crate::contexts::*; + pub use crate::history::*; + pub use crate::hooks::*; + pub use crate::navigation::*; + pub use crate::routable::*; + pub use crate::router_cfg::RouterConfig; + pub use dioxus_router_macro::Routable; + + #[cfg(feature = "ssr")] + pub use crate::incremental::*; + #[cfg(feature = "ssr")] + pub use dioxus_ssr::incremental::*; + + #[doc(hidden)] + /// A component with props used in the macro + pub trait HasProps { + /// The props type of the component. + type Props; + } + + impl

HasProps for dioxus::prelude::Component

{ + type Props = P; + } +} + +mod utils { + pub(crate) mod use_router_internal; } diff --git a/packages/router/src/navigation.rs b/packages/router/src/navigation.rs new file mode 100644 index 000000000..7d3e44352 --- /dev/null +++ b/packages/router/src/navigation.rs @@ -0,0 +1,114 @@ +//! Types pertaining to navigation. + +use std::{ + convert::TryFrom, + fmt::{Debug, Display}, + str::FromStr, +}; + +use url::{ParseError, Url}; + +use crate::routable::Routable; + +/// A target for the router to navigate to. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum NavigationTarget { + /// An internal path that the router can navigate to by itself. + /// + /// ```rust + /// # use dioxus::prelude::*; + /// # use dioxus_router::prelude::*; + /// # use dioxus_router::navigation::NavigationTarget; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { + /// # todo!() + /// # } + /// #[derive(Clone, Routable, PartialEq, Debug)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// } + /// let explicit = NavigationTarget::Internal(Route::Index {}); + /// let implicit: NavigationTarget:: = "/".parse().unwrap(); + /// assert_eq!(explicit, implicit); + /// ``` + Internal(R), + /// An external target that the router doesn't control. + /// + /// ```rust + /// # use dioxus::prelude::*; + /// # use dioxus_router::prelude::*; + /// # use dioxus_router::navigation::NavigationTarget; + /// # #[inline_props] + /// # fn Index(cx: Scope) -> Element { + /// # todo!() + /// # } + /// #[derive(Clone, Routable, PartialEq, Debug)] + /// enum Route { + /// #[route("/")] + /// Index {}, + /// } + /// let explicit = NavigationTarget::::External(String::from("https://dioxuslabs.com/")); + /// let implicit: NavigationTarget:: = "https://dioxuslabs.com/".parse().unwrap(); + /// assert_eq!(explicit, implicit); + /// ``` + External(String), +} + +impl TryFrom<&str> for NavigationTarget { + type Error = NavigationTargetParseError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl From for NavigationTarget { + fn from(value: R) -> Self { + Self::Internal(value) + } +} + +impl Display for NavigationTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NavigationTarget::Internal(r) => write!(f, "{}", r), + NavigationTarget::External(s) => write!(f, "{}", s), + } + } +} + +/// An error that can occur when parsing a [`NavigationTarget`]. +pub enum NavigationTargetParseError { + /// A URL that is not valid. + InvalidUrl(ParseError), + /// An internal URL that is not valid. + InvalidInternalURL(::Err), +} + +impl Debug for NavigationTargetParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NavigationTargetParseError::InvalidUrl(e) => write!(f, "Invalid URL: {}", e), + NavigationTargetParseError::InvalidInternalURL(_) => { + write!(f, "Invalid internal URL") + } + } + } +} + +impl FromStr for NavigationTarget { + type Err = NavigationTargetParseError; + + fn from_str(s: &str) -> Result { + match Url::parse(s) { + Ok(_) => Ok(Self::External(s.to_string())), + Err(ParseError::RelativeUrlWithoutBase) => { + Ok(Self::Internal(R::from_str(s).map_err(|e| { + NavigationTargetParseError::InvalidInternalURL(e) + })?)) + } + Err(e) => Err(NavigationTargetParseError::InvalidUrl(e)), + } + } +} diff --git a/packages/router/src/routable.rs b/packages/router/src/routable.rs new file mode 100644 index 000000000..b75770a51 --- /dev/null +++ b/packages/router/src/routable.rs @@ -0,0 +1,216 @@ +//! # Routable + +#![allow(non_snake_case)] +use dioxus::prelude::*; + +use std::{fmt::Display, str::FromStr}; + +/// An error that occurs when parsing a route +#[derive(Debug, PartialEq)] +pub struct RouteParseError { + /// The attempted routes that failed to match + pub attempted_routes: Vec, +} + +impl std::fmt::Display for RouteParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Route did not match:\nAttempted Matches:\n")?; + for (i, route) in self.attempted_routes.iter().enumerate() { + writeln!(f, "{}) {route}", i + 1)?; + } + Ok(()) + } +} + +/// Something that can be created from a query string +pub trait FromQuery { + /// Create an instance of `Self` from a query string + fn from_query(query: &str) -> Self; +} + +impl From<&'a str>> FromQuery for T { + fn from_query(query: &str) -> Self { + T::from(query) + } +} + +/// Something that can be created from a route segment +pub trait FromRouteSegment: Sized { + /// The error that can occur when parsing a route segment + type Err; + + /// Create an instance of `Self` from a route segment + fn from_route_segment(route: &str) -> Result; +} + +impl FromRouteSegment for T +where + ::Err: std::fmt::Display, +{ + type Err = ::Err; + + fn from_route_segment(route: &str) -> Result { + T::from_str(route) + } +} + +/// Something that can be converted to route segments +pub trait ToRouteSegments { + /// Display the route segments + fn display_route_segements(self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; +} + +impl ToRouteSegments for I +where + I: IntoIterator, +{ + fn display_route_segements(self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for segment in self { + write!(f, "/")?; + write!(f, "{}", segment)?; + } + Ok(()) + } +} + +/// Something that can be created from route segments +pub trait FromRouteSegments: Sized { + /// The error that can occur when parsing route segments + type Err; + + /// Create an instance of `Self` from route segments + fn from_route_segments(segments: &[&str]) -> Result; +} + +impl> FromRouteSegments for I { + type Err = ::Err; + + fn from_route_segments(segments: &[&str]) -> Result { + segments + .iter() + .map(|s| String::from_route_segment(s)) + .collect() + } +} + +/// Something that can be: +/// 1. Converted from a route +/// 2. Converted to a route +/// 3. Rendered as a component +/// +/// This trait can be derived using the `#[derive(Routable)]` macro +pub trait Routable: std::fmt::Display + std::str::FromStr + Clone + 'static { + /// The error that can occur when parsing a route + const SITE_MAP: &'static [SiteMapSegment]; + + /// Render the route at the given level + fn render<'a>(&self, cx: &'a ScopeState, level: usize) -> Element<'a>; + + /// Gets a list of all static routes + fn static_routes() -> Vec { + Self::SITE_MAP + .iter() + .flat_map(|segment| segment.flatten()) + .filter_map(|route| { + if route + .iter() + .all(|segment| matches!(segment, SegmentType::Static(_))) + { + Self::from_str( + &route + .iter() + .map(|segment| match segment { + SegmentType::Static(s) => s.to_string(), + _ => unreachable!(), + }) + .collect::>() + .join("/"), + ) + .ok() + } else { + None + } + }) + .collect() + } +} + +trait RoutableFactory { + type Err: std::fmt::Display; + type Routable: Routable + FromStr; +} + +impl RoutableFactory for R +where + ::Err: std::fmt::Display, +{ + type Err = ::Err; + type Routable = R; +} + +trait RouteRenderable: std::fmt::Display + 'static { + fn render<'a>(&self, cx: &'a ScopeState, level: usize) -> Element<'a>; +} + +impl RouteRenderable for R +where + ::Err: std::fmt::Display, +{ + fn render<'a>(&self, cx: &'a ScopeState, level: usize) -> Element<'a> { + self.render(cx, level) + } +} + +/// A type erased map of the site structurens +#[derive(Debug, Clone, PartialEq)] +pub struct SiteMapSegment { + /// The type of the route segment + pub segment_type: SegmentType, + /// The children of the route segment + pub children: &'static [SiteMapSegment], +} + +impl SiteMapSegment { + /// Take a map of the site structure and flatten it into a vector of routes + pub fn flatten(&self) -> Vec> { + let mut routes = Vec::new(); + self.flatten_inner(&mut routes, Vec::new()); + routes + } + + fn flatten_inner(&self, routes: &mut Vec>, current: Vec) { + let mut current = current; + current.push(self.segment_type.clone()); + if self.children.is_empty() { + routes.push(current); + } else { + for child in self.children { + child.flatten_inner(routes, current.clone()); + } + } + } +} + +/// The type of a route segment +#[derive(Debug, Clone, PartialEq)] +pub enum SegmentType { + /// A static route segment + Static(&'static str), + /// A dynamic route segment + Dynamic(&'static str), + /// A catch all route segment + CatchAll(&'static str), + /// A child router + Child, +} + +impl Display for SegmentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self { + SegmentType::Static(s) => write!(f, "/{}", s), + SegmentType::Child => Ok(()), + SegmentType::Dynamic(s) => write!(f, "/:{}", s), + SegmentType::CatchAll(s) => write!(f, "/:...{}", s), + } + } +} diff --git a/packages/router/src/routecontext.rs b/packages/router/src/routecontext.rs deleted file mode 100644 index 81383227b..000000000 --- a/packages/router/src/routecontext.rs +++ /dev/null @@ -1,24 +0,0 @@ -/// A `RouteContext` is a context that is provided by [`Route`](fn.Route.html) components. -/// -/// This signals to all child [`Route`] and [`Link`] components that they are -/// currently nested under this route. -#[derive(Debug, Clone)] -pub struct RouteContext { - /// The `declared_route` is the sub-piece of the route that matches this pattern. - /// - /// - /// It follows this pattern: - /// ```ignore - /// "name/:id" - /// ``` - pub declared_route: String, - - /// The `total_route` is the full route that matches this pattern. - /// - /// - /// It follows this pattern: - /// ```ignore - /// "/level0/level1/:id" - /// ``` - pub total_route: String, -} diff --git a/packages/router/src/router_cfg.rs b/packages/router/src/router_cfg.rs new file mode 100644 index 000000000..31eeeb4d5 --- /dev/null +++ b/packages/router/src/router_cfg.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use crate::contexts::router::RoutingCallback; +use crate::history::HistoryProvider; +use crate::routable::Routable; +use dioxus::prelude::*; + +use crate::prelude::*; + +/// Global configuration options for the router. +/// +/// This implements [`Default`] and follows the builder pattern, so you can use it like this: +/// ```rust,no_run +/// # use dioxus_router::prelude::*; +/// # use dioxus::prelude::*; +/// # #[inline_props] +/// # fn Index(cx: Scope) -> Element { +/// # todo!() +/// # } +/// #[derive(Clone, Routable)] +/// enum Route { +/// #[route("/")] +/// Index {}, +/// } +/// let cfg = RouterConfig::default().history(WebHistory::::default()); +/// ``` +pub struct RouterConfig { + pub(crate) failure_external_navigation: fn(Scope) -> Element, + pub(crate) history: Option>>, + pub(crate) on_update: Option>, +} + +#[cfg(feature = "serde")] +impl Default for RouterConfig +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + fn default() -> Self { + Self { + failure_external_navigation: FailureExternalNavigation::, + history: None, + on_update: None, + } + } +} + +#[cfg(feature = "serde")] +impl RouterConfig +where + ::Err: std::fmt::Display, + R: serde::Serialize + serde::de::DeserializeOwned, +{ + pub(crate) fn get_history(self) -> Box> { + self.history.unwrap_or_else(|| { + #[cfg(all(target_arch = "wasm32", feature = "web"))] + let history = Box::>::default(); + #[cfg(not(all(target_arch = "wasm32", feature = "web")))] + let history = Box::>::default(); + history + }) + } +} + +#[cfg(not(feature = "serde"))] +impl Default for RouterConfig +where + ::Err: std::fmt::Display, +{ + fn default() -> Self { + Self { + failure_external_navigation: FailureExternalNavigation::, + history: None, + on_update: None, + } + } +} + +#[cfg(not(feature = "serde"))] +impl RouterConfig +where + ::Err: std::fmt::Display, +{ + pub(crate) fn take_history(&mut self) -> Box> { + self.history.take().unwrap_or_else(|| { + #[cfg(all(target_arch = "wasm32", feature = "web"))] + let history = Box::>::default(); + #[cfg(not(all(target_arch = "wasm32", feature = "web")))] + let history = Box::>::default(); + history + }) + } +} + +impl RouterConfig { + /// A function to be called whenever the routing is updated. + /// + /// The callback is invoked after the routing is updated, but before components and hooks are + /// updated. + /// + /// If the callback returns a [`NavigationTarget`] the router will replace the current location + /// with it. If no navigation failure was triggered, the router will then updated dependent + /// components and hooks. + /// + /// The callback is called no more than once per rerouting. It will not be called if a + /// navigation failure occurs. + /// + /// Defaults to [`None`]. + pub fn on_update( + self, + callback: impl Fn(GenericRouterContext) -> Option> + 'static, + ) -> Self { + Self { + on_update: Some(Arc::new(callback)), + ..self + } + } + + /// The [`HistoryProvider`] the router should use. + /// + /// Defaults to a default [`MemoryHistory`]. + pub fn history(self, history: impl HistoryProvider + 'static) -> Self { + Self { + history: Some(Box::new(history)), + ..self + } + } + + /// A component to render when an external navigation fails. + /// + /// Defaults to a router-internal component called [`FailureExternalNavigation`] + pub fn failure_external_navigation(self, component: fn(Scope) -> Element) -> Self { + Self { + failure_external_navigation: component, + ..self + } + } +} diff --git a/packages/router/src/service.rs b/packages/router/src/service.rs deleted file mode 100644 index e089ca1be..000000000 --- a/packages/router/src/service.rs +++ /dev/null @@ -1,439 +0,0 @@ -// todo: how does router work in multi-window contexts? -// does each window have its own router? probably, lol - -use crate::cfg::RouterCfg; -use dioxus::core::{ScopeId, ScopeState, VirtualDom}; -use std::any::Any; -use std::rc::Weak; -use std::{ - cell::{Cell, RefCell}, - collections::{HashMap, HashSet}, - rc::Rc, - str::FromStr, - sync::Arc, -}; -use url::Url; - -/// A clonable handle to the router -pub type RouterContext = Rc; - -/// An abstraction over the platform's history API. -/// -/// The history is denoted using web-like semantics, with forward slashes delmitiing -/// routes and question marks denoting optional parameters. -/// -/// This RouterService is exposed so you can modify the history directly. It -/// does not provide a high-level ergonomic API for your components. Instead, -/// you should consider using the components and hooks instead. -/// - [`Route`](struct.Route.html) -/// - [`Link`](struct.Link.html) -/// - [`UseRoute`](struct.UseRoute.html) -/// - [`Router`](struct.Router.html) -/// -/// -/// # Example -/// -/// ```rust, ignore -/// let router = Router::new(); -/// router.push_route("/home/custom"); -/// cx.provide_context(router); -/// ``` -/// -/// # Platform Specific -/// -/// - On the web, this is a [`BrowserHistory`](https://docs.rs/gloo/0.3.0/gloo/history/struct.BrowserHistory.html). -/// - On desktop, mobile, and SSR, this is just a Vec of Strings. Currently on -/// desktop, there is no way to tap into forward/back for the app unless explicitly set. -pub struct RouterService { - pub(crate) route_found: Cell>, - - pub(crate) stack: RefCell>>, - - pub(crate) slots: Rc>>, - - pub(crate) ordering: Rc>>, - - pub(crate) onchange_listeners: Rc>>, - - pub(crate) history: Box, - - pub(crate) regen_any_route: Arc, - - pub(crate) router_id: ScopeId, - - pub(crate) cfg: RouterCfg, -} - -/// A route is a combination of window title, saved state, and a URL. -#[derive(Debug, Clone)] -pub struct ParsedRoute { - /// The URL of the route. - pub url: Url, - - /// The title of the route. - pub title: Option, - - /// The serialized state of the route. - pub serialized_state: Option, -} - -impl RouterService { - pub(crate) fn new(cx: &ScopeState, cfg: RouterCfg) -> RouterContext { - #[cfg(feature = "web")] - let history = Box::new(web::new()); - - #[cfg(not(feature = "web"))] - let history = Box::new(hash::new()); - - let route = match &cfg.initial_url { - Some(url) => Arc::new(ParsedRoute { - url: Url::from_str(url).unwrap_or_else(|_| - panic!( - "RouterCfg expects a valid initial_url, but got '{}'. Example: '{{scheme}}://{{?authority}}/{{?path}}'", - &url - ) - ), - title: None, - serialized_state: None, - }), - None => Arc::new(history.init_location()), - }; - - let svc = Rc::new(Self { - cfg, - regen_any_route: cx.schedule_update_any(), - router_id: cx.scope_id(), - route_found: Cell::new(None), - stack: RefCell::new(vec![route]), - ordering: Default::default(), - slots: Default::default(), - onchange_listeners: Default::default(), - history, - }); - - svc.history.attach_listeners(Rc::downgrade(&svc)); - - svc - } - - /// Push a new route with no custom title or serialized state. - /// - /// This is a convenience method for easily navigating. - pub fn navigate_to(&self, route: &str) { - self.push_route(route, None, None); - } - - /// Push a new route to the history. - /// - /// This will trigger a route change event. - /// - /// This does not modify the current route - pub fn push_route(&self, route: &str, title: Option, serialized_state: Option) { - let new_route = Arc::new(ParsedRoute { - url: self.current_location().url.join(route).ok().unwrap(), - title, - serialized_state, - }); - - self.history.push(&new_route); - self.stack.borrow_mut().push(new_route); - - self.regen_routes(); - } - - /// Instead of pushing a new route, replaces the current route. - pub fn replace_route( - &self, - route: &str, - title: Option, - serialized_state: Option, - ) { - let new_route = Arc::new(ParsedRoute { - url: self.current_location().url.join(route).ok().unwrap(), - title, - serialized_state, - }); - - self.history.replace(&new_route); - *self.stack.borrow_mut().last_mut().unwrap() = new_route; - - self.regen_routes(); - } - - /// Pop the current route from the history. - pub fn pop_route(&self) { - let mut stack = self.stack.borrow_mut(); - - if stack.len() > 1 { - stack.pop(); - } - - self.regen_routes(); - } - - /// Regenerate any routes that need to be regenerated, discarding the currently found route - /// - /// You probably don't need this method - pub fn regen_routes(&self) { - self.route_found.set(None); - - (self.regen_any_route)(self.router_id); - - for listener in self.onchange_listeners.borrow().iter() { - log::trace!("Regenerating scope {:?}", listener); - (self.regen_any_route)(*listener); - } - - for route in self.ordering.borrow().iter().rev() { - (self.regen_any_route)(*route); - } - } - - /// Get the current location of the Router - pub fn current_location(&self) -> Arc { - self.stack.borrow().last().unwrap().clone() - } - - /// Get the current native location of the Router - pub fn native_location(&self) -> Option> { - self.history.native_location().downcast::().ok() - } - - /// Registers a scope to regenerate on route change. - /// - /// This is useful if you've built some abstraction on top of the router service. - pub fn subscribe_onchange(&self, id: ScopeId) { - self.onchange_listeners.borrow_mut().insert(id); - } - - /// Unregisters a scope to regenerate on route change. - /// - /// This is useful if you've built some abstraction on top of the router service. - pub fn unsubscribe_onchange(&self, id: ScopeId) { - self.onchange_listeners.borrow_mut().remove(&id); - } - - pub(crate) fn register_total_route(&self, route: String, scope: ScopeId) { - let clean = clean_route(route); - self.slots.borrow_mut().insert(scope, clean); - self.ordering.borrow_mut().push(scope); - } - - pub(crate) fn should_render(&self, scope: ScopeId) -> bool { - if let Some(root_id) = self.route_found.get() { - return root_id == scope; - } - - let roots = self.slots.borrow(); - - if let Some(route) = roots.get(&scope) { - let cur = &self.current_location().url; - log::trace!("Checking if {} matches {}", cur, route); - - if route_matches_path(cur, route, self.cfg.base_url.as_ref()) || route.is_empty() { - self.route_found.set(Some(scope)); - true - } else { - false - } - } else { - false - } - } -} - -/// Get the router service from an existing VirtualDom. -/// -/// Takes an optional target_scope parameter to specify the scope to use if ScopeId is not the component -/// that owns the router. -/// -/// This might change in the future. -pub fn get_router_from_vdom(dom: &VirtualDom, target_scope: ScopeId) -> Option { - dom.get_scope(target_scope) - .and_then(|scope| scope.consume_context::()) -} - -fn clean_route(route: String) -> String { - if route.as_str() == "/" { - return route; - } - route.trim_end_matches('/').to_string() -} - -fn clean_path(path: &str) -> &str { - if path == "/" { - return path; - } - let sub = path.trim_end_matches('/'); - - if sub.starts_with('/') { - &path[1..] - } else { - sub - } -} - -fn route_matches_path(cur: &Url, attempt: &str, base_url: Option<&String>) -> bool { - let cur_piece_iter = cur.path_segments().unwrap(); - - let mut cur_pieces = match base_url { - // baseurl is naive right now and doesn't support multiple nesting levels - Some(_) => cur_piece_iter.skip(1).collect::>(), - None => cur_piece_iter.collect::>(), - }; - - if attempt == "/" && cur_pieces.len() == 1 && cur_pieces[0].is_empty() { - return true; - } - - // allow slashes at the end of the path - if cur_pieces.last() == Some(&"") { - cur_pieces.pop(); - } - - let attempt_pieces = clean_path(attempt).split('/').collect::>(); - - if attempt_pieces.len() != cur_pieces.len() { - return false; - } - - for (i, r) in attempt_pieces.iter().enumerate() { - // If this is a parameter then it matches as long as there's - // _any_thing in that spot in the path. - if r.starts_with(':') { - continue; - } - - if cur_pieces[i] != *r { - return false; - } - } - - true -} - -pub(crate) trait RouterProvider { - fn push(&self, route: &ParsedRoute); - fn replace(&self, route: &ParsedRoute); - fn native_location(&self) -> Box; - fn init_location(&self) -> ParsedRoute; - fn attach_listeners(&self, svc: Weak); -} - -#[cfg(not(feature = "web"))] -mod hash { - use super::*; - - pub fn new() -> HashRouter { - HashRouter {} - } - - /// a simple cross-platform hash-based router - pub struct HashRouter {} - - impl RouterProvider for HashRouter { - fn push(&self, _route: &ParsedRoute) {} - - fn native_location(&self) -> Box { - Box::new(()) - } - - fn init_location(&self) -> ParsedRoute { - ParsedRoute { - url: Url::parse("app:///").unwrap(), - title: None, - serialized_state: None, - } - } - - fn replace(&self, _route: &ParsedRoute) {} - - fn attach_listeners(&self, _svc: Weak) {} - } -} - -#[cfg(feature = "web")] -mod web { - use super::RouterProvider; - use crate::ParsedRoute; - - use gloo_events::EventListener; - use std::{any::Any, cell::Cell}; - use web_sys::History; - - pub struct WebRouter { - // keep it around so it drops when the router is dropped - _listener: Cell>, - - window: web_sys::Window, - history: History, - } - - impl RouterProvider for WebRouter { - fn push(&self, route: &ParsedRoute) { - let ParsedRoute { - url, - title, - serialized_state, - } = route; - - let _ = self.history.push_state_with_url( - &wasm_bindgen::JsValue::from_str(serialized_state.as_deref().unwrap_or("")), - title.as_deref().unwrap_or(""), - Some(url.as_str()), - ); - } - - fn replace(&self, route: &ParsedRoute) { - let ParsedRoute { - url, - title, - serialized_state, - } = route; - - let _ = self.history.replace_state_with_url( - &wasm_bindgen::JsValue::from_str(serialized_state.as_deref().unwrap_or("")), - title.as_deref().unwrap_or(""), - Some(url.as_str()), - ); - } - - fn native_location(&self) -> Box { - Box::new(self.window.location()) - } - - fn init_location(&self) -> ParsedRoute { - ParsedRoute { - url: url::Url::parse(&web_sys::window().unwrap().location().href().unwrap()) - .unwrap(), - title: web_sys::window() - .unwrap() - .document() - .unwrap() - .title() - .into(), - serialized_state: None, - } - } - - fn attach_listeners(&self, svc: std::rc::Weak) { - self._listener.set(Some(EventListener::new( - &web_sys::window().unwrap(), - "popstate", - move |_| { - if let Some(svc) = svc.upgrade() { - svc.pop_route(); - } - }, - ))); - } - } - - pub(crate) fn new() -> WebRouter { - WebRouter { - history: web_sys::window().unwrap().history().unwrap(), - window: web_sys::window().unwrap(), - _listener: Cell::new(None), - } - } -} diff --git a/packages/router/src/utils/use_router_internal.rs b/packages/router/src/utils/use_router_internal.rs new file mode 100644 index 000000000..898ef539f --- /dev/null +++ b/packages/router/src/utils/use_router_internal.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::{ScopeId, ScopeState}; + +use crate::{contexts::router::GenericRouterContext, prelude::*}; + +/// A private hook to subscribe to the router. +/// +/// Used to reduce redundancy within other components/hooks. Safe to call multiple times for a +/// single component, but not recommended. Multiple subscriptions will be discarded. +/// +/// # Return values +/// - [`None`], when the current component isn't a descendant of a [`GenericRouter`] component. +/// - Otherwise [`Some`]. +pub(crate) fn use_router_internal( + cx: &ScopeState, +) -> &Option> { + let inner = cx.use_hook(|| { + let router = cx.consume_context::>()?; + + let id = cx.scope_id(); + router.subscribe(id); + + Some(Subscription { router, id }) + }); + cx.use_hook(|| inner.as_ref().map(|s| s.router.clone())) +} + +struct Subscription { + router: GenericRouterContext, + id: ScopeId, +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.router.unsubscribe(self.id); + } +} diff --git a/packages/router/tests/via_ssr/link.rs b/packages/router/tests/via_ssr/link.rs new file mode 100644 index 000000000..882df71e0 --- /dev/null +++ b/packages/router/tests/via_ssr/link.rs @@ -0,0 +1,378 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_router::prelude::*; +use std::str::FromStr; + +fn prepare() -> String +where + ::Err: std::fmt::Display, +{ + let mut vdom = VirtualDom::new_with_props( + App, + AppProps:: { + phantom: std::marker::PhantomData, + }, + ); + let _ = vdom.rebuild(); + return dioxus_ssr::render(&vdom); + + #[derive(Props)] + struct AppProps { + phantom: std::marker::PhantomData, + } + + impl PartialEq for AppProps { + fn eq(&self, _other: &Self) -> bool { + false + } + } + + fn App(cx: Scope>) -> Element + where + ::Err: std::fmt::Display, + { + render! { + h1 { "App" } + GenericRouter:: { + config: || RouterConfig::default().history(MemoryHistory::default()) + } + } + } +} + +#[test] +fn href_internal() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="""#, + id = r#"id="""#, + rel = r#"rel="""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn href_external() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: NavigationTarget::External("https://dioxuslabs.com/".into()), + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="https://dioxuslabs.com/""#, + default = r#"dioxus-prevent-default="""#, + class = r#"class="""#, + id = r#"id="""#, + rel = r#"rel="noopener noreferrer""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_class() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="test_class""#, + id = r#"id="""#, + rel = r#"rel="""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_active_class_active() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Root {}, + active_class: "active_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="test_class active_class""#, + id = r#"id="""#, + rel = r#"rel="""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_active_class_inactive() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + active_class: "active_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="test_class""#, + id = r#"id="""#, + rel = r#"rel="""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_id() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + id: "test_id", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="""#, + id = r#"id="test_id""#, + rel = r#"rel="""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_new_tab() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + new_tab: true, + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="""#, + class = r#"class="""#, + id = r#"id="""#, + rel = r#"rel="""#, + target = r#"target="_blank""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_new_tab_external() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: NavigationTarget::External("https://dioxuslabs.com/".into()), + new_tab: true, + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="https://dioxuslabs.com/""#, + default = r#"dioxus-prevent-default="""#, + class = r#"class="""#, + id = r#"id="""#, + rel = r#"rel="noopener noreferrer""#, + target = r#"target="_blank""# + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_rel() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[inline_props] + fn Test(cx: Scope) -> Element { + todo!() + } + + #[inline_props] + fn Root(cx: Scope) -> Element { + render! { + Link { + target: Route::Test {}, + rel: "test_rel", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + default = r#"dioxus-prevent-default="onclick""#, + class = r#"class="""#, + id = r#"id="""#, + rel = r#"rel="test_rel""#, + target = r#"target="""# + ); + + assert_eq!(prepare::(), expected); +} diff --git a/packages/router/tests/via_ssr/main.rs b/packages/router/tests/via_ssr/main.rs new file mode 100644 index 000000000..f86bd4eb4 --- /dev/null +++ b/packages/router/tests/via_ssr/main.rs @@ -0,0 +1,2 @@ +mod link; +mod outlet; diff --git a/packages/router/tests/via_ssr/outlet.rs b/packages/router/tests/via_ssr/outlet.rs new file mode 100644 index 000000000..ba8eb8873 --- /dev/null +++ b/packages/router/tests/via_ssr/outlet.rs @@ -0,0 +1,145 @@ +#![allow(non_snake_case, unused)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props(App, AppProps { path: path.into() }); + let _ = vdom.rebuild(); + return vdom; + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + #[route("/")] + RootIndex {}, + #[nest("/fixed")] + #[layout(Fixed)] + #[route("/")] + FixedIndex {}, + #[route("/fixed")] + FixedFixed {}, + #[end_layout] + #[end_nest] + #[nest("/:id")] + #[layout(Parameter)] + #[route("/")] + ParameterIndex { id: u8 }, + #[route("/fixed")] + ParameterFixed { id: u8 }, + } + + #[derive(Debug, Props, PartialEq)] + struct AppProps { + path: String, + } + + fn App(cx: Scope) -> Element { + render! { + h1 { "App" } + Router { + config: { + let path = cx.props.path.parse().unwrap(); + move || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + } + } + } + } + + #[inline_props] + fn RootIndex(cx: Scope) -> Element { + render! { + h2 { "Root Index" } + } + } + + #[inline_props] + fn Fixed(cx: Scope) -> Element { + render! { + h2 { "Fixed" } + Outlet { } + } + } + + #[inline_props] + fn FixedIndex(cx: Scope) -> Element { + render! { + h3 { "Fixed - Index" } + } + } + + #[inline_props] + fn FixedFixed(cx: Scope) -> Element { + render! { + h3 { "Fixed - Fixed"} + } + } + + #[inline_props] + fn Parameter(cx: Scope, id: u8) -> Element { + render! { + h2 { "Parameter {id}" } + Outlet { } + } + } + + #[inline_props] + fn ParameterIndex(cx: Scope, id: u8) -> Element { + render! { + h3 { "Parameter - Index" } + } + } + + #[inline_props] + fn ParameterFixed(cx: Scope, id: u8) -> Element { + render! { + h3 { "Parameter - Fixed" } + } + } +} + +#[test] +fn root_index() { + let vdom = prepare("/"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

Root Index

"); +} + +#[test] +fn fixed() { + let vdom = prepare("/fixed"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

Fixed

Fixed - Index

"); +} + +#[test] +fn fixed_fixed() { + let vdom = prepare("/fixed/fixed"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

Fixed

Fixed - Fixed

"); +} + +#[test] +fn parameter() { + let vdom = prepare("/18"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!( + html, + "

App

Parameter 18

Parameter - Index

" + ); +} + +#[test] +fn parameter_fixed() { + let vdom = prepare("/18/fixed"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!( + html, + "

App

Parameter 18

Parameter - Fixed

" + ); +} diff --git a/packages/router/tests/web_router.rs b/packages/router/tests/web_router.rs deleted file mode 100644 index 13e2847a4..000000000 --- a/packages/router/tests/web_router.rs +++ /dev/null @@ -1,60 +0,0 @@ -#![cfg(target_arch = "wasm32")] -#![allow(non_snake_case)] - -use dioxus::prelude::*; -use dioxus_router::*; -use gloo_utils::document; -use wasm_bindgen_test::*; - -wasm_bindgen_test_configure!(run_in_browser); - -#[wasm_bindgen_test] -fn simple_test() { - fn main() { - console_error_panic_hook::set_once(); - wasm_logger::init(wasm_logger::Config::new(log::Level::Debug)); - dioxus_web::launch(app); - } - - fn app(cx: Scope) -> Element { - cx.render(rsx! { - Router { - onchange: move |router: RouterContext| log::trace!("route changed to {:?}", router.current_location()), - active_class: "is-active", - Route { to: "/", Home {} } - Route { to: "blog", - Route { to: "/", BlogList {} } - Route { to: ":id", BlogPost {} } - } - } - }) - } - - fn Home(cx: Scope) -> Element { - cx.render(rsx! { - div { - h1 { "Home" } - } - }) - } - - fn BlogList(cx: Scope) -> Element { - cx.render(rsx! { - div { - - } - }) - } - - fn BlogPost(cx: Scope) -> Element { - let _id = use_route(cx).parse_segment::("id").unwrap(); - - cx.render(rsx! { - div { } - }) - } - - main(); - - let _ = document(); -} diff --git a/packages/router/usage.md b/packages/router/usage.md deleted file mode 100644 index 3e4d9b59c..000000000 --- a/packages/router/usage.md +++ /dev/null @@ -1,221 +0,0 @@ - -## Usage - -Using Dioxus Router is pretty simple. Add a top-level Router to your app (not necessary but good practice) and then start adding routes, specifying the "to" field: - -```rust -fn app() { - cx.render(rsx! { - Router { - Route { to: "/", Component {} }, - Route { to: "/blog", Blog {} }, - Route { to: "/about", Blog {} }, - Route { to: "/contact", Blog {} }, - Route { to: "/shop", Blog {} }, - } - }) -} -``` - -All Routes must start with a forward slash. - -To have dynamic route segments, use the `:id` syntax. If concrete paths come *before* the dynamic syntax, then those will be chosen first. - -```rust -cx.render(rsx! { - Router { - Route { to: "/", Component {} }, - Route { to: "/blog", BlogList {} }, - Route { to: "/blog/welcome", BlogWelcome {} }, - Route { to: "/blog/:post", BlogPost {} }, - } -}) -``` - -### Nested `Routes` - -Routes can be composed at various levels, so you don't just need top-level routes. To do this, simple add Routes inside other Routes - -```rust -cx.render(rsx! { - Router { - Route { to: "/", Component {} }, - Route { to: "/blog", - BlogContainer { - h1 { "blog" } // always renders as long as we're on the "blog" subroute - Route { to: "/", BlogList {} } - Route { to: "welcome", BlogWelcome {} } - Route { to: ":post", BlogPost {} } - } - }, - } -}) -``` - -### Navigating with `Links` - -To navigate your app, regular, old, `a` tags are not going to work. We provide the `Link` component that wraps an `a` tag with the appropriate `href` attribute that generates semantic HTML. You can pass any children into this component and they will become clickable to the appropriate route. - -```rust -Link { to: "/blog/welcome", - h1 { "Welcome to my blog!" } -} -``` - -#### Active `Links` - -When your app has been navigated to a route that matches the route of a `Link`, this `Link` becomes 'active'. -Active links have a special class attached to them. By default it is simply called `"active"` but it can be -modified on the `Link` level or on the `Router` level. Both is done through the prop `active_class`. -If the active class is given on both, the `Router` and the `Link`, the one on the `Link` has precedence. - -```rust -Router { - active_class: "custom-active", // All active links in this router get this class. - Link { to: "/", "Home" }, - Link { - to: "/blog", - active_class: "is-active", // Only for this Link. Overwrites "custom-active" from Router. - "Blog" - }, -} -``` - -#### Initial url - -When working with ssr specially is crucial to have the ability to set an initial url. This is an optional property and defaults to `app:///` for desktop, mobile and ssr, and for web retrieves the url from the window current location. - -You must provide a valid URL `{scheme}://{?authority}/{?path}`. - -```rust -Router { - initial_url: "https://dioxuslab.com/blog", // Set the initial url. - Link { to: "/", "Home" }, - Link { to: "/blog", "Blog" }, // The router will render this route. -} -``` - -### Segments - -Each route in your app is comprised of segments and queries. Segments are the portions of the route delimited by forward slashes. - -For the route `/dogs/breeds/yorkie/hugo` our "segment list" would be: - -```rust -vec!["dogs", "breeds", "yorkie", "hugo"] -``` - -For any route, you can get a handle the current route with the `use_route` hook. - -```rust -fn Title(cx: Scope) -> Element { - let route = use_route(cx); - - assert_eq!(route.segments(), &["dogs", "breeds", "yorkie", "hugo"]); - - assert_eq!(route.nth_segment(1), "breeds"); - - assert_eq!(route.last_segment(), "hugo"); -} -``` - -As we've shown above, segments can also be named. We can get these named segments out by the last match at that route level: - -```rust -// For this router: -Router { - Route { to: "/", Component {} }, - Route { to: "/blog", BlogList {} }, - Route { to: "/blog/:post", BlogPost {} }, -} - -fn BlogPost(cx: Scope) -> Element { - let route = use_route(cx); - - match route.segment("post").and_then(parse) { - Some(post) => cx.render(rsx!{ div { "Post {post}" } }) - None => cx.render(rsx!{ div { "Could not find that blog post" } }), - } -} -``` - -### Queries - -### Listeners - -It's possible to connect to route change events from the router by attaching a listener to the Router's `onchange` parameter. This listener is guaranteed to run before any of your routes are matched, so you can perform redirects, add some logging, fetch some data, or do anything that you might want to be synchronous with clicks on Links. - -```rust -fn app() { - cx.render(rsx! { - Router { - onchange: move |router| { - let current = router.current_route(); - log::debug!("App has navigated to {:?}", current); - - // perform a redirect - if current == "404" { - router.navigate_to("/"); - } - }, - Route { to: "/", Component {} }, - } - }) -} -``` - -Listeners can also be attached downstream in your app with the `RouteListener` handler component: - -```rust -fn TitleCard(cx: Scope) -> Element { - let (title, set_title) = use_state(cx, || "First"); - - cx.render(rsx!{ - h1 { "render {title}" } - - RouteListener { onchange: move |_| set_title("Last") } - }) -} -``` - -### Working with Github Pages and other static hosts - -Most "static" hosts will have issues with single-page-app (SPA) routers. To get around this, you can either generate an index.html for each route or hijack the 404 page. - -For generating a static index.html, see `Generating a Route List`. - -To hijack the 404 page, we can simply make a copy of our index.html page and call it 404.html. When Github Pages serves this 404 page, your app will be served instead and the router will render the right corresponding route. - - - -### Generating a SiteMap or Route List - -If you want to statically generate and rehydrate all your pages, lean on Dioxus Router to do the heavy lifting. - -For this feature to work properly, each route (and nested) route will need to be probed, but this can be done automatically. - -```rust -let mut dom = VirtualDom::new(app); -dom.inject_root_context(RouterContext::new()); - -// populate the router -let _ = dom.rebuild(); - -// load the router context from the dom, generate a sitemap, and then pre-render each page -let mut prerendered_pages = dom - .consume_root_context::() - .unwrap() - .sitemap() - .into_iter() - .map(|route| { - // update the root context - router.navigate_to(route); - - // force our app to update - let _ = dom.rebuild(); - - // render the page and insert it into our map - (route, dioxus_ssr::render_vdom(&dom)) - }) - .collect::>(); -``` diff --git a/packages/router/webdriver.json b/packages/router/webdriver.json deleted file mode 100644 index 81413f24b..000000000 --- a/packages/router/webdriver.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "moz:firefoxOptions": { - "binary": "/usr/bin/firefox", - "prefs": { - "media.navigator.streams.fake": true, - "media.navigator.permission.disabled": true - }, - "args": [] - } - } - \ No newline at end of file diff --git a/packages/rsx-rosetta/Cargo.toml b/packages/rsx-rosetta/Cargo.toml index eef6b2499..d2df933fe 100644 --- a/packages/rsx-rosetta/Cargo.toml +++ b/packages/rsx-rosetta/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" edition = "2021" authors = ["Jonathan Kelley"] description = "Autofomatter for Dioxus RSX" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" documentation = "https://dioxuslabs.com" @@ -18,7 +18,7 @@ dioxus-rsx = { workspace = true } html_parser.workspace = true proc-macro2 = "1.0.49" quote = "1.0.23" -syn = { version = "1.0.107", features = ["full"] } +syn = { version = "2.0", features = ["full"] } convert_case = "0.5.0" # [features] diff --git a/packages/rsx-rosetta/src/lib.rs b/packages/rsx-rosetta/src/lib.rs index 4f30c0081..2ceb5f038 100644 --- a/packages/rsx-rosetta/src/lib.rs +++ b/packages/rsx-rosetta/src/lib.rs @@ -71,7 +71,6 @@ pub fn rsx_node_from_html(node: &Node) -> Option { name: el_name, children, attributes, - _is_static: false, key: None, brace: Default::default(), })) diff --git a/packages/rsx/Cargo.toml b/packages/rsx/Cargo.toml index d4b997187..8619845fd 100644 --- a/packages/rsx/Cargo.toml +++ b/packages/rsx/Cargo.toml @@ -2,7 +2,7 @@ name = "dioxus-rsx" version = "0.0.3" edition = "2018" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" description = "Core functionality for Dioxus - a concurrent renderer-agnostic Virtual DOM for interactive user experiences" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" @@ -14,8 +14,12 @@ keywords = ["dom", "ui", "gui", "react"] [dependencies] proc-macro2 = { version = "1.0", features = ["span-locations"] } dioxus-core = { workspace = true} -syn = { version = "1.0", features = ["full", "extra-traits"] } +syn = { version = "2.0", features = ["full", "extra-traits"] } quote = { version = "1.0" } -serde = { version = "1.0", features = ["derive"] } -internment = "0.7.0" -krates = "0.12.6" \ No newline at end of file +serde = { version = "1.0", features = ["derive"], optional = true } +internment = { version = "0.7.0", optional = true } +krates = { version = "0.12.6", optional = true } + +[features] +hot_reload = ["krates", "internment"] +serde = ["dep:serde"] diff --git a/packages/rsx/src/component.rs b/packages/rsx/src/component.rs index 45078b65f..6487e5ab7 100644 --- a/packages/rsx/src/component.rs +++ b/packages/rsx/src/component.rs @@ -164,6 +164,7 @@ impl ToTokens for Component { if !self.children.is_empty() { let renderer: TemplateRenderer = TemplateRenderer { roots: &self.children, + location: None, }; toks.append_all(quote! { diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs index 0fe6b93ee..8f45d91ff 100644 --- a/packages/rsx/src/element.rs +++ b/packages/rsx/src/element.rs @@ -20,7 +20,6 @@ pub struct Element { pub key: Option, pub attributes: Vec, pub children: Vec, - pub _is_static: bool, pub brace: syn::token::Brace, } @@ -161,7 +160,6 @@ impl Parse for Element { attributes, children, brace, - _is_static: false, }) } } diff --git a/packages/rsx/src/error.rs b/packages/rsx/src/error.rs index e75331066..16651fe5a 100644 --- a/packages/rsx/src/error.rs +++ b/packages/rsx/src/error.rs @@ -1,15 +1,18 @@ use std::fmt::Display; +#[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; /// An error produced when interperting the rsx -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub enum Error { ParseError(ParseError), RecompileRequiredError(RecompileReason), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub enum RecompileReason { Variable(String), Expression(String), @@ -18,14 +21,16 @@ pub enum RecompileReason { Attribute(String), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct CodeLocation { pub line: u32, pub column: u32, pub file_path: &'static str, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct ParseError { pub message: String, pub location: CodeLocation, diff --git a/packages/rsx/src/hot_reload/hot_reload_diff.rs b/packages/rsx/src/hot_reload/hot_reload_diff.rs index ea9918f99..22a01875d 100644 --- a/packages/rsx/src/hot_reload/hot_reload_diff.rs +++ b/packages/rsx/src/hot_reload/hot_reload_diff.rs @@ -85,7 +85,7 @@ fn find_rsx_item( (syn::ImplItem::Const(new_item), syn::ImplItem::Const(old_item)) => { find_rsx_expr(&new_item.expr, &old_item.expr, rsx_calls) } - (syn::ImplItem::Method(new_item), syn::ImplItem::Method(old_item)) => { + (syn::ImplItem::Fn(new_item), syn::ImplItem::Fn(old_item)) => { find_rsx_block(&new_item.block, &old_item.block, rsx_calls) } (syn::ImplItem::Type(new_item), syn::ImplItem::Type(old_item)) => { @@ -114,7 +114,6 @@ fn find_rsx_item( || new_item.semi_token != old_item.semi_token || new_item.ident != old_item.ident } - (syn::Item::Macro2(new_item), syn::Item::Macro2(old_item)) => old_item != new_item, (syn::Item::Mod(new_item), syn::Item::Mod(old_item)) => { match (&new_item.content, &old_item.content) { (Some((_, new_items)), Some((_, old_items))) => { @@ -186,7 +185,7 @@ fn find_rsx_trait( true } } - (syn::TraitItem::Method(new_item), syn::TraitItem::Method(old_item)) => { + (syn::TraitItem::Fn(new_item), syn::TraitItem::Fn(old_item)) => { if let (Some(new_block), Some(old_block)) = (&new_item.default, &old_item.default) { find_rsx_block(new_block, old_block, rsx_calls) } else { @@ -239,8 +238,9 @@ fn find_rsx_stmt( match (new_stmt, old_stmt) { (syn::Stmt::Local(new_local), syn::Stmt::Local(old_local)) => { (match (&new_local.init, &old_local.init) { - (Some((new_eq, new_expr)), Some((old_eq, old_expr))) => { - find_rsx_expr(new_expr, old_expr, rsx_calls) || new_eq != old_eq + (Some(new_local), Some(old_local)) => { + find_rsx_expr(&new_local.expr, &old_local.expr, rsx_calls) + || new_local != old_local } (None, None) => false, _ => true, @@ -252,11 +252,13 @@ fn find_rsx_stmt( (syn::Stmt::Item(new_item), syn::Stmt::Item(old_item)) => { find_rsx_item(new_item, old_item, rsx_calls) } - (syn::Stmt::Expr(new_expr), syn::Stmt::Expr(old_expr)) => { + (syn::Stmt::Expr(new_expr, _), syn::Stmt::Expr(old_expr, _)) => { find_rsx_expr(new_expr, old_expr, rsx_calls) } - (syn::Stmt::Semi(new_expr, new_semi), syn::Stmt::Semi(old_expr, old_semi)) => { - find_rsx_expr(new_expr, old_expr, rsx_calls) || new_semi != old_semi + (syn::Stmt::Macro(new_macro), syn::Stmt::Macro(old_macro)) => { + find_rsx_macro(&new_macro.mac, &old_macro.mac, rsx_calls) + || new_macro.attrs != old_macro.attrs + || new_macro.semi_token != old_macro.semi_token } _ => true, } @@ -285,12 +287,6 @@ fn find_rsx_expr( || new_expr.attrs != old_expr.attrs || new_expr.eq_token != old_expr.eq_token } - (syn::Expr::AssignOp(new_expr), syn::Expr::AssignOp(old_expr)) => { - find_rsx_expr(&new_expr.left, &old_expr.left, rsx_calls) - || find_rsx_expr(&new_expr.right, &old_expr.right, rsx_calls) - || new_expr.attrs != old_expr.attrs - || new_expr.op != old_expr.op - } (syn::Expr::Async(new_expr), syn::Expr::Async(old_expr)) => { find_rsx_block(&new_expr.block, &old_expr.block, rsx_calls) || new_expr.attrs != old_expr.attrs @@ -314,11 +310,6 @@ fn find_rsx_expr( || new_expr.attrs != old_expr.attrs || new_expr.label != old_expr.label } - (syn::Expr::Box(new_expr), syn::Expr::Box(old_expr)) => { - find_rsx_expr(&new_expr.expr, &old_expr.expr, rsx_calls) - || new_expr.attrs != old_expr.attrs - || new_expr.box_token != old_expr.box_token - } (syn::Expr::Break(new_expr), syn::Expr::Break(old_expr)) => { match (&new_expr.expr, &old_expr.expr) { (Some(new_inner), Some(old_inner)) => { @@ -478,7 +469,7 @@ fn find_rsx_expr( } (syn::Expr::Path(new_expr), syn::Expr::Path(old_expr)) => old_expr != new_expr, (syn::Expr::Range(new_expr), syn::Expr::Range(old_expr)) => { - match (&new_expr.from, &old_expr.from) { + match (&new_expr.start, &old_expr.start) { (Some(new_expr), Some(old_expr)) => { if find_rsx_expr(new_expr, old_expr, rsx_calls) { return true; @@ -487,7 +478,7 @@ fn find_rsx_expr( (None, None) => (), _ => return true, } - match (&new_expr.to, &old_expr.to) { + match (&new_expr.end, &old_expr.end) { (Some(new_inner), Some(old_inner)) => { find_rsx_expr(new_inner, old_inner, rsx_calls) || new_expr.attrs != old_expr.attrs @@ -568,12 +559,6 @@ fn find_rsx_expr( } new_expr.attrs != old_expr.attrs || new_expr.paren_token != old_expr.paren_token } - (syn::Expr::Type(new_expr), syn::Expr::Type(old_expr)) => { - find_rsx_expr(&new_expr.expr, &old_expr.expr, rsx_calls) - || new_expr.attrs != old_expr.attrs - || new_expr.colon_token != old_expr.colon_token - || new_expr.ty != old_expr.ty - } (syn::Expr::Unary(new_expr), syn::Expr::Unary(old_expr)) => { find_rsx_expr(&new_expr.expr, &old_expr.expr, rsx_calls) || new_expr.attrs != old_expr.attrs diff --git a/packages/rsx/src/ifmt.rs b/packages/rsx/src/ifmt.rs index 8146cea42..6c72f5025 100644 --- a/packages/rsx/src/ifmt.rs +++ b/packages/rsx/src/ifmt.rs @@ -20,6 +20,13 @@ pub struct IfmtInput { } impl IfmtInput { + pub fn new_static(input: &str) -> Self { + Self { + source: None, + segments: vec![Segment::Literal(input.to_string())], + } + } + pub fn is_static(&self) -> bool { matches!(self.segments.as_slice(), &[Segment::Literal(_)] | &[]) } diff --git a/packages/rsx/src/lib.rs b/packages/rsx/src/lib.rs index 07c887e65..1f01fcd72 100644 --- a/packages/rsx/src/lib.rs +++ b/packages/rsx/src/lib.rs @@ -15,18 +15,22 @@ mod errors; mod component; mod element; +#[cfg(feature = "hot_reload")] pub mod hot_reload; mod ifmt; mod node; -use std::{collections::HashMap, fmt::Debug, hash::Hash}; +use std::{fmt::Debug, hash::Hash}; // Re-export the namespaces into each other pub use component::*; +#[cfg(feature = "hot_reload")] use dioxus_core::{Template, TemplateAttribute, TemplateNode}; pub use element::*; +#[cfg(feature = "hot_reload")] pub use hot_reload::HotReloadingContext; pub use ifmt::*; +#[cfg(feature = "hot_reload")] use internment::Intern; pub use node::*; @@ -38,6 +42,7 @@ use syn::{ Result, Token, }; +#[cfg(feature = "hot_reload")] // interns a object into a static object, resusing the value if it already exists fn intern(s: impl Into>) -> &'static T { s.into().as_ref() @@ -50,6 +55,7 @@ pub struct CallBody { } impl CallBody { + #[cfg(feature = "hot_reload")] /// This will try to create a new template from the current body and the previous body. This will return None if the rsx has some dynamic part that has changed. /// This function intentionally leaks memory to create a static template. /// Keeping the template static allows us to simplify the core of dioxus and leaking memory in dev mode is less of an issue. @@ -59,9 +65,26 @@ impl CallBody { template: Option, location: &'static str, ) -> Option> { - let mut renderer: TemplateRenderer = TemplateRenderer { roots: &self.roots }; + let mut renderer: TemplateRenderer = TemplateRenderer { + roots: &self.roots, + location: None, + }; renderer.update_template::(template, location) } + + /// Render the template with a manually set file location. This should be used when multiple rsx! calls are used in the same macro + pub fn render_with_location(&self, location: String) -> TokenStream2 { + let body = TemplateRenderer { + roots: &self.roots, + location: Some(location), + }; + + quote! { + ::dioxus::core::LazyNodes::new( move | __cx: &::dioxus::core::ScopeState| -> ::dioxus::core::VNode { + #body + }) + } + } } impl Parse for CallBody { @@ -85,7 +108,10 @@ impl Parse for CallBody { /// Serialize the same way, regardless of flavor impl ToTokens for CallBody { fn to_tokens(&self, out_tokens: &mut TokenStream2) { - let body = TemplateRenderer { roots: &self.roots }; + let body = TemplateRenderer { + roots: &self.roots, + location: None, + }; out_tokens.append_all(quote! { ::dioxus::core::LazyNodes::new( move | __cx: &::dioxus::core::ScopeState| -> ::dioxus::core::VNode { @@ -102,6 +128,7 @@ impl ToTokens for RenderCallBody { fn to_tokens(&self, out_tokens: &mut TokenStream2) { let body: TemplateRenderer = TemplateRenderer { roots: &self.0.roots, + location: None, }; out_tokens.append_all(quote! { @@ -115,9 +142,11 @@ impl ToTokens for RenderCallBody { pub struct TemplateRenderer<'a> { pub roots: &'a [BodyNode], + pub location: Option, } impl<'a> TemplateRenderer<'a> { + #[cfg(feature = "hot_reload")] fn update_template( &mut self, previous_call: Option, @@ -185,6 +214,21 @@ impl<'a> ToTokens for TemplateRenderer<'a> { out }); + let name = match self.location { + Some(ref loc) => quote! { #loc }, + None => quote! { + concat!( + file!(), + ":", + line!(), + ":", + column!(), + ":", + #root_col + ) + }, + }; + // Render and release the mutable borrow on context let roots = quote! { #( #root_printer ),* }; let node_printer = &context.dynamic_nodes; @@ -194,15 +238,7 @@ impl<'a> ToTokens for TemplateRenderer<'a> { out_tokens.append_all(quote! { static TEMPLATE: ::dioxus::core::Template = ::dioxus::core::Template { - name: concat!( - file!(), - ":", - line!(), - ":", - column!(), - ":", - #root_col - ), + name: #name, roots: &[ #roots ], node_paths: &[ #(#node_paths),* ], attr_paths: &[ #(#attr_paths),* ], @@ -219,14 +255,16 @@ impl<'a> ToTokens for TemplateRenderer<'a> { } } +#[cfg(feature = "hot_reload")] #[derive(Default, Debug)] struct DynamicMapping { - attribute_to_idx: HashMap>, + attribute_to_idx: std::collections::HashMap>, last_attribute_idx: usize, - node_to_idx: HashMap>, + node_to_idx: std::collections::HashMap>, last_element_idx: usize, } +#[cfg(feature = "hot_reload")] impl DynamicMapping { fn from(nodes: Vec) -> Self { let mut new = Self::default(); @@ -320,6 +358,7 @@ pub struct DynamicContext<'a> { } impl<'a> DynamicContext<'a> { + #[cfg(feature = "hot_reload")] fn update_node( &mut self, root: &'a BodyNode, @@ -529,6 +568,7 @@ impl<'a> DynamicContext<'a> { } } +#[cfg(feature = "hot_reload")] #[test] fn create_template() { let input = quote! { @@ -614,6 +654,7 @@ fn create_template() { ) } +#[cfg(feature = "hot_reload")] #[test] fn diff_template() { use dioxus_core::Scope; diff --git a/packages/rsx/src/node.rs b/packages/rsx/src/node.rs index 54448c883..1bda081d9 100644 --- a/packages/rsx/src/node.rs +++ b/packages/rsx/src/node.rs @@ -132,7 +132,10 @@ impl ToTokens for BodyNode { pat, expr, body, .. } = exp; - let renderer: TemplateRenderer = TemplateRenderer { roots: body }; + let renderer: TemplateRenderer = TemplateRenderer { + roots: body, + location: None, + }; tokens.append_all(quote! { __cx.make_node( @@ -219,7 +222,7 @@ impl Parse for ForLoop { fn parse(input: ParseStream) -> Result { let for_token: Token![for] = input.parse()?; - let pat = input.parse()?; + let pat = Pat::parse_single(input)?; let in_token: Token![in] = input.parse()?; let expr: Expr = input.call(Expr::parse_without_eager_brace)?; diff --git a/packages/signals/src/lib.rs b/packages/signals/src/lib.rs index 4e950e0dc..5b6a740cf 100644 --- a/packages/signals/src/lib.rs +++ b/packages/signals/src/lib.rs @@ -10,8 +10,6 @@ mod rt; use dioxus_core::ScopeState; pub use rt::*; -use crate::rt::claim_rt; - pub fn use_init_signal_rt(cx: &ScopeState) { cx.use_hook(|| { let rt = claim_rt(cx.schedule_update_any()); @@ -96,11 +94,7 @@ impl std::ops::Deref for Signal { impl std::clone::Clone for Signal { fn clone(&self) -> Self { - Self { - t: PhantomData, - id: self.id, - rt: self.rt, - } + *self } } diff --git a/packages/ssr/Cargo.toml b/packages/ssr/Cargo.toml index e91ee25eb..f336b5f99 100644 --- a/packages/ssr/Cargo.toml +++ b/packages/ssr/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.0" authors = ["Jonathan Kelley"] edition = "2018" description = "Dioxus render-to-string" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/ssr.html" keywords = ["dom", "ui", "gui", "react", "ssr"] @@ -12,6 +12,12 @@ keywords = ["dom", "ui", "gui", "react", "ssr"] [dependencies] dioxus-core = { workspace = true, features = ["serialize"] } askama_escape = "0.10.3" +thiserror = "1.0.23" +rustc-hash = "1.1.0" +lru = "0.10.0" +log = "0.4.13" +http = "0.2.9" +tokio = { version = "1.28", features = ["full"] } [dev-dependencies] dioxus = { workspace = true } diff --git a/packages/ssr/src/fs_cache.rs b/packages/ssr/src/fs_cache.rs new file mode 100644 index 000000000..0abf9c319 --- /dev/null +++ b/packages/ssr/src/fs_cache.rs @@ -0,0 +1,125 @@ +#![allow(non_snake_case)] + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, + time::Duration, +}; + +/// Information about the freshness of a rendered response +#[derive(Debug, Clone, Copy)] +pub struct RenderFreshness { + /// The age of the rendered response + age: u64, + /// The maximum age of the rendered response + max_age: Option, +} + +impl RenderFreshness { + /// Create new freshness information + pub fn new(age: u64, max_age: u64) -> Self { + Self { + age, + max_age: Some(max_age), + } + } + + /// Create new freshness information with only the age + pub fn new_age(age: u64) -> Self { + Self { age, max_age: None } + } + + /// Create new freshness information at the current time + pub fn now(max_age: Option) -> Self { + Self { + age: 0, + max_age: max_age.map(|d| d.as_secs()), + } + } + + /// Get the age of the rendered response in seconds + pub fn age(&self) -> u64 { + self.age + } + + /// Get the maximum age of the rendered response in seconds + pub fn max_age(&self) -> Option { + self.max_age + } + + /// Write the freshness to the response headers. + pub fn write(&self, headers: &mut http::HeaderMap) { + let age = self.age(); + headers.insert(http::header::AGE, age.into()); + if let Some(max_age) = self.max_age() { + headers.insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_str(&format!("max-age={}", max_age)).unwrap(), + ); + } + } +} + +struct WriteBuffer { + buffer: Vec, +} + +impl std::fmt::Write for WriteBuffer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.buffer.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +impl Deref for WriteBuffer { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.buffer + } +} + +impl DerefMut for WriteBuffer { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.buffer + } +} + +pub(crate) struct ValidCachedPath { + pub(crate) full_path: PathBuf, + pub(crate) timestamp: std::time::SystemTime, +} + +impl ValidCachedPath { + pub fn try_from_path(value: PathBuf) -> Option { + if value.extension() != Some(std::ffi::OsStr::new("html")) { + return None; + } + let timestamp = decode_timestamp(value.file_stem()?.to_str()?)?; + let full_path = value; + Some(Self { + full_path, + timestamp, + }) + } + + pub fn freshness(&self, max_age: Option) -> Option { + let age = self.timestamp.elapsed().ok()?.as_secs(); + let max_age = max_age.map(|max_age| max_age.as_secs()); + Some(RenderFreshness::new(age, max_age?)) + } +} + +fn decode_timestamp(timestamp: &str) -> Option { + let timestamp = u64::from_str_radix(timestamp, 16).ok()?; + Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp)) +} + +pub fn timestamp() -> String { + let datetime = std::time::SystemTime::now(); + let timestamp = datetime + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + format!("{:x}", timestamp) +} diff --git a/packages/ssr/src/incremental.rs b/packages/ssr/src/incremental.rs new file mode 100644 index 000000000..0e999b493 --- /dev/null +++ b/packages/ssr/src/incremental.rs @@ -0,0 +1,286 @@ +//! Incremental file based incremental rendering + +#![allow(non_snake_case)] + +use crate::fs_cache::ValidCachedPath; +use dioxus_core::{Element, Scope, VirtualDom}; +use rustc_hash::FxHasher; +use std::{ + hash::BuildHasherDefault, + io::Write, + ops::{Deref, DerefMut}, + path::PathBuf, + time::{Duration, SystemTime}, +}; +use tokio::io::{AsyncWrite, AsyncWriteExt, BufReader}; + +pub use crate::fs_cache::*; +pub use crate::incremental_cfg::*; + +/// An incremental renderer. +pub struct IncrementalRenderer { + pub(crate) static_dir: PathBuf, + #[allow(clippy::type_complexity)] + pub(crate) memory_cache: + Option), BuildHasherDefault>>, + pub(crate) invalidate_after: Option, + pub(crate) ssr_renderer: crate::Renderer, + pub(crate) map_path: PathMapFn, +} + +impl IncrementalRenderer { + /// Get the inner renderer. + pub fn renderer(&self) -> &crate::Renderer { + &self.ssr_renderer + } + + /// Get the inner renderer mutably. + pub fn renderer_mut(&mut self) -> &mut crate::Renderer { + &mut self.ssr_renderer + } + + /// Create a new incremental renderer builder. + pub fn builder() -> IncrementalRendererConfig { + IncrementalRendererConfig::new() + } + + /// Remove a route from the cache. + pub fn invalidate(&mut self, route: &str) { + if let Some(cache) = &mut self.memory_cache { + cache.pop(route); + } + if let Some(path) = self.find_file(route) { + let _ = std::fs::remove_file(path.full_path); + } + } + + /// Remove all routes from the cache. + pub fn invalidate_all(&mut self) { + if let Some(cache) = &mut self.memory_cache { + cache.clear(); + } + // clear the static directory + let _ = std::fs::remove_dir_all(&self.static_dir); + } + + fn track_timestamps(&self) -> bool { + self.invalidate_after.is_some() + } + + fn render_and_cache<'a, P: 'static, R: WrapBody + Send + Sync>( + &'a mut self, + route: String, + comp: fn(Scope

) -> Element, + props: P, + output: &'a mut (impl AsyncWrite + Unpin + Send), + rebuild_with: impl FnOnce(&mut VirtualDom), + renderer: &'a R, + ) -> impl std::future::Future> + 'a + Send + { + let mut html_buffer = WriteBuffer { buffer: Vec::new() }; + let result_1; + let result2; + { + let mut vdom = VirtualDom::new_with_props(comp, props); + rebuild_with(&mut vdom); + + result_1 = renderer.render_before_body(&mut *html_buffer); + result2 = self.ssr_renderer.render_to(&mut html_buffer, &vdom); + } + async move { + result_1?; + result2?; + renderer.render_after_body(&mut *html_buffer)?; + let html_buffer = html_buffer.buffer; + + output.write_all(&html_buffer).await?; + + self.add_to_cache(route, html_buffer) + } + } + + fn add_to_cache( + &mut self, + route: String, + html: Vec, + ) -> Result { + let file_path = self.route_as_path(&route); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + let file = std::fs::File::create(file_path)?; + let mut file = std::io::BufWriter::new(file); + file.write_all(&html)?; + self.add_to_memory_cache(route, html); + Ok(RenderFreshness::now(self.invalidate_after)) + } + + fn add_to_memory_cache(&mut self, route: String, html: Vec) { + if let Some(cache) = self.memory_cache.as_mut() { + cache.put(route, (SystemTime::now(), html)); + } + } + + fn promote_memory_cache>(&mut self, route: K) { + if let Some(cache) = self.memory_cache.as_mut() { + cache.promote(route.as_ref()) + } + } + + async fn search_cache( + &mut self, + route: String, + output: &mut (impl AsyncWrite + Unpin + std::marker::Send), + ) -> Result, IncrementalRendererError> { + // check the memory cache + if let Some((timestamp, cache_hit)) = self + .memory_cache + .as_mut() + .and_then(|cache| cache.get(&route)) + { + if let Ok(elapsed) = timestamp.elapsed() { + let age = elapsed.as_secs(); + if let Some(invalidate_after) = self.invalidate_after { + if elapsed < invalidate_after { + log::trace!("memory cache hit {:?}", route); + output.write_all(cache_hit).await?; + let max_age = invalidate_after.as_secs(); + return Ok(Some(RenderFreshness::new(age, max_age))); + } + } else { + log::trace!("memory cache hit {:?}", route); + output.write_all(cache_hit).await?; + return Ok(Some(RenderFreshness::new_age(age))); + } + } + } + // check the file cache + if let Some(file_path) = self.find_file(&route) { + if let Some(freshness) = file_path.freshness(self.invalidate_after) { + if let Ok(file) = tokio::fs::File::open(file_path.full_path).await { + let mut file = BufReader::new(file); + tokio::io::copy_buf(&mut file, output).await?; + log::trace!("file cache hit {:?}", route); + self.promote_memory_cache(&route); + return Ok(Some(freshness)); + } + } + } + Ok(None) + } + + /// Render a route or get it from cache. + pub async fn render( + &mut self, + route: String, + component: fn(Scope

) -> Element, + props: P, + output: &mut (impl AsyncWrite + Unpin + std::marker::Send), + rebuild_with: impl FnOnce(&mut VirtualDom), + renderer: &R, + ) -> Result { + // check if this route is cached + if let Some(freshness) = self.search_cache(route.to_string(), output).await? { + Ok(freshness) + } else { + // if not, create it + let freshness = self + .render_and_cache(route, component, props, output, rebuild_with, renderer) + .await?; + log::trace!("cache miss"); + Ok(freshness) + } + } + + fn find_file(&self, route: &str) -> Option { + let mut file_path = self.static_dir.clone(); + for segment in route.split('/') { + file_path.push(segment); + } + if let Some(deadline) = self.invalidate_after { + // find the first file that matches the route and is a html file + file_path.push("index"); + if let Ok(dir) = std::fs::read_dir(file_path) { + let mut file = None; + for entry in dir.flatten() { + if let Some(cached_path) = ValidCachedPath::try_from_path(entry.path()) { + if let Ok(elapsed) = cached_path.timestamp.elapsed() { + if elapsed < deadline { + file = Some(cached_path); + continue; + } + } + // if the timestamp is invalid or passed, delete the file + if let Err(err) = std::fs::remove_file(entry.path()) { + log::error!("Failed to remove file: {}", err); + } + } + } + file + } else { + None + } + } else { + file_path.push("index.html"); + file_path.exists().then_some({ + ValidCachedPath { + full_path: file_path, + timestamp: SystemTime::now(), + } + }) + } + } + + fn route_as_path(&self, route: &str) -> PathBuf { + let mut file_path = (self.map_path)(route); + if self.track_timestamps() { + file_path.push("index"); + file_path.push(timestamp()); + } else { + file_path.push("index"); + } + file_path.set_extension("html"); + file_path + } +} + +struct WriteBuffer { + buffer: Vec, +} + +impl std::fmt::Write for WriteBuffer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.buffer.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +impl Deref for WriteBuffer { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.buffer + } +} + +impl DerefMut for WriteBuffer { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.buffer + } +} + +/// An error that can occur while rendering a route or retrieving a cached route. +#[derive(Debug, thiserror::Error)] +pub enum IncrementalRendererError { + /// An formatting error occurred while rendering a route. + #[error("RenderError: {0}")] + RenderError(#[from] std::fmt::Error), + /// An IO error occurred while rendering a route. + #[error("IoError: {0}")] + IoError(#[from] std::io::Error), + /// An IO error occurred while rendering a route. + #[error("Other: {0}")] + Other(#[from] Box), +} diff --git a/packages/ssr/src/incremental_cfg.rs b/packages/ssr/src/incremental_cfg.rs new file mode 100644 index 000000000..1a4ea3418 --- /dev/null +++ b/packages/ssr/src/incremental_cfg.rs @@ -0,0 +1,134 @@ +#![allow(non_snake_case)] + +use crate::incremental::IncrementalRenderer; +use crate::incremental::IncrementalRendererError; + +use std::{ + io::Write, + num::NonZeroUsize, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +/// Something that can render a HTML page from a body. +pub trait WrapBody { + /// Render the HTML before the body + fn render_before_body(&self, to: &mut R) -> Result<(), IncrementalRendererError>; + /// Render the HTML after the body + fn render_after_body(&self, to: &mut R) -> Result<(), IncrementalRendererError>; +} + +/// The default page renderer +pub struct DefaultRenderer { + /// The HTML before the body. + pub before_body: String, + /// The HTML after the body. + pub after_body: String, +} + +impl Default for DefaultRenderer { + fn default() -> Self { + let before = r#" + + + + + Dioxus Application + + "#; + let after = r#" + "#; + Self { + before_body: before.to_string(), + after_body: after.to_string(), + } + } +} + +impl WrapBody for DefaultRenderer { + fn render_before_body(&self, to: &mut R) -> Result<(), IncrementalRendererError> { + to.write_all(self.before_body.as_bytes())?; + Ok(()) + } + + fn render_after_body(&self, to: &mut R) -> Result<(), IncrementalRendererError> { + to.write_all(self.after_body.as_bytes())?; + Ok(()) + } +} + +pub(crate) type PathMapFn = Arc PathBuf + Send + Sync>; + +/// A configuration for the incremental renderer. +#[derive(Clone)] +pub struct IncrementalRendererConfig { + static_dir: PathBuf, + memory_cache_limit: usize, + invalidate_after: Option, + map_path: Option, +} + +impl Default for IncrementalRendererConfig { + fn default() -> Self { + Self::new() + } +} + +impl IncrementalRendererConfig { + /// Create a new incremental renderer configuration. + pub fn new() -> Self { + Self { + static_dir: PathBuf::from("./static"), + memory_cache_limit: 10000, + invalidate_after: None, + map_path: None, + } + } + + /// Set a mapping from the route to the file path. This will override the default mapping configured with `static_dir`. + /// The function should return the path to the folder to store the index.html file in. + pub fn map_path PathBuf + Send + Sync + 'static>(mut self, map_path: F) -> Self { + self.map_path = Some(Arc::new(map_path)); + self + } + + /// Set the static directory. + pub fn static_dir>(mut self, static_dir: P) -> Self { + self.static_dir = static_dir.as_ref().to_path_buf(); + self + } + + /// Set the memory cache limit. + pub const fn memory_cache_limit(mut self, memory_cache_limit: usize) -> Self { + self.memory_cache_limit = memory_cache_limit; + self + } + + /// Set the invalidation time. + pub fn invalidate_after(mut self, invalidate_after: Duration) -> Self { + self.invalidate_after = Some(invalidate_after); + self + } + + /// Build the incremental renderer. + pub fn build(self) -> IncrementalRenderer { + let static_dir = self.static_dir.clone(); + IncrementalRenderer { + static_dir: self.static_dir.clone(), + memory_cache: NonZeroUsize::new(self.memory_cache_limit) + .map(|limit| lru::LruCache::with_hasher(limit, Default::default())), + invalidate_after: self.invalidate_after, + ssr_renderer: crate::Renderer::new(), + map_path: self.map_path.unwrap_or_else(move || { + Arc::new(move |route: &str| { + let mut path = static_dir.clone(); + for segment in route.split('/') { + path.push(segment); + } + path + }) + }), + } + } +} diff --git a/packages/ssr/src/lib.rs b/packages/ssr/src/lib.rs index 7d2e87da4..0f91e02d9 100644 --- a/packages/ssr/src/lib.rs +++ b/packages/ssr/src/lib.rs @@ -2,8 +2,12 @@ mod cache; pub mod config; +mod fs_cache; +pub mod incremental; +mod incremental_cfg; pub mod renderer; pub mod template; + use dioxus_core::{Element, LazyNodes, Scope, VirtualDom}; use std::cell::Cell; diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 098ddfe65..74143537f 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -4,7 +4,7 @@ version = "0.3.2" authors = ["Jonathan Kelley"] edition = "2018" description = "Web renderer for Dioxus using websys" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com/docs/0.3/guide/en/getting_started/web.html" keywords = ["dom", "ui", "gui", "react", "wasm"] diff --git a/packages/web/src/rehydrate.rs b/packages/web/src/rehydrate.rs index 9af23534b..5cc9ea57e 100644 --- a/packages/web/src/rehydrate.rs +++ b/packages/web/src/rehydrate.rs @@ -90,7 +90,7 @@ impl WebsysDom { // make sure we set the root node ids even if the node is not dynamic set_node( hydrated, - vnode.root_ids.get(i).ok_or(VNodeNotInitialized)?, + *vnode.root_ids.borrow().get(i).ok_or(VNodeNotInitialized)?, current_child.clone()?, ); @@ -115,6 +115,12 @@ impl WebsysDom { node: &TemplateNode, last_node_was_static_text: &mut bool, ) -> Result<(), RehydrationError> { + log::trace!("rehydrate template node: {:?}", node); + if let Ok(current_child) = current_child { + if log::log_enabled!(log::Level::Trace) { + web_sys::console::log_1(¤t_child.clone().into()); + } + } match node { TemplateNode::Element { children, attrs, .. @@ -198,6 +204,12 @@ impl WebsysDom { dynamic: &DynamicNode, last_node_was_static_text: &mut bool, ) -> Result<(), RehydrationError> { + log::trace!("rehydrate dynamic node: {:?}", dynamic); + if let Ok(current_child) = current_child { + if log::log_enabled!(log::Level::Trace) { + web_sys::console::log_1(¤t_child.clone().into()); + } + } match dynamic { dioxus_core::DynamicNode::Text(VText { id, .. }) => { // skip comment separator before node diff --git a/playwrite-tests/fullstack.spec.js b/playwright-tests/fullstack.spec.js similarity index 100% rename from playwrite-tests/fullstack.spec.js rename to playwright-tests/fullstack.spec.js diff --git a/playwrite-tests/fullstack/.gitignore b/playwright-tests/fullstack/.gitignore similarity index 100% rename from playwrite-tests/fullstack/.gitignore rename to playwright-tests/fullstack/.gitignore diff --git a/playwrite-tests/fullstack/Cargo.toml b/playwright-tests/fullstack/Cargo.toml similarity index 93% rename from playwrite-tests/fullstack/Cargo.toml rename to playwright-tests/fullstack/Cargo.toml index a11bfa461..9cca0d2fa 100644 --- a/playwrite-tests/fullstack/Cargo.toml +++ b/playwright-tests/fullstack/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "dioxus-playwrite-fullstack-test" +name = "dioxus-playwright-fullstack-test" version = "0.1.0" edition = "2021" publish = false diff --git a/playwrite-tests/fullstack/src/main.rs b/playwright-tests/fullstack/src/main.rs similarity index 81% rename from playwrite-tests/fullstack/src/main.rs rename to playwright-tests/fullstack/src/main.rs index 98caadbb6..cd6089c91 100644 --- a/playwrite-tests/fullstack/src/main.rs +++ b/playwright-tests/fullstack/src/main.rs @@ -1,4 +1,4 @@ -// This test is used by playwrite configured in the root of the repo +// This test is used by playwright configured in the root of the repo // Tests: // - Server functions // - SSR @@ -20,7 +20,7 @@ fn main() { { // Start hot reloading hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { - execute::shell("dioxus build --features web") + execute::shell("dx build --features web") .spawn() .unwrap() .wait() @@ -69,12 +69,11 @@ fn app(cx: Scope) -> Element { class: "server-button", onclick: move |_| { to_owned![text]; - let sc = cx.sc(); async move { if let Ok(data) = get_server_data().await { println!("Client received: {}", data); text.set(data.clone()); - post_server_data(sc, data).await.unwrap(); + post_server_data(data).await.unwrap(); } } }, @@ -85,12 +84,8 @@ fn app(cx: Scope) -> Element { } #[server(PostServerData)] -async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { - // The server context contains information about the current request and allows you to modify the response. - cx.response_headers_mut() - .insert("Set-Cookie", "foo=bar".parse().unwrap()); +async fn post_server_data(data: String) -> Result<(), ServerFnError> { println!("Server received: {}", data); - println!("Request parts are {:?}", cx.request_parts()); Ok(()) } diff --git a/playwrite-tests/liveview.spec.js b/playwright-tests/liveview.spec.js similarity index 100% rename from playwrite-tests/liveview.spec.js rename to playwright-tests/liveview.spec.js diff --git a/playwrite-tests/liveview/Cargo.toml b/playwright-tests/liveview/Cargo.toml similarity index 70% rename from playwrite-tests/liveview/Cargo.toml rename to playwright-tests/liveview/Cargo.toml index 8c4cad1ef..ea9f3eb24 100644 --- a/playwrite-tests/liveview/Cargo.toml +++ b/playwright-tests/liveview/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "dioxus-playwrite-liveview-test" +name = "dioxus-playwright-liveview-test" version = "0.0.1" edition = "2021" -description = "Playwrite test for Dioxus Liveview" -license = "MIT/Apache-2.0" +description = "Playwright test for Dioxus Liveview" +license = "MIT OR Apache-2.0" publish = false [dependencies] diff --git a/playwrite-tests/liveview/src/main.rs b/playwright-tests/liveview/src/main.rs similarity index 96% rename from playwrite-tests/liveview/src/main.rs rename to playwright-tests/liveview/src/main.rs index 42bd9a3e3..d28e3b7fd 100644 --- a/playwrite-tests/liveview/src/main.rs +++ b/playwright-tests/liveview/src/main.rs @@ -1,4 +1,4 @@ -// This test is used by playwrite configured in the root of the repo +// This test is used by playwright configured in the root of the repo use axum::{extract::ws::WebSocketUpgrade, response::Html, routing::get, Router}; use dioxus::prelude::*; diff --git a/package-lock.json b/playwright-tests/package-lock.json similarity index 76% rename from package-lock.json rename to playwright-tests/package-lock.json index 348b505d4..4f1b932ac 100644 --- a/package-lock.json +++ b/playwright-tests/package-lock.json @@ -9,23 +9,23 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.34.3" + "@playwright/test": "^1.36.1" } }, "node_modules/@playwright/test": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.3.tgz", - "integrity": "sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==", + "version": "1.36.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.1.tgz", + "integrity": "sha512-YK7yGWK0N3C2QInPU6iaf/L3N95dlGdbsezLya4n0ZCh3IL7VgPGxC6Gnznh9ApWdOmkJeleT2kMTcWPRZvzqg==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.34.3" + "playwright-core": "1.36.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" }, "optionalDependencies": { "fsevents": "2.3.2" @@ -52,15 +52,15 @@ } }, "node_modules/playwright-core": { - "version": "1.34.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz", - "integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==", + "version": "1.36.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.1.tgz", + "integrity": "sha512-7+tmPuMcEW4xeCL9cp9KxmYpQYHKkyjwoXRnoeTowaeNat8PoBMk/HwCYhqkH2fRkshfKEOiVus/IhID2Pg8kg==", "dev": true, "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" } } } diff --git a/package.json b/playwright-tests/package.json similarity index 90% rename from package.json rename to playwright-tests/package.json index ceb38db89..31d444420 100644 --- a/package.json +++ b/playwright-tests/package.json @@ -12,6 +12,6 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.34.3" + "@playwright/test": "^1.36.1" } } diff --git a/playwright-tests/playwright-report/index.html b/playwright-tests/playwright-report/index.html new file mode 100644 index 000000000..71730368d --- /dev/null +++ b/playwright-tests/playwright-report/index.html @@ -0,0 +1,62 @@ + + + + + + + + + Playwright Test Report + + + + +

+ + + + \ No newline at end of file diff --git a/playwright.config.js b/playwright-tests/playwright.config.js similarity index 77% rename from playwright.config.js rename to playwright-tests/playwright.config.js index 5d2c3642f..54fe810a7 100644 --- a/playwright.config.js +++ b/playwright-tests/playwright.config.js @@ -1,6 +1,6 @@ // @ts-check -const { defineConfig, devices } = require('@playwright/test'); -const path = require('path'); +const { defineConfig, devices } = require("@playwright/test"); +const path = require("path"); /** * Read environment variables from file. @@ -12,7 +12,7 @@ const path = require('path'); * @see https://playwright.dev/docs/test-configuration */ module.exports = defineConfig({ - testDir: './playwrite-tests', + testDir: ".", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -22,21 +22,21 @@ module.exports = defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://127.0.0.1:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: "chromium", + use: { ...devices["Desktop Chrome"] }, }, // { @@ -73,27 +73,28 @@ module.exports = defineConfig({ /* Run your local dev server before starting the tests */ webServer: [ { - command: 'cargo run --package dioxus-playwrite-liveview-test --bin dioxus-playwrite-liveview-test', + command: + "cargo run --package dioxus-playwright-liveview-test --bin dioxus-playwright-liveview-test", port: 3030, timeout: 10 * 60 * 1000, reuseExistingServer: !process.env.CI, stdout: "pipe", }, { - cwd: path.join(process.cwd(), 'playwrite-tests', 'web'), - command: 'dioxus serve', + cwd: path.join(process.cwd(), "web"), + command: "cargo run --package dioxus-cli -- serve", port: 8080, timeout: 10 * 60 * 1000, reuseExistingServer: !process.env.CI, stdout: "pipe", }, { - cwd: path.join(process.cwd(), 'playwrite-tests', 'fullstack'), - command: 'dioxus build --features web\ncargo run --release --features ssr --no-default-features', + cwd: path.join(process.cwd(), 'fullstack'), + command: 'cargo run --package dioxus-cli -- build --features web --release\ncargo run --release --features ssr', port: 3333, timeout: 10 * 60 * 1000, reuseExistingServer: !process.env.CI, stdout: "pipe", - } + }, ], }); diff --git a/playwrite-tests/web.spec.js b/playwright-tests/web.spec.js similarity index 100% rename from playwrite-tests/web.spec.js rename to playwright-tests/web.spec.js diff --git a/playwrite-tests/web/Cargo.toml b/playwright-tests/web/Cargo.toml similarity index 62% rename from playwrite-tests/web/Cargo.toml rename to playwright-tests/web/Cargo.toml index b1becd86e..0cad7b207 100644 --- a/playwrite-tests/web/Cargo.toml +++ b/playwright-tests/web/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "dioxus-playwrite-web-test" +name = "dioxus-playwright-web-test" version = "0.0.1" edition = "2021" -description = "Playwrite test for Dioxus Web" -license = "MIT/Apache-2.0" +description = "Playwright test for Dioxus Web" +license = "MIT OR Apache-2.0" publish = false [dependencies] diff --git a/playwrite-tests/web/src/main.rs b/playwright-tests/web/src/main.rs similarity index 95% rename from playwrite-tests/web/src/main.rs rename to playwright-tests/web/src/main.rs index d1881539c..c4850e943 100644 --- a/playwrite-tests/web/src/main.rs +++ b/playwright-tests/web/src/main.rs @@ -1,4 +1,4 @@ -// This test is used by playwrite configured in the root of the repo +// This test is used by playwright configured in the root of the repo use dioxus::prelude::*; use dioxus_web::use_eval;