mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 06:42:35 -05:00
Compare commits
231 Commits
2901/trigg
...
v0.7.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8508df6c | ||
|
|
865c6df483 | ||
|
|
c1d7f0f8d1 | ||
|
|
8c2dd73b70 | ||
|
|
d5894555cc | ||
|
|
2ef1723607 | ||
|
|
13f7387d45 | ||
|
|
0a13f7c08c | ||
|
|
7c83904aea | ||
|
|
6e13ff9787 | ||
|
|
234d138f03 | ||
|
|
97110cd5ac | ||
|
|
5acc1b1a5a | ||
|
|
f3987246cb | ||
|
|
e5149fb348 | ||
|
|
d67ff03568 | ||
|
|
1dbca3005d | ||
|
|
af61be0c72 | ||
|
|
76facf9539 | ||
|
|
0e73d18d7b | ||
|
|
d306a15f86 | ||
|
|
bf95648dc9 | ||
|
|
00edfc0e0a | ||
|
|
396327b667 | ||
|
|
a437289f81 | ||
|
|
58e7897db6 | ||
|
|
1be1f41fba | ||
|
|
7b8cd90a6e | ||
|
|
d0ef7b904d | ||
|
|
7904e0c395 | ||
|
|
7b4c470155 | ||
|
|
98eccc9eb8 | ||
|
|
70d06e2716 | ||
|
|
67c3bf2478 | ||
|
|
f3aaae857a | ||
|
|
d727e53dd6 | ||
|
|
e4543ab5df | ||
|
|
1ca0f4430c | ||
|
|
b59fa11853 | ||
|
|
e55f08e017 | ||
|
|
fa1939e5b2 | ||
|
|
8b2f0eaf44 | ||
|
|
b118d69281 | ||
|
|
ee66f6c395 | ||
|
|
eba08ad592 | ||
|
|
4833b4e287 | ||
|
|
9d1be64e4d | ||
|
|
d6e6cd3be0 | ||
|
|
70476f9277 | ||
|
|
d8ddfc26e9 | ||
|
|
c8acc3e8bd | ||
|
|
547442243b | ||
|
|
6e58266f54 | ||
|
|
f0cd0fb41d | ||
|
|
7585faf57e | ||
|
|
da7f6a34e8 | ||
|
|
4f7fa41262 | ||
|
|
4becfa39ca | ||
|
|
f8388b122d | ||
|
|
f57a57b92b | ||
|
|
f0bcbd9cfe | ||
|
|
115477ef1d | ||
|
|
832b9cb321 | ||
|
|
b0150ceeec | ||
|
|
af8df34360 | ||
|
|
b2e6185b22 | ||
|
|
d2bfb3080b | ||
|
|
72ebd17042 | ||
|
|
e2f0b4deeb | ||
|
|
57c07e9aec | ||
|
|
0835066bc0 | ||
|
|
656e83fe24 | ||
|
|
ad0252ecfd | ||
|
|
77f05c6f4e | ||
|
|
a4ea491dc0 | ||
|
|
3c89b9c930 | ||
|
|
93d7ba0d5f | ||
|
|
e188993800 | ||
|
|
c1dc8c7629 | ||
|
|
ab9de1b8c0 | ||
|
|
b39985d9b8 | ||
|
|
5e8e93001d | ||
|
|
a4ed0cbe5b | ||
|
|
422fe9f43b | ||
|
|
36df36e16c | ||
|
|
95fc79034b | ||
|
|
7403e4084f | ||
|
|
8feee5e5d7 | ||
|
|
e6da266b4f | ||
|
|
0798e0812d | ||
|
|
03514e68be | ||
|
|
4092368581 | ||
|
|
dcc7865989 | ||
|
|
896f7de8e1 | ||
|
|
29b2d3024a | ||
|
|
c47893ad60 | ||
|
|
0d4f3b51e9 | ||
|
|
2431b19cdf | ||
|
|
4801e1ec6d | ||
|
|
e206b93ba5 | ||
|
|
f0675446d8 | ||
|
|
2ac7eaad15 | ||
|
|
8a040fda69 | ||
|
|
2f5c966cf4 | ||
|
|
45e2629f0e | ||
|
|
517566d085 | ||
|
|
6df8657700 | ||
|
|
2a4063a259 | ||
|
|
013ec4a09d | ||
|
|
d10fec08e2 | ||
|
|
94f4328586 | ||
|
|
2b70961110 | ||
|
|
699c54e16c | ||
|
|
1217ef4d8e | ||
|
|
9aba3efbe4 | ||
|
|
1b48a2f8d5 | ||
|
|
31cb766206 | ||
|
|
4c3bcaa68d | ||
|
|
fe060617d2 | ||
|
|
de28a317f0 | ||
|
|
d29433b98d | ||
|
|
f2582b6ac9 | ||
|
|
67845be161 | ||
|
|
2bf1f46b88 | ||
|
|
70ae3a0abb | ||
|
|
96e2b5cba1 | ||
|
|
ef27a198d9 | ||
|
|
47299926bb | ||
|
|
bdc2285658 | ||
|
|
9d4ce6e526 | ||
|
|
3d2cdc21a1 | ||
|
|
93d939aef8 | ||
|
|
fb04750607 | ||
|
|
a080496e7e | ||
|
|
9fc1002167 | ||
|
|
bc5c766530 | ||
|
|
17821f863a | ||
|
|
1ca4f34ef3 | ||
|
|
8f0a1554b1 | ||
|
|
38d4f26d03 | ||
|
|
2b04c2710d | ||
|
|
a4937a1236 | ||
|
|
f6f2c39686 | ||
|
|
d7eacf1ab5 | ||
|
|
d1a4bbe28e | ||
|
|
412ecd6b1b | ||
|
|
9bc0152121 | ||
|
|
4b05cada8f | ||
|
|
a818862704 | ||
|
|
173487debc | ||
|
|
449d96cc9a | ||
|
|
f9bf6a95ed | ||
|
|
5bf6c94bb2 | ||
|
|
e1ce94a28d | ||
|
|
2a62dcf27e | ||
|
|
3094766c5c | ||
|
|
a52804595d | ||
|
|
e72f12d32b | ||
|
|
e70083708a | ||
|
|
cbc4caef19 | ||
|
|
fbeee4dbf5 | ||
|
|
d13f7e5438 | ||
|
|
7b543bd31c | ||
|
|
1743724420 | ||
|
|
73e0add670 | ||
|
|
4f5eb444bc | ||
|
|
7de98823fb | ||
|
|
6d930573fc | ||
|
|
3317002ff5 | ||
|
|
99403d0167 | ||
|
|
23ce022c60 | ||
|
|
96e1fd0fb8 | ||
|
|
f28dac1093 | ||
|
|
ff28544fb2 | ||
|
|
27765b417c | ||
|
|
b0d8d4ee26 | ||
|
|
c4b1176a6a | ||
|
|
fd133dd79a | ||
|
|
9c2477a4cf | ||
|
|
f3b6d1f351 | ||
|
|
5af7b54c9c | ||
|
|
ba9604101d | ||
|
|
e136c1fc44 | ||
|
|
c581b3293e | ||
|
|
cc7f861637 | ||
|
|
642d6fc72b | ||
|
|
e69c7f4ae0 | ||
|
|
9ca36d4763 | ||
|
|
8dc600ca02 | ||
|
|
b621ead607 | ||
|
|
66cf21f650 | ||
|
|
f3dcdc057d | ||
|
|
2bdacf636e | ||
|
|
fc06980c60 | ||
|
|
550a3a4e6d | ||
|
|
3310e7766b | ||
|
|
5ab865e89d | ||
|
|
f0c60f6ef6 | ||
|
|
f3f685c923 | ||
|
|
3646bf31b0 | ||
|
|
b39895fa2d | ||
|
|
1fce8931ab | ||
|
|
6166f6edbd | ||
|
|
dc9fbb0585 | ||
|
|
d7b2f9d05b | ||
|
|
69c4090d32 | ||
|
|
fff5fa3459 | ||
|
|
e92b80c71e | ||
|
|
8bb04ef248 | ||
|
|
d7881ccfb5 | ||
|
|
96a1f80daf | ||
|
|
a083b57260 | ||
|
|
4fa6660a3f | ||
|
|
43f2ad7043 | ||
|
|
2bf04072ea | ||
|
|
efc6fc017d | ||
|
|
6cb10401df | ||
|
|
346efd66f5 | ||
|
|
7c0889e873 | ||
|
|
bb40576bd5 | ||
|
|
6baf20275f | ||
|
|
5a57d48913 | ||
|
|
73f0207a7d | ||
|
|
4e4fb8ab10 | ||
|
|
b9cccc6b91 | ||
|
|
d42163d888 | ||
|
|
2db3e4f4d8 | ||
|
|
45380a258a | ||
|
|
40292d0896 | ||
|
|
e8be9e31ff | ||
|
|
3d0fdb1ab0 |
6
.github/workflows/ci-changed-examples.yml
vendored
6
.github/workflows/ci-changed-examples.yml
vendored
@@ -1,23 +1,21 @@
|
||||
name: CI Changed Examples
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- leptos_0.6
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
- leptos_0.6
|
||||
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]
|
||||
|
||||
4
.github/workflows/ci-examples.yml
vendored
4
.github/workflows/ci-examples.yml
vendored
@@ -1,13 +1,13 @@
|
||||
name: CI Examples
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- leptos_0.6
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
- leptos_0.6
|
||||
jobs:
|
||||
get-leptos-changed:
|
||||
uses: ./.github/workflows/get-leptos-changed.yml
|
||||
|
||||
7
.github/workflows/ci-semver.yml
vendored
7
.github/workflows/ci-semver.yml
vendored
@@ -1,27 +1,24 @@
|
||||
name: CI semver
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- leptos_0.6
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
- leptos_0.6
|
||||
jobs:
|
||||
get-leptos-changed:
|
||||
uses: ./.github/workflows/get-leptos-changed.yml
|
||||
|
||||
test:
|
||||
needs: [get-leptos-changed]
|
||||
if: github.event.pull_request.labels[0].name == 'semver' # needs.get-leptos-changed.outputs.leptos_changed == 'true' && github.event.pull_request.labels[0].name != 'breaking'
|
||||
name: Run semver check (nightly-2024-08-01)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Semver Checks
|
||||
uses: obi1kenobi/cargo-semver-checks-action@v2
|
||||
with:
|
||||
|
||||
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
@@ -1,50 +1,25 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- leptos_0.6
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
- leptos_0.6
|
||||
jobs:
|
||||
get-leptos-changed:
|
||||
uses: ./.github/workflows/get-leptos-changed.yml
|
||||
|
||||
get-leptos-matrix:
|
||||
uses: ./.github/workflows/get-leptos-matrix.yml
|
||||
test:
|
||||
name: CI
|
||||
needs: [get-leptos-changed]
|
||||
needs: [get-leptos-changed, get-leptos-matrix]
|
||||
if: needs.get-leptos-changed.outputs.leptos_changed == 'true'
|
||||
strategy:
|
||||
matrix:
|
||||
directory:
|
||||
[
|
||||
any_error,
|
||||
any_spawner,
|
||||
const_str_slice_concat,
|
||||
either_of,
|
||||
hydration_context,
|
||||
integrations/actix,
|
||||
integrations/axum,
|
||||
integrations/utils,
|
||||
leptos,
|
||||
leptos_config,
|
||||
leptos_dom,
|
||||
leptos_hot_reload,
|
||||
leptos_macro,
|
||||
leptos_server,
|
||||
meta,
|
||||
next_tuple,
|
||||
oco,
|
||||
or_poisoned,
|
||||
reactive_graph,
|
||||
router,
|
||||
router_macro,
|
||||
server_fn,
|
||||
server_fn/server_fn_macro_default,
|
||||
server_fn_macro,
|
||||
]
|
||||
matrix: ${{ fromJSON(needs.get-leptos-matrix.outputs.matrix) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/run-cargo-make-task.yml
|
||||
with:
|
||||
directory: ${{ matrix.directory }}
|
||||
|
||||
6
.github/workflows/get-example-changed.yml
vendored
6
.github/workflows/get-example-changed.yml
vendored
@@ -1,12 +1,10 @@
|
||||
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
|
||||
@@ -18,7 +16,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get example files that changed
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v45
|
||||
@@ -26,13 +23,10 @@ jobs:
|
||||
files: |
|
||||
examples/**
|
||||
!examples/cargo-make/**
|
||||
!examples/gtk/**
|
||||
!examples/Makefile.toml
|
||||
!examples/*.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: |
|
||||
|
||||
18
.github/workflows/get-examples-matrix.yml
vendored
18
.github/workflows/get-examples-matrix.yml
vendored
@@ -1,38 +1,34 @@
|
||||
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 }}
|
||||
env:
|
||||
# separate examples using "|" (vertical bar) char like "a|b|c".
|
||||
# cargo-make should be excluded by default.
|
||||
EXCLUDED_EXAMPLES: cargo-make
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install jq
|
||||
|
||||
- name: Set Matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
examples=$(ls examples |
|
||||
awk '{print "examples/" $0}' |
|
||||
grep -v .md |
|
||||
grep -v examples/Makefile.toml |
|
||||
grep -v examples/cargo-make |
|
||||
grep -v examples/gtk |
|
||||
examples=$(ls -1d examples/*/ |
|
||||
grep -vE "($EXCLUDED_EXAMPLES)" |
|
||||
sed 's/\/$//' |
|
||||
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 }}"
|
||||
|
||||
37
.github/workflows/get-leptos-changed.yml
vendored
37
.github/workflows/get-leptos-changed.yml
vendored
@@ -1,12 +1,10 @@
|
||||
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
|
||||
@@ -18,40 +16,19 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get source files that changed
|
||||
id: changed-source
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files: |
|
||||
any_error/**
|
||||
any_spawner/**
|
||||
const_str_slice_concat/**
|
||||
either_of/**
|
||||
hydration_context/**
|
||||
integrations/actix/**
|
||||
integrations/axum/**
|
||||
integrations/utils/**
|
||||
leptos/**
|
||||
leptos_config/**
|
||||
leptos_dom/**
|
||||
leptos_hot_reload/**
|
||||
leptos_macro/**
|
||||
leptos_server/**
|
||||
meta/**
|
||||
next_tuple/**
|
||||
oco/**
|
||||
or_poisoned/**
|
||||
reactive_graph/**
|
||||
router/**
|
||||
router_macro/**
|
||||
server_fn/**
|
||||
server_fn/server_fn_macro_default/**
|
||||
server_fn_macro/**
|
||||
|
||||
files_ignore: |
|
||||
.*/**/*
|
||||
cargo-make/**/*
|
||||
examples/**/*
|
||||
projects/**/*
|
||||
benchmarks/**/*
|
||||
docs/**/*
|
||||
- name: List source files that changed
|
||||
run: echo '${{ steps.changed-source.outputs.all_changed_files }}'
|
||||
|
||||
- name: Set leptos_changed
|
||||
id: set-source-changed
|
||||
run: |
|
||||
|
||||
32
.github/workflows/get-leptos-matrix.yml
vendored
Normal file
32
.github/workflows/get-leptos-matrix.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Get Leptos Matrix Call
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
matrix:
|
||||
description: "Matrix"
|
||||
value: ${{ jobs.create.outputs.matrix }}
|
||||
jobs:
|
||||
create:
|
||||
name: Create Leptos Matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install jq
|
||||
run: sudo apt-get install jq
|
||||
- name: Set Matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
crates=$(cargo metadata --no-deps --quiet --format-version 1 |
|
||||
jq -r '.packages[] | select(.name != "workspace") | .manifest_path| rtrimstr("/Cargo.toml")' |
|
||||
sed "s|$(pwd)/||" |
|
||||
jq -R -s -c 'split("\n")[:-1]')
|
||||
echo "Leptos Directories: $crates"
|
||||
echo "matrix={\"directory\":$crates}" >> "$GITHUB_OUTPUT"
|
||||
- name: Print Location Info
|
||||
run: |
|
||||
echo "Workspace: ${{ github.workspace }}"
|
||||
pwd
|
||||
ls | sort -u
|
||||
30
.github/workflows/run-cargo-make-task.yml
vendored
30
.github/workflows/run-cargo-make-task.yml
vendored
@@ -1,5 +1,4 @@
|
||||
name: Run Task
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -12,70 +11,53 @@ on:
|
||||
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
|
||||
|
||||
steps:
|
||||
# Setup environment
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ inputs.toolchain }}
|
||||
|
||||
- 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: Install binstall
|
||||
uses: cargo-bins/cargo-binstall@main
|
||||
|
||||
- name: Install wasm-bindgen
|
||||
run: cargo binstall wasm-bindgen-cli --no-confirm
|
||||
|
||||
- name: Install cargo-leptos
|
||||
run: cargo binstall cargo-leptos --no-confirm
|
||||
|
||||
- name: Install Trunk
|
||||
uses: jetli/trunk-action@v0.5.0
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Print Trunk Version
|
||||
run: trunk --version
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
@@ -83,7 +65,6 @@ jobs:
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Maybe install chromedriver
|
||||
run: |
|
||||
project_makefile=${{inputs.directory}}/Makefile.toml
|
||||
@@ -99,7 +80,6 @@ jobs:
|
||||
else
|
||||
echo chromedriver is not required
|
||||
fi
|
||||
|
||||
- name: Maybe install playwright browser dependencies
|
||||
run: |
|
||||
for pw_path in $(find ${{inputs.directory}} -name playwright.config.ts)
|
||||
@@ -113,12 +93,16 @@ jobs:
|
||||
echo Playwright is not required
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Install Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
- name: Maybe install gtk-rs dependencies
|
||||
run: |
|
||||
if [ ! -z $(echo ${{inputs.directory}} | grep gtk) ]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libglib2.0-dev libgio2.0-cil-dev libgraphene-1.0-dev libcairo2-dev libpango1.0-dev libgtk-4-dev
|
||||
fi
|
||||
# Run Cargo Make Task
|
||||
- name: ${{ inputs.cargo_make_task }}
|
||||
run: |
|
||||
|
||||
42
Cargo.toml
42
Cargo.toml
@@ -40,36 +40,36 @@ members = [
|
||||
exclude = ["benchmarks", "examples", "projects"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0-beta4"
|
||||
version = "0.7.0-rc1"
|
||||
edition = "2021"
|
||||
rust-version = "1.76"
|
||||
|
||||
[workspace.dependencies]
|
||||
throw_error = { path = "./any_error/", version = "0.2.0-beta4" }
|
||||
throw_error = { path = "./any_error/", version = "0.2.0-rc1" }
|
||||
any_spawner = { path = "./any_spawner/", version = "0.1.0" }
|
||||
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1.0" }
|
||||
either_of = { path = "./either_of/", version = "0.1.0" }
|
||||
hydration_context = { path = "./hydration_context", version = "0.2.0-beta4" }
|
||||
leptos = { path = "./leptos", version = "0.7.0-beta4" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.7.0-beta4" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.7.0-beta4" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-beta4" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-beta4" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.7.0-beta4" }
|
||||
leptos_router = { path = "./router", version = "0.7.0-beta4" }
|
||||
leptos_router_macro = { path = "./router_macro", version = "0.7.0-beta4" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.7.0-beta4" }
|
||||
leptos_meta = { path = "./meta", version = "0.7.0-beta4" }
|
||||
next_tuple = { path = "./next_tuple", version = "0.1.0-beta4" }
|
||||
hydration_context = { path = "./hydration_context", version = "0.2.0-rc1" }
|
||||
leptos = { path = "./leptos", version = "0.7.0-rc1" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.7.0-rc1" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.7.0-rc1" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-rc1" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-rc1" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.7.0-rc1" }
|
||||
leptos_router = { path = "./router", version = "0.7.0-rc1" }
|
||||
leptos_router_macro = { path = "./router_macro", version = "0.7.0-rc1" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.7.0-rc1" }
|
||||
leptos_meta = { path = "./meta", version = "0.7.0-rc1" }
|
||||
next_tuple = { path = "./next_tuple", version = "0.1.0-rc1" }
|
||||
oco_ref = { path = "./oco", version = "0.2.0" }
|
||||
or_poisoned = { path = "./or_poisoned", version = "0.1.0" }
|
||||
reactive_graph = { path = "./reactive_graph", version = "0.1.0-beta4" }
|
||||
reactive_stores = { path = "./reactive_stores", version = "0.1.0-beta4" }
|
||||
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-beta4" }
|
||||
server_fn = { path = "./server_fn", version = "0.7.0-beta4" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-beta4" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-beta4" }
|
||||
tachys = { path = "./tachys", version = "0.1.0-beta4" }
|
||||
reactive_graph = { path = "./reactive_graph", version = "0.1.0-rc1" }
|
||||
reactive_stores = { path = "./reactive_stores", version = "0.1.0-rc1" }
|
||||
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-rc1" }
|
||||
server_fn = { path = "./server_fn", version = "0.7.0-rc1" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-rc1" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-rc1" }
|
||||
tachys = { path = "./tachys", version = "0.1.0-rc1" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "throw_error"
|
||||
version = "0.2.0-beta4"
|
||||
version = "0.2.0-rc1"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "Spawn asynchronous tasks in an executor-independent way."
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-executor = { version = "1.13.1", optional = true }
|
||||
futures = "0.3.30"
|
||||
glib = { version = "0.20.0", optional = true }
|
||||
thiserror = "1.0"
|
||||
@@ -19,12 +20,14 @@ tracing = { version = "0.1.40", optional = true }
|
||||
wasm-bindgen-futures = { version = "0.4.42", optional = true }
|
||||
|
||||
[features]
|
||||
async-executor = ["dep:async-executor"]
|
||||
tracing = ["dep:tracing"]
|
||||
tokio = ["dep:tokio"]
|
||||
glib = ["dep:glib"]
|
||||
wasm-bindgen = ["dep:wasm-bindgen-futures"]
|
||||
futures-executor = ["futures/thread-pool", "futures/executor"]
|
||||
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
use std::{future::Future, pin::Pin, sync::OnceLock};
|
||||
use thiserror::Error;
|
||||
|
||||
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||
pub(crate) type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
/// A future that has been pinned.
|
||||
pub type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||
/// A future that has been pinned.
|
||||
pub type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
|
||||
static SPAWN: OnceLock<fn(PinnedFuture<()>)> = OnceLock::new();
|
||||
static SPAWN_LOCAL: OnceLock<fn(PinnedLocalFuture<()>)> = OnceLock::new();
|
||||
static POLL_LOCAL: OnceLock<fn()> = OnceLock::new();
|
||||
|
||||
/// Errors that can occur when using the executor.
|
||||
#[derive(Error, Debug)]
|
||||
@@ -115,6 +118,14 @@ impl Executor {
|
||||
});
|
||||
_ = rx.await;
|
||||
}
|
||||
|
||||
/// Polls the current async executor.
|
||||
/// Not all async executors support polling, so this function may not do anything.
|
||||
pub fn poll_local() {
|
||||
if let Some(poller) = POLL_LOCAL.get() {
|
||||
poller()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Executor {
|
||||
@@ -193,13 +204,15 @@ impl Executor {
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "futures-executor")))]
|
||||
pub fn init_futures_executor() -> Result<(), ExecutorError> {
|
||||
use futures::{
|
||||
executor::{LocalPool, ThreadPool},
|
||||
executor::{LocalPool, LocalSpawner, ThreadPool},
|
||||
task::{LocalSpawnExt, SpawnExt},
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
|
||||
static THREAD_POOL: OnceLock<ThreadPool> = OnceLock::new();
|
||||
thread_local! {
|
||||
static LOCAL_POOL: LocalPool = LocalPool::new();
|
||||
static LOCAL_POOL: RefCell<LocalPool> = RefCell::new(LocalPool::new());
|
||||
static SPAWNER: LocalSpawner = LOCAL_POOL.with(|pool| pool.borrow().spawner());
|
||||
}
|
||||
|
||||
fn get_thread_pool() -> &'static ThreadPool {
|
||||
@@ -218,28 +231,131 @@ impl Executor {
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
SPAWN_LOCAL
|
||||
.set(|fut| {
|
||||
LOCAL_POOL.with(|pool| {
|
||||
let spawner = pool.spawner();
|
||||
SPAWNER.with(|spawner| {
|
||||
spawner.spawn_local(fut).expect("failed to spawn future");
|
||||
});
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
POLL_LOCAL
|
||||
.set(|| {
|
||||
LOCAL_POOL.with(|pool| {
|
||||
if let Ok(mut pool) = pool.try_borrow_mut() {
|
||||
pool.run_until_stalled();
|
||||
}
|
||||
// If we couldn't borrow_mut, we're in a nested call to poll, so we don't need to do anything.
|
||||
});
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Globally sets the [`async_executor`] executor as the executor used to spawn tasks,
|
||||
/// lazily creating a thread pool to spawn tasks into.
|
||||
///
|
||||
/// Returns `Err(_)` if an executor has already been set.
|
||||
///
|
||||
/// Requires the `async-executor` feature to be activated on this crate.
|
||||
#[cfg(feature = "async-executor")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "async-executor")))]
|
||||
pub fn init_async_executor() -> Result<(), ExecutorError> {
|
||||
use async_executor::{Executor, LocalExecutor};
|
||||
|
||||
static THREAD_POOL: OnceLock<Executor> = OnceLock::new();
|
||||
thread_local! {
|
||||
static LOCAL_POOL: LocalExecutor<'static> = const { LocalExecutor::new() };
|
||||
}
|
||||
|
||||
fn get_thread_pool() -> &'static Executor<'static> {
|
||||
THREAD_POOL.get_or_init(Executor::new)
|
||||
}
|
||||
|
||||
SPAWN
|
||||
.set(|fut| {
|
||||
get_thread_pool().spawn(fut).detach();
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
SPAWN_LOCAL
|
||||
.set(|fut| {
|
||||
LOCAL_POOL.with(|pool| pool.spawn(fut).detach());
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
POLL_LOCAL
|
||||
.set(|| {
|
||||
LOCAL_POOL.with(|pool| pool.try_tick());
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Globally sets a custom executor as the executor used to spawn tasks.
|
||||
///
|
||||
/// Returns `Err(_)` if an executor has already been set.
|
||||
pub fn init_custom_executor(
|
||||
custom_executor: impl CustomExecutor + Send + Sync + 'static,
|
||||
) -> Result<(), ExecutorError> {
|
||||
static EXECUTOR: OnceLock<Box<dyn CustomExecutor + Send + Sync>> =
|
||||
OnceLock::new();
|
||||
EXECUTOR
|
||||
.set(Box::new(custom_executor))
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
|
||||
SPAWN
|
||||
.set(|fut| {
|
||||
EXECUTOR.get().unwrap().spawn(fut);
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
SPAWN_LOCAL
|
||||
.set(|fut| EXECUTOR.get().unwrap().spawn_local(fut))
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
POLL_LOCAL
|
||||
.set(|| EXECUTOR.get().unwrap().poll_local())
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locally sets a custom executor as the executor used to spawn tasks
|
||||
/// in the current thread.
|
||||
///
|
||||
/// Returns `Err(_)` if an executor has already been set.
|
||||
pub fn init_local_custom_executor(
|
||||
custom_executor: impl CustomExecutor + 'static,
|
||||
) -> Result<(), ExecutorError> {
|
||||
thread_local! {
|
||||
static EXECUTOR: OnceLock<Box<dyn CustomExecutor>> = OnceLock::new();
|
||||
}
|
||||
EXECUTOR.with(|this| {
|
||||
this.set(Box::new(custom_executor))
|
||||
.map_err(|_| ExecutorError::AlreadySet)
|
||||
})?;
|
||||
|
||||
SPAWN
|
||||
.set(|fut| {
|
||||
EXECUTOR.with(|this| this.get().unwrap().spawn(fut));
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
SPAWN_LOCAL
|
||||
.set(|fut| {
|
||||
EXECUTOR.with(|this| this.get().unwrap().spawn_local(fut));
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
POLL_LOCAL
|
||||
.set(|| {
|
||||
EXECUTOR.with(|this| this.get().unwrap().poll_local());
|
||||
})
|
||||
.map_err(|_| ExecutorError::AlreadySet)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(feature = "futures-executor")]
|
||||
#[test]
|
||||
fn can_spawn_local_future() {
|
||||
use crate::Executor;
|
||||
use std::rc::Rc;
|
||||
Executor::init_futures_executor().expect("couldn't set executor");
|
||||
let rc = Rc::new(());
|
||||
Executor::spawn_local(async {
|
||||
_ = rc;
|
||||
});
|
||||
Executor::spawn(async {});
|
||||
}
|
||||
/// A trait for custom executors.
|
||||
/// Custom executors can be used to integrate with any executor that supports spawning futures.
|
||||
///
|
||||
/// All methods can be called recursively.
|
||||
pub trait CustomExecutor {
|
||||
/// Spawns a future, usually on a thread pool.
|
||||
fn spawn(&self, fut: PinnedFuture<()>);
|
||||
/// Spawns a local future. May require calling `poll_local` to make progress.
|
||||
fn spawn_local(&self, fut: PinnedLocalFuture<()>);
|
||||
/// Polls the executor, if it supports polling.
|
||||
fn poll_local(&self);
|
||||
}
|
||||
|
||||
55
any_spawner/tests/custom_runtime.rs
Normal file
55
any_spawner/tests/custom_runtime.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
#[cfg(feature = "futures-executor")]
|
||||
use any_spawner::{CustomExecutor, Executor, PinnedFuture, PinnedLocalFuture};
|
||||
#[cfg(feature = "futures-executor")]
|
||||
#[test]
|
||||
fn can_create_custom_executor() {
|
||||
use futures::{
|
||||
executor::{LocalPool, LocalSpawner},
|
||||
task::LocalSpawnExt,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
static LOCAL_POOL: RefCell<LocalPool> = RefCell::new(LocalPool::new());
|
||||
static SPAWNER: LocalSpawner = LOCAL_POOL.with(|pool| pool.borrow().spawner());
|
||||
}
|
||||
|
||||
struct CustomFutureExecutor;
|
||||
impl CustomExecutor for CustomFutureExecutor {
|
||||
fn spawn(&self, _fut: PinnedFuture<()>) {
|
||||
panic!("not supported in this test");
|
||||
}
|
||||
|
||||
fn spawn_local(&self, fut: PinnedLocalFuture<()>) {
|
||||
SPAWNER.with(|spawner| {
|
||||
spawner.spawn_local(fut).expect("failed to spawn future");
|
||||
});
|
||||
}
|
||||
|
||||
fn poll_local(&self) {
|
||||
LOCAL_POOL.with(|pool| {
|
||||
if let Ok(mut pool) = pool.try_borrow_mut() {
|
||||
pool.run_until_stalled();
|
||||
}
|
||||
// If we couldn't borrow_mut, we're in a nested call to poll, so we don't need to do anything.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Executor::init_custom_executor(CustomFutureExecutor)
|
||||
.expect("couldn't set executor");
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
Executor::spawn_local(async move {
|
||||
counter_clone.store(1, Ordering::Release);
|
||||
});
|
||||
Executor::poll_local();
|
||||
assert_eq!(counter.load(Ordering::Acquire), 1);
|
||||
}
|
||||
38
any_spawner/tests/futures_runtime.rs
Normal file
38
any_spawner/tests/futures_runtime.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
#[cfg(feature = "futures-executor")]
|
||||
use any_spawner::Executor;
|
||||
// All tests in this file use the same executor.
|
||||
|
||||
#[cfg(feature = "futures-executor")]
|
||||
#[test]
|
||||
fn can_spawn_local_future() {
|
||||
use std::rc::Rc;
|
||||
|
||||
let _ = Executor::init_futures_executor();
|
||||
let rc = Rc::new(());
|
||||
Executor::spawn_local(async {
|
||||
_ = rc;
|
||||
});
|
||||
Executor::spawn(async {});
|
||||
}
|
||||
|
||||
#[cfg(feature = "futures-executor")]
|
||||
#[test]
|
||||
fn can_make_local_progress() {
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
let _ = Executor::init_futures_executor();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
Executor::spawn_local({
|
||||
let counter = Arc::clone(&counter);
|
||||
async move {
|
||||
assert_eq!(counter.fetch_add(1, Ordering::AcqRel), 0);
|
||||
Executor::spawn_local(async {
|
||||
// Should not crash
|
||||
});
|
||||
}
|
||||
});
|
||||
Executor::poll_local();
|
||||
assert_eq!(counter.load(Ordering::Acquire), 1);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
name = "benchmarks"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[dependencies]
|
||||
l0410 = { package = "leptos", version = "0.4.10", features = [
|
||||
|
||||
@@ -18,7 +18,7 @@ fn leptos_ssr_bench(b: &mut Bencher) {
|
||||
}
|
||||
}
|
||||
|
||||
let rendered = view! {
|
||||
let rendered = view! {
|
||||
<main>
|
||||
<h1>"Welcome to our benchmark page."</h1>
|
||||
<p>"Here's some introductory text."</p>
|
||||
@@ -58,7 +58,7 @@ fn tachys_ssr_bench(b: &mut Bencher) {
|
||||
}
|
||||
}
|
||||
|
||||
let rendered = view! {
|
||||
let rendered = view! {
|
||||
<main>
|
||||
<h1>"Welcome to our benchmark page."</h1>
|
||||
<p>"Here's some introductory text."</p>
|
||||
@@ -92,13 +92,13 @@ fn tera_ssr_bench(b: &mut Bencher) {
|
||||
{% endfor %}
|
||||
</main>"#;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref TERA: Tera = {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_templates(vec![("template.html", TEMPLATE)]).unwrap();
|
||||
tera
|
||||
};
|
||||
}
|
||||
|
||||
static LazyCell<TERA>: Tera = LazyLock::new(|| {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_templates(vec![("template.html", TEMPLATE)]).unwrap();
|
||||
tera
|
||||
});
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Counter {
|
||||
|
||||
@@ -55,7 +55,7 @@ static TEMPLATE: &str = r#"<main>
|
||||
{% else %}
|
||||
<li><a href="/">All</a></li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if mode_active %}
|
||||
<li><a href="/active" class="selected">Active</a></li>
|
||||
{% else %}
|
||||
@@ -91,13 +91,13 @@ fn tera_todomvc_ssr(b: &mut Bencher) {
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tera::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref TERA: Tera = {
|
||||
|
||||
static LazyLock<TERA>: Tera = LazyLock( || {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_templates(vec![("template.html", TEMPLATE)]).unwrap();
|
||||
tera
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Todo {
|
||||
@@ -131,13 +131,13 @@ fn tera_todomvc_ssr_1000(b: &mut Bencher) {
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tera::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref TERA: Tera = {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_templates(vec![("template.html", TEMPLATE)]).unwrap();
|
||||
tera
|
||||
};
|
||||
}
|
||||
|
||||
static TERA: LazyLock<Tera> = LazyLock::new(|| {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_templates(vec![("template.html", TEMPLATE)]).unwrap();
|
||||
tera
|
||||
});
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Todo {
|
||||
|
||||
@@ -133,3 +133,104 @@ tuples!(EitherOf13 + EitherOf13Future + EitherOf13FutureProj => A, B, C, D, E, F
|
||||
tuples!(EitherOf14 + EitherOf14Future + EitherOf14FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N);
|
||||
tuples!(EitherOf15 + EitherOf15Future + EitherOf15FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
|
||||
tuples!(EitherOf16 + EitherOf16Future + EitherOf16FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
|
||||
|
||||
/// Matches over the first expression and returns an either ([`Either`], [`EitherOf3`], ... [`EitherOf6`])
|
||||
/// composed of the values returned by the match arms.
|
||||
///
|
||||
/// The pattern syntax is exactly the same as found in a match arm.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use either_of::*;
|
||||
/// let either2 = either!(Some("hello"),
|
||||
/// Some(s) => s.len(),
|
||||
/// None => 0.0,
|
||||
/// );
|
||||
/// assert!(matches!(either2, Either::<usize, f64>::Left(5)));
|
||||
///
|
||||
/// let either3 = either!(Some("admin"),
|
||||
/// Some("admin") => "hello admin",
|
||||
/// Some(_) => 'x',
|
||||
/// _ => 0,
|
||||
/// );
|
||||
/// assert!(matches!(either3, EitherOf3::<&str, char, i32>::A("hello admin")));
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! either {
|
||||
($match:expr, $left_pattern:pat => $left_expression:expr, $right_pattern:pat => $right_expression:expr,) => {
|
||||
match $match {
|
||||
$left_pattern => $crate::Either::Left($left_expression),
|
||||
$right_pattern => $crate::Either::Right($right_expression),
|
||||
}
|
||||
};
|
||||
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr,) => {
|
||||
match $match {
|
||||
$a_pattern => $crate::EitherOf3::A($a_expression),
|
||||
$b_pattern => $crate::EitherOf3::B($b_expression),
|
||||
$c_pattern => $crate::EitherOf3::C($c_expression),
|
||||
}
|
||||
};
|
||||
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr, $d_pattern:pat => $d_expression:expr,) => {
|
||||
match $match {
|
||||
$a_pattern => $crate::EitherOf4::A($a_expression),
|
||||
$b_pattern => $crate::EitherOf4::B($b_expression),
|
||||
$c_pattern => $crate::EitherOf4::C($c_expression),
|
||||
$d_pattern => $crate::EitherOf4::D($d_expression),
|
||||
}
|
||||
};
|
||||
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr, $d_pattern:pat => $d_expression:expr, $e_pattern:pat => $e_expression:expr,) => {
|
||||
match $match {
|
||||
$a_pattern => $crate::EitherOf5::A($a_expression),
|
||||
$b_pattern => $crate::EitherOf5::B($b_expression),
|
||||
$c_pattern => $crate::EitherOf5::C($c_expression),
|
||||
$d_pattern => $crate::EitherOf5::D($d_expression),
|
||||
$e_pattern => $crate::EitherOf5::E($e_expression),
|
||||
}
|
||||
};
|
||||
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr, $d_pattern:pat => $d_expression:expr, $e_pattern:pat => $e_expression:expr, $f_pattern:pat => $f_expression:expr,) => {
|
||||
match $match {
|
||||
$a_pattern => $crate::EitherOf6::A($a_expression),
|
||||
$b_pattern => $crate::EitherOf6::B($b_expression),
|
||||
$c_pattern => $crate::EitherOf6::C($c_expression),
|
||||
$d_pattern => $crate::EitherOf6::D($d_expression),
|
||||
$e_pattern => $crate::EitherOf6::E($e_expression),
|
||||
$f_pattern => $crate::EitherOf6::F($f_expression),
|
||||
}
|
||||
}; // if you need more eithers feel free to open a PR ;-)
|
||||
}
|
||||
|
||||
// compile time test
|
||||
#[test]
|
||||
fn either_macro() {
|
||||
let _: Either<&str, f64> = either!(12,
|
||||
12 => "12",
|
||||
_ => 0.0,
|
||||
);
|
||||
let _: EitherOf3<&str, f64, i32> = either!(12,
|
||||
12 => "12",
|
||||
13 => 0.0,
|
||||
_ => 12,
|
||||
);
|
||||
let _: EitherOf4<&str, f64, char, i32> = either!(12,
|
||||
12 => "12",
|
||||
13 => 0.0,
|
||||
14 => ' ',
|
||||
_ => 12,
|
||||
);
|
||||
let _: EitherOf5<&str, f64, char, f32, i32> = either!(12,
|
||||
12 => "12",
|
||||
13 => 0.0,
|
||||
14 => ' ',
|
||||
15 => 0.0f32,
|
||||
_ => 12,
|
||||
);
|
||||
let _: EitherOf6<&str, f64, char, f32, u8, i32> = either!(12,
|
||||
12 => "12",
|
||||
13 => 0.0,
|
||||
14 => ' ',
|
||||
15 => 0.0f32,
|
||||
16 => 24u8,
|
||||
_ => 12,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
let fallback = || view! { "Page not found." }.into_view();
|
||||
let ssr = SsrMode::Async;
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/axum_js_ssr.css"/>
|
||||
@@ -79,19 +78,19 @@ pub fn App() -> impl IntoView {
|
||||
<h1>"Leptos JavaScript Integration Demo with SSR in Axum"</h1>
|
||||
<FlatRoutes fallback>
|
||||
<Route path=path!("") view=HomePage/>
|
||||
<Route path=path!("naive") view=Naive ssr/>
|
||||
<Route path=path!("naive-alt") view=|| view! { <NaiveEvent/> } ssr/>
|
||||
<Route path=path!("naive-hook") view=|| view! { <NaiveEvent hook=true/> } ssr/>
|
||||
<Route path=path!("naive") view=Naive ssr=SsrMode::Async/>
|
||||
<Route path=path!("naive-alt") view=|| view! { <NaiveEvent/> } ssr=SsrMode::Async/>
|
||||
<Route path=path!("naive-hook") view=|| view! { <NaiveEvent hook=true/> } ssr=SsrMode::Async/>
|
||||
<Route path=path!("naive-fallback") view=|| view! {
|
||||
<NaiveEvent hook=true fallback=true/>
|
||||
} ssr/>
|
||||
<Route path=path!("signal-effect-script") view=CodeDemoSignalEffect ssr/>
|
||||
<Route path=path!("custom-event") view=CustomEvent ssr/>
|
||||
<Route path=path!("wasm-bindgen-naive") view=WasmBindgenNaive ssr/>
|
||||
<Route path=path!("wasm-bindgen-event") view=WasmBindgenJSHookReadyEvent ssr/>
|
||||
<Route path=path!("wasm-bindgen-effect") view=WasmBindgenEffect ssr/>
|
||||
<Route path=path!("wasm-bindgen-direct") view=WasmBindgenDirect ssr/>
|
||||
<Route path=path!("wasm-bindgen-direct-fixed") view=WasmBindgenDirectFixed ssr/>
|
||||
} ssr=SsrMode::Async/>
|
||||
<Route path=path!("signal-effect-script") view=CodeDemoSignalEffect ssr=SsrMode::Async/>
|
||||
<Route path=path!("custom-event") view=CustomEvent ssr=SsrMode::Async/>
|
||||
<Route path=path!("wasm-bindgen-naive") view=WasmBindgenNaive ssr=SsrMode::Async/>
|
||||
<Route path=path!("wasm-bindgen-event") view=WasmBindgenJSHookReadyEvent ssr=SsrMode::Async/>
|
||||
<Route path=path!("wasm-bindgen-effect") view=WasmBindgenEffect ssr=SsrMode::Async/>
|
||||
<Route path=path!("wasm-bindgen-direct") view=WasmBindgenDirect ssr=SsrMode::Async/>
|
||||
<Route path=path!("wasm-bindgen-direct-fixed") view=WasmBindgenDirectFixed ssr=SsrMode::Async/>
|
||||
</FlatRoutes>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
@@ -26,6 +26,7 @@ async fn main() {
|
||||
};
|
||||
use axum_js_ssr::app::*;
|
||||
use http_body_util::BodyExt;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use counter::*;
|
||||
use leptos::mount::mount_to;
|
||||
use leptos::prelude::*;
|
||||
use leptos::spawn::tick;
|
||||
use leptos::task::tick;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "counter_isomorphic"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -17,7 +19,6 @@ broadcaster = "1.0"
|
||||
console_log = "1.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.30"
|
||||
lazy_static = "1.5"
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
@@ -46,13 +47,13 @@ denylist = ["actix-files", "actix-web", "leptos_actix"]
|
||||
skip_feature_sets = [["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "counter_isomorphic"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
# When NOT using cargo-leptos this must be updated to "." or the counters will not work. The above warning still applies if you do switch to cargo-leptos later.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
# style-file = "src/styles/tailwind.css"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use leptos::{prelude::*, reactive_graph::actions::Action};
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::{
|
||||
components::{FlatRoutes, Route, Router, A},
|
||||
StaticSegment,
|
||||
@@ -10,12 +10,12 @@ use tracing::instrument;
|
||||
pub mod ssr_imports {
|
||||
pub use broadcaster::BroadcastChannel;
|
||||
pub use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static COUNT: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref COUNT_CHANNEL: BroadcastChannel<i32> = BroadcastChannel::new();
|
||||
}
|
||||
pub static COUNT_CHANNEL: LazyLock<BroadcastChannel<i32>> =
|
||||
LazyLock::new(BroadcastChannel::<i32>::new);
|
||||
}
|
||||
|
||||
#[server]
|
||||
|
||||
@@ -63,7 +63,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use counter_without_macros::counter;
|
||||
use leptos::{prelude::*, spawn::tick};
|
||||
use leptos::{prelude::*, task::tick};
|
||||
use pretty_assertions::assert_eq;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
@@ -4,7 +4,7 @@ use wasm_bindgen_test::*;
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
use counters::Counters;
|
||||
use leptos::prelude::*;
|
||||
use leptos::spawn::tick;
|
||||
use leptos::task::tick;
|
||||
use web_sys::HtmlElement;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use directives::App;
|
||||
use leptos::{prelude::*, spawn::tick};
|
||||
use leptos::{prelude::*, task::tick};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
use web_sys::HtmlElement;
|
||||
|
||||
@@ -6,9 +6,7 @@ use leptos_axum::ResponseOptions;
|
||||
// A basic function to display errors served by the error boundaries.
|
||||
// Feel free to do more complicated things here than just displaying them.
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
#[prop(into)] errors: MaybeSignal<Errors>,
|
||||
) -> impl IntoView {
|
||||
pub fn ErrorTemplate(#[prop(into)] errors: Signal<Errors>) -> impl IntoView {
|
||||
// Get Errors from Signal
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors = Memo::new(move |_| {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "gtk"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos" }
|
||||
throw_error = { path = "../../any_error/" }
|
||||
|
||||
# these are used to build the integration
|
||||
gtk = { version = "0.9.0", package = "gtk4" }
|
||||
next_tuple = { path = "../../next_tuple/" }
|
||||
paste = "1.0"
|
||||
|
||||
# we want to support using glib for the reactive runtime event loop
|
||||
any_spawner = { path = "../../any_spawner/", features = ["glib"] }
|
||||
# yes, we want effects to run: this is a "frontend," not a backend
|
||||
reactive_graph = { path = "../../reactive_graph", features = ["effects"] }
|
||||
@@ -1,8 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="color-scheme" content="dark">
|
||||
<link rel="css" href="style.css" data-trunk>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,627 +0,0 @@
|
||||
use self::properties::Connect;
|
||||
use gtk::{
|
||||
ffi::GtkWidget,
|
||||
glib::{
|
||||
object::{IsA, IsClass, ObjectExt},
|
||||
Object, Value,
|
||||
},
|
||||
prelude::{Cast, WidgetExt},
|
||||
Label, Orientation, Widget,
|
||||
};
|
||||
use leptos::{
|
||||
reactive_graph::effect::RenderEffect,
|
||||
tachys::{
|
||||
renderer::{CastFrom, Renderer},
|
||||
view::{Mountable, Render},
|
||||
},
|
||||
};
|
||||
use next_tuple::NextTuple;
|
||||
use std::{borrow::Cow, marker::PhantomData};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LeptosGtk;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Element(pub Widget);
|
||||
|
||||
impl Element {
|
||||
pub fn remove(&self) {
|
||||
self.0.unparent();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Text(pub Element);
|
||||
|
||||
impl<T> From<T> for Element
|
||||
where
|
||||
T: Into<Widget>,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
Element(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Mountable<LeptosGtk> for Element {
|
||||
fn unmount(&mut self) {
|
||||
self.remove()
|
||||
}
|
||||
|
||||
fn mount(
|
||||
&mut self,
|
||||
parent: &<LeptosGtk as Renderer>::Element,
|
||||
marker: Option<&<LeptosGtk as Renderer>::Node>,
|
||||
) {
|
||||
self.0
|
||||
.insert_before(&parent.0, marker.as_ref().map(|m| &m.0));
|
||||
}
|
||||
|
||||
fn insert_before_this(&self, child: &mut dyn Mountable<LeptosGtk>) -> bool {
|
||||
if let Some(parent) = self.0.parent() {
|
||||
child.mount(&Element(parent), Some(self));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Mountable<LeptosGtk> for Text {
|
||||
fn unmount(&mut self) {
|
||||
self.0.remove()
|
||||
}
|
||||
|
||||
fn mount(
|
||||
&mut self,
|
||||
parent: &<LeptosGtk as Renderer>::Element,
|
||||
marker: Option<&<LeptosGtk as Renderer>::Node>,
|
||||
) {
|
||||
self.0
|
||||
.0
|
||||
.insert_before(&parent.0, marker.as_ref().map(|m| &m.0));
|
||||
}
|
||||
|
||||
fn insert_before_this(&self, child: &mut dyn Mountable<LeptosGtk>) -> bool {
|
||||
self.0.insert_before_this(child)
|
||||
}
|
||||
}
|
||||
|
||||
impl CastFrom<Element> for Element {
|
||||
fn cast_from(source: Element) -> Option<Self> {
|
||||
Some(source)
|
||||
}
|
||||
}
|
||||
|
||||
impl CastFrom<Element> for Text {
|
||||
fn cast_from(source: Element) -> Option<Self> {
|
||||
source
|
||||
.0
|
||||
.downcast::<Label>()
|
||||
.ok()
|
||||
.map(|n| Text(Element::from(n)))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Element> for Element {
|
||||
fn as_ref(&self) -> &Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Element> for Text {
|
||||
fn as_ref(&self) -> &Element {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer for LeptosGtk {
|
||||
type Node = Element;
|
||||
type Element = Element;
|
||||
type Text = Text;
|
||||
type Placeholder = Element;
|
||||
|
||||
fn intern(text: &str) -> &str {
|
||||
text
|
||||
}
|
||||
|
||||
fn create_text_node(text: &str) -> Self::Text {
|
||||
Text(Element::from(Label::new(Some(text))))
|
||||
}
|
||||
|
||||
fn create_placeholder() -> Self::Placeholder {
|
||||
let label = Label::new(None);
|
||||
label.set_visible(false);
|
||||
Element::from(label)
|
||||
}
|
||||
|
||||
fn set_text(node: &Self::Text, text: &str) {
|
||||
let node_as_text = node.0 .0.downcast_ref::<Label>().unwrap();
|
||||
node_as_text.set_label(text);
|
||||
}
|
||||
|
||||
fn set_attribute(node: &Self::Element, name: &str, value: &str) {
|
||||
node.0.set_property(name, value);
|
||||
}
|
||||
|
||||
fn remove_attribute(node: &Self::Element, name: &str) {
|
||||
node.0.set_property(name, None::<&str>);
|
||||
}
|
||||
|
||||
fn insert_node(
|
||||
parent: &Self::Element,
|
||||
new_child: &Self::Node,
|
||||
marker: Option<&Self::Node>,
|
||||
) {
|
||||
new_child
|
||||
.0
|
||||
.insert_before(&parent.0, marker.as_ref().map(|n| &n.0));
|
||||
}
|
||||
|
||||
fn remove_node(
|
||||
parent: &Self::Element,
|
||||
child: &Self::Node,
|
||||
) -> Option<Self::Node> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn remove(node: &Self::Node) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_parent(node: &Self::Node) -> Option<Self::Node> {
|
||||
node.0.parent().map(Element::from)
|
||||
}
|
||||
|
||||
fn first_child(node: &Self::Node) -> Option<Self::Node> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn next_sibling(node: &Self::Node) -> Option<Self::Node> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn log_node(node: &Self::Node) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn clear_children(parent: &Self::Element) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root<Chil>(children: Chil) -> (Widget, impl Mountable<LeptosGtk>)
|
||||
where
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
let state = r#box()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.child(children)
|
||||
.build();
|
||||
(state.as_widget().clone(), state)
|
||||
}
|
||||
|
||||
pub trait WidgetClass {
|
||||
type Widget: Into<Widget> + IsA<Object> + IsClass;
|
||||
}
|
||||
|
||||
pub struct LGtkWidget<Widg, Props, Chil> {
|
||||
widget: PhantomData<Widg>,
|
||||
properties: Props,
|
||||
children: Chil,
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Chil: NextTuple,
|
||||
{
|
||||
pub fn child<T>(
|
||||
self,
|
||||
child: T,
|
||||
) -> LGtkWidget<Widg, Props, Chil::Output<T>> {
|
||||
let LGtkWidget {
|
||||
widget,
|
||||
properties,
|
||||
children,
|
||||
} = self;
|
||||
LGtkWidget {
|
||||
widget,
|
||||
properties,
|
||||
children: children.next_tuple(child),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Props: NextTuple,
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
pub fn connect<F>(
|
||||
self,
|
||||
signal_name: &'static str,
|
||||
callback: F,
|
||||
) -> LGtkWidget<Widg, Props::Output<Connect<F>>, Chil>
|
||||
where
|
||||
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
|
||||
{
|
||||
let LGtkWidget {
|
||||
widget,
|
||||
properties,
|
||||
children,
|
||||
} = self;
|
||||
LGtkWidget {
|
||||
widget,
|
||||
properties: properties.next_tuple(Connect {
|
||||
signal_name,
|
||||
callback,
|
||||
}),
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LGtkWidgetState<Widg, Props, Chil>
|
||||
where
|
||||
Chil: Render<LeptosGtk>,
|
||||
Props: Property,
|
||||
Widg: WidgetClass,
|
||||
{
|
||||
ty: PhantomData<Widg>,
|
||||
widget: Element,
|
||||
properties: Props::State,
|
||||
children: Chil::State,
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> LGtkWidgetState<Widg, Props, Chil>
|
||||
where
|
||||
Chil: Render<LeptosGtk>,
|
||||
Props: Property,
|
||||
Widg: WidgetClass,
|
||||
{
|
||||
pub fn as_widget(&self) -> &Widget {
|
||||
&self.widget.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> Render<LeptosGtk> for LGtkWidget<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Props: Property,
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
type State = LGtkWidgetState<Widg, Props, Chil>;
|
||||
|
||||
fn build(self) -> Self::State {
|
||||
let widget = Object::new::<Widg::Widget>();
|
||||
let widget = Element::from(widget);
|
||||
let properties = self.properties.build(&widget);
|
||||
let mut children = self.children.build();
|
||||
children.mount(&widget, None);
|
||||
LGtkWidgetState {
|
||||
ty: PhantomData,
|
||||
widget,
|
||||
properties,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(self, state: &mut Self::State) {
|
||||
self.properties
|
||||
.rebuild(&state.widget, &mut state.properties);
|
||||
self.children.rebuild(&mut state.children);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> Mountable<LeptosGtk>
|
||||
for LGtkWidgetState<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Props: Property,
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
fn unmount(&mut self) {
|
||||
self.children.unmount();
|
||||
self.widget.remove();
|
||||
}
|
||||
|
||||
fn mount(
|
||||
&mut self,
|
||||
parent: &<LeptosGtk as Renderer>::Element,
|
||||
marker: Option<&<LeptosGtk as Renderer>::Node>,
|
||||
) {
|
||||
self.children.mount(&self.widget, None);
|
||||
LeptosGtk::insert_node(parent, &self.widget, marker);
|
||||
}
|
||||
|
||||
fn insert_before_this(&self, child: &mut dyn Mountable<LeptosGtk>) -> bool {
|
||||
self.widget.insert_before_this(child)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Property {
|
||||
type State;
|
||||
|
||||
fn build(self, element: &Element) -> Self::State;
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State);
|
||||
}
|
||||
|
||||
impl<T, F> Property for F
|
||||
where
|
||||
T: Property,
|
||||
T::State: 'static,
|
||||
F: Fn() -> T + 'static,
|
||||
{
|
||||
type State = RenderEffect<T::State>;
|
||||
|
||||
fn build(self, widget: &Element) -> Self::State {
|
||||
let widget = widget.clone();
|
||||
RenderEffect::new(move |prev| {
|
||||
let value = self();
|
||||
if let Some(mut prev) = prev {
|
||||
value.rebuild(&widget, &mut prev);
|
||||
prev
|
||||
} else {
|
||||
value.build(&widget)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn rebuild(self, widget: &Element, state: &mut Self::State) {}
|
||||
}
|
||||
|
||||
pub fn button() -> LGtkWidget<gtk::Button, (), ()> {
|
||||
LGtkWidget {
|
||||
widget: PhantomData,
|
||||
properties: (),
|
||||
children: (),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn r#box() -> LGtkWidget<gtk::Box, (), ()> {
|
||||
LGtkWidget {
|
||||
widget: PhantomData,
|
||||
properties: (),
|
||||
children: (),
|
||||
}
|
||||
}
|
||||
|
||||
mod widgets {
|
||||
use super::WidgetClass;
|
||||
|
||||
impl WidgetClass for gtk::Button {
|
||||
type Widget = Self;
|
||||
}
|
||||
|
||||
impl WidgetClass for gtk::Box {
|
||||
type Widget = Self;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod properties {
|
||||
use super::{
|
||||
Element, LGtkWidget, LGtkWidgetState, LeptosGtk, Property, WidgetClass,
|
||||
};
|
||||
use gtk::glib::{object::ObjectExt, Value};
|
||||
use leptos::tachys::{renderer::Renderer, view::Render};
|
||||
use next_tuple::NextTuple;
|
||||
|
||||
pub struct Connect<F>
|
||||
where
|
||||
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
|
||||
{
|
||||
pub signal_name: &'static str,
|
||||
pub callback: F,
|
||||
}
|
||||
|
||||
impl<F> Property for Connect<F>
|
||||
where
|
||||
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
|
||||
{
|
||||
type State = ();
|
||||
|
||||
fn build(self, element: &Element) -> Self::State {
|
||||
element.0.connect(self.signal_name, false, self.callback);
|
||||
}
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State) {}
|
||||
}
|
||||
|
||||
/* examples for macro */
|
||||
pub struct Orientation {
|
||||
value: gtk::Orientation,
|
||||
}
|
||||
|
||||
pub struct OrientationState {
|
||||
value: gtk::Orientation,
|
||||
}
|
||||
|
||||
impl Property for Orientation {
|
||||
type State = OrientationState;
|
||||
|
||||
fn build(self, element: &Element) -> Self::State {
|
||||
element.0.set_property("orientation", self.value);
|
||||
OrientationState { value: self.value }
|
||||
}
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State) {
|
||||
if self.value != state.value {
|
||||
element.0.set_property("orientation", self.value);
|
||||
state.value = self.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Props: NextTuple,
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
pub fn orientation(
|
||||
self,
|
||||
value: impl Into<gtk::Orientation>,
|
||||
) -> LGtkWidget<Widg, Props::Output<Orientation>, Chil> {
|
||||
let LGtkWidget {
|
||||
widget,
|
||||
properties,
|
||||
children,
|
||||
} = self;
|
||||
LGtkWidget {
|
||||
widget,
|
||||
properties: properties.next_tuple(Orientation {
|
||||
value: value.into(),
|
||||
}),
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Spacing {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
pub struct SpacingState {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
impl Property for Spacing {
|
||||
type State = SpacingState;
|
||||
|
||||
fn build(self, element: &Element) -> Self::State {
|
||||
element.0.set_property("spacing", self.value);
|
||||
SpacingState { value: self.value }
|
||||
}
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State) {
|
||||
if self.value != state.value {
|
||||
element.0.set_property("spacing", self.value);
|
||||
state.value = self.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
|
||||
where
|
||||
Widg: WidgetClass,
|
||||
Props: NextTuple,
|
||||
Chil: Render<LeptosGtk>,
|
||||
{
|
||||
pub fn spacing(
|
||||
self,
|
||||
value: impl Into<i32>,
|
||||
) -> LGtkWidget<Widg, Props::Output<Spacing>, Chil> {
|
||||
let LGtkWidget {
|
||||
widget,
|
||||
properties,
|
||||
children,
|
||||
} = self;
|
||||
LGtkWidget {
|
||||
widget,
|
||||
properties: properties.next_tuple(Spacing {
|
||||
value: value.into(),
|
||||
}),
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* end examples for properties macro */
|
||||
|
||||
pub struct Label {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LabelState {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Property for Label {
|
||||
type State = LabelState;
|
||||
|
||||
fn build(self, element: &Element) -> Self::State {
|
||||
LeptosGtk::set_attribute(element, "label", &self.value);
|
||||
LabelState { value: self.value }
|
||||
}
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Property for () {
|
||||
type State = ();
|
||||
|
||||
fn build(self, _element: &Element) -> Self::State {}
|
||||
|
||||
fn rebuild(self, _element: &Element, _state: &mut Self::State) {}
|
||||
}
|
||||
|
||||
macro_rules! tuples {
|
||||
($($ty:ident),* $(,)?) => {
|
||||
impl<$($ty,)*> Property for ($($ty,)*)
|
||||
where $($ty: Property,)*
|
||||
{
|
||||
type State = ($($ty::State,)*);
|
||||
|
||||
fn build(self, element: &Element) -> Self::State {
|
||||
#[allow(non_snake_case)]
|
||||
let ($($ty,)*) = self;
|
||||
($($ty.build(element),)*)
|
||||
}
|
||||
|
||||
fn rebuild(self, element: &Element, state: &mut Self::State) {
|
||||
paste::paste! {
|
||||
#[allow(non_snake_case)]
|
||||
let ($($ty,)*) = self;
|
||||
#[allow(non_snake_case)]
|
||||
let ($([<state_ $ty:lower>],)*) = state;
|
||||
$($ty.rebuild(element, [<state_ $ty:lower>]));*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tuples!(A);
|
||||
tuples!(A, B);
|
||||
tuples!(A, B, C);
|
||||
tuples!(A, B, C, D);
|
||||
tuples!(A, B, C, D, E);
|
||||
tuples!(A, B, C, D, E, F);
|
||||
tuples!(A, B, C, D, E, F, G);
|
||||
tuples!(A, B, C, D, E, F, G, H);
|
||||
tuples!(A, B, C, D, E, F, G, H, I);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U);
|
||||
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V);
|
||||
tuples!(
|
||||
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W
|
||||
);
|
||||
tuples!(
|
||||
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X
|
||||
);
|
||||
tuples!(
|
||||
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X,
|
||||
Y
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
use any_spawner::Executor;
|
||||
use gtk::{prelude::*, Application, ApplicationWindow, Orientation};
|
||||
use leptos::prelude::*;
|
||||
use leptos_gtk::LeptosGtk;
|
||||
use std::{mem, thread, time::Duration};
|
||||
mod leptos_gtk;
|
||||
|
||||
const APP_ID: &str = "dev.leptos.Counter";
|
||||
|
||||
// Basic GTK app setup from https://gtk-rs.org/gtk4-rs/stable/latest/book/hello_world.html
|
||||
fn main() {
|
||||
// use the glib event loop to power the reactive system
|
||||
_ = Executor::init_glib();
|
||||
let app = Application::builder().application_id(APP_ID).build();
|
||||
|
||||
app.connect_startup(|_| load_css());
|
||||
|
||||
app.connect_activate(|app| {
|
||||
// Connect to "activate" signal of `app`
|
||||
let owner = Owner::new();
|
||||
let view = owner.with(ui);
|
||||
let (root, state) = leptos_gtk::root(view);
|
||||
|
||||
let window = ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("TachyGTK")
|
||||
.child(&root)
|
||||
.build();
|
||||
// Present window
|
||||
window.present();
|
||||
mem::forget((owner, state));
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn ui() -> impl Render<LeptosGtk> {
|
||||
let value = RwSignal::new(0);
|
||||
let rows = RwSignal::new(vec![1, 2, 3, 4, 5]);
|
||||
|
||||
Effect::new(move |_| {
|
||||
println!("value = {}", value.get());
|
||||
});
|
||||
|
||||
// just an example of multithreaded reactivity
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
value.update(|n| *n += 1);
|
||||
});
|
||||
|
||||
vstack((
|
||||
hstack((
|
||||
button("-1", move || {
|
||||
println!("clicked -1");
|
||||
value.update(|n| *n -= 1);
|
||||
}),
|
||||
move || value.get().to_string(),
|
||||
button("+1", move || value.update(|n| *n += 1)),
|
||||
)),
|
||||
button("Swap", move || {
|
||||
rows.update(|items| {
|
||||
items.swap(1, 3);
|
||||
})
|
||||
}),
|
||||
hstack(rows),
|
||||
))
|
||||
}
|
||||
|
||||
fn button(
|
||||
label: impl Render<LeptosGtk>,
|
||||
callback: impl Fn() + Send + Sync + 'static,
|
||||
) -> impl Render<LeptosGtk> {
|
||||
leptos_gtk::button()
|
||||
.child(label)
|
||||
.connect("clicked", move |_| {
|
||||
callback();
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn vstack(children: impl Render<LeptosGtk>) -> impl Render<LeptosGtk> {
|
||||
leptos_gtk::r#box()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.child(children)
|
||||
}
|
||||
|
||||
fn hstack(children: impl Render<LeptosGtk>) -> impl Render<LeptosGtk> {
|
||||
leptos_gtk::r#box()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.child(children)
|
||||
}
|
||||
|
||||
fn load_css() {
|
||||
use gtk::{gdk::Display, CssProvider};
|
||||
|
||||
let provider = CssProvider::new();
|
||||
provider.load_from_path("style.css");
|
||||
|
||||
// Add the provider to the default screen
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&Display::default().expect("Could not connect to a display."),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ mod routes;
|
||||
use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet};
|
||||
use leptos_router::{
|
||||
components::{FlatRoutes, Route, Router, RoutingProgress},
|
||||
ParamSegment, StaticSegment,
|
||||
OptionalParamSegment, ParamSegment, StaticSegment,
|
||||
};
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
use std::time::Duration;
|
||||
@@ -28,9 +28,7 @@ pub fn App() -> impl IntoView {
|
||||
<FlatRoutes fallback=|| "Not found.">
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
|
||||
<Route path=ParamSegment("stories") view=Stories/>
|
||||
// TODO allow optional params without duplication
|
||||
<Route path=StaticSegment("") view=Stories/>
|
||||
<Route path=OptionalParamSegment("stories") view=Stories/>
|
||||
</FlatRoutes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
|
||||
@@ -21,10 +21,16 @@ pub fn Nav() -> impl IntoView {
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
class="github"
|
||||
href="http://github.com/leptos-rs/leptos"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -50,30 +50,42 @@ pub fn Stories() -> impl IntoView {
|
||||
<div class="news-view">
|
||||
<div class="news-list-nav">
|
||||
<span>
|
||||
{move || if page() > 1 {
|
||||
Either::Left(view! {
|
||||
<a class="page-link"
|
||||
href=move || format!("/{}?page={}", story_type(), page() - 1)
|
||||
aria-label="Previous Page"
|
||||
>
|
||||
"< prev"
|
||||
</a>
|
||||
})
|
||||
} else {
|
||||
Either::Right(view! {
|
||||
<span class="page-link disabled" aria-hidden="true">
|
||||
"< prev"
|
||||
</span>
|
||||
})
|
||||
{move || {
|
||||
if page() > 1 {
|
||||
Either::Left(
|
||||
view! {
|
||||
<a
|
||||
class="page-link"
|
||||
href=move || {
|
||||
format!("/{}?page={}", story_type(), page() - 1)
|
||||
}
|
||||
aria-label="Previous Page"
|
||||
>
|
||||
"< prev"
|
||||
</a>
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Either::Right(
|
||||
view! {
|
||||
<span class="page-link disabled" aria-hidden="true">
|
||||
"< prev"
|
||||
</span>
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
|
||||
</span>
|
||||
<span>"page " {page}</span>
|
||||
<Suspense>
|
||||
<span class="page-link"
|
||||
<span
|
||||
class="page-link"
|
||||
class:disabled=hide_more_link
|
||||
aria-hidden=hide_more_link
|
||||
>
|
||||
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
<a
|
||||
href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
aria-label="Next Page"
|
||||
>
|
||||
"more >"
|
||||
@@ -83,14 +95,10 @@ pub fn Stories() -> impl IntoView {
|
||||
</div>
|
||||
<main class="news-list">
|
||||
<div>
|
||||
<Transition
|
||||
fallback=move || view! { <p>"Loading..."</p> }
|
||||
set_pending
|
||||
>
|
||||
<Show when=move || stories.read().as_ref().map(Option::is_none).unwrap_or(false)>
|
||||
>
|
||||
<p>"Error loading stories."</p>
|
||||
</Show>
|
||||
<Transition fallback=move || view! { <p>"Loading..."</p> } set_pending>
|
||||
<Show when=move || {
|
||||
stories.read().as_ref().map(Option::is_none).unwrap_or(false)
|
||||
}>> <p>"Error loading stories."</p></Show>
|
||||
<ul>
|
||||
<For
|
||||
each=move || stories.get().unwrap_or_default().unwrap_or_default()
|
||||
@@ -105,54 +113,78 @@ pub fn Stories() -> impl IntoView {
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Story(story: api::Story) -> impl IntoView {
|
||||
view! {
|
||||
<li class="news-item">
|
||||
<li class="news-item">
|
||||
<span class="score">{story.points}</span>
|
||||
<span class="title">
|
||||
{if !story.url.starts_with("item?id=") {
|
||||
Either::Left(view! {
|
||||
<span>
|
||||
<a href=story.url target="_blank" rel="noreferrer">
|
||||
{story.title.clone()}
|
||||
</a>
|
||||
<span class="host">"("{story.domain}")"</span>
|
||||
</span>
|
||||
})
|
||||
Either::Left(
|
||||
view! {
|
||||
<span>
|
||||
<a href=story.url target="_blank" rel="noreferrer">
|
||||
{story.title.clone()}
|
||||
</a>
|
||||
<span class="host">"(" {story.domain} ")"</span>
|
||||
</span>
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
Either::Right(view! { <A href=format!("/stories/{}", story.id)>{title}</A> })
|
||||
}}
|
||||
|
||||
</span>
|
||||
<br />
|
||||
<br/>
|
||||
<span class="meta">
|
||||
{if story.story_type != "job" {
|
||||
Either::Left(view! {
|
||||
<span>
|
||||
{"by "}
|
||||
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
|
||||
{format!(" {} | ", story.time_ago)}
|
||||
<A href=format!("/stories/{}", story.id)>
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!("{} comments", story.comments_count.unwrap_or_default())
|
||||
} else {
|
||||
"discuss".into()
|
||||
}}
|
||||
</A>
|
||||
</span>
|
||||
})
|
||||
Either::Left(
|
||||
view! {
|
||||
<span>
|
||||
{"by "}
|
||||
{story
|
||||
.user
|
||||
.map(|user| {
|
||||
view! {
|
||||
<A href=format!("/users/{user}")>{user.clone()}</A>
|
||||
}
|
||||
})} {format!(" {} | ", story.time_ago)}
|
||||
<A href=format!(
|
||||
"/stories/{}",
|
||||
story.id,
|
||||
)>
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!(
|
||||
"{} comments",
|
||||
story.comments_count.unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
"discuss".into()
|
||||
}}
|
||||
|
||||
</A>
|
||||
</span>
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
Either::Right(view! { <A href=format!("/item/{}", story.id)>{title}</A> })
|
||||
}}
|
||||
|
||||
</span>
|
||||
{(story.story_type != "link").then(|| view! {
|
||||
" "
|
||||
<span class="label">{story.story_type}</span>
|
||||
})}
|
||||
{(story.story_type != "link")
|
||||
.then(|| {
|
||||
view! {
|
||||
" "
|
||||
<span class="label">{story.story_type}</span>
|
||||
}
|
||||
})}
|
||||
|
||||
</li>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -28,18 +28,21 @@ pub fn Story() -> impl IntoView {
|
||||
<Meta name="description" content=story.title.clone()/>
|
||||
<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>})}
|
||||
<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">
|
||||
@@ -48,6 +51,7 @@ pub fn Story() -> impl IntoView {
|
||||
} else {
|
||||
"No comments yet.".into()
|
||||
}}
|
||||
|
||||
</p>
|
||||
<ul class="comment-children">
|
||||
<For
|
||||
@@ -55,7 +59,7 @@ pub fn Story() -> impl IntoView {
|
||||
key=|comment| comment.id
|
||||
let:comment
|
||||
>
|
||||
<Comment comment />
|
||||
<Comment comment/>
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -64,6 +68,7 @@ pub fn Story() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
}))).build())
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -72,43 +77,65 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<li class="comment">
|
||||
<div class="by">
|
||||
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
|
||||
{format!(" {}", comment.time_ago)}
|
||||
</div>
|
||||
<div class="text" inner_html=comment.content></div>
|
||||
{(!comment.comments.is_empty()).then(|| {
|
||||
view! {
|
||||
<div>
|
||||
<div class="toggle" class:open=open>
|
||||
<a on:click=move |_| set_open.update(|n| *n = !*n)>
|
||||
{
|
||||
let comments_len = comment.comments.len();
|
||||
move || if open.get() {
|
||||
"[-]".into()
|
||||
} else {
|
||||
format!("[+] {}{} collapsed", comments_len, pluralize(comments_len))
|
||||
}
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
{move || open.get().then({
|
||||
let comments = comment.comments.clone();
|
||||
move || view! {
|
||||
<ul class="comment-children">
|
||||
<For
|
||||
each=move || comments.clone()
|
||||
key=|comment| comment.id
|
||||
let:comment
|
||||
>
|
||||
<Comment comment />
|
||||
</For>
|
||||
</ul>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
<div class="by">
|
||||
<A href=format!(
|
||||
"/users/{}",
|
||||
comment.user.clone().unwrap_or_default(),
|
||||
)>{comment.user.clone()}</A>
|
||||
{format!(" {}", comment.time_ago)}
|
||||
</div>
|
||||
<div class="text" inner_html=comment.content></div>
|
||||
{(!comment.comments.is_empty())
|
||||
.then(|| {
|
||||
view! {
|
||||
<div>
|
||||
<div class="toggle" class:open=open>
|
||||
<a on:click=move |_| {
|
||||
set_open.update(|n| *n = !*n)
|
||||
}>
|
||||
|
||||
{
|
||||
let comments_len = comment.comments.len();
|
||||
move || {
|
||||
if open.get() {
|
||||
"[-]".into()
|
||||
} else {
|
||||
format!(
|
||||
"[+] {}{} collapsed",
|
||||
comments_len,
|
||||
pluralize(comments_len),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</a>
|
||||
</div>
|
||||
{move || {
|
||||
open
|
||||
.get()
|
||||
.then({
|
||||
let comments = comment.comments.clone();
|
||||
move || {
|
||||
view! {
|
||||
<ul class="comment-children">
|
||||
<For
|
||||
each=move || comments.clone()
|
||||
key=|comment| comment.id
|
||||
let:comment
|
||||
>
|
||||
<Comment comment/>
|
||||
</For>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
</li>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
@@ -18,30 +18,48 @@ pub fn User() -> impl IntoView {
|
||||
);
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || Suspend::new(async move { match user.await.clone() {
|
||||
None => Either::Left(view! { <h1>"User not found."</h1> }),
|
||||
Some(user) => Either::Right(view! {
|
||||
<div>
|
||||
<h1>"User: " {user.id.clone()}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span> {user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span> {user.karma}
|
||||
</li>
|
||||
<li inner_html={user.about} class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
})
|
||||
}})}
|
||||
<Suspense fallback=|| {
|
||||
view! { "Loading..." }
|
||||
}>
|
||||
{move || Suspend::new(async move {
|
||||
match user.await.clone() {
|
||||
None => Either::Left(view! { <h1>"User not found."</h1> }),
|
||||
Some(user) => {
|
||||
Either::Right(
|
||||
view! {
|
||||
<div>
|
||||
<h1>"User: " {user.id.clone()}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span>
|
||||
{user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span>
|
||||
{user.karma}
|
||||
</li>
|
||||
<li inner_html=user.about class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!(
|
||||
"https://news.ycombinator.com/submitted?id={}",
|
||||
user.id,
|
||||
)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!(
|
||||
"https://news.ycombinator.com/threads?id={}",
|
||||
user.id,
|
||||
)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})}
|
||||
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod routes;
|
||||
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
|
||||
use leptos_router::{
|
||||
components::{FlatRoutes, Route, Router, RoutingProgress},
|
||||
ParamSegment, StaticSegment,
|
||||
OptionalParamSegment, ParamSegment, StaticSegment,
|
||||
};
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
use std::time::Duration;
|
||||
@@ -46,9 +46,7 @@ pub fn App() -> impl IntoView {
|
||||
<FlatRoutes fallback=|| "Not found.">
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
|
||||
<Route path=ParamSegment("stories") view=Stories/>
|
||||
// TODO allow optional params without duplication
|
||||
<Route path=StaticSegment("") view=Stories/>
|
||||
<Route path=OptionalParamSegment("stories") view=Stories/>
|
||||
</FlatRoutes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -31,7 +31,6 @@ tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
http = { version = "1.1", optional = true }
|
||||
web-sys = { version = "0.3.70", features = ["AbortController", "AbortSignal"] }
|
||||
wasm-bindgen = "0.2.93"
|
||||
lazy_static = "1.5"
|
||||
rust-embed = { version = "8.5", features = [
|
||||
"axum",
|
||||
"mime_guess",
|
||||
|
||||
@@ -4,7 +4,7 @@ mod routes;
|
||||
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
|
||||
use leptos_router::{
|
||||
components::{FlatRoutes, Route, Router},
|
||||
ParamSegment, StaticSegment,
|
||||
OptionalParamSegment, ParamSegment, StaticSegment,
|
||||
};
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -42,9 +42,7 @@ pub fn App() -> impl IntoView {
|
||||
<FlatRoutes fallback=|| "Not found.">
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
|
||||
<Route path=ParamSegment("stories") view=Stories/>
|
||||
// TODO allow optional params without duplication
|
||||
<Route path=StaticSegment("") view=Stories/>
|
||||
<Route path=OptionalParamSegment("stories") view=Stories/>
|
||||
</FlatRoutes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -4,7 +4,7 @@ mod routes;
|
||||
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
|
||||
use leptos_router::{
|
||||
components::{FlatRoutes, Route, Router, RoutingProgress},
|
||||
ParamSegment, StaticSegment,
|
||||
OptionalParamSegment, ParamSegment, StaticSegment,
|
||||
};
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
use std::time::Duration;
|
||||
@@ -46,9 +46,7 @@ pub fn App() -> impl IntoView {
|
||||
<FlatRoutes fallback=|| "Not found.">
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
|
||||
<Route path=ParamSegment("stories") view=Stories/>
|
||||
// TODO allow optional params without duplication
|
||||
<Route path=StaticSegment("") view=Stories/>
|
||||
<Route path=OptionalParamSegment("stories") view=Stories/>
|
||||
</FlatRoutes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -17,7 +17,7 @@ leptos = { path = "../../leptos", features = [
|
||||
leptos_router = { path = "../../router" }
|
||||
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
|
||||
leptos_axum = { path = "../../integrations/axum", features = [
|
||||
"islands-router",
|
||||
"dont-use-islands-router",
|
||||
], optional = true }
|
||||
log = "0.4.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
# Leptos Todo App Sqlite with Axum
|
||||
# Work in Progress
|
||||
|
||||
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
|
||||
This example is something I wrote on a long layover in the Orlando airport in July. (It was really hot!)
|
||||
|
||||
## Getting Started
|
||||
It is the culmination of a couple years of thinking and working toward being able to do this, which you can see
|
||||
described pretty well in the pinned roadmap issue (#1830) and its discussion of different modes of client-side
|
||||
routing when you use islands.
|
||||
|
||||
See the [Examples README](../README.md) for setup and run instructions.
|
||||
This uses *only* server rendering, with no actual islands, but still maintains client-side state across page navigations.
|
||||
It does this by building on the fact that we now have a statically-typed view tree to do pretty smart updates with
|
||||
new HTML from the client, with extremely minimal diffing.
|
||||
|
||||
## E2E Testing
|
||||
|
||||
See the [E2E README](./e2e/README.md) for more information about the testing strategy.
|
||||
|
||||
## Rendering
|
||||
|
||||
See the [SSR Notes](../SSR_NOTES.md) for more information about Server Side Rendering.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run `cargo leptos watch` to run this example.
|
||||
The demo itself works, but the feature that supports it is incomplete. A couple people have accidentally
|
||||
used it and broken their applications in ways they don't understand, so I've renamed the feature to `dont-use-islands-router`.
|
||||
|
||||
@@ -162,22 +162,24 @@ pub fn App() -> impl IntoView {
|
||||
<table class="table table-hover table-striped test-data">
|
||||
<tbody>
|
||||
<For
|
||||
each={move || data.get()}
|
||||
key={|row| row.id}
|
||||
each=move || data.get()
|
||||
key=|row| row.id
|
||||
children=move |row: RowData| {
|
||||
let row_id = row.id;
|
||||
let label = row.label;
|
||||
let is_selected = is_selected.clone();
|
||||
ViewTemplate::new(view! {
|
||||
<tr class:danger={move || is_selected.selected(Some(row_id))}>
|
||||
<td class="col-md-1">{row_id.to_string()}</td>
|
||||
<td class="col-md-4"><a on:click=move |_| set_selected.set(Some(row_id))>{move || label.get()}</a></td>
|
||||
<td class="col-md-1"><a on:click=move |_| remove(row_id)><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a></td>
|
||||
<td class="col-md-6"/>
|
||||
</tr>
|
||||
})
|
||||
template! {
|
||||
< tr class : danger = { move || is_selected.selected(Some(row_id)) }
|
||||
> < td class = "col-md-1" > { row_id.to_string() } </ td > < td
|
||||
class = "col-md-4" >< a on : click = move | _ | set_selected
|
||||
.set(Some(row_id)) > { move || label.get() } </ a ></ td > < td
|
||||
class = "col-md-1" >< a on : click = move | _ | remove(row_id) ><
|
||||
span class = "glyphicon glyphicon-remove" aria - hidden = "true" ></
|
||||
span ></ a ></ td > < td class = "col-md-6" /> </ tr >
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<span class="preloadicon glyphicon glyphicon-remove" aria-hidden="true"></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use js_framework_benchmark_leptos::*;
|
||||
use leptos::{prelude::*, spawn::tick};
|
||||
use leptos::{prelude::*, task::tick};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ fn main() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
let handle = mount_to(
|
||||
helpers::document()
|
||||
document()
|
||||
.get_element_by_id("app")
|
||||
.unwrap()
|
||||
.unchecked_into(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
use leptos::spawn::tick;
|
||||
use leptos::task::tick;
|
||||
use leptos::{leptos_dom::helpers::document, mount::mount_to};
|
||||
use web_sys::HtmlButtonElement;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z"/>
|
||||
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
|
||||
<link data-trunk rel="css" href="style.css"/>
|
||||
<link data-trunk rel="css" href="style.css"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
||||
@@ -5,13 +5,14 @@ use leptos::prelude::*;
|
||||
use leptos_router::{
|
||||
components::{
|
||||
Form, Outlet, ParentRoute, ProtectedRoute, Redirect, Route, Router,
|
||||
Routes, A,
|
||||
Routes, RoutingProgress, A,
|
||||
},
|
||||
hooks::{use_navigate, use_params, use_query_map},
|
||||
params::Params,
|
||||
MatchNestedRoutes,
|
||||
};
|
||||
use leptos_router_macro::path;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
@@ -26,9 +27,14 @@ pub fn RouterExample() -> impl IntoView {
|
||||
|
||||
// this signal will be ued to set whether we are allowed to access a protected route
|
||||
let (logged_in, set_logged_in) = signal(true);
|
||||
let (is_routing, set_is_routing) = signal(false);
|
||||
|
||||
view! {
|
||||
<Router>
|
||||
<Router set_is_routing>
|
||||
// shows a progress bar while async data are loading
|
||||
<div class="routing-progress">
|
||||
<RoutingProgress is_routing max_time=Duration::from_millis(250)/>
|
||||
</div>
|
||||
<nav>
|
||||
// ordinary <a> elements can be used for client-side navigation
|
||||
// using <A> has two effects:
|
||||
@@ -44,7 +50,7 @@ pub fn RouterExample() -> impl IntoView {
|
||||
}>{move || if logged_in.get() { "Log Out" } else { "Log In" }}</button>
|
||||
</nav>
|
||||
<main>
|
||||
<Routes fallback=|| "This page could not be found.">
|
||||
<Routes transition=true fallback=|| "This page could not be found.">
|
||||
// paths can be created using the path!() macro, or provided as types like
|
||||
// StaticSegment("about")
|
||||
<Route path=path!("about") view=About/>
|
||||
@@ -64,8 +70,8 @@ pub fn RouterExample() -> impl IntoView {
|
||||
|
||||
// You can define other routes in their own component.
|
||||
// Routes implement the MatchNestedRoutes
|
||||
#[component]
|
||||
pub fn ContactRoutes() -> impl MatchNestedRoutes<Dom> + Clone {
|
||||
#[component(transparent)]
|
||||
pub fn ContactRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("") view=ContactList>
|
||||
<Route path=path!("/") view=|| "Select a contact."/>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
.routing-progress {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
a[aria-current] {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -12,12 +17,8 @@ a[aria-current] {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
animation: 0.5s fadeIn forwards;
|
||||
}
|
||||
|
||||
.fadeOut {
|
||||
animation: 0.5s fadeOut forwards;
|
||||
.contact {
|
||||
view-transition-name: contact;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -40,12 +41,44 @@ a[aria-current] {
|
||||
}
|
||||
}
|
||||
|
||||
.slideIn {
|
||||
animation: 0.25s slideIn forwards;
|
||||
.router-outlet-0 main {
|
||||
view-transition-name: main;
|
||||
}
|
||||
|
||||
.slideOut {
|
||||
animation: 0.25s slideOut forwards;
|
||||
.router-back main {
|
||||
view-transition-name: main-back;
|
||||
}
|
||||
|
||||
.router-outlet-1 .contact-list {
|
||||
view-transition-name: contact;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
::view-transition-old(contact) {
|
||||
animation: 0.5s fadeOut;
|
||||
}
|
||||
|
||||
::view-transition-new(contact) {
|
||||
animation: 0.5s fadeIn;
|
||||
}
|
||||
|
||||
::view-transition-old(main) {
|
||||
animation: 0.5s slideOut;
|
||||
}
|
||||
|
||||
::view-transition-new(main) {
|
||||
animation: 0.5s slideIn;
|
||||
}
|
||||
|
||||
::view-transition-old(main-back) {
|
||||
color: red;
|
||||
animation: 0.5s slideOutBack;
|
||||
}
|
||||
|
||||
::view-transition-new(main-back) {
|
||||
color: blue;
|
||||
animation: 0.5s slideInBack;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -66,14 +99,6 @@ a[aria-current] {
|
||||
}
|
||||
}
|
||||
|
||||
.slideInBack {
|
||||
animation: 0.25s slideInBack forwards;
|
||||
}
|
||||
|
||||
.slideOutBack {
|
||||
animation: 0.25s slideOutBack forwards;
|
||||
}
|
||||
|
||||
@keyframes slideInBack {
|
||||
from {
|
||||
transform: translate(-100vw, 0);
|
||||
|
||||
@@ -40,6 +40,8 @@ pin-project-lite = "0.2.14"
|
||||
dashmap = { version = "6.0", optional = true }
|
||||
once_cell = { version = "1.19", optional = true }
|
||||
async-broadcast = { version = "0.7.1", optional = true }
|
||||
bytecheck = "0.8.0"
|
||||
rkyv = { version = "0.8.8" }
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use futures::StreamExt;
|
||||
use http::Method;
|
||||
use leptos::{html::Input, prelude::*, spawn::spawn_local};
|
||||
use leptos::{html::Input, prelude::*, task::spawn_local};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use server_fn::{
|
||||
client::{browser::BrowserClient, Client},
|
||||
@@ -417,7 +417,6 @@ pub fn FileUploadWithProgress() -> impl IntoView {
|
||||
/// This requires us to store some global state of all the uploads. In a real app, you probably
|
||||
/// shouldn't do exactly what I'm doing here in the demo. For example, this map just
|
||||
/// distinguishes between files by filename, not by user.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod progress {
|
||||
use async_broadcast::{broadcast, Receiver, Sender};
|
||||
|
||||
@@ -4,6 +4,8 @@ use leptos::{config::get_configuration, logging};
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use server_fns_axum::*;
|
||||
|
||||
// cargo make cli: error: unneeded `return` statement
|
||||
#[allow(clippy::needless_return)]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Error)
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Then {
|
||||
// the type with Option<...> and marking the option as #[prop(optional)].
|
||||
#[slot]
|
||||
struct ElseIf {
|
||||
cond: MaybeSignal<bool>,
|
||||
cond: Signal<bool>,
|
||||
children: ChildrenFn,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct Fallback {
|
||||
// Slots are added to components like any other prop.
|
||||
#[component]
|
||||
fn SlotIf(
|
||||
cond: MaybeSignal<bool>,
|
||||
cond: Signal<bool>,
|
||||
then: Then,
|
||||
#[prop(default=vec![])] else_if: Vec<ElseIf>,
|
||||
#[prop(optional)] fallback: Option<Fallback>,
|
||||
@@ -43,9 +43,9 @@ fn SlotIf(
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let (count, set_count) = signal(0);
|
||||
let is_even = MaybeSignal::derive(move || count.get() % 2 == 0);
|
||||
let is_div5 = MaybeSignal::derive(move || count.get() % 5 == 0);
|
||||
let is_div7 = MaybeSignal::derive(move || count.get() % 7 == 0);
|
||||
let is_even = Signal::derive(move || count.get() % 2 == 0);
|
||||
let is_div5 = Signal::derive(move || count.get() % 5 == 0);
|
||||
let is_div7 = Signal::derive(move || count.get() % 7 == 0);
|
||||
|
||||
view! {
|
||||
<button on:click=move |_| set_count.update(|value| *value += 1)>"+1"</button>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "ssr_modes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -11,7 +13,6 @@ actix-files = { version = "0.6.6", optional = true }
|
||||
actix-web = { version = "4.8", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
console_log = "1.0"
|
||||
lazy_static = "1.5"
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_meta = { path = "../../meta" }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
@@ -38,12 +39,12 @@ denylist = ["actix-files", "actix-web", "leptos_actix"]
|
||||
skip_feature_sets = [["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ssr_modes"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::{
|
||||
@@ -146,8 +147,9 @@ fn Post() -> impl IntoView {
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
|
||||
static POSTS: LazyLock<[Post; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
@@ -163,8 +165,8 @@ lazy_static! {
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
|
||||
@@ -39,7 +39,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
name = "ssr_modes_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -9,7 +11,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1.7"
|
||||
console_log = "1.0"
|
||||
lazy_static = "1.5"
|
||||
leptos = { path = "../../leptos", features = [
|
||||
"hydration",
|
||||
] } #"nightly", "hydration"] }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::MetaTags;
|
||||
use leptos_meta::*;
|
||||
@@ -261,8 +262,9 @@ pub fn Admin() -> impl IntoView {
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
|
||||
static POSTS: LazyLock<[Post; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
@@ -278,8 +280,8 @@ lazy_static! {
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use axum::Router;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use ssr_modes_axum::app::*;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use axum::Router;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list_with_ssg, LeptosRoutes};
|
||||
use static_routing::app::*;
|
||||
|
||||
@@ -13,6 +13,9 @@ leptos = { path = "../../leptos", features = ["csr"] }
|
||||
reactive_stores = { path = "../../reactive_stores" }
|
||||
reactive_stores_macro = { path = "../../reactive_stores_macro" }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen = "0.2.93"
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z"/>
|
||||
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
|
||||
<style>
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,43 +1,88 @@
|
||||
use leptos::prelude::*;
|
||||
use reactive_stores::{Field, Store, StoreFieldIterator};
|
||||
use reactive_stores_macro::Store;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug, Store)]
|
||||
use chrono::{Local, NaiveDate};
|
||||
use leptos::logging::warn;
|
||||
use leptos::prelude::*;
|
||||
use reactive_stores::{Field, Patch, Store};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ID starts higher than 0 because we have a few starting todos by default
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(3);
|
||||
|
||||
#[derive(Debug, Store, Serialize, Deserialize)]
|
||||
struct Todos {
|
||||
user: String,
|
||||
user: User,
|
||||
#[store(key: usize = |todo| todo.id)]
|
||||
todos: Vec<Todo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Store)]
|
||||
#[derive(Debug, Store, Patch, Serialize, Deserialize)]
|
||||
struct User {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Store, Serialize, Deserialize)]
|
||||
struct Todo {
|
||||
id: usize,
|
||||
label: String,
|
||||
completed: bool,
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Store, Serialize, Deserialize)]
|
||||
enum Status {
|
||||
#[default]
|
||||
Pending,
|
||||
Scheduled,
|
||||
ScheduledFor {
|
||||
date: NaiveDate,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn next_step(&mut self) {
|
||||
*self = match self {
|
||||
Status::Pending => Status::ScheduledFor {
|
||||
date: Local::now().naive_local().into(),
|
||||
},
|
||||
Status::Scheduled | Status::ScheduledFor { .. } => Status::Done,
|
||||
Status::Done => Status::Done,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Todo {
|
||||
pub fn new(label: impl ToString) -> Self {
|
||||
Self {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
label: label.to_string(),
|
||||
completed: false,
|
||||
status: Status::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn data() -> Todos {
|
||||
Todos {
|
||||
user: "Bob".to_string(),
|
||||
user: User {
|
||||
name: "Bob".to_string(),
|
||||
email: "lawblog@bobloblaw.com".into(),
|
||||
},
|
||||
todos: vec![
|
||||
Todo {
|
||||
id: 0,
|
||||
label: "Create reactive store".to_string(),
|
||||
completed: true,
|
||||
status: Status::Pending,
|
||||
},
|
||||
Todo {
|
||||
id: 1,
|
||||
label: "???".to_string(),
|
||||
completed: false,
|
||||
status: Status::Pending,
|
||||
},
|
||||
Todo {
|
||||
id: 2,
|
||||
label: "Profit".to_string(),
|
||||
completed: false,
|
||||
status: Status::Pending,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -49,17 +94,10 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
let input_ref = NodeRef::new();
|
||||
|
||||
let rows = move || {
|
||||
store
|
||||
.todos()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, todo)| view! { <TodoRow store idx todo/> })
|
||||
.collect_view()
|
||||
};
|
||||
|
||||
view! {
|
||||
<p>"Hello, " {move || store.user().get()}</p>
|
||||
<p>"Hello, " {move || store.user().name().get()}</p>
|
||||
<UserForm user=store.user()/>
|
||||
<hr/>
|
||||
<form on:submit=move |ev| {
|
||||
ev.prevent_default();
|
||||
store.todos().write().push(Todo::new(input_ref.get().unwrap().value()));
|
||||
@@ -67,30 +105,65 @@ pub fn App() -> impl IntoView {
|
||||
<label>"Add a Todo" <input type="text" node_ref=input_ref/></label>
|
||||
<input type="submit"/>
|
||||
</form>
|
||||
<ol>{rows}</ol>
|
||||
<div style="display: flex"></div>
|
||||
<ol>
|
||||
// because `todos` is a keyed field, `store.todos()` returns a struct that
|
||||
// directly implements IntoIterator, so we can use it in <For/> and
|
||||
// it will manage reactivity for the store fields correctly
|
||||
<For
|
||||
each=move || store.todos()
|
||||
key=|row| row.id().get()
|
||||
let:todo
|
||||
>
|
||||
<TodoRow store todo/>
|
||||
</For>
|
||||
|
||||
</ol>
|
||||
<pre>{move || serde_json::to_string_pretty(&*store.read())}</pre>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn UserForm(#[prop(into)] user: Field<User>) -> impl IntoView {
|
||||
let error = RwSignal::new(None);
|
||||
|
||||
view! {
|
||||
{move || error.get().map(|n| view! { <p>{n}</p> })}
|
||||
<form on:submit:target=move |ev| {
|
||||
ev.prevent_default();
|
||||
match User::from_event(&ev) {
|
||||
Ok(new_user) => {
|
||||
error.set(None);
|
||||
user.patch(new_user);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
}>
|
||||
<label>
|
||||
"Name" <input type="text" name="name" prop:value=move || user.name().get()/>
|
||||
</label>
|
||||
<label>
|
||||
"Email" <input type="email" name="email" prop:value=move || user.email().get()/>
|
||||
</label>
|
||||
<input type="submit"/>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TodoRow(
|
||||
store: Store<Todos>,
|
||||
idx: usize,
|
||||
#[prop(into)] todo: Field<Todo>,
|
||||
) -> impl IntoView {
|
||||
let completed = todo.completed();
|
||||
let status = todo.status();
|
||||
let title = todo.label();
|
||||
|
||||
let editing = RwSignal::new(false);
|
||||
let editing = RwSignal::new(true);
|
||||
|
||||
view! {
|
||||
<li
|
||||
style:text-decoration=move || {
|
||||
completed.get().then_some("line-through").unwrap_or_default()
|
||||
}
|
||||
<li style:text-decoration=move || {
|
||||
status.done().then_some("line-through").unwrap_or_default()
|
||||
}>
|
||||
|
||||
class:foo=move || completed.get()
|
||||
>
|
||||
<p
|
||||
class:hidden=move || editing.get()
|
||||
on:click=move |_| {
|
||||
@@ -106,25 +179,48 @@ fn TodoRow(
|
||||
prop:value=move || title.get()
|
||||
on:change=move |ev| {
|
||||
title.set(event_target_value(&ev));
|
||||
editing.set(false);
|
||||
}
|
||||
|
||||
on:blur=move |_| editing.set(false)
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
prop:checked=move || completed.get()
|
||||
on:click=move |_| { completed.update(|n| *n = !*n) }
|
||||
/>
|
||||
|
||||
<button on:click=move |_| {
|
||||
store
|
||||
.todos()
|
||||
.update(|todos| {
|
||||
todos.remove(idx);
|
||||
});
|
||||
status.write().next_step()
|
||||
}>
|
||||
{move || {
|
||||
if todo.status().done() {
|
||||
"Done"
|
||||
} else if status.scheduled() || status.scheduled_for() {
|
||||
"Scheduled"
|
||||
} else {
|
||||
"Pending"
|
||||
}
|
||||
}}
|
||||
|
||||
</button>
|
||||
|
||||
<button on:click=move |_| {
|
||||
let id = todo.id().get();
|
||||
store.todos().write().retain(|todo| todo.id != id);
|
||||
}>"X"</button>
|
||||
<input
|
||||
type="date"
|
||||
prop:value=move || {
|
||||
todo.status().scheduled_for_date().map(|n| n.get().to_string())
|
||||
}
|
||||
|
||||
class:hidden=move || !todo.status().scheduled_for()
|
||||
on:change:target=move |ev| {
|
||||
if let Some(date) = todo.status().scheduled_for_date() {
|
||||
let value = ev.target().value();
|
||||
match NaiveDate::parse_from_str(&value, "%Y-%m-%d") {
|
||||
Ok(new_date) => {
|
||||
date.set(new_date);
|
||||
}
|
||||
Err(e) => warn!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
actix-files = { version = "0.6.6", optional = true }
|
||||
actix-web = { version = "4.8", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
js-sys = { version = "0.3.70", optional = true }
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
@@ -19,7 +20,10 @@ serde = "1.0"
|
||||
tokio = { version = "1.39", features = ["time", "rt"], optional = true }
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
hydrate = [
|
||||
"dep:js-sys",
|
||||
"leptos/hydrate",
|
||||
]
|
||||
ssr = [
|
||||
"dep:actix-files",
|
||||
"dep:actix-web",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
@check_instrumented
|
||||
Feature: Instrumented Counters showing the expected values
|
||||
|
||||
Scenario: I can fresh CSR instrumented counters
|
||||
Given I see the app
|
||||
When I access the instrumented counters via CSR
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 0 |
|
||||
| item_overview | 0 |
|
||||
| item_inspect | 0 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: I should see counter going up after viewing Item Listing
|
||||
Given I see the app
|
||||
When I select the following links
|
||||
| Instrumented |
|
||||
| Item Listing |
|
||||
| Counters |
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 0 |
|
||||
| item_inspect | 0 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 1 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
# the reload has happened in Item Listing, it follows a suspend
|
||||
# will be called as hydration happens.
|
||||
Scenario: Refreshing Item Listing should have only suspend counters
|
||||
Given I see the app
|
||||
When I access the instrumented counters via SSR
|
||||
And I select the component Item Listing
|
||||
And I reload the page
|
||||
And I select the component Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 0 |
|
||||
| item_inspect | 0 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Reset CSR Counters work as expected.
|
||||
Given I see the app
|
||||
When I access the instrumented counters via SSR
|
||||
And I select the component Item Listing
|
||||
And I click on Reset CSR Counters
|
||||
And I select the component Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 0 |
|
||||
| item_overview | 0 |
|
||||
| item_inspect | 0 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Standard usage of the instruments traversing down
|
||||
Given I see the app
|
||||
When I select the following links
|
||||
| Instrumented |
|
||||
| Item Listing |
|
||||
| Item 2 |
|
||||
| Inspect path3 |
|
||||
| Inspect path3/field1 |
|
||||
And I access the instrumented counters via CSR
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 1 |
|
||||
| item_inspect | 2 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 1 |
|
||||
| get_item | 1 |
|
||||
| inspect_item_root | 1 |
|
||||
| inspect_item_field | 1 |
|
||||
@@ -0,0 +1,195 @@
|
||||
@check_instrumented_suspense_resource
|
||||
Feature: Using instrumented counters for real
|
||||
Check that the suspend/suspense and the underlying resources are
|
||||
called with the expected number of times for CSR rendering.
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
And I select the mode Instrumented
|
||||
|
||||
Scenario: Emulate steps 1 to 5 of issue #2961
|
||||
Given I select the link Target 3##
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Item Listing |
|
||||
| Target 4## |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 2 |
|
||||
| item_inspect | 0 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 2 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Emulate step 6 of issue #2961
|
||||
Given I select the link Target 41#
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Target 4## |
|
||||
| Target 42# |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 0 |
|
||||
| item_overview | 1 |
|
||||
| item_inspect | 2 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 1 |
|
||||
| inspect_item_root | 2 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Emulate step 7 of issue #2961
|
||||
Given I select the link Target 42#
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Target 4## |
|
||||
| Target 42# |
|
||||
| Target 41# |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 0 |
|
||||
| item_overview | 1 |
|
||||
| item_inspect | 3 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 1 |
|
||||
| inspect_item_root | 3 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Emulate step 8, "not trigger double fetch".
|
||||
Given I select the link Target 3##
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Item Listing |
|
||||
| Target 4## |
|
||||
| Target 41# |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 2 |
|
||||
| item_inspect | 1 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 2 |
|
||||
| inspect_item_root | 1 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Like above, for the "double fetch" which shouldn't happen
|
||||
Given I select the link Target 3##
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Item Listing |
|
||||
| Target 4## |
|
||||
| Target 41# |
|
||||
| Target 3## |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 3 |
|
||||
| item_inspect | 1 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 3 |
|
||||
| inspect_item_root | 1 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Like above, but using 4## instead
|
||||
Given I select the link Target 3##
|
||||
And I refresh the page
|
||||
When I select the following links
|
||||
| Item Listing |
|
||||
| Target 4## |
|
||||
| Target 41# |
|
||||
| Target 4## |
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Suspend Calls | |
|
||||
| item_listing | 1 |
|
||||
| item_overview | 3 |
|
||||
| item_inspect | 1 |
|
||||
And the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 2 |
|
||||
| inspect_item_root | 1 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
# Currently, get_item is invoked with `3` as the argument upon
|
||||
# selection of `Item Listing` despite that `Item Listing` doesn't
|
||||
# need `get_item` calls. Seems like it may be due to the system
|
||||
# still reacting to the unmounting of the component that needed
|
||||
# view that generated the original `Item 3` (hydrated from SSR).
|
||||
# Tests above may also have this type of behavior, but is somewhat
|
||||
# masked because the direction of going down and then back up, but
|
||||
# if this behavior changes for the better (avoiding this spurious
|
||||
# resource fetch) then the above tests may need updating to reflect
|
||||
# the corrected behavior. Note the difference with the fully CSR
|
||||
# scenario after this one
|
||||
Scenario: Emulate part of step 8 of issue #2961
|
||||
Given I select the link Target 3##
|
||||
And I refresh the page
|
||||
When I select the link Item Listing
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 1 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
# Instead of refreshing the page like above, CSR counters is reset
|
||||
# instead to keep the starting counter conditions identical.
|
||||
Scenario: Emulate above, instead of refresh page, reset csr counters
|
||||
Given I select the link Target 3##
|
||||
And I click on Reset CSR Counters
|
||||
When I select the link Item Listing
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
# Again, the following two sets demostrates resources making stale
|
||||
# and redundant requests when hydrated, and not do so when under
|
||||
# CSR.
|
||||
Scenario: Start with hydration from Target 41# and go up
|
||||
Given I select the link Target 41#
|
||||
And I refresh the page
|
||||
When I select the link Target 4##
|
||||
And I select the link Item Listing
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 1 |
|
||||
| inspect_item_root | 1 |
|
||||
| inspect_item_field | 0 |
|
||||
|
||||
Scenario: Start with hydration from Target 41# and go up
|
||||
Given I select the link Target 41#
|
||||
And I click on Reset CSR Counters
|
||||
When I select the link Target 4##
|
||||
And I select the link Item Listing
|
||||
And I go check the Counters
|
||||
Then I see the following counters under section
|
||||
| Server Calls (CSR) | |
|
||||
| list_items | 0 |
|
||||
| get_item | 0 |
|
||||
| inspect_item_root | 0 |
|
||||
| inspect_item_field | 0 |
|
||||
@@ -37,3 +37,19 @@ pub async fn click_second_button(client: &Client) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_reset_counters_button(client: &Client) -> Result<()> {
|
||||
let reset_counter = find::reset_counter(client).await?;
|
||||
|
||||
reset_counter.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_reset_csr_counters_button(client: &Client) -> Result<()> {
|
||||
let reset_counter = find::reset_csr_counter(client).await?;
|
||||
|
||||
reset_counter.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -63,3 +63,21 @@ pub async fn second_count_is(client: &Client, expected: u32) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn instrumented_counts(
|
||||
client: &Client,
|
||||
expected: &[(&str, u32)],
|
||||
) -> Result<()> {
|
||||
let mut actual = Vec::<(&str, u32)>::new();
|
||||
|
||||
for (selector, _) in expected.iter() {
|
||||
actual.push((
|
||||
selector,
|
||||
find::instrumented_count(client, selector).await?,
|
||||
))
|
||||
}
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -77,6 +77,43 @@ pub async fn second_button(client: &Client) -> Result<Element> {
|
||||
Ok(counter_button)
|
||||
}
|
||||
|
||||
pub async fn instrumented_count(
|
||||
client: &Client,
|
||||
selector: &str,
|
||||
) -> Result<u32> {
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Id(selector))
|
||||
.await
|
||||
.expect(format!("Element #{selector} not found.")
|
||||
.as_str());
|
||||
let text = element.text().await?;
|
||||
let count = text.parse::<u32>()
|
||||
.expect(format!("Element #{selector} does not contain a number.")
|
||||
.as_str());
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn reset_counter(client: &Client) -> Result<Element> {
|
||||
let reset_button = client
|
||||
.wait()
|
||||
.for_element(Locator::Id("reset-counters"))
|
||||
.await
|
||||
.expect("Reset counter input not found");
|
||||
|
||||
Ok(reset_button)
|
||||
}
|
||||
|
||||
pub async fn reset_csr_counter(client: &Client) -> Result<Element> {
|
||||
let reset_button = client
|
||||
.wait()
|
||||
.for_element(Locator::Id("reset-csr-counters"))
|
||||
.await
|
||||
.expect("Reset CSR counter input not found");
|
||||
|
||||
Ok(reset_button)
|
||||
}
|
||||
|
||||
async fn component_message(client: &Client, id: &str) -> Result<String> {
|
||||
let element =
|
||||
client.wait().for_element(Locator::Id(id)).await.expect(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::fixtures::{action, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::{given, when};
|
||||
use cucumber::{given, when, gherkin::Step};
|
||||
|
||||
#[given("I see the app")]
|
||||
#[when("I open the app")]
|
||||
@@ -12,19 +12,13 @@ async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
|
||||
}
|
||||
|
||||
#[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<()> {
|
||||
#[given(regex = "^I select the link (.*)$")]
|
||||
#[when(regex = "^I select the link (.*)$")]
|
||||
#[when(regex = "^I click on the link (.*)$")]
|
||||
#[when(regex = "^I go check the (.*)$")]
|
||||
async fn i_select_the_link(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_link(client, &text).await?;
|
||||
|
||||
@@ -59,3 +53,69 @@ async fn i_click_the_second_button_n_times(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I (refresh|reload) the (browser|page)$")]
|
||||
#[when(regex = "^I (refresh|reload) the (browser|page)$")]
|
||||
async fn i_refresh_the_browser(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
client.refresh().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I click on Reset Counters")]
|
||||
async fn i_click_on_reset_counters(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_reset_counters_button(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I click on Reset CSR Counters")]
|
||||
#[when(expr = "I click on Reset CSR Counters")]
|
||||
async fn i_click_on_reset_csr_counters(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_reset_csr_counters_button(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I access the instrumented counters via SSR")]
|
||||
async fn i_access_the_instrumented_counters_page_via_ssr(
|
||||
world: &mut AppWorld,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_link(client, "Instrumented").await?;
|
||||
action::click_link(client, "Counters").await?;
|
||||
client.refresh().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I access the instrumented counters via CSR")]
|
||||
async fn i_access_the_instrumented_counters_page_via_csr(
|
||||
world: &mut AppWorld,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_link(client, "Instrumented").await?;
|
||||
action::click_link(client, "Counters").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(expr = "I select the following links")]
|
||||
#[when(expr = "I select the following links")]
|
||||
async fn i_select_the_following_links(
|
||||
world: &mut AppWorld,
|
||||
step: &Step,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
if let Some(table) = step.table.as_ref() {
|
||||
for row in table.rows.iter() {
|
||||
action::click_link(client, &row[0]).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::fixtures::{check, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
use cucumber::{then, gherkin::Step};
|
||||
|
||||
#[then(regex = r"^I see the page title is (.*)$")]
|
||||
async fn i_see_the_page_title_is(
|
||||
@@ -79,3 +79,23 @@ async fn i_see_the_second_count_is(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(expr = "I see the following counters under section")]
|
||||
#[then(expr = "the following counters under section")]
|
||||
async fn i_see_the_following_counters_under_section(
|
||||
world: &mut AppWorld,
|
||||
step: &Step,
|
||||
) -> Result<()> {
|
||||
// FIXME ideally check the mode; for now leave it because effort
|
||||
let client = &world.client;
|
||||
if let Some(table) = step.table.as_ref() {
|
||||
let expected = table.rows
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|row| (row[0].as_str(), row[1].parse::<u32>().unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
check::instrumented_counts(client, &expected).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::instrumented::InstrumentedRoutes;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::{
|
||||
components::{Outlet, ParentRoute, Redirect, Route, Router, Routes, A},
|
||||
@@ -41,6 +42,7 @@ pub fn App() -> impl IntoView {
|
||||
<A href="/out-of-order">"Out-of-Order"</A>
|
||||
<A href="/in-order">"In-Order"</A>
|
||||
<A href="/async">"Async"</A>
|
||||
<A href="/instrumented/">"Instrumented"</A>
|
||||
</nav>
|
||||
<main>
|
||||
<Routes fallback=|| "Page not found.">
|
||||
@@ -110,6 +112,7 @@ pub fn App() -> impl IntoView {
|
||||
<Route path=StaticSegment("local") view=LocalResource/>
|
||||
<Route path=StaticSegment("none") view=None/>
|
||||
</ParentRoute>
|
||||
<InstrumentedRoutes/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
667
examples/suspense_tests/src/instrumented.rs
Normal file
667
examples/suspense_tests/src/instrumented.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::{
|
||||
components::{ParentRoute, Route, A},
|
||||
hooks::use_params,
|
||||
nested_router::Outlet,
|
||||
params::Params,
|
||||
MatchNestedRoutes, ParamSegment, SsrMode, StaticSegment, WildcardSegment,
|
||||
};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(super) mod counter {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
LazyLock, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Counter(AtomicU32);
|
||||
|
||||
impl Counter {
|
||||
pub const fn new() -> Self {
|
||||
Self(AtomicU32::new(0))
|
||||
}
|
||||
|
||||
pub fn get(&self) -> u32 {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn inc(&self) -> u32 {
|
||||
self.0.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.0.store(0, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Counters {
|
||||
pub list_items: Counter,
|
||||
pub get_item: Counter,
|
||||
pub inspect_item_root: Counter,
|
||||
pub inspect_item_field: Counter,
|
||||
}
|
||||
|
||||
impl From<&mut Counters> for super::Counters {
|
||||
fn from(counter: &mut Counters) -> Self {
|
||||
Self {
|
||||
get_item: counter.get_item.get(),
|
||||
inspect_item_root: counter.inspect_item_root.get(),
|
||||
inspect_item_field: counter.inspect_item_field.get(),
|
||||
list_items: counter.list_items.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
pub fn reset(&self) {
|
||||
self.get_item.reset();
|
||||
self.inspect_item_root.reset();
|
||||
self.inspect_item_field.reset();
|
||||
self.list_items.reset();
|
||||
}
|
||||
}
|
||||
|
||||
pub static COUNTERS: LazyLock<Mutex<HashMap<u64, Counters>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct Item {
|
||||
id: i64,
|
||||
name: Option<String>,
|
||||
field: Option<String>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn list_items(ticket: u64) -> Result<Vec<i64>, ServerFnError> {
|
||||
// emulate database query overhead
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
(*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.list_items
|
||||
.inc();
|
||||
Ok(vec![1, 2, 3, 4])
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct GetItemResult(pub Item, pub Vec<String>);
|
||||
|
||||
#[server]
|
||||
async fn get_item(
|
||||
ticket: u64,
|
||||
id: i64,
|
||||
) -> Result<GetItemResult, ServerFnError> {
|
||||
// emulate database query overhead
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
(*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.get_item
|
||||
.inc();
|
||||
let name = None::<String>;
|
||||
let field = None::<String>;
|
||||
Ok(GetItemResult(
|
||||
Item { id, name, field },
|
||||
["path1", "path2", "path3"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct InspectItemResult(pub Item, pub String, pub Vec<String>);
|
||||
|
||||
#[server]
|
||||
async fn inspect_item(
|
||||
ticket: u64,
|
||||
id: i64,
|
||||
path: String,
|
||||
) -> Result<InspectItemResult, ServerFnError> {
|
||||
// emulate database query overhead
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
let mut split = path.split('/');
|
||||
let name = split.next().map(str::to_string);
|
||||
let path = name
|
||||
.clone()
|
||||
.expect("name should have been defined at this point");
|
||||
let field = split
|
||||
.next()
|
||||
.and_then(|s| (!s.is_empty()).then(|| s.to_string()));
|
||||
if field.is_none() {
|
||||
(*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.inspect_item_root
|
||||
.inc();
|
||||
} else {
|
||||
(*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.inspect_item_field
|
||||
.inc();
|
||||
}
|
||||
Ok(InspectItemResult(
|
||||
Item { id, name, field },
|
||||
path,
|
||||
["field1", "field2", "field3"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct Counters {
|
||||
pub get_item: u32,
|
||||
pub inspect_item_root: u32,
|
||||
pub inspect_item_field: u32,
|
||||
pub list_items: u32,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn get_counters(ticket: u64) -> Result<Counters, ServerFnError> {
|
||||
Ok((*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.into())
|
||||
}
|
||||
|
||||
#[server(ResetCounters)]
|
||||
async fn reset_counters(ticket: u64) -> Result<(), ServerFnError> {
|
||||
(*counter::COUNTERS)
|
||||
.lock()
|
||||
.expect("somehow panicked elsewhere")
|
||||
.entry(ticket)
|
||||
.or_default()
|
||||
.reset();
|
||||
// leptos::logging::log!("counters for ticket {ticket} have been reset");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SuspenseCounters {
|
||||
item_overview: u32,
|
||||
item_inspect: u32,
|
||||
item_listing: u32,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn InstrumentedRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
// TODO should make this mode configurable via feature flag?
|
||||
let ssr = SsrMode::Async;
|
||||
view! {
|
||||
<ParentRoute path=StaticSegment("instrumented") view=InstrumentedRoot ssr>
|
||||
<Route path=StaticSegment("/") view=InstrumentedTop/>
|
||||
<ParentRoute path=StaticSegment("item") view=ItemRoot>
|
||||
<Route path=StaticSegment("/") view=ItemListing/>
|
||||
<ParentRoute path=ParamSegment("id") view=ItemTop>
|
||||
<Route path=StaticSegment("/") view=ItemOverview/>
|
||||
<Route path=WildcardSegment("path") view=ItemInspect/>
|
||||
</ParentRoute>
|
||||
</ParentRoute>
|
||||
<Route path=StaticSegment("counters") view=ShowCounters/>
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Ticket(pub u64);
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct CSRTicket(pub u64);
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn inst_ticket() -> u64 {
|
||||
// SSR will always use 0 for the ticket
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
fn inst_ticket() -> u64 {
|
||||
// CSR will use a random number for the ticket
|
||||
(js_sys::Math::random() * ((u64::MAX - 1) as f64) + 1f64) as u64
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InstrumentedRoot() -> impl IntoView {
|
||||
let counters = RwSignal::new(SuspenseCounters::default());
|
||||
provide_context(counters);
|
||||
provide_field_nav_portlet_context();
|
||||
|
||||
// Generate a ID directly on this component. Rather than relying on
|
||||
// additional server functions, doing it this way emulates more
|
||||
// standard workflows better and to avoid having to add another
|
||||
// thing to instrument/interfere with the typical use case.
|
||||
// Downside is that randomness has a chance to conflict.
|
||||
//
|
||||
// Furthermore, this approach **will** result in unintuitive
|
||||
// behavior when it isn't accounted for - specifically, the reason
|
||||
// for this design is that when SSR it will guarantee usage of `0`
|
||||
// as the ticket, while CSR it will be of some other value as the
|
||||
// version it uses will be random. However, when trying to get back
|
||||
// the counters associated with the ticket, rendering using SSR will
|
||||
// always produce the SSR version and this quirk will need to be
|
||||
// accounted for.
|
||||
let ticket = inst_ticket();
|
||||
// leptos::logging::log!(
|
||||
// "Ticket for this InstrumentedRoot instance: {ticket}"
|
||||
// );
|
||||
provide_context(Ticket(ticket));
|
||||
|
||||
let csr_ticket = RwSignal::<Option<CSRTicket>>::new(None);
|
||||
|
||||
let reset_counters = ServerAction::<ResetCounters>::new();
|
||||
|
||||
Effect::new(move |_| {
|
||||
let ticket = expect_context::<Ticket>().0;
|
||||
csr_ticket.set(Some(CSRTicket(ticket)));
|
||||
});
|
||||
|
||||
view! {
|
||||
<section id="instrumented">
|
||||
<nav>
|
||||
<a href="/">"Site Root"</a>
|
||||
<A href="./" exact=true>"Instrumented Root"</A>
|
||||
<A href="item/" strict_trailing_slash=true>"Item Listing"</A>
|
||||
<A href="counters" strict_trailing_slash=true>"Counters"</A>
|
||||
</nav>
|
||||
<FieldNavPortlet/>
|
||||
<Outlet/>
|
||||
<Suspense>{
|
||||
move || Suspend::new(async move {
|
||||
let clear_suspense_counters = move |_| {
|
||||
counters.update(|c| *c = SuspenseCounters::default());
|
||||
};
|
||||
csr_ticket.get().map(|ticket| {
|
||||
let ticket = ticket.0;
|
||||
view! {
|
||||
<ActionForm action=reset_counters>
|
||||
<input type="hidden" name="ticket" value=format!("{ticket}") />
|
||||
<input
|
||||
id="reset-csr-counters"
|
||||
type="submit"
|
||||
value="Reset CSR Counters"
|
||||
on:click=clear_suspense_counters/>
|
||||
</ActionForm>
|
||||
}
|
||||
})
|
||||
})
|
||||
}</Suspense>
|
||||
<footer>
|
||||
<nav>
|
||||
<A href="item/3/">"Target 3##"</A>
|
||||
<A href="item/4/">"Target 4##"</A>
|
||||
<A href="item/4/path1/">"Target 41#"</A>
|
||||
<A href="item/4/path2/">"Target 42#"</A>
|
||||
<A href="item/4/path2/field1">"Target 421"</A>
|
||||
<A href="item/1/path2/field3">"Target 123"</A>
|
||||
</nav>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InstrumentedTop() -> impl IntoView {
|
||||
view! {
|
||||
<h1>"Instrumented Tests"</h1>
|
||||
<p>"These tests validates the number of invocations of server functions and suspenses per access."</p>
|
||||
<ul>
|
||||
// not using `A` because currently some bugs with artix
|
||||
<li><a href="item/">"Item Listing"</a></li>
|
||||
<li><a href="item/4/path1/">"Target 41#"</a></li>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ItemRoot() -> impl IntoView {
|
||||
let ticket = expect_context::<Ticket>().0;
|
||||
provide_context(Resource::new_blocking(
|
||||
move || (),
|
||||
move |_| async move { list_items(ticket).await },
|
||||
));
|
||||
|
||||
view! {
|
||||
<h2>"<ItemRoot/>"</h2>
|
||||
<Outlet/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ItemListing() -> impl IntoView {
|
||||
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
|
||||
let resource =
|
||||
expect_context::<Resource<Result<Vec<i64>, ServerFnError>>>();
|
||||
let item_listing = move || {
|
||||
Suspend::new(async move {
|
||||
let result = resource.await.map(|items| items
|
||||
.into_iter()
|
||||
.map(move |item|
|
||||
// FIXME seems like relative link isn't working, it is currently
|
||||
// adding an extra `/` in artix; manually construct `a` instead.
|
||||
// <li><A href=format!("./{item}/")>"Item "{item}</A></li>
|
||||
view! {
|
||||
<li><a href=format!("/instrumented/item/{item}/")>"Item "{item}</a></li>
|
||||
}
|
||||
)
|
||||
.collect_view()
|
||||
);
|
||||
suspense_counters.update_untracked(|c| c.item_listing += 1);
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<h3>"<ItemListing/>"</h3>
|
||||
<ul>
|
||||
<Suspense>
|
||||
{item_listing}
|
||||
</Suspense>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, PartialEq, Clone, Debug)]
|
||||
struct ItemTopParams {
|
||||
id: Option<i64>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ItemTop() -> impl IntoView {
|
||||
let ticket = expect_context::<Ticket>().0;
|
||||
let params = use_params::<ItemTopParams>();
|
||||
// map result to an option as the focus isn't error rendering
|
||||
provide_context(Resource::new_blocking(
|
||||
move || params.get().map(|p| p.id),
|
||||
move |id| async move {
|
||||
match id {
|
||||
Err(_) => None,
|
||||
Ok(Some(id)) => get_item(ticket, id).await.ok(),
|
||||
_ => None,
|
||||
}
|
||||
},
|
||||
));
|
||||
view! {
|
||||
<h4>"<ItemTop/>"</h4>
|
||||
<Outlet/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ItemOverview() -> impl IntoView {
|
||||
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
|
||||
let resource = expect_context::<Resource<Option<GetItemResult>>>();
|
||||
let item_view = move || {
|
||||
Suspend::new(async move {
|
||||
let result = resource.await.map(|GetItemResult(item, names)| view! {
|
||||
<p>{format!("Viewing {item:?}")}</p>
|
||||
<ul>{
|
||||
names.into_iter()
|
||||
.map(|name| {
|
||||
// FIXME seems like relative link isn't working, it is currently
|
||||
// adding an extra `/` in artix; manually construct `a` instead.
|
||||
// <li><A href=format!("./{name}/")>{format!("Inspect {name}")}</A></li>
|
||||
let id = item.id;
|
||||
view! {
|
||||
<li><a href=format!("/instrumented/item/{id}/{name}/")>
|
||||
"Inspect "{name.clone()}
|
||||
</a></li>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}</ul>
|
||||
});
|
||||
suspense_counters.update_untracked(|c| c.item_overview += 1);
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<h5>"<ItemOverview/>"</h5>
|
||||
<Suspense>
|
||||
{item_view}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, PartialEq, Clone, Debug)]
|
||||
struct ItemInspectParams {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ItemInspect() -> impl IntoView {
|
||||
let ticket = expect_context::<Ticket>().0;
|
||||
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
|
||||
let params = use_params::<ItemInspectParams>();
|
||||
let res_overview = expect_context::<Resource<Option<GetItemResult>>>();
|
||||
let res_inspect = Resource::new_blocking(
|
||||
move || params.get().map(|p| p.path),
|
||||
move |p| async move {
|
||||
// leptos::logging::log!("res_inspect: res_overview.await");
|
||||
let overview = res_overview.await;
|
||||
// leptos::logging::log!("res_inspect: resolved res_overview.await");
|
||||
// let result =
|
||||
match (overview, p) {
|
||||
(Some(item), Ok(Some(path))) => {
|
||||
// leptos::logging::log!("res_inspect: inspect_item().await");
|
||||
inspect_item(ticket, item.0.id, path.clone()).await.ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
// ;
|
||||
// leptos::logging::log!("res_inspect: resolved inspect_item().await");
|
||||
// result
|
||||
},
|
||||
);
|
||||
on_cleanup(|| {
|
||||
if let Some(c) = use_context::<WriteSignal<Option<FieldNavCtx>>>() {
|
||||
c.set(None);
|
||||
}
|
||||
});
|
||||
let inspect_view = move || {
|
||||
// leptos::logging::log!("inspect_view closure invoked");
|
||||
Suspend::new(async move {
|
||||
// leptos::logging::log!("inspect_view Suspend::new() called");
|
||||
let result = res_inspect.await.map(|InspectItemResult(item, name, fields)| {
|
||||
// leptos::logging::log!("inspect_view res_inspect awaited");
|
||||
let id = item.id;
|
||||
expect_context::<WriteSignal<Option<FieldNavCtx>>>().set(Some(
|
||||
fields.iter()
|
||||
.map(|field| FieldNavItem {
|
||||
href: format!("/instrumented/item/{id}/{name}/{field}"),
|
||||
text: field.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
));
|
||||
view! {
|
||||
<p>{format!("Inspecting {item:?}")}</p>
|
||||
<ul>{
|
||||
fields.iter()
|
||||
.map(|field| {
|
||||
// FIXME seems like relative link to root for a wildcard isn't
|
||||
// working as expected, so manually construct `a` instead.
|
||||
// let text = format!("Inspect {name}/{field}");
|
||||
// view! {
|
||||
// <li><A href=format!("{field}")>{text}</A></li>
|
||||
// }
|
||||
view! {
|
||||
<li><a href=format!("/instrumented/item/{id}/{name}/{field}")>{
|
||||
format!("Inspect {name}/{field}")
|
||||
}</a></li>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}</ul>
|
||||
}
|
||||
});
|
||||
suspense_counters.update_untracked(|c| c.item_inspect += 1);
|
||||
// leptos::logging::log!(
|
||||
// "returning result, result.is_some() = {}, count = {}",
|
||||
// result.is_some(),
|
||||
// suspense_counters.get().item_inspect,
|
||||
// );
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<h5>"<ItemInspect/>"</h5>
|
||||
<Suspense>
|
||||
{inspect_view}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ShowCounters() -> impl IntoView {
|
||||
// There is _weirdness_ in this view. The `Server Calls` counters
|
||||
// will be acquired via the expected mode and be rendered as such.
|
||||
//
|
||||
// However, upon `Reset Counters`, the mode from which the reset
|
||||
// was issued will result in the rendering be reflected as such, so
|
||||
// if the intial state was SSR, resetting under CSR will result in
|
||||
// the CSR counters be rendered after. However for the intents and
|
||||
// purpose for the testing only the CSR is cared for.
|
||||
//
|
||||
// At the end of the day, it is possible to have both these be
|
||||
// separated out, but for the purpose of this test the focus is not
|
||||
// on the SSR side of things (at least until further regression is
|
||||
// discovered that affects SSR directly).
|
||||
let ticket = expect_context::<Ticket>().0;
|
||||
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
|
||||
let reset_counters = ServerAction::<ResetCounters>::new();
|
||||
let res_counter = Resource::new(
|
||||
move || reset_counters.version().get(),
|
||||
move |_| async move {
|
||||
(
|
||||
get_counters(ticket).await,
|
||||
if ticket == 0 { "SSR" } else { "CSR" }.to_string(),
|
||||
ticket,
|
||||
)
|
||||
},
|
||||
);
|
||||
let counter_view = move || {
|
||||
Suspend::new(async move {
|
||||
// ensure current mode and ticket are both updated
|
||||
let (counters, mode, ticket) = res_counter.await;
|
||||
counters.map(|counters| {
|
||||
let clear_suspense_counters = move |_| {
|
||||
suspense_counters.update(|c| {
|
||||
// leptos::logging::log!("resetting suspense counters");
|
||||
*c = SuspenseCounters::default();
|
||||
});
|
||||
};
|
||||
view! {
|
||||
<h3 id="server-calls">"Server Calls ("{mode}")"</h3>
|
||||
<dl>
|
||||
<dt>"list_items"</dt>
|
||||
<dd id="list_items">{counters.list_items}</dd>
|
||||
<dt>"get_item"</dt>
|
||||
<dd id="get_item">{counters.get_item}</dd>
|
||||
<dt>"inspect_item_root"</dt>
|
||||
<dd id="inspect_item_root">{counters.inspect_item_root}</dd>
|
||||
<dt>"inspect_item_field"</dt>
|
||||
<dd id="inspect_item_field">{counters.inspect_item_field}</dd>
|
||||
</dl>
|
||||
<ActionForm action=reset_counters>
|
||||
<input type="hidden" name="ticket" value=format!("{ticket}") />
|
||||
<input
|
||||
id="reset-counters"
|
||||
type="submit"
|
||||
value="Reset Counters"
|
||||
on:click=clear_suspense_counters/>
|
||||
</ActionForm>
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<h2>"Counters"</h2>
|
||||
|
||||
<h3 id="suspend-calls">"Suspend Calls"</h3>
|
||||
{move || suspense_counters.with(|c| view! {
|
||||
<dl>
|
||||
<dt>"item_listing"</dt>
|
||||
<dd id="item_listing">{c.item_listing}</dd>
|
||||
<dt>"item_overview"</dt>
|
||||
<dd id="item_overview">{c.item_overview}</dd>
|
||||
<dt>"item_inspect"</dt>
|
||||
<dd id="item_inspect">{c.item_inspect}</dd>
|
||||
</dl>
|
||||
})}
|
||||
|
||||
<Suspense>
|
||||
{counter_view}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct FieldNavItem {
|
||||
pub href: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct FieldNavCtx(pub Option<Vec<FieldNavItem>>);
|
||||
|
||||
impl From<Vec<FieldNavItem>> for FieldNavCtx {
|
||||
fn from(item: Vec<FieldNavItem>) -> Self {
|
||||
Self(Some(item))
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FieldNavPortlet() -> impl IntoView {
|
||||
let ctx = expect_context::<ReadSignal<Option<FieldNavCtx>>>();
|
||||
move || {
|
||||
let ctx = ctx.get();
|
||||
ctx.map(|ctx| {
|
||||
view! {
|
||||
<div id="FieldNavPortlet">
|
||||
<span>"FieldNavPortlet:"</span>
|
||||
<nav>{
|
||||
ctx.0.map(|ctx| {
|
||||
ctx.into_iter()
|
||||
.map(|FieldNavItem { href, text }| {
|
||||
view! {
|
||||
<A href=href>{text}</A>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
})
|
||||
}</nav>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provide_field_nav_portlet_context() {
|
||||
// wrapping the Ctx in an Option allows better ergonomics whenever it isn't needed
|
||||
let (ctx, set_ctx) = signal(None::<FieldNavCtx>);
|
||||
provide_context(ctx);
|
||||
provide_context(set_ctx);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
mod instrumented;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
|
||||
@@ -41,7 +41,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
})
|
||||
.bind(addr)?
|
||||
.workers(1)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("should see the welcome message", async ({ page }) => {
|
||||
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
|
||||
await page.goto("http://localhost:3000/");
|
||||
|
||||
await expect(page.locator("h2")).toHaveText("Welcome to Leptos with Tailwind");
|
||||
await expect(page).toHaveTitle("Leptos + Tailwindcss");
|
||||
});
|
||||
|
||||
@@ -22,24 +22,29 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn Home() -> impl IntoView {
|
||||
let (count, set_count) = signal(0);
|
||||
let (value, set_value) = signal(0);
|
||||
|
||||
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
|
||||
view! {
|
||||
<main class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
"Something's here | "
|
||||
{move || if count.get() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count.get().to_string()
|
||||
}}
|
||||
" | Some more text"
|
||||
</button>
|
||||
<Title text="Leptos + Tailwindcss"/>
|
||||
<main>
|
||||
<div class="bg-gradient-to-tl from-blue-800 to-blue-500 text-white font-mono flex flex-col min-h-screen">
|
||||
<div class="flex flex-row-reverse flex-wrap m-auto">
|
||||
<button on:click=move |_| set_value.update(|value| *value += 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
|
||||
"+"
|
||||
</button>
|
||||
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
|
||||
{value}
|
||||
</button>
|
||||
<button
|
||||
on:click=move |_| set_value.update(|value| *value -= 1)
|
||||
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
|
||||
class:invisible=move || {value.get() < 1}
|
||||
>
|
||||
"-"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: {
|
||||
content: {
|
||||
files: ["*.html", "./src/**/*.rs"],
|
||||
transform: {
|
||||
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and links to intro page", async ({ page }) => {
|
||||
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
|
||||
await page.goto("http://localhost:3000/");
|
||||
|
||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
||||
await expect(page).toHaveTitle("Leptos + Tailwindcss");
|
||||
});
|
||||
|
||||
@@ -54,7 +54,11 @@ fn Home() -> impl IntoView {
|
||||
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
|
||||
{value}
|
||||
</button>
|
||||
<button on:click=move |_| set_value.update(|value| *value -= 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
|
||||
<button
|
||||
on:click=move |_| set_value.update(|value| *value -= 1)
|
||||
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
|
||||
class:invisible=move || {value.get() < 1}
|
||||
>
|
||||
"-"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["*.html", "./src/**/*.rs",],
|
||||
content: {
|
||||
files: ["*.html", "./src/**/*.rs"],
|
||||
transform: {
|
||||
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and links to intro page", async ({ page }) => {
|
||||
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
|
||||
await page.goto("http://localhost:8080/");
|
||||
|
||||
await expect(page).toHaveTitle("Leptos • Counter with Tailwind");
|
||||
|
||||
await expect(page.locator("h2")).toHaveText("Welcome to Leptos with Tailwind");
|
||||
await expect(page).toHaveTitle("Leptos + Tailwindcss");
|
||||
});
|
||||
|
||||
@@ -22,24 +22,29 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn Home() -> impl IntoView {
|
||||
let (count, set_count) = signal(0);
|
||||
let (value, set_value) = signal(0);
|
||||
|
||||
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
|
||||
view! {
|
||||
<div class="my-0 mx-auto max-w-3xl text-center">
|
||||
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
|
||||
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
|
||||
on:click=move |_| set_count.update(|count| *count += 1)
|
||||
>
|
||||
"Something's here | "
|
||||
{move || if count.get() == 0 {
|
||||
"Click me!".to_string()
|
||||
} else {
|
||||
count.get().to_string()
|
||||
}}
|
||||
" | Some more text"
|
||||
</button>
|
||||
</div>
|
||||
<Title text="Leptos + Tailwindcss"/>
|
||||
<main>
|
||||
<div class="bg-gradient-to-tl from-blue-800 to-blue-500 text-white font-mono flex flex-col min-h-screen">
|
||||
<div class="flex flex-row-reverse flex-wrap m-auto">
|
||||
<button on:click=move |_| set_value.update(|value| *value += 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
|
||||
"+"
|
||||
</button>
|
||||
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
|
||||
{value}
|
||||
</button>
|
||||
<button
|
||||
on:click=move |_| set_value.update(|value| *value -= 1)
|
||||
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
|
||||
class:invisible=move || {value.get() < 1}
|
||||
>
|
||||
"-"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: {
|
||||
content: {
|
||||
files: ["*.html", "./src/**/*.rs"],
|
||||
transform: {
|
||||
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
extend: {},
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn TimerDemo() -> impl IntoView {
|
||||
pub fn use_interval<T, F>(interval_millis: T, f: F)
|
||||
where
|
||||
F: Fn() + Clone + 'static,
|
||||
T: Into<MaybeSignal<u64>> + 'static,
|
||||
T: Into<Signal<u64>> + 'static,
|
||||
{
|
||||
let interval_millis = interval_millis.into();
|
||||
Effect::new(move |prev_handle: Option<IntervalHandle>| {
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn main() -> std::io::Result<()> {
|
||||
</html>
|
||||
}
|
||||
}})
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/", site_root.as_ref()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
|
||||
@@ -319,10 +319,7 @@ pub fn Todo(todo: Todo) -> impl IntoView {
|
||||
node_ref=todo_input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
prop:checked=move || todo.completed.get()
|
||||
on:input:target=move |ev| {
|
||||
todo.completed.set(ev.target().checked());
|
||||
}
|
||||
bind:checked=todo.completed
|
||||
/>
|
||||
|
||||
<label on:dblclick=move |_| {
|
||||
|
||||
58
flake.lock
generated
58
flake.lock
generated
@@ -5,29 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"lastModified": 1726560853,
|
||||
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -38,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1703961334,
|
||||
"narHash": "sha256-M1mV/Cq+pgjk0rt6VxoyyD+O8cOUiai8t9Q6Yyq4noY=",
|
||||
"lastModified": 1727634051,
|
||||
"narHash": "sha256-S5kVU7U82LfpEukbn/ihcyNt2+EvG7Z5unsKW9H/yFA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b0d36bd0a420ecee3bc916c91886caca87c894e9",
|
||||
"rev": "06cf0e1da4208d3766d898b7fdab6513366d45b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -54,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681358109,
|
||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -77,15 +59,14 @@
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1704075545,
|
||||
"narHash": "sha256-L3zgOuVKhPjKsVLc3yTm2YJ6+BATyZBury7wnhyc8QU=",
|
||||
"lastModified": 1727749966,
|
||||
"narHash": "sha256-DUS8ehzqB1DQzfZ4bRXVSollJhu+y7cvh1DJ9mbWebE=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "a0df72e106322b67e9c6e591fe870380bd0da0d5",
|
||||
"rev": "00decf1b4f9886d25030b9ee4aed7bfddccb5f66",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -108,21 +89,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hydration_context"
|
||||
version = "0.2.0-beta4"
|
||||
version = "0.2.0-rc1"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
|
||||
@@ -44,6 +44,18 @@ pub type PinnedStream<T> = Pin<Box<dyn Stream<Item = T> + Send + Sync>>;
|
||||
/// from the server to the client.
|
||||
pub struct SerializedDataId(usize);
|
||||
|
||||
impl SerializedDataId {
|
||||
/// Create a new instance of [`SerializedDataId`].
|
||||
pub fn new(id: usize) -> Self {
|
||||
SerializedDataId(id)
|
||||
}
|
||||
|
||||
/// Consume into the inner usize identifier.
|
||||
pub fn into_inner(self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SerializedDataId> for ErrorId {
|
||||
fn from(value: SerializedDataId) -> Self {
|
||||
value.0.into()
|
||||
|
||||
@@ -58,6 +58,27 @@ impl SsrSharedContext {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the data buffers, awaiting all async resources,
|
||||
/// returning both sync and async buffers.
|
||||
/// Useful to implement custom hydration contexts.
|
||||
///
|
||||
/// WARNING: this will clear the internal buffers, it should only be called once.
|
||||
/// A second call would return an empty `vec![]`.
|
||||
pub async fn consume_buffers(&self) -> Vec<(SerializedDataId, String)> {
|
||||
let sync_data = mem::take(&mut *self.sync_buf.write().or_poisoned());
|
||||
let async_data = mem::take(&mut *self.async_buf.write().or_poisoned());
|
||||
|
||||
let mut all_data = Vec::new();
|
||||
for resolved in sync_data {
|
||||
all_data.push((resolved.0, resolved.1));
|
||||
}
|
||||
for (id, fut) in async_data {
|
||||
let data = fut.await;
|
||||
all_data.push((id, data));
|
||||
}
|
||||
all_data
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for SsrSharedContext {
|
||||
|
||||
@@ -33,7 +33,7 @@ once_cell = "1"
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
|
||||
[features]
|
||||
islands-router = []
|
||||
dont-use-islands-router = []
|
||||
tracing = ["dep:tracing"]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
|
||||
@@ -24,7 +24,7 @@ use leptos::{
|
||||
config::LeptosOptions,
|
||||
context::{provide_context, use_context},
|
||||
prelude::expect_context,
|
||||
reactive_graph::{computed::ScopedFuture, owner::Owner},
|
||||
reactive::{computed::ScopedFuture, owner::Owner},
|
||||
IntoView,
|
||||
};
|
||||
use leptos_integration_utils::{
|
||||
@@ -35,7 +35,7 @@ use leptos_router::{
|
||||
components::provide_server_redirect,
|
||||
location::RequestUrl,
|
||||
static_routes::{RegenerationFn, ResolvedStaticPath},
|
||||
Method, PathSegment, RouteList, RouteListing, SsrMode,
|
||||
ExpandOptionals, Method, PathSegment, RouteList, RouteListing, SsrMode,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
@@ -44,6 +44,7 @@ use server_fn::{
|
||||
redirect::REDIRECT_HEADER, request::actix::ActixRequest, ServerFnError,
|
||||
};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fmt::{Debug, Display},
|
||||
future::Future,
|
||||
ops::{Deref, DerefMut},
|
||||
@@ -749,7 +750,7 @@ where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
Box::pin(async move {
|
||||
let app = if cfg!(feature = "islands-router") {
|
||||
let app = if cfg!(feature = "dont-use-islands-router") {
|
||||
app.to_html_stream_in_order_branching()
|
||||
} else {
|
||||
app.to_html_stream_in_order()
|
||||
@@ -900,7 +901,7 @@ trait ActixPath {
|
||||
fn to_actix_path(&self) -> String;
|
||||
}
|
||||
|
||||
impl ActixPath for &[PathSegment] {
|
||||
impl ActixPath for Vec<PathSegment> {
|
||||
fn to_actix_path(&self) -> String {
|
||||
let mut path = String::new();
|
||||
for segment in self.iter() {
|
||||
@@ -922,6 +923,14 @@ impl ActixPath for &[PathSegment] {
|
||||
path.push_str(":.*}");
|
||||
}
|
||||
PathSegment::Unit => {}
|
||||
PathSegment::OptionalParam(_) => {
|
||||
#[cfg(feature = "tracing")]
|
||||
tracing::error!(
|
||||
"to_axum_path should only be called on expanded \
|
||||
paths, which do not have OptionalParam any longer"
|
||||
);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
@@ -935,25 +944,38 @@ pub struct ActixRouteListing {
|
||||
mode: SsrMode,
|
||||
methods: Vec<leptos_router::Method>,
|
||||
regenerate: Vec<RegenerationFn>,
|
||||
exclude: bool,
|
||||
}
|
||||
|
||||
impl From<RouteListing> for ActixRouteListing {
|
||||
fn from(value: RouteListing) -> Self {
|
||||
let path = value.path().to_actix_path();
|
||||
let path = if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let mode = value.mode();
|
||||
let methods = value.methods().collect();
|
||||
let regenerate = value.regenerate().into();
|
||||
Self {
|
||||
path,
|
||||
mode: mode.clone(),
|
||||
methods,
|
||||
regenerate,
|
||||
}
|
||||
trait IntoRouteListing: Sized {
|
||||
fn into_route_listing(self) -> Vec<ActixRouteListing>;
|
||||
}
|
||||
|
||||
impl IntoRouteListing for RouteListing {
|
||||
fn into_route_listing(self) -> Vec<ActixRouteListing> {
|
||||
self.path()
|
||||
.to_vec()
|
||||
.expand_optionals()
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let path = path.to_actix_path();
|
||||
let path = if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let mode = self.mode();
|
||||
let methods = self.methods().collect();
|
||||
let regenerate = self.regenerate().into();
|
||||
ActixRouteListing {
|
||||
path,
|
||||
mode: mode.clone(),
|
||||
methods,
|
||||
regenerate,
|
||||
exclude: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,6 +992,7 @@ impl ActixRouteListing {
|
||||
mode,
|
||||
methods: methods.into_iter().collect(),
|
||||
regenerate: regenerate.into(),
|
||||
exclude: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,27 +1050,37 @@ where
|
||||
let mut routes = routes
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(ActixRouteListing::from)
|
||||
.flat_map(IntoRouteListing::into_route_listing)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(
|
||||
if routes.is_empty() {
|
||||
vec![ActixRouteListing::new(
|
||||
"/".to_string(),
|
||||
Default::default(),
|
||||
[leptos_router::Method::Get],
|
||||
vec![],
|
||||
)]
|
||||
} else {
|
||||
// Routes to exclude from auto generation
|
||||
if let Some(excluded_routes) = excluded_routes {
|
||||
routes
|
||||
.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
|
||||
}
|
||||
routes
|
||||
},
|
||||
generator,
|
||||
)
|
||||
let routes = if routes.is_empty() {
|
||||
vec![ActixRouteListing::new(
|
||||
"/".to_string(),
|
||||
Default::default(),
|
||||
[leptos_router::Method::Get],
|
||||
vec![],
|
||||
)]
|
||||
} else {
|
||||
// Routes to exclude from auto generation
|
||||
if let Some(excluded_routes) = &excluded_routes {
|
||||
routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
|
||||
}
|
||||
routes
|
||||
};
|
||||
|
||||
let excluded =
|
||||
excluded_routes
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|path| ActixRouteListing {
|
||||
path,
|
||||
mode: Default::default(),
|
||||
methods: Vec::new(),
|
||||
regenerate: Vec::new(),
|
||||
exclude: true,
|
||||
});
|
||||
|
||||
(routes.into_iter().chain(excluded).collect(), generator)
|
||||
}
|
||||
|
||||
/// Allows generating any prerendered routes.
|
||||
@@ -1353,15 +1386,24 @@ where
|
||||
{
|
||||
let mut router = self;
|
||||
|
||||
let excluded = paths
|
||||
.iter()
|
||||
.filter(|&p| p.exclude)
|
||||
.map(|p| p.path.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// register server functions first to allow for wildcard route in Leptos's Router
|
||||
for (path, _) in server_fn::actix::server_fn_paths() {
|
||||
let additional_context = additional_context.clone();
|
||||
let handler = handle_server_fns_with_context(additional_context);
|
||||
router = router.route(path, handler);
|
||||
if !excluded.contains(path) {
|
||||
let additional_context = additional_context.clone();
|
||||
let handler =
|
||||
handle_server_fns_with_context(additional_context);
|
||||
router = router.route(path, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// register routes defined in Leptos's Router
|
||||
for listing in paths.iter() {
|
||||
for listing in paths.iter().filter(|p| !p.exclude) {
|
||||
let path = listing.path();
|
||||
let mode = listing.mode();
|
||||
|
||||
@@ -1381,39 +1423,41 @@ where
|
||||
),
|
||||
)
|
||||
} else {
|
||||
router.route(
|
||||
path,
|
||||
match mode {
|
||||
SsrMode::OutOfOrder => {
|
||||
render_app_to_stream_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
)
|
||||
}
|
||||
SsrMode::PartiallyBlocked => {
|
||||
render_app_to_stream_with_context_and_replace_blocks(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
true,
|
||||
)
|
||||
}
|
||||
SsrMode::InOrder => {
|
||||
render_app_to_stream_in_order_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
)
|
||||
}
|
||||
SsrMode::Async => render_app_async_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
),
|
||||
_ => unreachable!()
|
||||
},
|
||||
)
|
||||
router
|
||||
.route(path, web::head().to(HttpResponse::Ok))
|
||||
.route(
|
||||
path,
|
||||
match mode {
|
||||
SsrMode::OutOfOrder => {
|
||||
render_app_to_stream_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
)
|
||||
}
|
||||
SsrMode::PartiallyBlocked => {
|
||||
render_app_to_stream_with_context_and_replace_blocks(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
true,
|
||||
)
|
||||
}
|
||||
SsrMode::InOrder => {
|
||||
render_app_to_stream_in_order_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
)
|
||||
}
|
||||
SsrMode::Async => render_app_async_with_context(
|
||||
additional_context_and_method.clone(),
|
||||
app_fn.clone(),
|
||||
method,
|
||||
),
|
||||
_ => unreachable!()
|
||||
},
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1455,15 +1499,24 @@ impl LeptosRoutes for &mut ServiceConfig {
|
||||
{
|
||||
let mut router = self;
|
||||
|
||||
let excluded = paths
|
||||
.iter()
|
||||
.filter(|&p| p.exclude)
|
||||
.map(|p| p.path.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// register server functions first to allow for wildcard route in Leptos's Router
|
||||
for (path, _) in server_fn::actix::server_fn_paths() {
|
||||
let additional_context = additional_context.clone();
|
||||
let handler = handle_server_fns_with_context(additional_context);
|
||||
router = router.route(path, handler);
|
||||
if !excluded.contains(path) {
|
||||
let additional_context = additional_context.clone();
|
||||
let handler =
|
||||
handle_server_fns_with_context(additional_context);
|
||||
router = router.route(path, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// register routes defined in Leptos's Router
|
||||
for listing in paths.iter() {
|
||||
for listing in paths.iter().filter(|p| !p.exclude) {
|
||||
let path = listing.path();
|
||||
let mode = listing.mode();
|
||||
|
||||
@@ -1552,7 +1605,10 @@ where
|
||||
ServerFnError::new("HttpRequest should have been provided via context")
|
||||
})?;
|
||||
|
||||
T::extract(&req)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))
|
||||
SendWrapper::new(async move {
|
||||
T::extract(&req)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ axum = { version = "0.7.5", default-features = false, features = [
|
||||
] }
|
||||
dashmap = "6"
|
||||
futures = "0.3.30"
|
||||
http = "1.1"
|
||||
http-body-util = "0.1.2"
|
||||
leptos = { workspace = true, features = ["nonce", "ssr"] }
|
||||
server_fn = { workspace = true, features = ["axum-no-default"] }
|
||||
leptos_macro = { workspace = true, features = ["axum"] }
|
||||
@@ -26,9 +24,8 @@ leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_integration_utils = { workspace = true }
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12.3"
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.39", default-features = false }
|
||||
tower = "0.4.13"
|
||||
tower = { version = "0.4.13", features = ["util"] }
|
||||
tower-http = "0.5.2"
|
||||
tracing = { version = "0.1.40", optional = true }
|
||||
|
||||
@@ -38,8 +35,8 @@ tokio = { version = "1.39", features = ["net", "rt-multi-thread"] }
|
||||
|
||||
[features]
|
||||
wasm = []
|
||||
default = ["tokio/fs", "tokio/sync", "tower-http/fs"]
|
||||
islands-router = []
|
||||
default = ["tokio/fs", "tokio/sync", "tower-http/fs", "tower/util"]
|
||||
dont-use-islands-router = []
|
||||
tracing = ["dep:tracing"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
|
||||
@@ -53,7 +53,7 @@ use leptos::{
|
||||
config::LeptosOptions,
|
||||
context::{provide_context, use_context},
|
||||
prelude::*,
|
||||
reactive_graph::{computed::ScopedFuture, owner::Owner},
|
||||
reactive::{computed::ScopedFuture, owner::Owner},
|
||||
IntoView,
|
||||
};
|
||||
use leptos_integration_utils::{
|
||||
@@ -66,7 +66,7 @@ use leptos_router::{
|
||||
components::provide_server_redirect,
|
||||
location::RequestUrl,
|
||||
static_routes::{RegenerationFn, StaticParamsMap},
|
||||
PathSegment, RouteList, RouteListing, SsrMode,
|
||||
ExpandOptionals, PathSegment, RouteList, RouteListing, SsrMode,
|
||||
};
|
||||
#[cfg(feature = "default")]
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -74,9 +74,9 @@ use parking_lot::RwLock;
|
||||
use server_fn::{redirect::REDIRECT_HEADER, ServerFnError};
|
||||
#[cfg(feature = "default")]
|
||||
use std::path::Path;
|
||||
use std::{fmt::Debug, io, pin::Pin, sync::Arc};
|
||||
use std::{collections::HashSet, fmt::Debug, io, pin::Pin, sync::Arc};
|
||||
#[cfg(feature = "default")]
|
||||
use tower::ServiceExt;
|
||||
use tower::util::ServiceExt;
|
||||
#[cfg(feature = "default")]
|
||||
use tower_http::services::ServeDir;
|
||||
// use tracing::Instrument; // TODO check tracing span -- was this used in 0.6 for a missing link?
|
||||
@@ -606,9 +606,9 @@ where
|
||||
/// use axum::{
|
||||
/// body::Body,
|
||||
/// extract::Path,
|
||||
/// http::Request,
|
||||
/// response::{IntoResponse, Response},
|
||||
/// };
|
||||
/// use http::Request;
|
||||
/// use leptos::{config::LeptosOptions, context::provide_context, prelude::*};
|
||||
///
|
||||
/// async fn custom_handler(
|
||||
@@ -784,7 +784,7 @@ where
|
||||
_ = replace_blocks; // TODO
|
||||
handle_response(additional_context, app_fn, |app, chunks| {
|
||||
Box::pin(async move {
|
||||
let app = if cfg!(feature = "islands-router") {
|
||||
let app = if cfg!(feature = "dont-use-islands-router") {
|
||||
app.to_html_stream_out_of_order_branching()
|
||||
} else {
|
||||
app.to_html_stream_out_of_order()
|
||||
@@ -806,9 +806,9 @@ where
|
||||
/// use axum::{
|
||||
/// body::Body,
|
||||
/// extract::Path,
|
||||
/// http::Request,
|
||||
/// response::{IntoResponse, Response},
|
||||
/// };
|
||||
/// use http::Request;
|
||||
/// use leptos::context::provide_context;
|
||||
///
|
||||
/// async fn custom_handler(
|
||||
@@ -849,7 +849,7 @@ where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
handle_response(additional_context, app_fn, |app, chunks| {
|
||||
let app = if cfg!(feature = "islands-router") {
|
||||
let app = if cfg!(feature = "dont-use-islands-router") {
|
||||
app.to_html_stream_in_order_branching()
|
||||
} else {
|
||||
app.to_html_stream_in_order()
|
||||
@@ -1025,9 +1025,9 @@ where
|
||||
/// use axum::{
|
||||
/// body::Body,
|
||||
/// extract::Path,
|
||||
/// http::Request,
|
||||
/// response::{IntoResponse, Response},
|
||||
/// };
|
||||
/// use http::Request;
|
||||
/// use leptos::context::provide_context;
|
||||
///
|
||||
/// async fn custom_handler(
|
||||
@@ -1069,7 +1069,7 @@ where
|
||||
{
|
||||
handle_response(additional_context, app_fn, |app, chunks| {
|
||||
Box::pin(async move {
|
||||
let app = if cfg!(feature = "islands-router") {
|
||||
let app = if cfg!(feature = "dont-use-islands-router") {
|
||||
app.to_html_stream_in_order_branching()
|
||||
} else {
|
||||
app.to_html_stream_in_order()
|
||||
@@ -1093,9 +1093,9 @@ where
|
||||
/// use axum::{
|
||||
/// body::Body,
|
||||
/// extract::Path,
|
||||
/// http::Request,
|
||||
/// response::{IntoResponse, Response},
|
||||
/// };
|
||||
/// use http::Request;
|
||||
/// use leptos::context::provide_context;
|
||||
///
|
||||
/// async fn custom_handler(
|
||||
@@ -1146,7 +1146,7 @@ where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
Box::pin(async move {
|
||||
let app = if cfg!(feature = "islands-router") {
|
||||
let app = if cfg!(feature = "dont-use-islands-router") {
|
||||
app.to_html_stream_in_order_branching()
|
||||
} else {
|
||||
app.to_html_stream_in_order()
|
||||
@@ -1263,25 +1263,38 @@ pub struct AxumRouteListing {
|
||||
methods: Vec<leptos_router::Method>,
|
||||
#[allow(unused)]
|
||||
regenerate: Vec<RegenerationFn>,
|
||||
exclude: bool,
|
||||
}
|
||||
|
||||
impl From<RouteListing> for AxumRouteListing {
|
||||
fn from(value: RouteListing) -> Self {
|
||||
let path = value.path().to_axum_path();
|
||||
let path = if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let mode = value.mode();
|
||||
let methods = value.methods().collect();
|
||||
let regenerate = value.regenerate().into();
|
||||
Self {
|
||||
path,
|
||||
mode: mode.clone(),
|
||||
methods,
|
||||
regenerate,
|
||||
}
|
||||
trait IntoRouteListing: Sized {
|
||||
fn into_route_listing(self) -> Vec<AxumRouteListing>;
|
||||
}
|
||||
|
||||
impl IntoRouteListing for RouteListing {
|
||||
fn into_route_listing(self) -> Vec<AxumRouteListing> {
|
||||
self.path()
|
||||
.to_vec()
|
||||
.expand_optionals()
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let path = path.to_axum_path();
|
||||
let path = if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let mode = self.mode();
|
||||
let methods = self.methods().collect();
|
||||
let regenerate = self.regenerate().into();
|
||||
AxumRouteListing {
|
||||
path,
|
||||
mode: mode.clone(),
|
||||
methods,
|
||||
regenerate,
|
||||
exclude: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1298,6 +1311,7 @@ impl AxumRouteListing {
|
||||
mode,
|
||||
methods: methods.into_iter().collect(),
|
||||
regenerate: regenerate.into(),
|
||||
exclude: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1342,8 +1356,7 @@ where
|
||||
.with(|| {
|
||||
// stub out a path for now
|
||||
provide_context(RequestUrl::new(""));
|
||||
let (mock_parts, _) =
|
||||
http::Request::new(Body::from("")).into_parts();
|
||||
let (mock_parts, _) = Request::new(Body::from("")).into_parts();
|
||||
let (mock_meta, _) = ServerMetaContext::new();
|
||||
provide_contexts("", &mock_meta, mock_parts, Default::default());
|
||||
additional_context();
|
||||
@@ -1361,27 +1374,36 @@ where
|
||||
let mut routes = routes
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(AxumRouteListing::from)
|
||||
.flat_map(IntoRouteListing::into_route_listing)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(
|
||||
if routes.is_empty() {
|
||||
vec![AxumRouteListing::new(
|
||||
"/".to_string(),
|
||||
Default::default(),
|
||||
[leptos_router::Method::Get],
|
||||
vec![],
|
||||
)]
|
||||
} else {
|
||||
// Routes to exclude from auto generation
|
||||
if let Some(excluded_routes) = excluded_routes {
|
||||
routes
|
||||
.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
|
||||
}
|
||||
routes
|
||||
},
|
||||
generator,
|
||||
)
|
||||
let routes = if routes.is_empty() {
|
||||
vec![AxumRouteListing::new(
|
||||
"/".to_string(),
|
||||
Default::default(),
|
||||
[leptos_router::Method::Get],
|
||||
vec![],
|
||||
)]
|
||||
} else {
|
||||
// Routes to exclude from auto generation
|
||||
if let Some(excluded_routes) = &excluded_routes {
|
||||
routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
|
||||
}
|
||||
routes
|
||||
};
|
||||
let excluded =
|
||||
excluded_routes
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|path| AxumRouteListing {
|
||||
path,
|
||||
mode: Default::default(),
|
||||
methods: Vec::new(),
|
||||
regenerate: Vec::new(),
|
||||
exclude: true,
|
||||
});
|
||||
|
||||
(routes.into_iter().chain(excluded).collect(), generator)
|
||||
}
|
||||
|
||||
/// Allows generating any prerendered routes.
|
||||
@@ -1402,8 +1424,8 @@ impl StaticRouteGenerator {
|
||||
let add_context = additional_context.clone();
|
||||
move || {
|
||||
let full_path = format!("http://leptos.dev{path}");
|
||||
let mock_req = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
let mock_req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.header("Accept", "text/html")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -1495,10 +1517,12 @@ impl StaticRouteGenerator {
|
||||
_ = routes;
|
||||
_ = app_fn;
|
||||
_ = additional_context;
|
||||
panic!(
|
||||
"Static routes are not currently supported on WASM32 server \
|
||||
targets."
|
||||
);
|
||||
Self(Box::new(|_| {
|
||||
panic!(
|
||||
"Static routes are not currently supported on WASM32 \
|
||||
server targets."
|
||||
);
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1692,7 +1716,7 @@ trait AxumPath {
|
||||
fn to_axum_path(&self) -> String;
|
||||
}
|
||||
|
||||
impl AxumPath for &[PathSegment] {
|
||||
impl AxumPath for Vec<PathSegment> {
|
||||
fn to_axum_path(&self) -> String {
|
||||
let mut path = String::new();
|
||||
for segment in self.iter() {
|
||||
@@ -1712,6 +1736,14 @@ impl AxumPath for &[PathSegment] {
|
||||
path.push_str(s);
|
||||
}
|
||||
PathSegment::Unit => {}
|
||||
PathSegment::OptionalParam(_) => {
|
||||
#[cfg(feature = "tracing")]
|
||||
tracing::error!(
|
||||
"to_axum_path should only be called on expanded \
|
||||
paths, which do not have OptionalParam any longer"
|
||||
);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
@@ -1767,32 +1799,41 @@ where
|
||||
|
||||
let mut router = self;
|
||||
|
||||
let excluded = paths
|
||||
.iter()
|
||||
.filter(|&p| p.exclude)
|
||||
.map(|p| p.path.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// register server functions
|
||||
for (path, method) in server_fn::axum::server_fn_paths() {
|
||||
let cx_with_state = cx_with_state.clone();
|
||||
let handler = move |req: Request<Body>| async move {
|
||||
handle_server_fns_with_context(cx_with_state, req).await
|
||||
};
|
||||
router = router.route(
|
||||
path,
|
||||
match method {
|
||||
Method::GET => get(handler),
|
||||
Method::POST => post(handler),
|
||||
Method::PUT => put(handler),
|
||||
Method::DELETE => delete(handler),
|
||||
Method::PATCH => patch(handler),
|
||||
_ => {
|
||||
panic!(
|
||||
"Unsupported server function HTTP method: \
|
||||
{method:?}"
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if !excluded.contains(path) {
|
||||
router = router.route(
|
||||
path,
|
||||
match method {
|
||||
Method::GET => get(handler),
|
||||
Method::POST => post(handler),
|
||||
Method::PUT => put(handler),
|
||||
Method::DELETE => delete(handler),
|
||||
Method::PATCH => patch(handler),
|
||||
_ => {
|
||||
panic!(
|
||||
"Unsupported server function HTTP method: \
|
||||
{method:?}"
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// register router paths
|
||||
for listing in paths.iter() {
|
||||
for listing in paths.iter().filter(|p| !p.exclude) {
|
||||
let path = listing.path();
|
||||
|
||||
for method in listing.methods() {
|
||||
@@ -1901,7 +1942,7 @@ where
|
||||
T: 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for listing in paths.iter() {
|
||||
for listing in paths.iter().filter(|p| !p.exclude) {
|
||||
for method in listing.methods() {
|
||||
router = router.route(
|
||||
listing.path(),
|
||||
@@ -1933,7 +1974,7 @@ where
|
||||
///
|
||||
/// #[server]
|
||||
/// pub async fn request_method() -> Result<String, ServerFnError> {
|
||||
/// use http::Method;
|
||||
/// use axum::http::Method;
|
||||
/// use leptos_axum::extract;
|
||||
///
|
||||
/// // you can extract anything that a regular Axum extractor can extract
|
||||
@@ -1992,7 +2033,7 @@ where
|
||||
move |uri: Uri, State(options): State<S>, req: Request<Body>| {
|
||||
Box::pin(async move {
|
||||
let options = LeptosOptions::from_ref(&options);
|
||||
let res = get_static_file(uri, &options.site_root);
|
||||
let res = get_static_file(uri, &options.site_root, req.headers());
|
||||
let res = res.await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
@@ -2026,14 +2067,26 @@ where
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
let req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
use axum::http::header::ACCEPT_ENCODING;
|
||||
|
||||
let req = Request::builder().uri(uri);
|
||||
|
||||
let req = match headers.get(ACCEPT_ENCODING) {
|
||||
Some(value) => req.header(ACCEPT_ENCODING, value),
|
||||
None => req,
|
||||
};
|
||||
|
||||
let req = req.body(Body::empty()).unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
match ServeDir::new(root)
|
||||
.precompressed_gzip()
|
||||
.precompressed_br()
|
||||
.oneshot(req)
|
||||
.await
|
||||
{
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user