Compare commits

..

82 Commits

Author SHA1 Message Date
Greg Johnston
8e03c00a36 avoid panic in use_navigate during SSR 2023-08-07 11:09:42 -04:00
Greg Johnston
809652474a 0.5.0-beta 2023-08-07 08:47:42 -04:00
Greg Johnston
36672c07b8 fix example 2023-08-07 08:37:48 -04:00
Greg Johnston
f3498ddbf5 sometimes Rust's variable scoping rules baffle me (should fix panic with overlapping BorrowMut in on_cleanup) 2023-08-07 08:36:31 -04:00
Greg Johnston
c5122865a3 erroneous log 2023-08-07 08:36:31 -04:00
Greg Johnston
406403dda9 fix unsetting of title when navigating between multiple pages 2023-08-07 08:36:31 -04:00
Greg Johnston
f88085ad1a fix Suspense-For-Suspense panic 2023-08-07 08:36:31 -04:00
Greg Johnston
bcce4d116e pull in changes to view macor from main 2023-08-07 08:36:31 -04:00
Greg Johnston
b898e2f309 remove removed API 2023-08-07 08:36:31 -04:00
Greg Johnston
a230157d54 on_cleanup untracked (fixes #1494) 2023-08-07 08:36:31 -04:00
Greg Johnston
9a365668b7 remove deprecated APIs 2023-08-07 08:36:31 -04:00
Greg Johnston
4a2a3cc213 embed requestAnimationFrame into navigate() 2023-08-07 08:36:31 -04:00
Greg Johnston
97bdbde2d6 BAD rebase, NO rebase. 2023-08-07 08:36:31 -04:00
Greg Johnston
d8f9678619 impl Serialize and Deserialize for signal types 2023-08-07 08:36:31 -04:00
Greg Johnston
85f567dd87 add some useful From implementations 2023-08-07 08:36:31 -04:00
Ben Wishovich
030f0a971a fix: render_route() path matching (#1479) 2023-08-07 08:36:31 -04:00
Greg Johnston
df4bc0a533 fix <For/> in SSR 2023-08-07 08:36:31 -04:00
Greg Johnston
8a6d4bea89 fix: restore missing fixes in <For/> (closes #1473) 2023-08-07 08:36:31 -04:00
Greg Johnston
e07eff19da alpha 2 2023-08-07 08:36:30 -04:00
Greg Johnston
0de52d6ef4 remove duplicate from merge 2023-08-07 08:36:30 -04:00
Greg Johnston
924ab445fb clippy 2023-08-07 08:36:30 -04:00
Greg Johnston
f1634db7d5 clean up window click listener 2023-08-07 08:36:30 -04:00
Greg Johnston
5c0011d99b cancelable handle for window_event_listener 2023-08-07 08:36:30 -04:00
Greg Johnston
213d8ec0a9 remove scope 2023-08-07 08:36:30 -04:00
Greg Johnston
fd3310c06f closes #1465 2023-08-07 08:36:30 -04:00
Greg Johnston
901cf87ef0 better error messages for expect_context 2023-08-07 08:36:30 -04:00
Greg Johnston
06ff34d8d2 was breaking something 2023-08-07 08:36:30 -04:00
Greg Johnston
a128f1e458 don't double-create children in nested suspense in ssr 2023-08-07 08:36:30 -04:00
Greg Johnston
ea637a446f track caller for better error messages 2023-08-07 08:36:30 -04:00
Greg Johnston
7c5c921a59 better error messages 2023-08-07 08:36:30 -04:00
Greg Johnston
f157a6e9ec clippy 2023-08-07 08:36:30 -04:00
Greg Johnston
e16f2bd720 fix routing progress display 2023-08-07 08:36:30 -04:00
Ben Wishovich
4f15c3ec69 Add render_route functions to respect SsrMode on routes when using custom handlers (#1460) 2023-08-07 08:36:30 -04:00
Greg Johnston
a2e13ed73e add <Route data/> loaders again 2023-08-07 08:36:30 -04:00
Greg Johnston
b4de4bb114 even even better error message 2023-08-07 08:36:30 -04:00
Greg Johnston
abd118d1bb typo in doctest 2023-08-07 08:36:30 -04:00
Greg Johnston
f222d3bbc6 fix ssr tests 2023-08-07 08:36:30 -04:00
Greg Johnston
ca00b84aea fix release build SSR 2023-08-07 08:36:30 -04:00
Greg Johnston
9c90f06438 gtfo 2023-08-07 08:36:30 -04:00
Greg Johnston
d727aed5d6 don't need to render EachItem markers for View::Element nodes 2023-08-07 08:36:30 -04:00
Greg Johnston
febf1a11a5 restore continuing from current id 2023-08-07 08:36:30 -04:00
Greg Johnston
fcea83f718 add separate error field in hydration keys 2023-08-07 08:36:30 -04:00
IcosaHedron
9aa8b6705e cleanups must be called before properties are disposed (#1449) 2023-08-07 08:36:30 -04:00
Greg Johnston
6bf2a0a8c5 add MaybeProp 2023-08-07 08:36:30 -04:00
Greg Johnston
67fd08d5ab clean up logs 2023-08-07 08:36:30 -04:00
Greg Johnston
96cef1a48a fix spawn docs examples 2023-08-07 08:36:30 -04:00
Greg Johnston
8602ca3a6f fix reactive tests 2023-08-07 08:36:30 -04:00
Greg Johnston
a6eab0ec3c cx in tests 2023-08-07 08:36:30 -04:00
Greg Johnston
4c8f9bf834 v0.5.0-alpha 2023-08-07 08:36:30 -04:00
Greg Johnston
d56cdc792f add some panic docs 2023-08-07 08:36:30 -04:00
Greg Johnston
e352ed5d0d clean up docs 2023-08-07 08:36:30 -04:00
Greg Johnston
7fa104efbc clippy 2023-08-07 08:36:30 -04:00
Greg Johnston
a516cc8b71 fix errorboundary 2023-08-07 08:36:30 -04:00
Greg Johnston
8af0481936 fix views in component macro docs 2023-08-07 08:36:30 -04:00
Greg Johnston
6310244d69 component macro 2023-08-07 08:36:30 -04:00
Greg Johnston
8997c2f420 fix issues in examples 2023-08-07 08:36:30 -04:00
Greg Johnston
5b60d32464 tests 2023-08-07 08:36:30 -04:00
Greg Johnston
fd69248cd5 ssr tests 2023-08-07 08:36:30 -04:00
Greg Johnston
c97c300656 fix 2023-08-07 08:36:30 -04:00
Greg Johnston
be8390b77c fixing tests 2023-08-07 08:36:30 -04:00
Greg Johnston
587abfc299 clippy 2023-08-07 08:36:30 -04:00
Greg Johnston
fb62fb2707 remove _ = cx; 2023-08-07 08:36:30 -04:00
Greg Johnston
ec7b986cfb clippy 2023-08-07 08:36:30 -04:00
Greg Johnston
e0349c102d fix merge issues 2023-08-07 08:36:30 -04:00
Greg Johnston
a67cca986e clean up 2023-08-07 08:36:30 -04:00
Greg Johnston
559ebbfc4a clean up 2023-08-07 08:36:30 -04:00
Greg Johnston
36ce979258 remove Scope from docs 2023-08-07 08:36:30 -04:00
Greg Johnston
0d55c61b0a remove cx from docs 2023-08-07 08:36:30 -04:00
Greg Johnston
7dba8f29d1 fix Viz 2023-08-07 08:36:30 -04:00
Greg Johnston
b81cb24ded move forbid(unsafe) to crate level 2023-08-07 08:36:30 -04:00
Greg Johnston
0698b38280 fix watch 2023-08-07 08:36:30 -04:00
Greg Johnston
5592174152 even better error msg 2023-08-07 08:36:30 -04:00
Greg Johnston
1441bf9532 better error when failing to deserialize resource JSON 2023-08-07 08:36:30 -04:00
Greg Johnston
7af4235247 fix suspense ownership chain 2023-08-07 08:36:30 -04:00
Greg Johnston
07718e9c24 unused import 2023-08-07 08:36:30 -04:00
Greg Johnston
34f522291c get AnimatedOutlet working 2023-08-07 08:36:30 -04:00
Greg Johnston
bbcde97315 restore <AnimatedRoutes/> 2023-08-07 08:36:30 -04:00
Greg Johnston
a98752240a fix suspense 2023-08-07 08:36:30 -04:00
Greg Johnston
4130ecb782 cleaning up tests 2023-08-07 08:36:30 -04:00
Greg Johnston
b417960dfb default impls 2023-08-07 08:36:30 -04:00
Greg Johnston
3d6c7022dd don't store runtime ID in signals 2023-08-07 08:36:30 -04:00
Greg Johnston
201a302cc5 squash work on reactive ownership 2023-08-07 08:36:30 -04:00
201 changed files with 1443 additions and 5206 deletions

View File

@@ -9,21 +9,66 @@ on:
- main
jobs:
get-leptos-changed:
uses: ./.github/workflows/get-leptos-changed.yml
setup:
name: Get Examples
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
source_changed: ${{ steps.set-source-changed.outputs.source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v3
get-examples-matrix:
uses: ./.github/workflows/get-examples-matrix.yml
- name: Install JQ Tool
uses: mbround18/install-jq@v1
test:
- name: Set Matrix
id: set-matrix
run: |
examples=$(ls examples |
awk '{print "examples/" $0}' |
grep -v examples/README.md |
grep -v examples/Makefile.toml |
grep -v examples/cargo-make |
grep -v examples/gtk |
jq -R -s -c 'split("\n")[:-1]')
echo "Example Directories: $examples"
echo "matrix={\"directory\":$examples}" >> "$GITHUB_OUTPUT"
- name: Get source files that changed
id: changed-source
uses: tj-actions/changed-files@v36
with:
files: |
integrations
leptos
leptos_config
leptos_dom
leptos_hot_reload
leptos_macro
leptos_reactive
leptos_server
meta
router
server_fn
server_fn_macro
- name: List source files that changed
run: echo '${{ steps.changed-source.outputs.all_changed_files }}'
- name: Set source_changed
id: set-source-changed
run: |
echo "source_changed=${{ steps.changed-source.outputs.any_changed }}" >> "$GITHUB_OUTPUT"
matrix-job:
name: Check
needs: [get-leptos-changed, get-examples-matrix]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true'
needs: [setup]
if: needs.setup.outputs.source_changed == 'true'
strategy:
matrix: ${{ fromJSON(needs.get-examples-matrix.outputs.matrix) }}
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "check"
toolchain: nightly

View File

@@ -2,25 +2,45 @@ name: Check stable
on:
push:
branches:
- main
branches: [main]
pull_request:
branches:
- main
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
get-leptos-changed:
uses: ./.github/workflows/get-leptos-changed.yml
test:
name: Check
needs: [get-leptos-changed]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true'
name: Check examples ${{ matrix.os }} (using rustc ${{ matrix.rust }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
directory: [examples/counters_stable, examples/counter_without_macros]
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "check"
toolchain: stable
rust:
- stable
os:
- ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Add wasm32-unknown-unknown
run: rustup target add wasm32-unknown-unknown
- name: Setup cargo-make
uses: davidB/rust-cargo-make@v1
- name: Cargo generate-lockfile
run: cargo generate-lockfile
- uses: Swatinem/rust-cache@v2
- name: Run cargo check on all examples
run: cargo make --profile=github-actions check-stable

View File

@@ -1,32 +0,0 @@
name: CI Changed Examples
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
get-example-changed:
uses: ./.github/workflows/get-example-changed.yml
get-matrix:
needs: [get-example-changed]
uses: ./.github/workflows/get-changed-examples-matrix.yml
with:
example_changed: ${{ fromJSON(needs.get-example-changed.outputs.example_changed) }}
test:
name: CI
needs: [get-example-changed, get-matrix]
if: needs.get-example-changed.outputs.example_changed == 'true'
strategy:
matrix: ${{ fromJSON(needs.get-matrix.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "ci"
toolchain: nightly

View File

@@ -9,13 +9,45 @@ on:
- main
jobs:
get-leptos-changed:
uses: ./.github/workflows/get-leptos-changed.yml
setup:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
source_changed: ${{ steps.set-source-changed.outputs.source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v3
test:
- name: Get source files that changed
id: changed-source
uses: tj-actions/changed-files@v36
with:
files: |
integrations
leptos
leptos_config
leptos_dom
leptos_hot_reload
leptos_macro
leptos_reactive
leptos_server
meta
router
server_fn
server_fn_macro
- name: List source files that changed
run: echo '${{ steps.changed-source.outputs.all_changed_files }}'
- name: Set source_changed
id: set-source-changed
run: |
echo "source_changed=${{ steps.changed-source.outputs.any_changed }}" >> "$GITHUB_OUTPUT"
matrix-job:
name: CI
needs: [get-leptos-changed]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true'
needs: [setup]
if: needs.setup.outputs.source_changed == 'true'
strategy:
matrix:
directory:
@@ -41,4 +73,3 @@ jobs:
with:
directory: ${{ matrix.directory }}
cargo_make_task: "ci"
toolchain: nightly

View File

@@ -1,39 +0,0 @@
name: Examples Changed Call
on:
workflow_call:
outputs:
example_changed:
description: "Example Changed"
value: ${{ jobs.get-example-changed.outputs.example_changed }}
jobs:
get-example-changed:
name: Get Example Changed
runs-on: ubuntu-latest
outputs:
example_changed: ${{ steps.set-example-changed.outputs.example_changed }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get example files that changed
id: changed-files
uses: tj-actions/changed-files@v36
with:
files: |
examples
!examples/cargo-make
!examples/gtk
!examples/Makefile.toml
!examples/README.md
- name: List example files that changed
run: echo '${{ steps.changed-files.outputs.all_changed_files }}'
- name: Set example_changed
id: set-example-changed
run: |
echo "example_changed=${{ steps.changed-files.outputs.any_changed }}" >> "$GITHUB_OUTPUT"

View File

@@ -1,40 +0,0 @@
name: Get Examples Matrix Call
on:
workflow_call:
outputs:
matrix:
description: "Matrix"
value: ${{ jobs.create.outputs.matrix }}
jobs:
create:
name: Create Examples Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install JQ Tool
uses: mbround18/install-jq@v1
- name: Set Matrix
id: set-matrix
run: |
examples=$(ls examples |
awk '{print "examples/" $0}' |
grep -v examples/README.md |
grep -v examples/Makefile.toml |
grep -v examples/cargo-make |
grep -v examples/gtk |
jq -R -s -c 'split("\n")[:-1]')
echo "Example Directories: $examples"
echo "matrix={\"directory\":$examples}" >> "$GITHUB_OUTPUT"
- name: Print Location Info
run: |
echo "Workspace: ${{ github.workspace }}"
pwd
ls | sort -u

View File

@@ -1,44 +0,0 @@
name: Get Leptos Changed Call
on:
workflow_call:
outputs:
leptos_changed:
description: "Leptos Changed"
value: ${{ jobs.create.outputs.leptos_changed }}
jobs:
create:
name: Detect Source Change
runs-on: ubuntu-latest
outputs:
leptos_changed: ${{ steps.set-source-changed.outputs.leptos_changed }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get source files that changed
id: changed-source
uses: tj-actions/changed-files@v36
with:
files: |
integrations
leptos
leptos_config
leptos_dom
leptos_hot_reload
leptos_macro
leptos_reactive
leptos_server
meta
router
server_fn
server_fn_macro
- name: List source files that changed
run: echo '${{ steps.changed-source.outputs.all_changed_files }}'
- name: Set leptos_changed
id: set-source-changed
run: |
echo "leptos_changed=${{ steps.changed-source.outputs.any_changed }}" >> "$GITHUB_OUTPUT"

View File

@@ -1,8 +1,9 @@
name: Deploy book
on:
push:
tags:
- '*-?v[0-9]+*'
paths: ['docs/book/**']
branches:
- main
jobs:
deploy:
@@ -33,4 +34,4 @@ jobs:
mv ../book/* .
git add .
git commit -m "Deploy book $GITHUB_SHA to gh-pages"
git push --force --set-upstream origin gh-pages
git push --force --set-upstream origin gh-pages

View File

@@ -9,27 +9,34 @@ on:
cargo_make_task:
required: true
type: string
toolchain:
required: true
type: string
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
test:
name: Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }})
runs-on: ubuntu-latest
name: Run ${{ matrix.os }} (using rustc ${{ matrix.rust }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
rust:
- nightly
os:
- ubuntu-latest
steps:
# Setup environment
- name: Install playwright browser dependencies
run: |
sudo apt-get update
sudo apt-get install libegl1 libvpx7 libevent-2.1-7 libopus0 libopengl0 libwoff1 libharfbuzz-icu0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0 libhyphen0 libmanette-0.2-0 libgles2 gstreamer1.0-libav
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ inputs.toolchain }}
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
@@ -77,35 +84,12 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Maybe install chromedriver
run: |
project_makefile=${{inputs.directory}}/Makefile.toml
webdriver_count=$(cat $project_makefile | grep "cargo-make/webdriver.toml" | wc -l)
if [ $webdriver_count -eq 1 ]; then
if ! command -v chromedriver &>/dev/null; then
echo chromedriver required
sudo apt-get update
sudo apt-get install chromium-chromedriver
else
echo chromedriver is already installed
fi
else
echo chromedriver is not required
fi
- name: Maybe install playwright browser dependencies
run: |
playwright_count=$(find ${{inputs.directory}} -name playwright.config.ts | wc -l)
if [ $playwright_count -eq 1 ]; then
echo playwright required
sudo apt-get update
sudo apt-get install libegl1 libvpx7 libevent-2.1-7 libopus0 libopengl0 libwoff1 libharfbuzz-icu0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0 libhyphen0 libmanette-0.2-0 libgles2 gstreamer1.0-libav
else
echo playwright is not required
fi
# Run Cargo Make Task
- name: ${{ inputs.cargo_make_task }}
run: |
cd ${{ inputs.directory }}
cargo make --profile=github-actions ${{ inputs.cargo_make_task }}
if [ "${{ inputs.directory }}" = "INTERNAL" ]; then
echo No verification required
else
cd ${{ inputs.directory }}
cargo make --profile=github-actions ${{ inputs.cargo_make_task }}
fi

View File

@@ -1,4 +1,4 @@
name: CI Examples
name: Verify All Examples
on:
workflow_dispatch:
@@ -10,17 +10,38 @@ on:
- cron: "0 8 * * *"
jobs:
get-examples-matrix:
uses: ./.github/workflows/get-examples-matrix.yml
setup:
name: Get Examples
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v3
test:
name: CI
needs: [get-examples-matrix]
- name: Install JQ Tool
uses: mbround18/install-jq@v1
- name: Set Matrix
id: set-matrix
run: |
examples=$(ls examples |
awk '{print "examples/" $0}' |
grep -v examples/README.md |
grep -v examples/Makefile.toml |
grep -v examples/cargo-make |
grep -v examples/gtk |
jq -R -s -c 'split("\n")[:-1]')
echo "Example Directories: $examples"
echo "matrix={\"directory\":$examples}" >> "$GITHUB_OUTPUT"
matrix-job:
name: Verify
needs: [setup]
strategy:
matrix: ${{ fromJSON(needs.get-examples-matrix.outputs.matrix) }}
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "ci"
toolchain: nightly
cargo_make_task: "verify-flow"

View File

@@ -1,20 +1,16 @@
name: Changed Examples Matrix Call
name: Verify Changed Examples
on:
workflow_call:
inputs:
example_changed:
description: "Example Changed"
required: true
type: boolean
outputs:
matrix:
description: "Matrix"
value: ${{ jobs.get-example-changed.outputs.matrix }}
push:
branches:
- main
pull_request:
branches:
- main
jobs:
get-example-changed:
name: Get Changed Example Matrix
setup:
name: Get Changes
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -24,6 +20,16 @@ jobs:
with:
fetch-depth: 0
- name: Get all example files that changed
id: changed-files
uses: tj-actions/changed-files@v36
with:
files: |
examples
- name: List all example files that changed
run: echo '${{ steps.changed-files.outputs.all_changed_files }}'
- name: Get example project directories that changed
id: changed-dirs
uses: tj-actions/changed-files@v36
@@ -45,10 +51,21 @@ jobs:
- name: Set Matrix
id: set-matrix
run: |
if [ ${{ inputs.example_changed }} == 'true' ]; then
if [ ${{ steps.changed-files.outputs.any_changed }} == 'true' ]; then
# Create matrix with changed directories
echo "matrix={\"directory\":${{ steps.changed-dirs.outputs.all_changed_files }}}" >> "$GITHUB_OUTPUT"
else
# Create matrix with one item to prevent an empty vector error
echo "matrix={\"directory\":[\"NO_CHANGE\"]}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":[\"INTERNAL\"]}" >> "$GITHUB_OUTPUT"
fi
matrix-job:
name: Verify
needs: [setup]
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
fail-fast: false
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
cargo_make_task: "verify-flow"

View File

@@ -26,22 +26,22 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.5.0"
version = "0.5.0-beta"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.5.0" }
leptos_dom = { path = "./leptos_dom", version = "0.5.0" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.5.0" }
leptos_macro = { path = "./leptos_macro", version = "0.5.0" }
leptos_reactive = { path = "./leptos_reactive", version = "0.5.0" }
leptos_server = { path = "./leptos_server", version = "0.5.0" }
server_fn = { path = "./server_fn", version = "0.5.0" }
server_fn_macro = { path = "./server_fn_macro", version = "0.5.0" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.5.0" }
leptos_config = { path = "./leptos_config", version = "0.5.0" }
leptos_router = { path = "./router", version = "0.5.0" }
leptos_meta = { path = "./meta", version = "0.5.0" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.5.0" }
leptos = { path = "./leptos", version = "0.5.0-beta" }
leptos_dom = { path = "./leptos_dom", version = "0.5.0-beta" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.5.0-beta" }
leptos_macro = { path = "./leptos_macro", version = "0.5.0-beta" }
leptos_reactive = { path = "./leptos_reactive", version = "0.5.0-beta" }
leptos_server = { path = "./leptos_server", version = "0.5.0-beta" }
server_fn = { path = "./server_fn", version = "0.5.0-beta" }
server_fn_macro = { path = "./server_fn_macro", version = "0.5.0-beta" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.5.0-beta" }
leptos_config = { path = "./leptos_config", version = "0.5.0-beta" }
leptos_router = { path = "./router", version = "0.5.0-beta" }
leptos_meta = { path = "./meta", version = "0.5.0-beta" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.5.0-beta" }
[profile.release]
codegen-units = 1

View File

@@ -16,9 +16,9 @@
use leptos::*;
#[component]
pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
pub fn SimpleCounter(cx: Scope, initial_value: i32) -> impl IntoView {
// create a reactive signal with the initial value
let (value, set_value) = create_signal(initial_value);
let (value, set_value) = create_signal(cx, initial_value);
// create event handlers for our buttons
// note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures
@@ -27,29 +27,23 @@ pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
let increment = move |_| set_value.update(|value| *value += 1);
// create user interfaces with the declarative `view!` macro
view! {
view! { cx,
<div>
<button on:click=clear>Clear</button>
<button on:click=decrement>-1</button>
// text nodes can be quoted or unquoted
<button on:click=clear>"Clear"</button>
<button on:click=decrement>"-1"</button>
<span>"Value: " {value} "!"</span>
<button on:click=increment>+1</button>
<button on:click=increment>"+1"</button>
</div>
}
}
// Easy to use with Trunk (trunkrs.dev) or with a simple wasm-bindgen setup
pub fn main() {
mount_to_body(|| view! {
<SimpleCounter initial_value=3 />
})
mount_to_body(|cx| view! { cx, <SimpleCounter initial_value=3 /> })
}
```
### Important Note
This example, and the entire `main` branch, now reflect the upcoming `0.5.0` release. You can use `0.5.0` with the `0.5.0-beta` release on crates.io or by a git dependency on the `main` branch of this repo. [Click here for the 0.4.9 `README`](https://crates.io/crates/leptos).
## About the Framework
Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces.
@@ -119,7 +113,7 @@ People usually mean one of three things by this question.
1. **Are the APIs stable?** i.e., will I have to rewrite my whole app from Leptos 0.1 to 0.2 to 0.3 to 0.4, or can I write it now and benefit from new features and updates as new versions come?
The APIs are basically settled. Were adding new features, but were very happy with where the type system and patterns have landed. I would not expect major breaking changes to your code to adapt to future releases, in terms of architecture.
The APIs are basically settled. Were adding new features, but were very happy with where the type system and patterns have landed. I would not expect major breaking changes to your code to adapt to future releases. The sorts of breaking changes that we discuss are things like “Oh yeah, that function should probably take `cx` as its argument...” not major changes to the way you write your application.
2. **Are there bugs?**
@@ -158,13 +152,13 @@ There are some practical differences that make a significant difference:
- **Templating:** Leptos uses a JSX-like template format (built on [syn-rsx](https://github.com/stoically/syn-rsx)) for its `view` macro. Sycamore offers the choice of its own templating DSL or a builder syntax.
- **Server integration:** Leptos provides primitives that encourage HTML streaming and allow for easy async integration and RPC calls, even without WASM enabled, making it easy to opt into integrations between your frontend and backend code without pushing you toward any particular metaframework patterns.
- **Read-write segregation:** Leptos, like Solid, encourages read-write segregation between signal getters and setters, so you end up accessing signals with tuples like `let (count, set_count) = create_signal(0);` _(If you prefer or if it's more convenient for your API, you can use [`create_rw_signal`](https://docs.rs/leptos/latest/leptos/fn.create_rw_signal.html) to give a unified read/write signal.)_
- **Read-write segregation:** Leptos, like Solid, encourages read-write segregation between signal getters and setters, so you end up accessing signals with tuples like `let (count, set_count) = create_signal(cx, 0);` _(If you prefer or if it's more convenient for your API, you can use [`create_rw_signal`](https://docs.rs/leptos/latest/leptos/fn.create_rw_signal.html) to give a unified read/write signal.)_
- **Signals are functions:** In Leptos, you can call a signal to access it rather than calling a specific method (so, `count()` instead of `count.get()`) This creates a more consistent mental model: accessing a reactive value is always a matter of calling a function. For example:
```rust
let (count, set_count) = create_signal(0); // a signal
let (count, set_count) = create_signal(cx, 0); // a signal
let double_count = move || count() * 2; // a derived signal
let memoized_count = create_memo(move |_| count() * 3); // a memo
let memoized_count = create_memo(cx, move |_| count() * 3); // a memo
// all are accessed by calling them
assert_eq!(count(), 0);
assert_eq!(double_count(), 0);

View File

@@ -7,15 +7,15 @@ fn leptos_deep_creation(b: &mut Bencher) {
let runtime = create_runtime();
b.iter(|| {
create_scope(runtime, || {
let signal = create_rw_signal(0);
create_scope(runtime, |cx| {
let signal = create_rw_signal(cx, 0);
let mut memos = Vec::<Memo<usize>>::new();
for _ in 0..1000usize {
let prev = memos.last().copied();
if let Some(prev) = prev {
memos.push(create_memo(move |_| prev.get() + 1));
memos.push(create_memo(cx, move |_| prev.get() + 1));
} else {
memos.push(create_memo(move |_| signal.get() + 1));
memos.push(create_memo(cx, move |_| signal.get() + 1));
}
}
})
@@ -31,14 +31,14 @@ fn leptos_deep_update(b: &mut Bencher) {
let runtime = create_runtime();
b.iter(|| {
create_scope(runtime, || {
let signal = create_rw_signal(0);
create_scope(runtime, |cx| {
let signal = create_rw_signal(cx, 0);
let mut memos = Vec::<Memo<usize>>::new();
for _ in 0..1000usize {
if let Some(prev) = memos.last().copied() {
memos.push(create_memo(move |_| prev.get() + 1));
memos.push(create_memo(cx, move |_| prev.get() + 1));
} else {
memos.push(create_memo(move |_| signal.get() + 1));
memos.push(create_memo(cx, move |_| signal.get() + 1));
}
}
signal.set(1);
@@ -56,11 +56,12 @@ fn leptos_narrowing_down(b: &mut Bencher) {
let runtime = create_runtime();
b.iter(|| {
create_scope(runtime, || {
let sigs = (0..1000).map(|n| create_signal(n)).collect::<Vec<_>>();
create_scope(runtime, |cx| {
let sigs =
(0..1000).map(|n| create_signal(cx, n)).collect::<Vec<_>>();
let reads = sigs.iter().map(|(r, _)| *r).collect::<Vec<_>>();
let writes = sigs.iter().map(|(_, w)| *w).collect::<Vec<_>>();
let memo = create_memo(move |_| {
let memo = create_memo(cx, move |_| {
reads.iter().map(|r| r.get()).sum::<i32>()
});
assert_eq!(memo(), 499500);
@@ -77,10 +78,10 @@ fn leptos_fanning_out(b: &mut Bencher) {
let runtime = create_runtime();
b.iter(|| {
create_scope(runtime, || {
let sig = create_rw_signal(0);
create_scope(runtime, |cx| {
let sig = create_rw_signal(cx, 0);
let memos = (0..1000)
.map(|_| create_memo(move |_| sig.get()))
.map(|_| create_memo(cx, move |_| sig.get()))
.collect::<Vec<_>>();
assert_eq!(memos.iter().map(|m| m.get()).sum::<i32>(), 0);
sig.set(1);
@@ -98,16 +99,17 @@ fn leptos_narrowing_update(b: &mut Bencher) {
let runtime = create_runtime();
b.iter(|| {
create_scope(runtime, || {
create_scope(runtime, |cx| {
let acc = Rc::new(Cell::new(0));
let sigs = (0..1000).map(|n| create_signal(n)).collect::<Vec<_>>();
let sigs =
(0..1000).map(|n| create_signal(cx, n)).collect::<Vec<_>>();
let reads = sigs.iter().map(|(r, _)| *r).collect::<Vec<_>>();
let writes = sigs.iter().map(|(_, w)| *w).collect::<Vec<_>>();
let memo = create_memo(move |_| {
let memo = create_memo(cx, move |_| {
reads.iter().map(|r| r.get()).sum::<i32>()
});
assert_eq!(memo(), 499500);
create_isomorphic_effect({
create_isomorphic_effect(cx, {
let acc = Rc::clone(&acc);
move |_| {
acc.set(memo());
@@ -139,9 +141,9 @@ fn leptos_scope_creation_and_disposal(b: &mut Bencher) {
.map(|_| {
create_scope(runtime, {
let acc = Rc::clone(&acc);
move || {
let (r, w) = create_signal(0);
create_isomorphic_effect({
move |cx| {
let (r, w) = create_signal(cx, 0);
create_isomorphic_effect(cx, {
move |_| {
acc.set(r());
}
@@ -161,9 +163,7 @@ fn leptos_scope_creation_and_disposal(b: &mut Bencher) {
#[bench]
fn rs_deep_update(b: &mut Bencher) {
use reactive_signals::{
runtimes::ClientRuntime, signal, types::Func, Scope, Signal,
};
use reactive_signals::{Scope, Signal, signal, runtimes::ClientRuntime, types::Func};
let sc = ClientRuntime::new_root_scope();
b.iter(|| {
@@ -184,9 +184,7 @@ fn rs_deep_update(b: &mut Bencher) {
#[bench]
fn rs_fanning_out(b: &mut Bencher) {
use reactive_signals::{
runtimes::ClientRuntime, signal, types::Func, Scope, Signal,
};
use reactive_signals::{Scope, Signal, signal, runtimes::ClientRuntime, types::Func};
let cx = ClientRuntime::new_root_scope();
b.iter(|| {
@@ -202,17 +200,18 @@ fn rs_fanning_out(b: &mut Bencher) {
#[bench]
fn rs_narrowing_update(b: &mut Bencher) {
use reactive_signals::{
runtimes::ClientRuntime, signal, types::Func, Scope, Signal,
};
use reactive_signals::{Scope, Signal, signal, runtimes::ClientRuntime, types::Func};
let cx = ClientRuntime::new_root_scope();
b.iter(|| {
let acc = Rc::new(Cell::new(0));
let sigs = (0..1000).map(|n| signal!(cx, n)).collect::<Vec<_>>();
let sigs =
(0..1000).map(|n| signal!(cx, n)).collect::<Vec<_>>();
let memo = signal!(cx, {
let sigs = sigs.clone();
move || sigs.iter().map(|r| r.get()).sum::<i32>()
move || {
sigs.iter().map(|r| r.get()).sum::<i32>()
}
});
assert_eq!(memo.get(), 499500);
signal!(cx, {

View File

@@ -7,7 +7,7 @@ fn leptos_ssr_bench(b: &mut Bencher) {
leptos_dom::HydrationCtx::reset_id();
_ = create_scope(create_runtime(), |cx| {
#[component]
fn Counter(initial: i32) -> impl IntoView {
fn Counter(cx: Scope, initial: i32) -> impl IntoView {
let (value, set_value) = create_signal(cx, initial);
view! {
cx,

View File

@@ -1,7 +1,7 @@
pub use leptos::*;
use miniserde::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Todos(pub Vec<Todo>);
@@ -9,13 +9,13 @@ pub struct Todos(pub Vec<Todo>);
const STORAGE_KEY: &str = "todos-leptos";
impl Todos {
pub fn new() -> Self {
pub fn new(cx: Scope) -> Self {
Self(vec![])
}
pub fn new_with_1000() -> Self {
pub fn new_with_1000(cx: Scope) -> Self {
let todos = (0..1000)
.map(|id| Todo::new(id, format!("Todo #{id}")))
.map(|id| Todo::new(cx, id, format!("Todo #{id}")))
.collect();
Self(todos)
}
@@ -72,17 +72,13 @@ pub struct Todo {
}
impl Todo {
pub fn new(id: usize, title: String) -> Self {
Self::new_with_completed(id, title, false)
pub fn new(cx: Scope, id: usize, title: String) -> Self {
Self::new_with_completed(cx, id, title, false)
}
pub fn new_with_completed(
id: usize,
title: String,
completed: bool,
) -> Self {
let (title, set_title) = create_signal(title);
let (completed, set_completed) = create_signal(completed);
pub fn new_with_completed(cx: Scope, id: usize, title: String, completed: bool) -> Self {
let (title, set_title) = create_signal(cx, title);
let (completed, set_completed) = create_signal(cx, completed);
Self {
id,
title,
@@ -102,7 +98,7 @@ const ESCAPE_KEY: u32 = 27;
const ENTER_KEY: u32 = 13;
#[component]
pub fn TodoMVC(todos: Todos) -> impl IntoView {
pub fn TodoMVC(cx: Scope, todos: Todos) -> impl IntoView {
let mut next_id = todos
.0
.iter()
@@ -111,10 +107,10 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
.map(|last| last + 1)
.unwrap_or(0);
let (todos, set_todos) = create_signal(todos);
provide_context(set_todos);
let (todos, set_todos) = create_signal(cx, todos);
provide_context(cx, set_todos);
let (mode, set_mode) = create_signal(Mode::All);
let (mode, set_mode) = create_signal(cx, Mode::All);
let add_todo = move |ev: web_sys::KeyboardEvent| {
let target = event_target::<HtmlInputElement>(&ev);
@@ -124,7 +120,7 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
let title = event_target_value(&ev);
let title = title.trim();
if !title.is_empty() {
let new = Todo::new(next_id, title.to_string());
let new = Todo::new(cx, next_id, title.to_string());
set_todos.update(|t| t.add(new));
next_id += 1;
target.set_value("");
@@ -132,7 +128,7 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
}
};
let filtered_todos = create_memo::<Vec<Todo>>(move |_| {
let filtered_todos = create_memo::<Vec<Todo>>(cx, move |_| {
todos.with(|todos| match mode.get() {
Mode::All => todos.0.to_vec(),
Mode::Active => todos
@@ -152,7 +148,7 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
// effect to serialize to JSON
// this does reactive reads, so it will automatically serialize on any relevant change
create_effect(move |_| {
create_effect(cx, move |_| {
if let Ok(Some(storage)) = window().local_storage() {
let objs = todos
.get()
@@ -167,7 +163,7 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
}
});
view! {
view! { cx,
<main>
<section class="todoapp">
<header class="header">
@@ -192,8 +188,8 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
<For
each=filtered_todos
key=|todo| todo.id
view=move |todo: Todo| {
view! { <Todo todo=todo.clone()/> }
view=move |cx, todo: Todo| {
view! { cx, <Todo todo=todo.clone()/> }
}
/>
</ul>
@@ -240,14 +236,14 @@ pub fn TodoMVC(todos: Todos) -> impl IntoView {
<p>"Part of " <a href="http://todomvc.com">"TodoMVC"</a></p>
</footer>
</main>
}.into_view()
}.into_view(cx)
}
#[component]
pub fn Todo(todo: Todo) -> impl IntoView {
let (editing, set_editing) = create_signal(false);
let set_todos = use_context::<WriteSignal<Todos>>().unwrap();
//let input = NodeRef::new();
pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
let (editing, set_editing) = create_signal(cx, false);
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
//let input = NodeRef::new(cx);
let save = move |value: &str| {
let value = value.trim();
@@ -259,7 +255,7 @@ pub fn Todo(todo: Todo) -> impl IntoView {
set_editing(false);
};
view! {
view! { cx,
<li class="todo" class:editing=editing class:completed=move || (todo.completed)()>
<div class="view">
<input class="toggle" type="checkbox" prop:checked=move || (todo.completed)()/>
@@ -272,7 +268,7 @@ pub fn Todo(todo: Todo) -> impl IntoView {
{move || {
editing()
.then(|| {
view! {
view! { cx,
<input
class="edit"
class:hidden=move || !(editing)()
@@ -323,8 +319,8 @@ pub struct TodoSerialized {
}
impl TodoSerialized {
pub fn into_todo(self, ) -> Todo {
Todo::new_with_completed(self.id, self.title, self.completed)
pub fn into_todo(self, cx: Scope) -> Todo {
Todo::new_with_completed(cx, self.id, self.title, self.completed)
}
}

View File

@@ -12,8 +12,8 @@ fn leptos_todomvc_ssr(b: &mut Bencher) {
b.iter(|| {
use crate::todomvc::leptos::*;
let html = ::leptos::ssr::render_to_string(|| {
view! { <TodoMVC todos=Todos::new()/> }
let html = ::leptos::ssr::render_to_string(|cx| {
view! { cx, <TodoMVC todos=Todos::new(cx)/> }
});
assert!(html.len() > 1);
});

View File

@@ -17,4 +17,4 @@ understand Leptos.
You can find more detailed docs for each part of the API at [Docs.rs](https://docs.rs/leptos/latest/leptos/).
> The source code for the book is available [here](https://github.com/leptos-rs/leptos/tree/main/docs/book). PRs for typos or clarification are always welcome.
**The guide is a work in progress.**

View File

@@ -43,6 +43,5 @@
- [Responses and Redirects](./server/27_response.md)
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
- [Deployment](./deployment.md)
- [Appendix: How Does the Reactive System Work?](./appendix_reactive_graph.md)
- [Deployment]()
- [Appendix: Optimizing WASM Binary Size](./appendix_binary_size.md)

View File

@@ -1,243 +0,0 @@
# Appendix: How does the Reactive System Work?
You dont need to know very much about how the reactive system actually works in order to use the library successfully. But its always useful to understand whats going on behind the scenes once you start working with the framework at an advanced level.
The reactive primitives you use are divided into three sets:
- **Signals** (`ReadSignal`/`WriteSignal`, `RwSignal`, `Resource`, `Trigger`) Values you can actively change to trigger reactive updates.
- **Computations** (`Memo`s) Values that depend on signals (or other computations) and derive a new reactive value through some pure computation.
- **Effects** Observers that listen to changes in some signals or computations and run a function, causing some side effect.
Derived signals are a kind of non-primitve computation: as plain closures, they simply allow you to refactor some repeated signal-based computation into a reusable function that can be called in multiple places, but they are not represented in the reactive system itself.
All the other primitives actually exist in the reactive system as nodes in a reactive graph.
Most of the work of the reactive system consists of propagating changes from signals to effects, possibly through some intervening memos.
The assumption of the reactive system is that effects (like rendering to the DOM or making a network request) are orders of magnitude more expensive than things like updating a Rust data structure inside your app.
So the **primary goal** of the reactive system is to **run effects as infrequently as possible**.
Leptos does this through the construction of a reactive graph.
> Leptoss current reactive system is based heavily on the [Reactively](https://github.com/modderme123/reactively) library for JavaScript. You can read Milos article “[Super-Charging Fine-Grained Reactivity](https://dev.to/modderme123/super-charging-fine-grained-reactive-performance-47ph)” for an excellent account of its algorithm, as well as fine-grained reactivity in general—including some beautiful diagrams!
## The Reactive Graph
Signals, memos, and effects all share three characteristics:
- **Value** They have a current value: either the signals value, or (for memos and effects) the value returned by the previous run, if any.
- **Sources** Any other reactive primitives they depend on. (For signals, this is an empty set.)
- **Subscribers** Any other reactive primitives that depend on them. (For effects, this is an empty set.)
In reality then, signals, memos, and effects are just conventional names for one generic concept of a “node” in a reactive graph. Signals are always “root nodes,” with no sources/parents. Effects are always “leaf nodes,” with no subscribers. Memos typically have both sources and subscribers.
### Simple Dependencies
So imagine the following code:
```rust
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
create_effect(move |_| {
log!("{}", name_upper());
});
set_name("Bob");
```
You can easily imagine the reactive graph here: `name` is the only signal/origin node, the `create_effect` is the only effect/terminal node, and theres one intervening memo.
```
A (name)
|
B (name_upper)
|
C (the effect)
```
### Splitting Branches
Lets make it a little more complex.
```rust
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
let name_len = create_memo(move |_| name.len());
// D
create_effect(move |_| {
log!("len = {}", name_len());
});
// E
create_effect(move |_| {
log!("name = {}", name_upper());
});
```
This is also pretty straightforward: a signal source signal (`name`/`A`) divides into two parallel tracks: `name_upper`/`B` and `name_len`/`C`, each of which has an effect that depends on it.
```
__A__
| |
B C
| |
D E
```
Now lets update the signal.
```rust
set_name("Bob");
```
We immediately log
```
len = 3
name = BOB
```
Lets do it again.
```rust
set_name("Tim");
```
The log should shows
```
name = TIM
```
`len = 3` does not log again.
Remember: the goal of the reactive system is to run effects as infrequently as possible. Changing `name` from `"Bob"` to `"Tim"` will cause each of the memos to re-run. But they will only notify their subscribers if their value has actually changed. `"BOB"` and `"TIM"` are different, so that effect runs again. But both names have the length `3`, so they do not run again.
### Reuniting Branches
One more example, of whats sometimes called **the diamond problem**.
```rust
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
let name_len = create_memo(move |_| name.len());
// D
create_effect(move |_| {
log!("{} is {} characters long", name_upper(), name_len());
});
```
What does the graph look like for this?
```
__A__
| |
B C
| |
|__D__|
```
You can see why it's called the “diamond problem.” If Id connected the nodes with straight lines instead of bad ASCII art, it would form a diamond: two memos, each of which depend on a signal, which feed into the same effect.
A naive, push-based reactive implementation would cause this effect to run twice, which would be bad. (Remember, our goal is to run effects as infrequently as we can.) For example, you could implement a reactive system such that signals and memos immediately propagate their changes all the way down the graph, through each dependency, essentially traversing the graph depth-first. In other words, updating `A` would notify `B`, which would notify `D`; then `A` would notify `C`, which would notify `D` again. This is both inefficient (`D` runs twice) and glitchy (`D` actually runs with the incorrect value for the second memo during its first run.)
## Solving the Diamond Problem
Any reactive implementation worth its salt is dedicated to solving this issue. There are a number of different approaches (again, [see Milos article](https://dev.to/modderme123/super-charging-fine-grained-reactive-performance-47ph) for an excellent overview).
Heres how ours works, in brief.
A reactive node is always in one of three states:
- `Clean`: it is known not to have changed
- `Check`: it is possible it has changed
- `Dirty`: it has definitely changed
Updating a signal `Dirty` marks that signal `Dirty`, and marks all its descendants `Check`, recursively. Any of its descendants that are effects are added to a queue to be re-run.
```
____A (DIRTY)___
| |
B (CHECK) C (CHECK)
| |
|____D (CHECK)__|
```
Now those effects are run. (All of the effects will be marked `Check` at this point.) Before re-running its computation, the effect checks its parents to see if they are dirty. So
- So `D` goes to `B` and checks if it is `Dirty`.
- But `B` is also marked `Check`. So `B` does the same thing:
- `B` goes to `A`, and finds that it is `Dirty`.
- This means `B` needs to re-run, because one of its sources has changed.
- `B` re-runs, generating a new value, and marks itself `Clean`
- Because `B` is a memo, it then checks its prior value against the new value.
- If they are the same, `B` returns "no change." Otherwise, it returns "yes, I changed."
- If `B` returned “yes, I changed,” `D` knows that it definitely needs to run and re-runs immediately before checking any other sources.
- If `B` returned “no, I didnt change,” `D` continues on to check `C` (see process above for `B`.)
- If neither `B` nor `C` has changed, the effect does not need to re-run.
- If either `B` or `C` did change, the effect now re-runs.
Because the effect is only marked `Check` once and only queued once, it only runs once.
If the naive version was a “push-based” reactive system, simply pushing reactive changes all the way down the graph and therefore running the effect twice, this version could be called “push-pull.” It pushes the `Check` status all the way down the graph, but then “pulls” its way back up. In fact, for large graphs it may end up bouncing back up and down and left and right on the graph as it tries to determine exactly which nodes need to re-run.
**Note this important trade-off**: Push-based reactivity propagates signal changes more quickly, at the expense of over-re-running memos and effects. Remember: the reactive system is designed to minimize how often you re-run effects, on the (accurate) assumption that side effects are orders of magnitude more expensive than this kind of cache-friendly graph traversal happening entirely inside the librarys Rust code. The measurement of a good reactive system is not how quickly it propagates changes, but how quickly it propagates changes _without over-notifying_.
## Memos vs. Signals
Note that signals always notify their children; i.e., a signal is always marked `Dirty` when it updates, even if its new value is the same as the old value. Otherwise, wed have to require `PartialEq` on signals, and this is actually quite an expensive check on some types. (For example, add an unnecessary equality check to something like `some_vec_signal.update(|n| n.pop())` when its clear that it has in fact changed.)
Memos, on the other hand, check whether they change before notifying their children. They only run their calculation once, no matter how many times you `.get()` the result, but they run whenever their signal sources change. This means that if the memos computation is _very_ expensive, you may actually want to memoize its inputs as well, so that the memo only re-calculates when it is sure its inputs have changed.
## Memos vs. Derived Signals
All of this is cool, and memos are pretty great. But most actual applications have reactive graphs that are quite shallow and quite wide: you might have 100 source signals and 500 effects, but no memos or, in rare case, three or four memos between the signal and the effect. Memos are extremely good at what they do: limiting how often they notify their subscribers that they have changed. But as this description of the reactive system should show, they come with overhead in two forms:
1. A `PartialEq` check, which may or may not be expensive.
2. Added memory cost of storing another node in the reactive system.
3. Added computational cost of reactive graph traversal.
In cases in which the computation itself is cheaper than this reactive work, you should avoid “over-wrapping” with memos and simply use derived signals. Heres a great example in which you should never use a memo:
```rust
let (a, set_a) = create_signal(1);
// none of these make sense as memos
let b = move || a() + 2;
let c = move || b() % 2 == 0;
let d = move || if c() { "even" } else { "odd" };
set_a(2);
set_a(3);
set_a(5);
```
Even though memoizing would technically save an extra calculation of `d` between setting `a` to `3` and `5`, these calculations are themselves cheaper than the reactive algorithm.
At the very most, you might consider memoizing the final node before running some expensive side effect:
```rust
let text = create_memo(move |_| {
d()
});
create_effect(move |_| {
engrave_text_into_bar_of_gold(&text());
});
```

View File

@@ -69,34 +69,6 @@ Every time one of the resources is reloading, the `"Loading..."` fallback will s
This inversion of the flow of control makes it easier to add or remove individual resources, as you dont need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which well talk about during a later chapter.
## `<Await/>`
In youre simply trying to wait for some `Future` to resolve before rendering, you may find the `<Await/>` component helpful in reducing boilerplate. `<Await/>` essentially combines a resource with the source argument `|| ()` with a `<Suspense/>` with no fallback.
In other words:
1. It only polls the `Future` once, and does not respond to any reactive changes.
2. It does not render anything until the `Future` resolves.
3. After the `Future` resolves, its binds its data to whatever variable name you choose and then renders its children with that variable in scope.
```rust
async fn fetch_monkeys(monkey: i32) -> i32 {
// maybe this didn't need to be async
monkey * 2
}
view! {
<Await
// `future` provides the `Future` to be resolved
future=|| fetch_monkeys(3)
// the data is bound to whatever variable name you provide
bind:data
>
// you receive the data by reference and can use it in your view here
<p>{*data} " little monkeys, jumping on the bed."</p>
</Await>
}
```
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs)
<iframe src="https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs" width="100%" height="1000px" style="max-height: 100vh"></iframe>

View File

@@ -1,74 +0,0 @@
# Deployment
There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.
## General Advice
1. Remember: Always deploy Rust apps built in `--release` mode, not debug mode. This has a huge effect on both performance and binary size.
2. Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so its possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, its likely a framework-level bug and you should open a GitHub issue with a reproduction.)
> We asked users to submit their deployment setups to help with this chapter. Ill quote from them below, but you can read the full thread [here](https://github.com/leptos-rs/leptos/issues/1152).
## Deploying a Client-Side-Rendered App
If youve been building an app that only uses client-side rendering, working with Trunk as a dev server and build tool, the process is quite easy.
```bash
trunk build --release
```
`trunk build` will create a number of build artifacts in a `dist/` directory. Publishing `dist` somewhere online should be all you need to deploy your app. This should work very similarly to deploying any JavaScript application.
> Read more: [Deploying to Vercel with GitHub Actions](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1577861900).
## Deploying a Full-Stack App
The most popular way for people to deploy full-stack apps built with `cargo-leptos` is to use a cloud hosting service that supports deployment via a Docker build. Heres a sample `Dockerfile`, which is based on the one we use to deploy the Leptos website.
```dockerfile
# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-bullseye as builder
# If youre using stable, use this instead
# FROM rust:1.70-bullseye as builder
# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin
# Install cargo-leptos
RUN cargo binstall cargo-leptos -y
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Make an /app dir, which everything will eventually live in
RUN mkdir -p /app
WORKDIR /app
COPY . .
# Build the app
RUN cargo leptos build --release -vv
FROM rustlang/rust:nightly-bullseye as runner
# Copy the server binary to the /app directory
COPY --from=builder /app/target/server/release/leptos_website /app/
# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site
# Copy Cargo.toml if its needed at runtime
COPY --from=builder /app/Cargo.toml /app/
WORKDIR /app
# Set any required env variables and
ENV RUST_LOG="info"
ENV APP_ENVIRONMENT="production"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8080
# Run the server
CMD ["/app/leptos_website"]
```
> Read more: [`gnu` and `musl` build files for Leptos apps](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1634916088).

View File

@@ -109,34 +109,6 @@ create_effect(move |prev_value| {
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
## Explicit, Cancelable Tracking with `watch`
In addition to `create_effect`, Leptos provides a [`watch`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.watch.html) function, which can be used for two main purposes:
1. Separating tracking and responding to changes by explicitly passing in a set of values to track.
2. Canceling tracking by calling a stop function.
Like `create_resource`, `watch` takes a first argument, which is reactively tracked, and a second, which is not. Whenever a reactive value in its `deps` argument is changed, the `callback` is run. `watch` returns a function that can be called to stop tracking the dependencies.
```rust
let (num, set_num) = create_signal(0);
let stop = watch(
move || num.get(),
move |num, prev_num, _| {
log::debug!("Number: {}; Prev: {:?}", num, prev_num);
},
false,
);
set_num.set(1); // > "Number: 1; Prev: Some(0)"
stop(); // stop watching
set_num.set(2); // (nothing happens)
```
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>

View File

@@ -94,7 +94,7 @@ The `view` is a function that returns a view. Any component with no props works
</Routes>
```
> `view` takes a `Fn() -> impl IntoView`. If a component has no props, it can be passed directly into the `view`. In this case, `view=Home` is just a shorthand for `|| view! { <Home/> }`.
> `view` takes a `Fn(Scope) -> impl IntoView`. If a component has no props, it is a function that takes `Scope` and returns `impl IntoView`, so it can be passed directly into the `view`. In this case, `view=Home` is just a shorthand for `|cx| view! { cx, <Home/> }`.
Now if you navigate to `/` or to `/users` youll get the home page or the `<Users/>`. If you go to `/users/3` or `/blahblah` youll get a user profile or your 404 page (`<NotFound/>`). On every navigation, the router determines which `<Route/>` should be matched, and therefore what content should be displayed where the `<Routes/>` component is defined.

View File

@@ -96,7 +96,7 @@ You can easily define this with nested routes
<Routes>
<Route path="/contacts" view=ContactList>
<Route path=":id" view=ContactInfo/>
<Route path="" view=|| view! {
<Route path="" view=|cx| view! { cx,
<p>"Select a contact to view more info."</p>
}/>
</Route>
@@ -205,7 +205,7 @@ fn App() -> impl IntoView {
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<Route path="" view=|cx| view! { cx,
<div class="tab">
"(Contact Info)"
</div>

View File

@@ -120,7 +120,7 @@ fn App() -> impl IntoView {
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<Route path="" view=|cx| view! { cx,
<div class="tab">
"(Contact Info)"
</div>

View File

@@ -18,21 +18,6 @@ The router also provides an [`<A>`](https://docs.rs/leptos_router/latest/leptos_
1. Correctly resolves relative nested routes. Relative routing with ordinary `<a>` tags can be tricky. For example, if you have a route like `/post/:id`, `<A href="1">` will generate the correct relative route, but `<a href="1">` likely will not (depending on where it appears in your view.) `<A/>` resolves routes relative to the path of the nested route within which it appears.
2. Sets the `aria-current` attribute to `page` if this link is the active link (i.e., its a link to the page youre on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if its a link to the page youre currently on, you can match this attribute with a CSS selector.
## Navigating Programmatically
Your most-used methods of navigating between pages should be with `<a>` and `<form>` elements or with the enhanced `<A/>` and `<Form/>` components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.
On occasion, though, youll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the [`use_navigate`](https://docs.rs/leptos_router/latest/leptos_router/fn.use_navigate.html) function.
```rust
let navigate = leptos_router::use_navigate();
navigate("/somewhere", Default::default());
```
> You should almost never do something like `<button on:click=move |_| navigate(/* ... */)>`. Any `on:click` that navigates should be an `<a>`, for reasons of accessibility.
The second argument here is a set of [`NavigateOptions`](https://docs.rs/leptos_router/latest/leptos_router/struct.NavigateOptions.html), which includes options to resolve the navigation relative to the current route as the `<A/>` component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.
> Once again, this is the same example. Check out the relative `<A/>` components, and take a look at the CSS in `index.html` to see the ARIA-based styling.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)
@@ -73,7 +58,7 @@ fn App() -> impl IntoView {
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<Route path="" view=|cx| view! { cx,
<div class="tab">
"(Contact Info)"
</div>

View File

@@ -62,21 +62,7 @@ pub async fn axum_extract() -> Result<String, ServerFnError> {
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
The Axum `extract` function only supports extractors for which the state is `()`. If you need an extractor that uses `State`, you should use [`extract_with_state`](https://docs.rs/leptos_axum/latest/leptos_axum/fn.extract_with_state.html). This requires you to provide the state. You can do this by extending the existing `LeptosOptions` state using the Axum `FromRef` pattern, which providing the state as context during render and server functions with custom handlers.
```rust
use axum::extract::FromRef;
/// Derive FromRef to allow multiple items in state, using Axums
/// SubStates pattern.
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
pub leptos_options: LeptosOptions,
pub pool: SqlitePool
}
```
[Click here for an example of providing context in custom handlers](https://github.com/leptos-rs/leptos/blob/19ea6fae6aec2a493d79cc86612622d219e6eebb/examples/session_auth_axum/src/main.rs#L24-L44).
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
## A Note about Data-Loading Patterns

View File

@@ -53,38 +53,87 @@ pub fn TodoApp() -> impl IntoView {
In general, the less of your logic is wrapped into your components themselves, the
more idiomatic your code will feel and the easier it will be to test.
## 2. Test components with end-to-end (`e2e`) testing
## 2. Test components with `wasm-bindgen-test`
Our [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples) directory has several examples with extensive end-to-end testing, using different testing tools.
[`wasm-bindgen-test`](https://crates.io/crates/wasm-bindgen-test) is a great utility
for integrating or end-to-end testing WebAssembly apps in a headless browser.
The easiest way to see how to use these is to take a look at the test examples themselves:
To use this testing utility, you need to add `wasm-bindgen-test` to your `Cargo.toml`:
### `wasm-bindgen-test` with [`counter`](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/web.rs)
```toml
[dev-dependencies]
wasm-bindgen-test = "0.3.0"
```
This is a fairly simple manual testing setup that uses the [`wasm-pack test`](https://rustwasm.github.io/wasm-pack/book/commands/test.html) command.
You should create tests in a separate `tests` directory. You can then run your tests in the browser of your choice:
#### Sample Test
```bash
wasm-pack test --firefox
```
````rust
> To see the full setup, check out the tests for the [`counter`](https://github.com/leptos-rs/leptos/tree/main/examples/counter) example.
### Writing Your Tests
Most tests will involve some combination of vanilla DOM manipulation and comparison to a `view`. For example, heres a test [for the
`counter` example](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/web.rs).
First, we set up the testing environment.
```rust
use wasm_bindgen_test::*;
use counter::*;
use leptos::*;
use web_sys::HtmlElement;
// tell the test runner to run tests in the browser
wasm_bindgen_test_configure!(run_in_browser);
```
Im going to create a simpler wrapper for each test case, and mount it there.
This makes it easy to encapsulate the test results.
```rust
// like marking a regular test with #[test]
#[wasm_bindgen_test]
fn clear() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
let _ = document.body().unwrap().append_child(&test_wrapper);
document.body().unwrap().append_child(&test_wrapper);
// start by rendering our counter and mounting it to the DOM
// note that we start at the initial value of 10
mount_to(
test_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=10 step=1/> },
);
}
```
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
Well use some manual DOM operations to grab the `<div>` that wraps
the whole component, as well as the `clear` button.
clear.click();
```rust
// now we extract the buttons by iterating over the DOM
// this would be easier if they had IDs
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
```
Now we can use ordinary DOM APIs to simulate user interaction.
```rust
// now let's click the `clear` button
clear.click();
```
You can test individual DOM element attributes or text node values. Sometimes
I like to test the whole view at once. We can do this by testing the elements
`outerHTML` against our expectations.
```rust
assert_eq!(
@@ -108,112 +157,24 @@ assert_eq!(
.outer_html()
})
);
````
### [`wasm-bindgen-test` with `counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable/tests/web)
This more developed test suite uses a system of fixtures to refactor the manual DOM manipulation of the `counter` tests and easily test a wide range of cases.
#### Sample Test
```rust
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::increment_counter(1);
ui::increment_counter(1);
ui::increment_counter(1);
// Then
assert_eq!(ui::total(), 3);
}
```
### [Playwright with `counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable/e2e)
That test involved us manually replicating the `view` thats inside the component.
There's actually an easier way to do this... We can just test against a `<SimpleCounter/>`
with the initial value `0`. This is where our wrapping element comes in: Ill just test
the wrappers `innerHTML` against another comparison case.
These tests use the common JavaScript testing tool Playwright to run end-to-end tests on the same example, using a library and testing approach familiar to may who have done frontend development before.
#### Sample Test
```js
import { test, expect } from "@playwright/test";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Increment Count", () => {
test("should increase the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
await ui.incrementCount();
await ui.incrementCount();
await ui.incrementCount();
await expect(ui.total).toHaveText("3");
});
```rust
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
```
### [Gherkin/Cucumber Tests with `todo_app_sqlite`](https://github.com/leptos-rs/leptos/blob/main/examples/todo_app_sqlite/e2e/README.md)
This is only a very limited introduction to testing. But I hope its useful as you begin to build applications.
You can integrate any testing tool youd like into this flow. This example uses Cucumber, a testing framework based on natural language.
```
@add_todo
Feature: Add Todo
Background:
Given I see the app
@add_todo-see
Scenario: Should see the todo
Given I set the todo as Buy Bread
When I click the Add button
Then I see the todo named Buy Bread
# @allow.skipped
@add_todo-style
Scenario: Should see the pending todo
When I add a todo as Buy Oranges
Then I see the pending todo
```
The definitions for these actions are defined in Rust code.
```rust
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::add_todo(client, text.as_str()).await?;
Ok(())
}
// etc.
```
### Learning More
Feel free to check out the CI setup in the Leptos repo to learn more about how to use these tools in your own application. All of these testing methods are run regularly against actual Leptos example apps.
> For more, see [the testing section of the `wasm-bindgen` guide](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/index.html#testing-on-wasm32-unknown-unknown-with-wasm-bindgen-test).

View File

@@ -144,27 +144,10 @@ let double_count = move || count() * 2;
Derived signals let you create reactive computed values that can be used in multiple
places in your application with minimal overhead.
Note: Using a derived signal like this means that the calculation runs once per
signal change per place we access `double_count`; in other words, twice. This is a
very cheap calculation, so thats fine. Well look at memos in a later chapter, which
are designed to solve this problem for expensive calculations.
> #### Advanced Topic: Injecting Raw HTML
>
> The `view` macro provides support for an additional attribute, `inner_html`, which
> can be used to directly set the HTML contents of any element, wiping out any other
> children youve given it. Note that this does _not_ escape the HTML you provide. You
> should make sure that it only contains trusted input or that any HTML entities are
> escaped, to prevent cross-site scripting (XSS) attacks.
>
> ```rust
> let html = "<p>This HTML will be injected.</p>";
> view! {
> <div inner_html=html/>
> }
> ```
>
> [Click here for the full `view` macros docs](https://docs.rs/leptos/latest/leptos/macro.view.html).
> Note: Using a derived signal like this means that the calculation runs once per
> signal change per place we access `double_count`; in other words, twice. This is a
> very cheap calculation, so thats fine. Well look at memos in a later chapter, which
> are designed to solve this problem for expensive calculations.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/2-dynamic-attribute-pqyvzl?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)

View File

@@ -224,10 +224,11 @@ This generic can also be specified inline:
```rust
#[component]
fn ProgressBar<F: Fn() -> i32 + 'static>(
cx: Scope,
#[prop(default = 100)] max: u16,
progress: F,
) -> impl IntoView {
view! {
view! { cx,
<progress
max=max
value=progress
@@ -293,10 +294,11 @@ Note that you cant specify optional generic props for a component. Lets se
```rust,compile_fail
#[component]
fn ProgressBar<F: Fn() -> i32 + 'static>(
cx: Scope,
#[prop(optional)] progress: Option<F>,
) -> impl IntoView {
progress.map(|progress| {
view! {
view! { cx,
<progress
max=100
value=progress
@@ -306,8 +308,8 @@ fn ProgressBar<F: Fn() -> i32 + 'static>(
}
#[component]
pub fn App() -> impl IntoView {
view! {
pub fn App(cx: Scope) -> impl IntoView {
view! { cx,
<ProgressBar/>
}
}
@@ -335,10 +337,11 @@ However, you can get around this by providing a concrete type using `Box<dyn _>`
```rust
#[component]
fn ProgressBar(
cx: Scope,
#[prop(optional)] progress: Option<Box<dyn Fn() -> i32>>,
) -> impl IntoView {
progress.map(|progress| {
view! {
view! { cx,
<progress
max=100
value=progress
@@ -348,8 +351,8 @@ fn ProgressBar(
}
#[component]
pub fn App() -> impl IntoView {
view! {
pub fn App(cx: Scope) -> impl IntoView {
view! { cx,
<ProgressBar/>
}
}
@@ -394,24 +397,6 @@ type, and each of the fields used to add props. It can be a little hard to
understand how powerful this is until you hover over the component name or props
and see the power of the `#[component]` macro combined with rust-analyzer here.
> #### Advanced Topic: `#[component(transparent)]`
>
> All Leptos components return `-> impl IntoView`. Some, though, need to return
> some data directly without any additional wrapping. These can be marked with
> `#[component(transparent)]`, in which case they return exactly the value they
> return, without the rendering system transforming them in any way.
>
> This is mostly used in two situations:
>
> 1. Creating wrappers around `<Suspense/>` or `<Transition/>`, which return a
> transparent suspense structure to integrate with SSR and hydration properly.
> 2. Refactoring `<Route/>` definitions for `leptos_router` out into separate
> components, because `<Route/>` is a transparent component that returns a
> `RouteDefinition` struct rather than a view.
>
> In general, you should not need to use transparent components unless you are
> creating custom wrapping components that fall into one of these two categories.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/3-components-50t2e7?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A7%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A7%7D%5D)
<iframe src="https://codesandbox.io/p/sandbox/3-components-50t2e7?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A7%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A7%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>

View File

@@ -19,8 +19,7 @@ There are two important things to remember:
2. The `value` _attribute_ only sets the initial value of the input, i.e., it
only updates the input up to the point that you begin typing. The `value`
_property_ continues updating the input after that. You usually want to set
`prop:value` for this reason. (The same is true for `checked` and `prop:checked`
on an `<input type="checkbox">`.)
`prop:value` for this reason.
```rust
let (name, set_name) = create_signal("Controlled".to_string());
@@ -43,33 +42,6 @@ view! {
}
```
> #### Why do you need `prop:value`?
>
> Web browsers are the most ubiquitous and stable platform for rendering graphical user interfaces in existence. They have also maintained an incredible backwards compatibility over their three decades of existence. Inevitably, this means there are some quirks.
>
> One odd quirk is that there is a distinction between HTML attributes and DOM element properties, i.e., between something called an “attribute” which is parsed from HTML and can be set on a DOM element with `.setAttribute()`, and something called a “property” which is a field of the JavaScript class representation of that parsed HTML element.
>
> In the case of an `<input value=...>`, setting the `value` *attribute* is defined as setting the initial value for the input, and setting `value` *property* sets its current value. It maybe easiest to understand this by opening `about:blank` and running the following JavaScript in the browser console, line by line:
>
> ```js
> // create an input and append it to the DOM
> const el = document.createElement("input")
> document.body.appendChild(el)
>
> el.setAttribute("value", "test") // updates the input
> el.setAttribute("value", "another test") // updates the input again
>
> // now go and type into the input: delete some characters, etc.
>
> el.setAttribute("value", "one more time?")
> // nothing should have changed. setting the "initial value" does nothing now
>
> // however...
> el.value = "But this works"
> ```
>
> Many other frontend frameworks conflate attributes and properties, or create a special case for inputs that sets the value correctly. Maybe Leptos should do this too; but for now, I prefer giving users the maximum amount of control over whether theyre setting an attribute or a property, and doing my best to educate people about the actual underlying browser behavior rather than obscuring it.
## Uncontrolled Inputs
In an "uncontrolled input," the browser controls the state of the input element.

View File

@@ -208,8 +208,7 @@ view! {
`<Show/>` memoizes the `when` condition, so it only renders its `<Small/>` once,
continuing to show the same component until `value` is greater than five;
then it renders `<Big/>` once, continuing to show it indefinitely or until `value`
goes below five and then renders `<Small/>` again.
then it renders `<Big/>` once, continuing to show it indefinitely.
This is a helpful tool to avoid rerendering when using dynamic `if` expressions.
As always, there's some overhead: for a very simple node (like updating a single

View File

@@ -26,7 +26,6 @@ CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
"slots",
"ssr_modes",
"ssr_modes_axum",
"suspense_tests",
"tailwind",
"tailwind_csr_trunk",
"timer",

View File

@@ -1,7 +1,5 @@
extend = { path = "./cargo-leptos.toml" }
[tasks.integration-test]
dependencies = ["install-cargo-leptos", "cargo-leptos-e2e"]
dependencies = ["cargo-leptos-e2e"]
[tasks.cargo-leptos-e2e]
command = "cargo"

View File

@@ -1,55 +0,0 @@
[tasks.install-cargo-leptos]
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
[tasks.build]
clear = true
command = "cargo"
args = ["leptos", "build"]
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[tasks.check-debug]
toolchain = "nightly"
command = "cargo"
args = ["check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-release]
toolchain = "nightly"
command = "cargo"
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"
[tasks.start-client]
command = "cargo"
args = ["leptos", "watch"]
[tasks.stop-client]
condition = { env_set = ["APP_PROCESS_NAME"] }
script = '''
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
pkill -f todo_app_sqlite
fi
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
pkill -f cargo-leptos
fi
'''
[tasks.client-status]
condition = { env_set = ["APP_PROCESS_NAME"] }
script = '''
if [ -z $(pidof ${APP_PROCESS_NAME}) ]; then
echo " ${APP_PROCESS_NAME} is not running"
else
echo " ${APP_PROCESS_NAME} is up"
fi
if [ -z $(pidof cargo-leptos) ]; then
echo " cargo-leptos is not running"
else
echo " cargo-leptos is up"
fi
'''

View File

@@ -1,30 +0,0 @@
[tasks.start-webdriver]
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
RED="\e[0;31m"
RESET="\e[0m"
if command -v chromedriver; then
if [ -z $(pidof chromedriver) ]; then
chromedriver --port=4444 &
fi
else
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
exit 1
fi
'''
[tasks.stop-webdriver]
script = '''
pkill -f "chromedriver"
'''
[tasks.webdriver-status]
script = '''
if [ -z $(pidof chromedriver) ]; then
echo chromedriver is not running
else
echo chromedriver is up
fi
'''

View File

@@ -15,12 +15,13 @@ cfg_if! {
}
}
#[server]
// "/api" is an optional prefix that allows you to locate server functions wherever you'd like on the server
#[server(GetServerCount, "/api")]
pub async fn get_server_count() -> Result<i32, ServerFnError> {
Ok(COUNT.load(Ordering::Relaxed))
}
#[server]
#[server(AdjustServerCount, "/api")]
pub async fn adjust_server_count(
delta: i32,
msg: String,
@@ -32,7 +33,7 @@ pub async fn adjust_server_count(
Ok(new)
}
#[server]
#[server(ClearServerCount, "/api")]
pub async fn clear_server_count() -> Result<i32, ServerFnError> {
COUNT.store(0, Ordering::Relaxed);
_ = COUNT_CHANNEL.send(&0).await;
@@ -111,9 +112,9 @@ pub fn Counter() -> impl IntoView {
);
let value =
move || counter.get().map(|count| count.unwrap_or(0)).unwrap_or(0);
move || counter.read().map(|count| count.unwrap_or(0)).unwrap_or(0);
let error_msg = move || {
counter.get().and_then(|res| match res {
counter.read().and_then(|res| match res {
Ok(_) => None,
Err(e) => Some(e),
})
@@ -146,8 +147,6 @@ pub fn Counter() -> impl IntoView {
// but uses HTML forms to submit the actions
#[component]
pub fn FormCounter() -> impl IntoView {
// these struct names are auto-generated by #[server]
// they are just the PascalCased versions of the function names
let adjust = create_server_action::<AdjustServerCount>();
let clear = create_server_action::<ClearServerCount>();
@@ -160,7 +159,7 @@ pub fn FormCounter() -> impl IntoView {
);
let value = move || {
log::debug!("FormCounter looking for value");
counter.get().and_then(|n| n.ok()).unwrap_or(0)
counter.read().and_then(|n| n.ok()).unwrap_or(0)
};
view! {

View File

@@ -11,27 +11,29 @@ pub fn counter(initial_value: i32, step: u32) -> impl IntoView {
// children can be added with .child()
// this takes any type that implements IntoView as its argument
// for example, a string or an HtmlElement<_>
// it can also take an array of types that impl IntoView
// or a tuple of up to 26 objects that impl IntoView
.child((
.child(
button()
// typed events found in leptos::ev
// 1) prevent typos in event names
// 2) allow for correct type inference in callbacks
.on(ev::click, move |_| set_count.update(|count| count.clear()))
.child("Clear"),
)
.child(
button()
.on(ev::click, move |_| {
set_count.update(|count| count.decrease())
})
.child("-1"),
)
.child(
span()
.child("Value: ")
// reactive values are passed to .child() as a tuple
// (Scope, [child function]) so an effect can be created
.child(move || count.get().value())
.child("!"),
))
)
.child(
button()
.on(ev::click, move |_| {

View File

@@ -5,15 +5,12 @@ edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos_meta = { path = "../../meta", features = ["csr"] }
log = "0.4"
console_log = "1"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen = "0.2.87"
wasm-bindgen-test = "0.3.37"
pretty_assertions = "1.4.0"
wasm-bindgen-test = "0.3.0"
[dev-dependencies.web-sys]
features = [

View File

@@ -1,6 +1,5 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/wasm-test.toml" },
{ path = "../cargo-make/trunk_server.toml" },
{ path = "../cargo-make/playwright-test.toml" },
]

View File

@@ -1,7 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs />
</head>
<body></body>
</html>
<head>
<title>Counters (Stable)</title>
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
</head>
<body></body>
</html>

View File

@@ -1,106 +0,0 @@
use leptos::*;
use leptos_meta::*;
const MANY_COUNTERS: usize = 1000;
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
#[derive(Copy, Clone)]
struct CounterUpdater {
set_counters: WriteSignal<CounterHolder>,
}
#[component]
pub fn Counters() -> impl IntoView {
let (next_counter_id, set_next_counter_id) = create_signal(0);
let (counters, set_counters) = create_signal::<CounterHolder>(vec![]);
provide_context(CounterUpdater { set_counters });
let add_counter = move |_| {
let id = next_counter_id.get();
let sig = create_signal(0);
set_counters.update(move |counters| counters.push((id, sig)));
set_next_counter_id.update(|id| *id += 1);
};
let add_many_counters = move |_| {
let next_id = next_counter_id.get();
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
let signal = create_signal(0);
(id, signal)
});
set_counters.update(move |counters| counters.extend(new_counters));
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
};
let clear_counters = move |_| {
set_counters.update(|counters| counters.clear());
};
view! {
<Title text="Counters (Stable)" />
<div>
<button on:click=add_counter>
"Add Counter"
</button>
<button on:click=add_many_counters>
{format!("Add {MANY_COUNTERS} Counters")}
</button>
<button on:click=clear_counters>
"Clear Counters"
</button>
<p>
"Total: "
<span data-testid="total">{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
" counters."
</p>
<ul>
<For
each={move || counters.get()}
key={|counter| counter.0}
view=move |(id, (value, set_value))| {
view! {
<Counter id value set_value/>
}
}
/>
</ul>
</div>
}
}
#[component]
fn Counter(
id: usize,
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context().unwrap();
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
view! {
<li>
<button data-testid="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
<input data-testid="counter_input" type="text"
prop:value={move || value.get().to_string()}
on:input=input
/>
<span>{value}</span>
<button data-testid="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
<button data-testid="remove_counter" on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
</li>
}
}

View File

@@ -1,4 +1,3 @@
use counters_stable::Counters;
use leptos::*;
fn main() {
@@ -6,3 +5,106 @@ fn main() {
console_error_panic_hook::set_once();
mount_to_body(|| view! { <Counters/> })
}
const MANY_COUNTERS: usize = 1000;
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
#[derive(Copy, Clone)]
struct CounterUpdater {
set_counters: WriteSignal<CounterHolder>,
}
#[component]
pub fn Counters() -> impl IntoView {
let (next_counter_id, set_next_counter_id) = create_signal(0);
let (counters, set_counters) = create_signal::<CounterHolder>(vec![]);
provide_context(CounterUpdater { set_counters });
let add_counter = move |_| {
let id = next_counter_id.get();
let sig = create_signal(0);
set_counters.update(move |counters| counters.push((id, sig)));
set_next_counter_id.update(|id| *id += 1);
};
let add_many_counters = move |_| {
let next_id = next_counter_id.get();
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
let signal = create_signal(0);
(id, signal)
});
set_counters.update(move |counters| counters.extend(new_counters));
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
};
let clear_counters = move |_| {
set_counters.update(|counters| counters.clear());
};
view! {
<div>
<button on:click=add_counter>
"Add Counter"
</button>
<button on:click=add_many_counters>
{format!("Add {MANY_COUNTERS} Counters")}
</button>
<button on:click=clear_counters>
"Clear Counters"
</button>
<p>
"Total: "
<span data-testid="total">{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
" counters."
</p>
<ul>
<For
each={move || counters.get()}
key={|counter| counter.0}
view=move |(id, (value, set_value))| {
view! {
<Counter id value set_value/>
}
}
/>
</ul>
</div>
}
}
#[component]
fn Counter(
id: usize,
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context().unwrap();
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
view! {
<li>
<button id="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
<input type="text"
prop:value={move || value.get().to_string()}
on:input=input
/>
<span>{value}</span>
<button id="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
<button on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
</li>
}
}

View File

@@ -1,17 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_number_of_counters() {
// Given
ui::view_counters();
// When
ui::add_1k_counters();
ui::add_1k_counters();
ui::add_1k_counters();
// Then
assert_eq!(ui::counters(), 3000);
}

View File

@@ -1,17 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_number_of_counters() {
// Given
ui::view_counters();
// When
ui::add_counter();
ui::add_counter();
ui::add_counter();
// Then
assert_eq!(ui::counters(), 3);
}

View File

@@ -1,19 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_reset_the_counts() {
// Given
ui::view_counters();
ui::add_counter();
ui::add_counter();
ui::add_counter();
// When
ui::clear_counters();
// Then
assert_eq!(ui::total(), 0);
assert_eq!(ui::counters(), 0);
}

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_decrease_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::decrement_counter(1);
ui::decrement_counter(1);
ui::decrement_counter(1);
// Then
assert_eq!(ui::total(), -3);
}

View File

@@ -1,112 +0,0 @@
use counters_stable::Counters;
use leptos::*;
use wasm_bindgen::JsCast;
use web_sys::{Element, Event, EventInit, HtmlElement, HtmlInputElement};
// Actions
pub fn add_1k_counters() {
find_by_text("Add 1000 Counters").click();
}
pub fn add_counter() {
find_by_text("Add Counter").click();
}
pub fn clear_counters() {
find_by_text("Clear Counters").click();
}
pub fn decrement_counter(index: u32) {
counter_html_element(index, "decrement_count").click();
}
pub fn enter_count(index: u32, count: i32) {
let input = counter_input_element(index, "counter_input");
input.set_value(count.to_string().as_str());
let mut event_init = EventInit::new();
event_init.bubbles(true);
let event = Event::new_with_event_init_dict("input", &event_init).unwrap();
input.dispatch_event(&event).unwrap();
}
pub fn increment_counter(index: u32) {
counter_html_element(index, "increment_count").click();
}
pub fn remove_counter(index: u32) {
counter_html_element(index, "remove_counter").click();
}
pub fn view_counters() {
remove_existing_counters();
mount_to_body(|| view! { <Counters/> });
}
// Results
pub fn counters() -> i32 {
data_test_id("counters").parse::<i32>().unwrap()
}
pub fn title() -> String {
leptos::document().title()
}
pub fn total() -> i32 {
data_test_id("total").parse::<i32>().unwrap()
}
// Internal
fn counter_element(index: u32, text: &str) -> Element {
let selector =
format!("li:nth-child({}) [data-testid=\"{}\"]", index, text);
leptos::document()
.query_selector(&selector)
.unwrap()
.unwrap()
}
fn counter_html_element(index: u32, text: &str) -> HtmlElement {
counter_element(index, text)
.dyn_into::<HtmlElement>()
.unwrap()
}
fn counter_input_element(index: u32, text: &str) -> HtmlInputElement {
counter_element(index, text)
.dyn_into::<HtmlInputElement>()
.unwrap()
}
fn data_test_id(id: &str) -> String {
let selector = format!("[data-testid=\"{}\"]", id);
leptos::document()
.query_selector(&selector)
.unwrap()
.expect("counters not found")
.text_content()
.unwrap()
}
fn find_by_text(text: &str) -> HtmlElement {
let xpath = format!("//*[text()='{}']", text);
let document = leptos::document();
document
.evaluate(&xpath, &document)
.unwrap()
.iterate_next()
.unwrap()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap()
}
fn remove_existing_counters() {
if let Some(counter) =
leptos::document().query_selector("body div").unwrap()
{
counter.remove();
}
}

View File

@@ -1 +0,0 @@
pub mod counters_page;

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::increment_counter(1);
ui::increment_counter(1);
ui::increment_counter(1);
// Then
assert_eq!(ui::total(), 3);
}

View File

@@ -1,16 +0,0 @@
use wasm_bindgen_test::*;
// Test Suites
pub mod add_1k_counters;
pub mod add_counter;
pub mod clear_counters;
pub mod decrement_counter;
pub mod enter_count;
pub mod increment_counter;
pub mod remove_counter;
pub mod view_counters;
pub mod fixtures;
pub use fixtures::*;
wasm_bindgen_test_configure!(run_in_browser);

View File

@@ -1,18 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_decrement_the_number_of_counters() {
// Given
ui::view_counters();
ui::add_counter();
ui::add_counter();
ui::add_counter();
// When
ui::remove_counter(2);
// Then
assert_eq!(ui::counters(), 2);
}

View File

@@ -1,22 +0,0 @@
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_see_the_initial_counts() {
// When
ui::view_counters();
// Then
assert_eq!(ui::total(), 0);
assert_eq!(ui::counters(), 0);
}
#[wasm_bindgen_test]
fn should_see_the_title() {
// When
ui::view_counters();
// Then
assert_eq!(ui::title(), "Counters (Stable)");
}

View File

@@ -67,12 +67,14 @@ pub fn fetch_example() -> impl IntoView {
// the renderer can handle Option<_> and Result<_> states
// by displaying nothing for None if the resource is still loading
// and by using the ErrorBoundary fallback to catch Err(_)
// so we'll just use `.and_then()` to map over the happy path
// so we'll just implement our happy path and let the framework handle the rest
let cats_view = move || {
cats.and_then(|data| {
data.iter()
.map(|s| view! { <p><img src={s}/></p> })
.collect_view()
cats.read().map(|data| {
data.map(|data| {
data.iter()
.map(|s| view! { <p><img src={s}/></p> })
.collect_view()
})
})
};

View File

@@ -17,14 +17,6 @@ where
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if the Scope is disposed
// i.e., if we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
@@ -35,6 +27,13 @@ where
.await
.ok()?;
// abort in-flight requests if the Scope is disposed
// i.e., if we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
T::de(&json).ok()
}

View File

@@ -36,8 +36,8 @@ pub fn Stories() -> impl IntoView {
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending()
pending()
|| stories.read().unwrap_or(None).unwrap_or_default().len() < 28
};
view! {
@@ -65,16 +65,20 @@ pub fn Stories() -> impl IntoView {
}}
</span>
<span>"page " {page}</span>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
<Transition
fallback=move || view! { <p>"Loading..."</p> }
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
"more >"
</a>
</span>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
</a>
</span>
</Transition>
</div>
<main class="news-list">
<div>
@@ -82,7 +86,7 @@ pub fn Stories() -> impl IntoView {
fallback=move || view! { <p>"Loading..."</p> }
set_pending=set_pending.into()
>
{move || match stories.get() {
{move || match stories.read() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {

View File

@@ -19,51 +19,54 @@ pub fn Story() -> impl IntoView {
);
let meta_description = move || {
story
.get()
.read()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
view! {
<Suspense fallback=|| view! { "Loading..." }>
<>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
<Suspense fallback=|| view! { "Loading..." }>
{move || story.read().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move |comment| view! { <Comment comment /> }
/>
</ul>
</div>
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move |comment| view! { <Comment comment /> }
/>
</ul>
</div>
</div>
}})}
</Suspense>
}})
}
</Suspense>
</>
}
}

View File

@@ -18,7 +18,7 @@ pub fn User() -> impl IntoView {
view! {
<div class="user-view">
<Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| match user {
{move || user.read().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_view(),
Some(user) => view! {
<div>

View File

@@ -17,14 +17,6 @@ where
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if the Scope is disposed
// i.e., if we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
@@ -35,6 +27,12 @@ where
.await
.ok()?;
// abort in-flight requests if e.g., we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
T::de(&json).ok()
}

View File

@@ -36,11 +36,12 @@ pub fn Stories() -> impl IntoView {
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending()
pending()
|| stories.read().unwrap_or(None).unwrap_or_default().len() < 28
};
view! {
<div class="news-view">
<div class="news-list-nav">
<span>
@@ -64,16 +65,20 @@ pub fn Stories() -> impl IntoView {
}}
</span>
<span>"page " {page}</span>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
<Transition
fallback=move || view! { <p>"Loading..."</p> }
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
"more >"
</a>
</span>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
</a>
</span>
</Transition>
</div>
<main class="news-list">
<div>
@@ -81,7 +86,7 @@ pub fn Stories() -> impl IntoView {
fallback=move || view! { <p>"Loading..."</p> }
set_pending=set_pending.into()
>
{move || match stories.get() {
{move || match stories.read() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {

View File

@@ -19,51 +19,54 @@ pub fn Story() -> impl IntoView {
);
let meta_description = move || {
story
.get()
.read()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
view! {
<Suspense fallback=|| view! { "Loading..." }>
<>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
<Suspense fallback=|| view! { "Loading..." }>
{move || story.read().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move | comment| view! { <Comment comment /> }
/>
</ul>
</div>
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move | comment| view! { <Comment comment /> }
/>
</ul>
</div>
</div>
}})}
</Suspense>
}})
}
</Suspense>
</>
}
}

View File

@@ -18,7 +18,7 @@ pub fn User() -> impl IntoView {
view! {
<div class="user-view">
<Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| match user {
{move || user.read().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_any(),
Some(user) => view! {
<div>

View File

@@ -87,7 +87,7 @@ pub fn ContactList() -> impl IntoView {
let location = use_location();
let contacts = create_resource(move || location.search.get(), get_contacts);
let contacts = move || {
contacts.get().map(|contacts| {
contacts.read().map(|contacts| {
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
contacts
.into_iter()
@@ -147,7 +147,7 @@ pub fn Contact() -> impl IntoView {
log!("params = {:#?}", params.get());
});
let contact_display = move || match contact.get() {
let contact_display = move || match contact.read() {
// None => loading, but will be caught by Suspense fallback
// I'm only doing this explicitly for the example
None => None,

View File

@@ -107,8 +107,7 @@ pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
}
}
// The struct name and path prefix arguments are optional.
#[server]
#[server(DeleteTodo, "/api")]
pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> {
let pool = pool()?;
@@ -148,7 +147,7 @@ pub fn TodoApp() -> impl IntoView {
fallback=move || view! {<span>"Loading..."</span>}
>
{move || {
user.get().map(|user| match user {
user.read().map(|user| match user {
Err(e) => view! {
<A href="/signup">"Signup"</A>", "
<A href="/login">"Login"</A>", "
@@ -216,7 +215,7 @@ pub fn Todos() -> impl IntoView {
{move || {
let existing_todos = {
move || {
todos.get()
todos.read()
.map(move |todos| match todos {
Err(e) => {
view! { <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view()

View File

@@ -27,11 +27,6 @@ pub fn App() -> impl IntoView {
view=Post
ssr=SsrMode::Async
/>
<Route
path="/post_in_order/:id"
view=Post
ssr=SsrMode::InOrder
/>
</Routes>
</main>
</Router>
@@ -44,16 +39,14 @@ fn HomePage() -> impl IntoView {
let posts =
create_resource(|| (), |_| async { list_post_metadata().await });
let posts_view = move || {
posts.and_then(|posts| {
posts.iter()
.map(|post| view! {
<li>
<a href=format!("/post/{}", post.id)>{&post.title}</a> "|"
<a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a>
</li>
})
.collect_view()
})
posts.with(|posts| posts
.clone()
.map(|posts| {
posts.iter()
.map(|post| view! { <li><a href=format!("/post/{}", post.id)>{&post.title}</a></li>})
.collect_view()
})
)
};
view! {
@@ -88,22 +81,21 @@ fn Post() -> impl IntoView {
}
});
// this view needs to take the `Scope` from the `<Suspense/>`, not
// from the parent component, so we take that as an argument and
// pass it in under the `<Suspense/>` so that it is correct
let post_view = move || {
post.and_then(|post| {
view! {
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
post.with(|post| {
post.clone().map(|post| {
view! {
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title.clone()/>
<Meta name="description" content=post.content.clone()/>
}
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title/>
<Meta name="description" content=post.content/>
}
})
})
};
@@ -173,7 +165,7 @@ pub struct PostMetadata {
title: String,
}
#[server]
#[server(ListPostMetadata, "/api")]
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS
@@ -185,7 +177,7 @@ pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
.collect())
}
#[server]
#[server(GetPost, "/api")]
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS.iter().find(|post| post.id == id).cloned())

View File

@@ -43,23 +43,22 @@ fn HomePage() -> impl IntoView {
// load the posts
let posts =
create_resource(|| (), |_| async { list_post_metadata().await });
let posts_view = move || {
posts.and_then(|posts| {
posts.iter()
.map(|post| view! {
<li>
<a href=format!("/post/{}", post.id)>{&post.title}</a> "|"
<a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a>
</li>
})
.collect_view()
})
};
view! {
<h1>"My Great Blog"</h1>
<Suspense fallback=move || view! { <p>"Loading posts..."</p> }>
<ul>{posts_view}</ul>
<ul>
{move || {
posts.with(|posts| posts
.clone()
.map(|posts| {
posts.iter()
.map(|post| view! { <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
.collect_view()
})
)
}}
</ul>
</Suspense>
}
}
@@ -92,19 +91,23 @@ fn Post() -> impl IntoView {
// from the parent component, so we take that as an argument and
// pass it in under the `<Suspense/>` so that it is correct
let post_view = move || {
post.and_then(|post| {
view! {
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
move || {
post.with(|post| {
post.clone().map(|post| {
view! {
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title.clone()/>
<Meta name="description" content=post.content.clone()/>
}
})
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title/>
<Meta name="description" content=post.content/>
}
})
})
}
};
view! {
@@ -173,7 +176,7 @@ pub struct PostMetadata {
title: String,
}
#[server]
#[server(ListPostMetadata, "/api")]
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS
@@ -185,7 +188,7 @@ pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
.collect())
}
#[server]
#[server(GetPost, "/api")]
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS.iter().find(|post| post.id == id).cloned())

View File

@@ -1,24 +0,0 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/webdriver.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]
APP_PROCESS_NAME = "suspense_tests"
[tasks.integration-test]
dependencies = [
"install-cargo-leptos",
"start-webdriver",
"test-e2e-with-auto-start",
]
[tasks.test-e2e-with-auto-start]
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.test-ui]
cwd = "./e2e"
command = "cargo"
args = ["make", "test-ui", "${@}"]

View File

@@ -1,18 +0,0 @@
[package]
name = "suspense_tests_e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = "0.19.1"
fantoccini = "0.19.3"
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
[[test]]
name = "app_suite"
harness = false # Allow Cucumber to print output instead of libtest

View File

@@ -1,20 +0,0 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.test]
env = { RUN_AUTOMATICALLY = false }
condition = { env_true = ["RUN_AUTOMATICALLY"] }
[tasks.ci]
[tasks.test-ui]
command = "cargo"
args = [
"test",
"--test",
"app_suite",
"--",
"--retry",
"2",
"--fail-fast",
"${@}",
]

View File

@@ -1,34 +0,0 @@
# E2E Testing
This example demonstrates e2e testing with Rust using executable requirements.
## Testing Stack
| | Role | Description |
|---|---|---|
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
## Testing Organization
Testing is organized around what a user can do and see/not see. Test scenarios are grouped by the **user action** and the **object** of that action. This makes it easier to locate and reason about requirements.
Here is a brief overview of how things fit together.
```bash
features
└── {action}_{object}.feature # Specify test scenarios
tests
├── fixtures
│ ├── action.rs # Perform a user action (click, type, etc.)
│ ├── check.rs # Assert what a user can see/not see
│ ├── find.rs # Query page elements
│ ├── mod.rs
│ └── world
│ ├── action_steps.rs # Map Gherkin steps to user actions
│ ├── check_steps.rs # Map Gherkin steps to user expectations
│ └── mod.rs
└── app_suite.rs # Test main
```

View File

@@ -1,19 +0,0 @@
@click_inside_component_count
Feature: Click Inside Component Count
Background:
Given I see the app
Scenario Outline: Should increase the count
Given I select the mode <Mode>
And I select the component Inside Component
When I click the count 3 times
Then I see the count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,19 +0,0 @@
@click_nested_count
Feature: Click Nested Count
Background:
Given I see the app
Scenario Outline: Should increase the count
Given I select the mode <Mode>
And I select the component Nested
When I click the count 3 times
Then I see the count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,19 +0,0 @@
@click_nested_inside_count
Feature: Click Nested Inside Count
Background:
Given I see the app
Scenario Outline: Should increase the count
Given I select the mode <Mode>
And I select the component Nested (resource created inside)
When I click the count 3 times
Then I see the count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,20 +0,0 @@
@click_no_resources_counts
Feature: Click No Resources Count (1)
Background:
Given I see the app
Scenario Outline: Should increase the first and second counts
Given I select the mode <Mode>
And I select the component No Resources
When I click the first count 3 times
Then I see the first count is 3
And I see the second count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,20 +0,0 @@
@click_no_resources_counts_2
Feature: Click No Resources Count (2)
Background:
Given I see the app
Scenario Outline: Should increase the first and second counts
Given I select the mode <Mode>
And I select the component No Resources
When I click the second count 3 times
Then I see the first count is 3
And I see the second count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,20 +0,0 @@
@click_parallel_counts_1
Feature: Click Parallel Count (1)
Background:
Given I see the app
Scenario Outline: Should increase the first and second counts
Given I select the mode <Mode>
And I select the component Parallel
When I click the first count 3 times
Then I see the first count is 3
And I see the second count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,20 +0,0 @@
@click_parallel_counts_2
Feature: Click Parallel Count (2)
Background:
Given I see the app
Scenario Outline: Should increase the first and second counts
Given I select the mode <Mode>
And I select the component Parallel
When I click the second count 3 times
Then I see the first count is 3
And I see the second count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,19 +0,0 @@
@click_single_count
Feature: Click Single Count
Background:
Given I see the app
Scenario Outline: Should increase the count
Given I select the mode <Mode>
And I select the component Single
When I click the count 3 times
Then I see the count is 3
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,7 +0,0 @@
@open_app
Feature: Open App
@open_app-title
Scenario: Should see the initial page title
When I open the app
Then I see the page title is Out-of-Order

View File

@@ -1,55 +0,0 @@
@view_inside_component
Feature: View Inside Component
Background:
Given I see the app
@view_inside_component
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component Inside Component
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_inside_component-one
Scenario Outline: Should see the one second message
Given I select the mode <Mode>
When I select the component Inside Component
Then I see the one second message is One Second: Loaded 1!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_inside_component-inside
Scenario Outline: Should see the inside message
Given I select the mode <Mode>
When I select the component Inside Component
Then I see the inside message is Suspense inside another component should work.
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_inside_component-following
Scenario Outline: Should see the following message
Given I select the mode <Mode>
When I select the component Inside Component
Then I see the following message is Children following Suspense should hydrate properly.
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,45 +0,0 @@
@view_nested
Feature: View Nested
Background:
Given I see the app
@view_nested-title
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component Nested
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_nested-one
Scenario Outline: Should see the one second message
Given I select the mode <Mode>
When I select the component Nested
Then I see the one second message is One Second: Loaded 1!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_nested-two
Scenario Outline: Should see the two second message
Given I select the mode <Mode>
When I select the component Nested
Then I see the two second message is Two Second: Loaded 2!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,42 +0,0 @@
@view_nested_inside
Feature: View Nested Inside
Background:
Given I see the app
@view_nested_inside-title
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component Nested (resource created inside)
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_nested_inside-one
Scenario Outline: Should see the one second message
Given I select the mode <Mode>
When I select the component Nested (resource created inside)
Then I see the one second message is One Second: Loaded 1!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_nested_inside-two
Scenario Outline: Should see the two second message
Given I select the mode <Mode>
When I select the component Nested (resource created inside)
Then I see the two second message is Loaded 2 (created inside first suspense)!: Ok(())
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,42 +0,0 @@
@view_no_resources
Feature: view No Resources
Background:
Given I see the app
@view_no_resources-title
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component No Resources
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_no_resources-another
Scenario Outline: Should see the inside message
Given I select the mode <Mode>
When I select the component No Resources
Then I see the inside message is Children inside Suspense should hydrate properly.
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_no_resources-following
Scenario Outline: Should see the following message
Given I select the mode <Mode>
When I select the component No Resources
Then I see the following message is Children following Suspense should hydrate properly.
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,42 +0,0 @@
@view_parallel
Feature: View Parallel
Background:
Given I see the app
@view_parallel-title
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component Parallel
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_parallel-one
Scenario Outline: Should see the one second message
Given I select the mode <Mode>
When I select the component Parallel
Then I see the one second message is One Second: Loaded 1!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_parallel-two
Scenario Outline: Should see the two second message
Given I select the mode <Mode>
When I select the component Parallel
Then I see the two second message is Two Second: Loaded 2!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,42 +0,0 @@
@view_single
Feature: View Single
Background:
Given I see the app
@view_single-title
Scenario Outline: Should see the page title
Given I select the mode <Mode>
When I select the component Single
Then I see the page title is <Mode>
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_single-one
Scenario Outline: Should see the one second message
Given I select the mode <Mode>
When I select the component Single
Then I see the one second message is One Second: Loaded 1!
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |
@view_single-following
Scenario Outline: Should see the following message
Given I select the mode <Mode>
When I select the component Single
Then I see the following message is Children following Suspense should hydrate properly.
Examples:
| Mode |
| Out-of-Order |
| In-Order |
| Async |

View File

@@ -1,14 +0,0 @@
mod fixtures;
use anyhow::Result;
use cucumber::World;
use fixtures::world::AppWorld;
#[tokio::main]
async fn main() -> Result<()> {
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit("./features")
.await;
Ok(())
}

View File

@@ -1,39 +0,0 @@
use super::{find, world::HOST};
use anyhow::Result;
use fantoccini::{Client, Locator};
use std::result::Result::Ok;
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
client.goto(&url).await?;
Ok(())
}
pub async fn click_link(client: &Client, text: &str) -> Result<()> {
let link = client
.wait()
.for_element(Locator::LinkText(text))
.await
.expect(format!("Link not found by `{}`", text).as_str());
link.click().await?;
Ok(())
}
pub async fn click_first_button(client: &Client) -> Result<()> {
let counter_button = find::first_button(client).await?;
counter_button.click().await?;
Ok(())
}
pub async fn click_second_button(client: &Client) -> Result<()> {
let counter_button = find::second_button(client).await?;
counter_button.click().await?;
Ok(())
}

View File

@@ -1,65 +0,0 @@
use crate::fixtures::find;
use anyhow::{Ok, Result};
use fantoccini::Client;
use pretty_assertions::assert_eq;
pub async fn page_title_is(client: &Client, expected_text: &str) -> Result<()> {
let actual = find::page_title(client).await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn loaded_one_message_is(
client: &Client,
expected_text: &str,
) -> Result<()> {
let actual = find::loaded_one_message(client).await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn loaded_two_message_is(
client: &Client,
expected_text: &str,
) -> Result<()> {
let actual = find::loaded_two_message(client).await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn inside_message_is(
client: &Client,
expected_text: &str,
) -> Result<()> {
let actual = find::inside_message(client).await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn following_message_is(
client: &Client,
expected_text: &str,
) -> Result<()> {
let actual = find::following_message(client).await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn first_count_is(client: &Client, expected: u32) -> Result<()> {
let actual = find::first_count(client).await?;
assert_eq!(actual, expected);
Ok(())
}
pub async fn second_count_is(client: &Client, expected: u32) -> Result<()> {
let actual = find::second_count(client).await?;
assert_eq!(actual, expected);
Ok(())
}

View File

@@ -1,89 +0,0 @@
use anyhow::{Ok, Result};
use fantoccini::{elements::Element, Client, Locator};
pub async fn page_title(client: &Client) -> Result<String> {
let selector = "h1";
let element = client
.wait()
.for_element(Locator::Css(selector))
.await
.expect(
format!("Page title not found by Css selector `{}`", selector)
.as_str(),
);
let text = element.text().await?;
Ok(text)
}
pub async fn loaded_one_message(client: &Client) -> Result<String> {
let text = component_message(client, "loaded-1").await?;
Ok(text)
}
pub async fn loaded_two_message(client: &Client) -> Result<String> {
let text = component_message(client, "loaded-2").await?;
Ok(text)
}
pub async fn following_message(client: &Client) -> Result<String> {
let text = component_message(client, "following-message").await?;
Ok(text)
}
pub async fn inside_message(client: &Client) -> Result<String> {
let text = component_message(client, "inside-message").await?;
Ok(text)
}
pub async fn first_count(client: &Client) -> Result<u32> {
let element = first_button(client).await?;
let text = element.text().await?;
let count = text.parse::<u32>().unwrap();
Ok(count)
}
pub async fn first_button(client: &Client) -> Result<Element> {
let counter_button = client
.wait()
.for_element(Locator::Css("button"))
.await
.expect("First button not found");
Ok(counter_button)
}
pub async fn second_count(client: &Client) -> Result<u32> {
let element = second_button(client).await?;
let text = element.text().await?;
let count = text.parse::<u32>().unwrap();
Ok(count)
}
pub async fn second_button(client: &Client) -> Result<Element> {
let counter_button = client
.wait()
.for_element(Locator::Id("second-count"))
.await
.expect("Second button not found");
Ok(counter_button)
}
async fn component_message(client: &Client, id: &str) -> Result<String> {
let element =
client.wait().for_element(Locator::Id(id)).await.expect(
format!("loaded message not found by id `{}`", id).as_str(),
);
let text = element.text().await?;
Ok(text)
}

View File

@@ -1,4 +0,0 @@
pub mod action;
pub mod check;
pub mod find;
pub mod world;

View File

@@ -1,61 +0,0 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = r"^I select the mode (.*)$")]
async fn i_select_the_mode(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::click_link(client, &text).await?;
Ok(())
}
#[given(regex = r"^I select the component (.*)$")]
#[when(regex = "^I select the component (.*)$")]
async fn i_select_the_component(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
action::click_link(client, &text).await?;
Ok(())
}
#[when(expr = "I click the first count {int} times")]
#[when(expr = "I click the count {int} times")]
async fn i_click_the_first_button_n_times(
world: &mut AppWorld,
times: u32,
) -> Result<()> {
let client = &world.client;
for _ in 1..=times {
action::click_first_button(client).await?;
}
Ok(())
}
#[when(expr = "I click the second count {int} times")]
async fn i_click_the_second_button_n_times(
world: &mut AppWorld,
times: u32,
) -> Result<()> {
let client = &world.client;
for _ in 1..=times {
action::click_second_button(client).await?;
}
Ok(())
}

View File

@@ -1,81 +0,0 @@
use crate::fixtures::{check, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::then;
#[then(regex = r"^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::page_title_is(client, &text).await?;
Ok(())
}
#[then(regex = r"^I see the one second message is (.*)$")]
async fn i_see_the_one_second_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::loaded_one_message_is(client, &text).await?;
Ok(())
}
#[then(regex = r"^I see the two second message is (.*)$")]
async fn i_see_the_two_second_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::loaded_two_message_is(client, &text).await?;
Ok(())
}
#[then(regex = r"^I see the following message is (.*)$")]
async fn i_see_the_following_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::following_message_is(client, &text).await?;
Ok(())
}
#[then(regex = r"^I see the inside message is (.*)$")]
async fn i_see_the_inside_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::inside_message_is(client, &text).await?;
Ok(())
}
#[then(expr = "I see the first count is {int}")]
#[then(expr = "I see the count is {int}")]
async fn i_see_the_first_count_is(
world: &mut AppWorld,
expected: u32,
) -> Result<()> {
let client = &world.client;
check::first_count_is(client, expected).await?;
Ok(())
}
#[then(expr = "I see the second count is {int}")]
async fn i_see_the_second_count_is(
world: &mut AppWorld,
expected: u32,
) -> Result<()> {
let client = &world.client;
check::second_count_is(client, expected).await?;
Ok(())
}

View File

@@ -1,39 +0,0 @@
pub mod action_steps;
pub mod check_steps;
use anyhow::Result;
use cucumber::World;
use fantoccini::{
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
};
pub const HOST: &str = "http://127.0.0.1:3000";
#[derive(Debug, World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub client: Client,
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let webdriver_client = build_client().await?;
Ok(Self {
client: webdriver_client,
})
}
}
async fn build_client() -> Result<Client, NewSessionError> {
let mut cap = Capabilities::new();
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
cap.insert("goog:chromeOptions".to_string(), arg);
let client = ClientBuilder::native()
.capabilities(cap)
.connect("http://localhost:4444")
.await?;
Ok(client)
}

View File

@@ -64,8 +64,7 @@ site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "cargo make test-ui"
end2end-dir = "e2e"
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head

View File

@@ -1,24 +1,17 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/webdriver.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
extend = { path = "../cargo-make/main.toml" }
[env]
APP_PROCESS_NAME = "todo_app_sqlite"
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[tasks.integration-test]
dependencies = [
"install-cargo-leptos",
"start-webdriver",
"test-e2e-with-auto-start",
]
[tasks.test-e2e-with-auto-start]
[tasks.check-debug]
toolchain = "nightly"
command = "cargo"
args = ["leptos", "end-to-end"]
args = ["check-all-features"]
install_crate = "cargo-all-features"
[tasks.test-ui]
cwd = "./e2e"
[tasks.check-release]
toolchain = "nightly"
command = "cargo"
args = ["make", "test-ui", "${@}"]
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"

View File

@@ -52,23 +52,3 @@ wasm-pack build --target=web --debug --no-default-features --features=hydrate
```bash
cargo run --no-default-features --features=ssr
```
## Testing
This example includes quality checks and end-to-end testing.
To get started run this once.
```bash
cargo make ci
```
To only run the UI tests...
```bash
cargo make start-webdriver
cargo leptos watch # or cargo run...
cargo make test-ui
```
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._

Binary file not shown.

View File

@@ -1,18 +0,0 @@
[package]
name = "todo_app_sqlite_e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = "0.19.1"
fantoccini = "0.19.3"
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
[[test]]
name = "manage_todos"
harness = false # Allow Cucumber to print output instead of libtest

View File

@@ -1,11 +0,0 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.test]
env = { RUN_AUTOMATICALLY = false }
condition = { env_true = ["RUN_AUTOMATICALLY"] }
[tasks.ci]
[tasks.test-ui]
command = "cargo"
args = ["test", "--test", "manage_todos", "--", "--fail-fast", "${@}"]

Some files were not shown because too many files have changed in this diff Show More